Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55deb663da | ||
|
|
7eef12be44 | ||
|
|
dd0458d44e | ||
|
|
be797bb82d | ||
|
|
d66116eb8f | ||
|
|
9588698874 | ||
|
|
a3ee15bde7 | ||
|
|
64b8a2d1a0 | ||
|
|
fad4359857 | ||
|
|
a66227bd20 | ||
|
|
eaa938277b | ||
|
|
bb9fd994fc | ||
|
|
d387f3d7bf | ||
|
|
3bead4a21f | ||
|
|
422904c9b9 | ||
|
|
51b9c56c3c | ||
|
|
2af0f44fbe | ||
|
|
7492888b1a | ||
|
|
17223cf0bb | ||
|
|
21c770afb3 | ||
|
|
aee23dc751 | ||
|
|
705835a66f | ||
|
|
c2302c033e | ||
|
|
596f76bbaa | ||
|
|
85f77f2332 | ||
|
|
d471255340 | ||
|
|
dcb1e891f1 | ||
|
|
7bf41fa0f8 | ||
|
|
a23ea81c48 | ||
|
|
52aa48fda7 | ||
|
|
264b1ebbf4 | ||
|
|
bd04f7692c | ||
|
|
26de9d369d | ||
|
|
27e5b41038 | ||
|
|
2119a36969 | ||
|
|
8d3081a086 | ||
|
|
d1664b28de | ||
|
|
252689038e | ||
|
|
406642050e | ||
|
|
ddf3e203a0 | ||
|
|
e8ed4c30e1 | ||
|
|
d7315caa20 | ||
|
|
62ea5ce9d3 | ||
|
|
254fafe42b | ||
|
|
f9dd7a22b7 | ||
|
|
7d7d16ca31 | ||
|
|
6ed0b30230 | ||
|
|
8d3d28c240 | ||
|
|
f9738b0c23 | ||
|
|
39ca7d869a | ||
|
|
0c36ed2a75 | ||
|
|
e84378cc5b | ||
|
|
57dcda5b54 | ||
|
|
df33eca5f2 | ||
|
|
dc841a91e0 | ||
|
|
f7805cb0b5 | ||
|
|
33c61202d9 | ||
|
|
9f05a53ed2 | ||
|
|
a2b6793406 | ||
|
|
eda616b42e | ||
|
|
7c17bf2ff4 | ||
|
|
9ca10fc6ee | ||
|
|
994fa010b8 | ||
|
|
5c20c5f6eb | ||
|
|
c4bb7ae0b2 | ||
|
|
0c985a280c | ||
|
|
17ebb9fd42 | ||
|
|
cadc2b14db | ||
|
|
1fdca903be | ||
|
|
5e7658aa80 | ||
|
|
bea31032fb | ||
|
|
f7c1dbdbc4 | ||
|
|
a1ed010963 | ||
|
|
7be6b7ac31 | ||
|
|
20c20b71ea | ||
|
|
0fb3df0b3a | ||
|
|
e10b09a9b5 | ||
|
|
806a576380 | ||
|
|
3c365c6792 | ||
|
|
4fb4a85ac4 | ||
|
|
e7312e5d6e | ||
|
|
12c2de7ed8 | ||
|
|
8a3cc95c66 | ||
|
|
9c06ffdb48 | ||
|
|
870a903015 | ||
|
|
b5768e3b17 | ||
|
|
02b33dbf07 | ||
|
|
a0c0b875f0 | ||
|
|
512bf8c746 | ||
|
|
04257e8a66 | ||
|
|
c04b2ecee2 | ||
|
|
e10fae0710 |
@@ -21,9 +21,9 @@ labels:
|
|||||||
- Calendula version: <!-- Settings → bottom of the screen -->
|
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||||
- Android version:
|
- Android version:
|
||||||
- Device:
|
- Device:
|
||||||
- Installed from: <!-- official F-Droid / the self-hosted repo / built from source -->
|
- Installed from: <!-- official F-Droid / the self-hosted repo / Google Play / Codeberg APK or Obtainium / built from source -->
|
||||||
- Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local,
|
- Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local,
|
||||||
subscribed/WebCal, birthdays — provider behaviour differs
|
subscribed/WebCal, birthdays. Provider behaviour differs
|
||||||
a lot per account type, so this often points straight at
|
a lot per account type, so this often points straight at
|
||||||
the cause -->
|
the cause -->
|
||||||
- Time zone: <!-- only if the problem involves dates or all-day events -->
|
- Time zone: <!-- only if the problem involves dates or all-day events -->
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ contact_links:
|
|||||||
- name: Translate Calendula
|
- name: Translate Calendula
|
||||||
url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
|
url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
|
||||||
about: >-
|
about: >-
|
||||||
Translations are managed on Weblate, not here — it owns every values-*
|
Translations are managed on Weblate. It owns every values-* file, so a
|
||||||
file, so a hand-edited translation gets overwritten on the next sync.
|
hand-edited translation gets overwritten on the next sync.
|
||||||
No coding needed: pick or request a language and translate in the browser.
|
No coding needed: pick or request a language and translate in the browser.
|
||||||
|
|
||||||
- name: Contributing guide
|
- name: Contributing guide
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: Crash report
|
name: Crash report
|
||||||
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
|
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash). It copies the report to your clipboard and prefills this form.
|
||||||
title: "Crash: "
|
title: "Crash: "
|
||||||
labels:
|
labels:
|
||||||
- bug
|
- bug
|
||||||
@@ -11,10 +11,10 @@ labels:
|
|||||||
<!--
|
<!--
|
||||||
Thanks for reporting a crash in Calendula!
|
Thanks for reporting a crash in Calendula!
|
||||||
|
|
||||||
If the app prefilled this for you, the crash report is already below — just add
|
If the app prefilled this for you, the crash report is already below. Add
|
||||||
what you were doing and submit. Otherwise, paste the report from your clipboard
|
what you were doing and submit. Otherwise, paste the report from your clipboard
|
||||||
into the code block. The report contains only app/Android/device versions and the
|
into the code block. The report contains only app/Android/device versions and the
|
||||||
stack trace — no personal data or calendar content.
|
stack trace, with no personal data or calendar content.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
### What happened
|
### What happened
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ labels:
|
|||||||
### What would you like Calendula to do?
|
### What would you like Calendula to do?
|
||||||
|
|
||||||
|
|
||||||
### Why — what problem does it solve?
|
### What problem does it solve?
|
||||||
|
|
||||||
|
|
||||||
### Anything else
|
### Anything else
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Please skim CONTRIBUTING.md if you haven't:
|
|||||||
https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
|
https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
|
||||||
|
|
||||||
Two things it's easy to get wrong:
|
Two things it's easy to get wrong:
|
||||||
• Features need a discussed issue first — an undiscussed feature PR may be
|
• Features need a discussed issue first. An undiscussed feature PR may be
|
||||||
closed unmerged even when the code is good.
|
closed unmerged even when the code is good.
|
||||||
• Target the release branch for your issue's milestone (milestone 2.18.0 →
|
• Target the release branch for your issue's milestone (milestone 2.18.0 →
|
||||||
release/v2.18.0), not main. If you targeted main, just say so below and it
|
release/v2.18.0), not main. If you targeted main, just say so below and it
|
||||||
@@ -17,7 +17,7 @@ Two things it's easy to get wrong:
|
|||||||
|
|
||||||
### Why
|
### Why
|
||||||
|
|
||||||
<!-- Closes #123 — link the issue this implements or fixes. -->
|
<!-- Closes #123: link the issue this implements or fixes. -->
|
||||||
|
|
||||||
|
|
||||||
### How it was tested
|
### How it was tested
|
||||||
@@ -34,7 +34,7 @@ are especially useful for UI changes.
|
|||||||
### Checklist
|
### Checklist
|
||||||
|
|
||||||
- [ ] There's an issue for this, and (for a feature) it got a go-ahead
|
- [ ] There's an issue for this, and (for a feature) it got a go-ahead
|
||||||
- [ ] Targeting the release branch for that issue's milestone — or `main`, noted above
|
- [ ] Targeting the release branch for that issue's milestone (or `main`, noted above)
|
||||||
- [ ] `./gradlew lint test assembleDebug` passes locally
|
- [ ] `./gradlew lint test assembleDebug` passes locally
|
||||||
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
|
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
|
||||||
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
|
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
|
||||||
|
|||||||
@@ -37,6 +37,25 @@ jobs:
|
|||||||
- name: Reproducible-release invariant
|
- name: Reproducible-release invariant
|
||||||
run: bash scripts/check_reproducible_release.sh
|
run: bash scripts/check_reproducible_release.sh
|
||||||
|
|
||||||
|
# Play rejects a "What's New" over 500 characters, which would fail the
|
||||||
|
# upload after the release had already shipped everywhere else. Cheap, so
|
||||||
|
# it runs on every PR rather than only on the release merge.
|
||||||
|
- name: Changelog length invariant
|
||||||
|
run: bash scripts/check_changelog_lengths.sh
|
||||||
|
|
||||||
|
# The whole listing goes to Play with every release; a field over its cap
|
||||||
|
# or an image Play rejects would fail that upload.
|
||||||
|
- name: Store listing invariant
|
||||||
|
run: |
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update && apt-get install -y python3
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
apk add --no-cache python3
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
python3 scripts/check_store_listing.py
|
||||||
|
|
||||||
# Decide whether anything that affects the app build changed. Docs, store
|
# Decide whether anything that affects the app build changed. Docs, store
|
||||||
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
||||||
# the SDK + Gradle work below but still report a green `ci`.
|
# the SDK + Gradle work below but still report a green `ci`.
|
||||||
|
|||||||
+218
@@ -7,6 +7,194 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Pages can open the split agenda.** Swipe up from a full month to reveal the compact grid and month agenda; tap a day to filter it, then swipe down to return. The transition gives haptic feedback ([#111]).
|
||||||
|
- **Week numbers open their week.** Tap a week number in the month grid to open Week at that row; Back returns to Month ([#197]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Split view opens with the month's agenda.** It starts at today in the current month; tapping a day filters the list, and the filter chip restores the full month. Swiping the list moves by day when filtered and by month otherwise ([#142]).
|
||||||
|
- **Today stands out in the month grid.** Its whole cell has a distinct background; tapping Today briefly expands that cell and nudges its neighbours aside ([#241]).
|
||||||
|
- **Store listing in every app language.** Google Play and F-Droid show the description in all 16 languages the app ships, and each release publishes the Play listing from the repository ([#285]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Multi-day events stay above shorter events.** Month and Week bars, Split day lists, and Agenda now place multi-day events before one-day all-day events, then timed events; declined events remain last ([#291]).
|
||||||
|
|
||||||
|
## [2.21.0] — 2026-09-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Setup makes sure you have a calendar to write to.** A fresh install could
|
||||||
|
finish onboarding and land on an empty grid with nothing saying why — either
|
||||||
|
because the device had no calendar at all, or because every calendar it had
|
||||||
|
was switched off. Setup now covers both: it offers to create a calendar on
|
||||||
|
this device or to add an account that syncs one, and it lists the calendars
|
||||||
|
that exist but are switched off so you can turn them back on ([#287]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The month grid gives its days more room.** Week numbers are set smaller and
|
||||||
|
their gutter is measured from the widest number it has to seat instead of
|
||||||
|
holding a fixed 40dp; event chips spend less of their width on padding; and
|
||||||
|
the labels in both are tracked more tightly, which is worth roughly a
|
||||||
|
character per chip ([#189], [#190], [#212], [#213]).
|
||||||
|
- **A month day shows as many events as it has height for.** The limit was a
|
||||||
|
flat three at every size, so a tall week row threw away space it could have
|
||||||
|
filled, while a cramped one drew a third chip its band could not hold and put
|
||||||
|
the "more" marker below the clip, where nothing showed the day held more at
|
||||||
|
all. It is measured per week row now, and a tablet or a landscape phone seats
|
||||||
|
more of them.
|
||||||
|
- **Split view no longer pads a short month out to six rows.** A five-row month
|
||||||
|
was handing the day pane a blank row and its gap — a sixth of the grid. The
|
||||||
|
grid now draws only the rows its month spans and the pane grows into the rest
|
||||||
|
([#162]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Short events show their title in week and day view.** A block needed 20dp
|
||||||
|
of height before it drew any title at all, which at fit-the-whole-day put the
|
||||||
|
cutoff around the half hour. The padding now tapers as the block shrinks, so
|
||||||
|
a short event keeps its name ([#289]).
|
||||||
|
- **A fully zoomed-out timeline sits still.** Rounding its height up left it as
|
||||||
|
much as a pixel per hour taller than the viewport those hours are meant to
|
||||||
|
fill — some 24px of leftover scroll, enough to bounce off Android's
|
||||||
|
overscroll stretch ([#290]).
|
||||||
|
- **Backup & restore says what is going on instead of coming up blank.** It
|
||||||
|
built its rows straight from the calendar list, so loading, a provider error,
|
||||||
|
and a device with nothing to export all rendered as an empty page with no
|
||||||
|
buttons on it ([#304]).
|
||||||
|
- **An event with no title reads in your language.** It was labelled "(Ohne
|
||||||
|
Titel)" on every screen but the event's own, whatever language the phone was
|
||||||
|
set to: the German text was chosen before the translated one could be
|
||||||
|
([#321]).
|
||||||
|
- **A calendar the system gives no name reads in your language.** The same
|
||||||
|
cause, in the calendar lists and pickers — and because the calendar editor
|
||||||
|
starts from that name, saving such a calendar wrote the German text into it
|
||||||
|
for real ([#329]).
|
||||||
|
|
||||||
|
## [2.20.4] — 2026-09-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The app no longer reopens the event you last came in on.** Opening an event
|
||||||
|
from a reminder or a home-screen widget made the app return to that same
|
||||||
|
event on every later launch — and show an error screen once the event had
|
||||||
|
been deleted. Android hands an app the launch it was started with again each
|
||||||
|
time it is rebuilt, and Calendula was acting on it every time instead of
|
||||||
|
once ([#309]).
|
||||||
|
- **"Today" works in the agenda again.** Scrolling a few days ahead and tapping
|
||||||
|
today did nothing, because the agenda is browsed by scrolling rather than by
|
||||||
|
moving its date, and the button only moved the date. It now brings the list
|
||||||
|
back to today, and it stays offered while the list is scrolled away — before,
|
||||||
|
it was hidden in exactly the situation it is for ([#305]).
|
||||||
|
- **Calendula is filed under "Calendar & Agenda" in the self-hosted repo.**
|
||||||
|
Browsing by category in an F-Droid client set to prefer that repo listed the
|
||||||
|
app under "Time" instead ([#294]).
|
||||||
|
|
||||||
|
## [2.20.3] — 2026-09-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A custom agenda range crashed the app in French.** Choosing a custom range
|
||||||
|
for the agenda — or simply typing a number into that field — closed the app on
|
||||||
|
any phone set to French. The French translation of the "%d days" label was
|
||||||
|
missing the wording French uses for counts of two and above, and Android has
|
||||||
|
nothing to fall back on when that particular form is absent, so every value
|
||||||
|
except one day was affected ([#297], [#298]).
|
||||||
|
|
||||||
|
## [2.20.2] — 2026-09-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Crash reports are filed in English.** Reporting a crash from a phone set to
|
||||||
|
another language prefilled the issue — title and body — in that language, so
|
||||||
|
a French crash arrived on Codeberg as a French issue ([#297], [#298]). The
|
||||||
|
text that is *posted* is now English for everyone; the dialog that asks you
|
||||||
|
to send it, and everything else you see in the app, is still translated.
|
||||||
|
|
||||||
|
## [2.20.1] — 2026-09-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **An empty calendar now explains itself.** "No calendars configured" offered
|
||||||
|
an "Open system calendar settings" button that opened nothing — every screen
|
||||||
|
wired it to the same reload that had just produced the empty state. It now
|
||||||
|
opens the system's account chooser, filtered to the providers that sync
|
||||||
|
calendars ([#239]).
|
||||||
|
- **Calendars that are all switched off no longer look like an empty month.**
|
||||||
|
When every calendar on the device is hidden at system level there is nothing
|
||||||
|
to draw, but the app said nothing at all: it only ever checked whether the
|
||||||
|
device had *no* calendars, so a device that had them and hid them fell
|
||||||
|
through to a blank grid with no hint. That case now says so, and points at
|
||||||
|
Settings → Calendars — the one screen that can switch a calendar back on. The
|
||||||
|
navigation drawer's calendar list drew the same conflation and is fixed with
|
||||||
|
it ([#239]).
|
||||||
|
|
||||||
|
## [2.20.0] — 2026-09-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Long-press to copy.** Holding down the title, the location or the
|
||||||
|
description on an event puts that whole field on the clipboard. Tapping still
|
||||||
|
does what it always did — the location opens maps, a link in the description
|
||||||
|
opens — and the times, calendar, repeat rule and guests stay uncopyable. Below
|
||||||
|
Android 13 a short confirmation appears; from Android 13 on the system shows
|
||||||
|
its own clipboard chip ([#195]).
|
||||||
|
- **The open-source projects Calendula ships** are now listed under Settings →
|
||||||
|
About, each with its copyright holder and licence, and each row tapping
|
||||||
|
through to that project's source ([#281]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Event titles no longer end in an ellipsis. The "…" took the width of a couple
|
||||||
|
of characters and told you nothing you couldn't already see, so the title now
|
||||||
|
simply runs to the edge of its chip in the month, week and day views — a few
|
||||||
|
more letters per event, which is often the difference between two events you
|
||||||
|
can tell apart and two you can't. Right-to-left languages keep the ellipsis,
|
||||||
|
where clipping would cut the beginning of a title rather than its end
|
||||||
|
([#164]).
|
||||||
|
- Month-view events now show their start time before the title, where the chip
|
||||||
|
is wide enough to keep the title readable — in landscape, on a foldable and on
|
||||||
|
a tablet, and never at the cost of a narrow phone column. All-day events show
|
||||||
|
no time, and neither does a bar carried in from the previous week, whose start
|
||||||
|
is not in that row. Event times across the month, week and day views are also
|
||||||
|
set in a regular weight against the title's, so a time reads as a time rather
|
||||||
|
than as part of the name next to it ([#219]).
|
||||||
|
- **The hour lines in week and day view are now an hour grid.** The setting
|
||||||
|
that drew a faint separator line at each hour now seats every hour in its own
|
||||||
|
rounded cell, so an hour boundary reads as the seam between two surfaces
|
||||||
|
rather than a line drawn across one — the same negative space that separates
|
||||||
|
the month grid's days and the week's columns. An event that starts on the hour
|
||||||
|
fills its cell instead of overhanging the seam, and one that runs past
|
||||||
|
midnight still meets the edge of its column ([#113]).
|
||||||
|
- **An invitation you haven't answered now reads as an outline.** An event still
|
||||||
|
waiting on your reply is drawn with the calendar's colour on its border and
|
||||||
|
its title and nothing behind it, so it stands apart from the ones you have
|
||||||
|
accepted — on the month chips, the all-day bars and the week and day blocks
|
||||||
|
alike. Declined events keep their filled, struck-through chip and now sort
|
||||||
|
last in the month view as well, so a day with more events than fit drops the
|
||||||
|
ones you said no to first ([#230]).
|
||||||
|
- The calendar titles now shorten instead of being cut off. When the full month
|
||||||
|
name doesn't fit the top bar, the month and week views fall back to its
|
||||||
|
three-letter form and the day view drops the weekday, rather than trailing off
|
||||||
|
mid-word at large font sizes. The view switcher, the title and the agenda's
|
||||||
|
range bar also line up with the grid underneath them ([#165]).
|
||||||
|
- The jump-to-today button in the toolbar now shows today's date. It drew a
|
||||||
|
generic calendar icon, which told you nothing you didn't already know from
|
||||||
|
tapping it; it now carries the current day number in an outlined box, so the
|
||||||
|
bar says what day it is as well as taking you there. It rolls over at midnight
|
||||||
|
on its own ([#220]).
|
||||||
|
- The quick-switch pill disappears when there is nothing left to switch between,
|
||||||
|
and you can now turn calendar views off all the way down to a single one — the
|
||||||
|
setting used to hold two open purely to keep the pill fed ([#150]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A long location no longer runs off the edge of its field.** The location on
|
||||||
|
the edit screen sat on one line, so a full postal address or a long meeting
|
||||||
|
link scrolled sideways out of sight as you typed it. It now wraps and the card
|
||||||
|
grows to fit, with the pin and the contacts button staying level with the first
|
||||||
|
line at any font size. A multi-line address you paste in is joined with commas
|
||||||
|
— the way one picked from your contacts always has been — instead of running
|
||||||
|
together into a single word ([#273]).
|
||||||
|
- **The event form capitalizes sentences again.** The title, description and
|
||||||
|
guest fields had stopped starting a sentence with a capital letter; they
|
||||||
|
follow the keyboard's usual behaviour once more. The location is deliberately
|
||||||
|
left alone, since it is as often a URL as an address ([#146]).
|
||||||
|
- **A block you pick up no longer loses its second line.** Dragging an event
|
||||||
|
whose time or title wrapped to two lines clipped the floating copy to one line
|
||||||
|
for as long as you held it, and it snapped back on drop. The copy now measures
|
||||||
|
its text exactly as the block does ([#267]).
|
||||||
|
|
||||||
## [2.19.4] — 2026-09-01
|
## [2.19.4] — 2026-09-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -1560,6 +1748,8 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||||
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||||
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
||||||
|
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164
|
||||||
|
[#165]: https://codeberg.org/jlmakiola/calendula/issues/165
|
||||||
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
||||||
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
||||||
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
||||||
@@ -1569,5 +1759,33 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
|
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
|
||||||
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
||||||
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
||||||
|
[#219]: https://codeberg.org/jlmakiola/calendula/issues/219
|
||||||
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
||||||
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
|
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
|
||||||
|
[#273]: https://codeberg.org/jlmakiola/calendula/issues/273
|
||||||
|
[#113]: https://codeberg.org/jlmakiola/calendula/issues/113
|
||||||
|
[#220]: https://codeberg.org/jlmakiola/calendula/issues/220
|
||||||
|
[#195]: https://codeberg.org/jlmakiola/calendula/issues/195
|
||||||
|
[#281]: https://codeberg.org/jlmakiola/calendula/issues/281
|
||||||
|
[#230]: https://codeberg.org/jlmakiola/calendula/issues/230
|
||||||
|
[#150]: https://codeberg.org/jlmakiola/calendula/issues/150
|
||||||
|
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
|
||||||
|
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
|
||||||
|
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
|
||||||
|
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
|
||||||
|
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
|
||||||
|
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
|
||||||
|
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
|
||||||
|
[#294]: https://codeberg.org/jlmakiola/calendula/issues/294
|
||||||
|
[#287]: https://codeberg.org/jlmakiola/calendula/issues/287
|
||||||
|
[#189]: https://codeberg.org/jlmakiola/calendula/issues/189
|
||||||
|
[#190]: https://codeberg.org/jlmakiola/calendula/issues/190
|
||||||
|
[#212]: https://codeberg.org/jlmakiola/calendula/issues/212
|
||||||
|
[#213]: https://codeberg.org/jlmakiola/calendula/issues/213
|
||||||
|
[#162]: https://codeberg.org/jlmakiola/calendula/issues/162
|
||||||
|
[#289]: https://codeberg.org/jlmakiola/calendula/issues/289
|
||||||
|
[#290]: https://codeberg.org/jlmakiola/calendula/issues/290
|
||||||
|
[#304]: https://codeberg.org/jlmakiola/calendula/issues/304
|
||||||
|
[#321]: https://codeberg.org/jlmakiola/calendula/issues/321
|
||||||
|
[#329]: https://codeberg.org/jlmakiola/calendula/issues/329
|
||||||
|
[#285]: https://codeberg.org/jlmakiola/calendula/issues/285
|
||||||
|
|||||||
+29
-29
@@ -1,13 +1,13 @@
|
|||||||
# Contributing to Calendula
|
# Contributing to Calendula
|
||||||
|
|
||||||
Calendula is a Material 3 Expressive calendar app that lives strictly on top of
|
Calendula is a Material 3 Expressive calendar app that lives strictly on top of
|
||||||
Android's `CalendarContract` — no app database, no sync stack, no network access.
|
Android's `CalendarContract`, with no app database, sync stack or network access.
|
||||||
That constraint shapes most review comments, so
|
That constraint shapes most review comments, so skim
|
||||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) is worth skimming before you write
|
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) before you write code. This file
|
||||||
code. This file is the practical how.
|
covers the practical side.
|
||||||
|
|
||||||
**[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home** —
|
[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home for
|
||||||
issues, pull requests, releases. The self-hosted Gitea instance referenced in the
|
issues, pull requests and releases. The self-hosted Gitea instance referenced in the
|
||||||
release docs is build infrastructure only; there is nothing to contribute there.
|
release docs is build infrastructure only; there is nothing to contribute there.
|
||||||
Be decent to the people you meet in the tracker.
|
Be decent to the people you meet in the tracker.
|
||||||
|
|
||||||
@@ -18,20 +18,20 @@ Be decent to the people you meet in the tracker.
|
|||||||
| Add a feature | **Open an issue first** and wait for a go-ahead |
|
| Add a feature | **Open an issue first** and wait for a go-ahead |
|
||||||
| Fix a bug | Open an issue, then a pull request |
|
| Fix a bug | Open an issue, then a pull request |
|
||||||
| Fix a typo, a comment, or docs | Just open the pull request |
|
| Fix a typo, a comment, or docs | Just open the pull request |
|
||||||
| Add or fix a translation | **Don't** — [use Weblate](#translations) |
|
| Add or fix a translation | Use [Weblate](#translations), not a pull request |
|
||||||
|
|
||||||
Features get an opinion before they get code: whether Calendula should do a
|
Features get an opinion before they get code: whether Calendula should do a
|
||||||
thing at all is the one decision a patch can't make. A feature PR that arrives
|
thing at all is the one decision a patch can't make. A feature PR that arrives
|
||||||
without a discussed issue may be closed unmerged even when the code is good —
|
without a discussed issue may be closed unmerged even when the code is good, so
|
||||||
please don't spend a weekend on one first.
|
please don't spend a weekend on one first.
|
||||||
|
|
||||||
Bugs are more straightforward, but still start with an issue: it's what carries
|
Bugs are more straightforward, but still start with an issue: it's what carries
|
||||||
the milestone and gives the changelog something to link.
|
the milestone and gives the changelog something to link.
|
||||||
|
|
||||||
Issue templates cover bug, crash, feature and question. For a crash, let the app
|
Issue templates cover bug, crash, feature and question. For a crash, let the app
|
||||||
do the work — **Settings → Report a problem**, or the prompt shown after a crash,
|
do the work: *Settings → Report a problem*, or the prompt shown after a crash,
|
||||||
captures the stack trace and prefills the form. The report contains app, Android
|
captures the stack trace and prefills the form. The report contains app, Android
|
||||||
and device versions plus the trace; no calendar content, no personal data.
|
and device versions plus the trace, and no calendar content or personal data.
|
||||||
|
|
||||||
## Which branch to target
|
## Which branch to target
|
||||||
|
|
||||||
@@ -45,20 +45,20 @@ Once your issue has a milestone, that milestone names your branch:
|
|||||||
| `2.18.0` | `release/v2.18.0` |
|
| `2.18.0` | `release/v2.18.0` |
|
||||||
|
|
||||||
Every milestone has a matching branch. If it's somehow missing, target `main` and
|
Every milestone has a matching branch. If it's somehow missing, target `main` and
|
||||||
mention it in the PR — it will be retargeted. Don't pick an older release branch:
|
mention it in the PR, and it will be retargeted. Don't pick an older release branch:
|
||||||
they're kept after shipping, so the newest one isn't necessarily yours.
|
they're kept after shipping, so the newest one isn't necessarily yours.
|
||||||
|
|
||||||
## Translations
|
## Translations
|
||||||
|
|
||||||
**Never edit a `values-*/strings.xml` file in a pull request** — German included.
|
**Never edit a `values-*/strings.xml` file in a pull request**, German included.
|
||||||
Translations are owned by a self-hosted Weblate that writes to this repository
|
Translations are owned by a self-hosted Weblate that writes to this repository
|
||||||
directly, and a hand-edit is overwritten on the next sync.
|
directly, and a hand-edit is overwritten on the next sync.
|
||||||
|
|
||||||
→ **[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)
|
||||||
|
|
||||||
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
|
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
|
||||||
picks it up and offers it to translators. Partial translations are expected and
|
picks it up and offers it to translators. Partial translations are expected and
|
||||||
fine — missing keys are informational. Stale and orphaned keys are not, so run
|
fine; missing keys are informational. Stale and orphaned keys are not, so run
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 scripts/check_translations.py
|
python3 scripts/check_translations.py
|
||||||
@@ -67,7 +67,7 @@ python3 scripts/check_translations.py
|
|||||||
before pushing. It reports those more clearly than lint's `MissingTranslation`
|
before pushing. It reports those more clearly than lint's `MissingTranslation`
|
||||||
does.
|
does.
|
||||||
|
|
||||||
## Build & test
|
## Build and test
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
|
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
|
||||||
@@ -77,11 +77,11 @@ The `floret-kit` submodule is a composite build compiled from source. An
|
|||||||
existing clone needs `git submodule update --init --recursive`, or nothing
|
existing clone needs `git submodule update --init --recursive`, or nothing
|
||||||
resolves.
|
resolves.
|
||||||
|
|
||||||
- **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. Set
|
- **JDK 17**, not newer: the Android Gradle Plugin requires exactly 17. Set
|
||||||
`JAVA_HOME` if your default differs.
|
`JAVA_HOME` if your default differs.
|
||||||
- **Android SDK** — platform 37 (`compileSdk`) and build-tools 36.0.0, located
|
- **Android SDK**: platform 37 (`compileSdk`) and build-tools 36.0.0, located
|
||||||
via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you
|
via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you
|
||||||
go the `local.properties` route the included build needs its own copy at
|
go the `local.properties` route, the included build needs its own copy at
|
||||||
`floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the
|
`floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the
|
||||||
easier path.
|
easier path.
|
||||||
|
|
||||||
@@ -109,21 +109,21 @@ These are the ones that turn into review comments.
|
|||||||
|
|
||||||
1. **No network.** Calendula holds no `INTERNET` permission, and that's a
|
1. **No network.** Calendula holds no `INTERNET` permission, and that's a
|
||||||
feature rather than an oversight. Anything that would need one is a product
|
feature rather than an oversight. Anything that would need one is a product
|
||||||
decision before it's a patch — the crash reporter deliberately opens a
|
decision before it's a patch. The crash reporter opens a prefilled web issue
|
||||||
prefilled web issue instead of posting anything itself.
|
on purpose instead of posting anything itself.
|
||||||
2. **The provider is the only database.** No Room, no cache, no local mirror of
|
2. **The provider is the only database.** No Room, no cache, no local mirror of
|
||||||
events. `CalendarContract` is the single source of truth, which is also why
|
events. `CalendarContract` is the single source of truth, which is also why
|
||||||
externally synced changes work for free.
|
externally synced changes work for free.
|
||||||
3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and
|
3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and
|
||||||
views recompose from fresh provider state. Hand-patching a list after saving
|
views recompose from fresh provider state. Hand-patching a list after saving
|
||||||
appears to work, then quietly diverges from what the provider actually stored.
|
appears to work, then drifts away from what the provider stored.
|
||||||
4. **`domain/` has no Android imports.** Models, validation, recurrence
|
4. **`domain/` has no Android imports.** Models, validation, recurrence
|
||||||
rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they
|
rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they
|
||||||
remain JVM-testable.
|
remain JVM-testable.
|
||||||
5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you:
|
5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you:
|
||||||
fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps
|
fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps
|
||||||
through `ColumnReader` instead of cursors. Instrumented tests are a last
|
through `ColumnReader` instead of cursors. Instrumented tests are a last
|
||||||
resort, not a default.
|
resort.
|
||||||
6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs
|
6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs
|
||||||
DURATION, exception URIs, series splits), save-conflict detection and reminder
|
DURATION, exception URIs, series splits), save-conflict detection and reminder
|
||||||
delivery (post-before-mark) follow provider-driven rules that are documented
|
delivery (post-before-mark) follow provider-driven rules that are documented
|
||||||
@@ -138,14 +138,14 @@ These are the ones that turn into review comments.
|
|||||||
## UI conventions
|
## UI conventions
|
||||||
|
|
||||||
Material 3 Expressive throughout, built from the system's own tokens and
|
Material 3 Expressive throughout, built from the system's own tokens and
|
||||||
components — colour-scheme tokens rather than hardcoded colours, `ListItem` for
|
components: colour-scheme tokens rather than hardcoded colours, `ListItem` for
|
||||||
settings rows.
|
settings rows.
|
||||||
|
|
||||||
**Selection pickers are full-screen.** Every browse-style "choose one" surface
|
**Selection pickers are full-screen.** Every browse-style "choose one" surface
|
||||||
uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or
|
uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or
|
||||||
extra action passes it through the picker's `actions` slot. The exception is the
|
extra action passes it through the picker's `actions` slot. The exception is the
|
||||||
recurring-scope chooser (*this / this and following / all*), which stays a
|
recurring-scope chooser (*this / this and following / all*), which stays a
|
||||||
compact dialog — a two- or three-option decision reads better as a popup than as
|
compact dialog, because a two- or three-option decision reads better as a popup than as
|
||||||
a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio-
|
a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio-
|
||||||
or text-list dialogs aren't used at all.
|
or text-list dialogs aren't used at all.
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ Shared UI machinery lives in the `floret-kit` submodule and has
|
|||||||
changing it means a pull request against that repository plus a submodule bump
|
changing it means a pull request against that repository plus a submodule bump
|
||||||
here.
|
here.
|
||||||
|
|
||||||
## Commits & pull requests
|
## Commits and pull requests
|
||||||
|
|
||||||
Conventional commits, scoped to the area you touched:
|
Conventional commits, scoped to the area you touched:
|
||||||
|
|
||||||
@@ -165,13 +165,13 @@ docs(architecture): record what the second review pass changed
|
|||||||
```
|
```
|
||||||
|
|
||||||
Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build`
|
Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build`
|
||||||
`revert`. Reference the issue in the subject or the body. Keep commits small —
|
`revert`. Reference the issue in the subject or the body. Keep commits small.
|
||||||
small commits revert cleanly, which matters more here than a tidy history.
|
Small commits revert cleanly, which matters more here than a tidy history.
|
||||||
|
|
||||||
If your change is user-visible, add an entry under `## [Unreleased]` in
|
If your change is user-visible, add an entry under `## [Unreleased]` in
|
||||||
[`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe
|
[`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe
|
||||||
what changed *for the person using the app*, and why, not what changed in the
|
what changed *for the person using the app*, and why. Code changes don't go
|
||||||
code. Link the issue and add its reference at the bottom of the file. It may get
|
there. Link the issue and add its reference at the bottom of the file. It may get
|
||||||
reworded when the release is cut, so don't agonise over it.
|
reworded when the release is cut, so don't agonise over it.
|
||||||
|
|
||||||
Please don't commit planning or design documents. Code, tests, architecture notes
|
Please don't commit planning or design documents. Code, tests, architecture notes
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
<h1>Calendula</h1>
|
<h1>Calendula</h1>
|
||||||
|
|
||||||
<p><strong>A modern Material 3 Expressive calendar for Android.</strong><br>
|
<p><strong>A Material 3 Expressive calendar for Android.</strong><br>
|
||||||
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
It works on top of the system calendar and has no network access.</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://codeberg.org/jlmakiola/calendula/actions"><img src="https://codeberg.org/jlmakiola/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
<a href="https://codeberg.org/jlmakiola/calendula/actions"><img src="https://codeberg.org/jlmakiola/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
||||||
@@ -26,84 +26,89 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="16%" alt="Week view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="13%" alt="Week view">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="16%" alt="Month view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="13%" alt="Month view">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-day.png" width="16%" alt="Day view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-agenda.png" width="13%" alt="Agenda view">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="16%" alt="Event detail">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="13%" alt="Event detail">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-agenda.png" width="16%" alt="Agenda view">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-recurring.png" width="13%" alt="Editing a recurring event">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="16%" alt="Reminder onboarding">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-calendars.png" width="13%" alt="Calendars from your accounts">
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/07-dark.png" width="13%" alt="Dark theme">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Calendula is named after the flower whose name — like the word *calendar* —
|
Calendula is named after the flower. Both its name and the word *calendar*
|
||||||
comes from the Latin *kalendae*, the first day of the month. It lives
|
come from the Latin *kalendae*, the first day of the month.
|
||||||
entirely on top of Android's `CalendarContract`: any calendar synced to your
|
|
||||||
device (CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply
|
|
||||||
appears, and everything you create or edit syncs back the same way. No own
|
|
||||||
database, no sync stack reinvented.
|
|
||||||
|
|
||||||
## ✨ Features
|
The app works directly on Android's `CalendarContract`. Any calendar synced to
|
||||||
|
your device (CalDAV via DAVx5, Google, local, WebCal subscriptions) shows up in
|
||||||
|
Calendula, and whatever you create or edit syncs back the same way. Calendula
|
||||||
|
has no database or sync code of its own.
|
||||||
|
|
||||||
**Calendar**
|
## Features
|
||||||
|
|
||||||
- Month, week, and day views with a one-tap view switcher
|
### Calendar
|
||||||
- Full event details — attendees and their responses, reminders, recurrence
|
|
||||||
(humanized), availability, visibility, foreign time zones
|
|
||||||
- Per-calendar visibility toggle, grouped by account
|
|
||||||
|
|
||||||
**Editing**
|
- Month, week and day views, with a switcher in the top bar
|
||||||
|
- Event details with attendees and their responses, reminders, recurrence in
|
||||||
|
plain words, availability, visibility and foreign time zones
|
||||||
|
- Calendars can be shown or hidden one by one, grouped by account
|
||||||
|
- Your contacts' birthdays, anniversaries and other dates, if you turn it on.
|
||||||
|
Contacts are read on the device and never changed.
|
||||||
|
|
||||||
- Create, edit, and delete events — including recurring events with scoped
|
### Editing
|
||||||
writes: *only this event*, *this and all following*, or *the whole series*
|
|
||||||
- Recurrence picker with one-tap presets and custom rules (interval, weekday
|
|
||||||
toggles, end conditions); rules it can't express are preserved verbatim
|
|
||||||
- Conflict-safe saves: if an event changed elsewhere while you were editing,
|
|
||||||
Calendula asks instead of silently overwriting
|
|
||||||
- Read-only calendars (WebCal, birthdays) are detected and respected
|
|
||||||
|
|
||||||
**Reminders**
|
- Create, edit and delete events. For a recurring event you choose whether a
|
||||||
|
change applies to *only this event*, *this and all following*, or *the whole
|
||||||
|
series*.
|
||||||
|
- The repeat picker has presets and custom rules (interval, weekdays, end
|
||||||
|
condition). Rules it can't show are kept as they are.
|
||||||
|
- If an event changed elsewhere while you were editing it, Calendula asks
|
||||||
|
before saving over it.
|
||||||
|
- Read-only calendars such as WebCal subscriptions and birthdays are detected
|
||||||
|
and can't be edited by accident.
|
||||||
|
|
||||||
- Event reminders delivered by Calendula itself as notifications —
|
### Reminders
|
||||||
essential when it's your only calendar app, since Android delegates
|
|
||||||
reminder delivery to calendar apps
|
|
||||||
- Tap a reminder to land on the event
|
|
||||||
|
|
||||||
**Design & privacy**
|
- Calendula shows event reminders as notifications itself. Android leaves
|
||||||
|
reminder delivery to calendar apps, so if Calendula is your only one, this is
|
||||||
|
where your reminders come from.
|
||||||
|
- Tapping a reminder opens the event.
|
||||||
|
|
||||||
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
### Design and privacy
|
||||||
expressive motion and shapes, light/dark theme
|
|
||||||
- English and German UI plus community translations (Spanish, French, Italian,
|
|
||||||
Polish, and more in progress), per-app language setting — and [open to more
|
|
||||||
languages](#-translations)
|
|
||||||
- **Zero telemetry, zero analytics, no internet permission** — your data
|
|
||||||
never leaves the device
|
|
||||||
|
|
||||||
## 📦 Install
|
- Material 3 Expressive throughout, with dynamic color on Android 12+, light
|
||||||
|
and dark themes, and expressive motion and shapes
|
||||||
|
- Available in 16 languages, with a per-app language setting.
|
||||||
|
[Help translate](#translations).
|
||||||
|
- No telemetry, no analytics and no internet permission. Your data stays on
|
||||||
|
the device.
|
||||||
|
|
||||||
Pick whichever channel you already use — they all install the same app:
|
## Install
|
||||||
|
|
||||||
|
All channels install the same app:
|
||||||
|
|
||||||
| Channel | Updates | Notes |
|
| Channel | Updates | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
|
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
|
||||||
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once |
|
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; add the repo 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 automatic with Obtainium |
|
||||||
| [Google Play](#google-play) | Per release | Signed with Google's key — see below |
|
| [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)
|
||||||
|
|
||||||
Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
|
Calendula is in the official [F-Droid](https://f-droid.org) repository. Search
|
||||||
just search for **Calendula** in any F-Droid client, or
|
for Calendula in any F-Droid client, or
|
||||||
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
||||||
|
|
||||||
F-Droid rebuilds from source on its own schedule, so a new version usually
|
F-Droid builds from source on its own schedule, so a new version usually shows
|
||||||
shows up there a few days after release.
|
up there a few days after release.
|
||||||
|
|
||||||
### Self-hosted F-Droid repo (fastest updates)
|
### Self-hosted F-Droid repo (fastest updates)
|
||||||
|
|
||||||
Every release is built, signed, and published to a self-hosted F-Droid
|
The release pipeline builds, signs and publishes every release to a
|
||||||
repository as part of the release pipeline, so it lands there first. Add it once
|
self-hosted F-Droid repository, so new versions land there first. Add it once
|
||||||
and your F-Droid client handles updates from then on:
|
and your F-Droid client handles updates from then on:
|
||||||
|
|
||||||
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
||||||
@@ -117,76 +122,86 @@ and your F-Droid client handles updates from then on:
|
|||||||
fingerprint (SHA-256):
|
fingerprint (SHA-256):
|
||||||
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
|
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
|
||||||
|
|
||||||
2. Refresh, search for **Calendula**, install.
|
2. Refresh, search for Calendula and install it.
|
||||||
|
|
||||||
### Codeberg release / Obtainium
|
### Codeberg release / Obtainium
|
||||||
|
|
||||||
If you'd rather not use F-Droid at all, every release is also published on
|
Every release is also on
|
||||||
**[Codeberg](https://codeberg.org/jlmakiola/calendula/releases)** with the
|
[Codeberg](https://codeberg.org/jlmakiola/calendula/releases) with the signed
|
||||||
signed APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum attached — download
|
APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum. Download it and install
|
||||||
and install it directly.
|
it directly.
|
||||||
|
|
||||||
For automatic updates from that channel, use
|
For automatic updates from Codeberg, use
|
||||||
**[Obtainium](https://github.com/ImranR98/Obtainium)** — on the phone,
|
[Obtainium](https://github.com/ImranR98/Obtainium). On your phone,
|
||||||
**[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula)**,
|
[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula),
|
||||||
or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula`
|
or add it by hand: *Add App*, paste `https://codeberg.org/jlmakiola/calendula`,
|
||||||
→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new
|
then *Add*. Obtainium then watches the releases and tells you when a new one
|
||||||
one appears.
|
is out.
|
||||||
|
|
||||||
### Google Play
|
### Google Play
|
||||||
|
|
||||||
Calendula is live on Google Play:
|
Calendula is on
|
||||||
**[play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula)**.
|
[Google Play](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula).
|
||||||
|
|
||||||
Play builds are signed with Google's key rather than mine, so switching between
|
Play builds are signed with Google's key rather than mine, so switching between
|
||||||
Play and any other channel requires an uninstall (and with it, a fresh start for
|
Play and any other channel means uninstalling first. You lose the app settings.
|
||||||
app settings — your events live in the system calendar and are unaffected).
|
Your events live in the system calendar and aren't affected.
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
The build is a plain Gradle build with no proprietary dependencies — see
|
It's a plain Gradle build with no proprietary dependencies. See
|
||||||
**[docs/BUILDING.md](docs/BUILDING.md)** (note the `floret-kit` submodule).
|
[docs/BUILDING.md](docs/BUILDING.md), and note the `floret-kit` submodule.
|
||||||
|
|
||||||
<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 share the
|
||||||
the same signing key, so you can switch freely between them without
|
same signing key, so you can switch between them without reinstalling. Google
|
||||||
reinstalling. Google Play is the exception — see above.</sub>
|
Play is the exception (see above).</sub>
|
||||||
|
|
||||||
## 📚 Documentation
|
## Documentation
|
||||||
|
|
||||||
- **[Contributing](CONTRIBUTING.md)** — how to report, propose, and patch
|
- [Contributing](CONTRIBUTING.md): how to report bugs, propose features and send patches
|
||||||
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
- [Building from source](docs/BUILDING.md): requirements and Gradle tasks
|
||||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
- [Architecture](docs/ARCHITECTURE.md): the layers and the main pipelines
|
||||||
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
|
- [Milestones](https://codeberg.org/jlmakiola/calendula/milestones): what has shipped and what's next
|
||||||
|
- [Privacy policy](docs/PRIVACY.md): the source of the page at
|
||||||
|
[jeanlucmakiola.de/calendula/privacy](https://jeanlucmakiola.de/calendula/privacy)
|
||||||
|
|
||||||
## 🤝 Contributing
|
## Contributing
|
||||||
|
|
||||||
Bug reports, ideas, and patches are all welcome on
|
Bug reports, ideas and patches are welcome on
|
||||||
**[Codeberg](https://codeberg.org/jlmakiola/calendula/issues)**.
|
[Codeberg](https://codeberg.org/jlmakiola/calendula/issues).
|
||||||
|
|
||||||
The short version: **start with an issue.** Features get a yes-or-no before they
|
Start with an issue. A feature gets a yes or no before anyone writes code, and
|
||||||
get code, and both features and bugs are assigned a milestone whose
|
features and bugs are assigned a milestone whose `release/vX.Y.Z` branch your
|
||||||
`release/vX.Y.Z` branch your pull request then targets. Typo and docs fixes can
|
pull request targets. Typo and docs fixes can go straight to a pull request.
|
||||||
skip straight to a pull request. Translations don't go through pull requests at
|
Translations go through [Weblate](#translations), not pull requests.
|
||||||
all — [Weblate owns them](#-translations).
|
|
||||||
|
|
||||||
Read **[CONTRIBUTING.md](CONTRIBUTING.md)** before writing code: it covers the
|
Read [CONTRIBUTING.md](CONTRIBUTING.md) before writing code. It covers the
|
||||||
workflow, the build (note the `floret-kit` submodule), and the architectural
|
workflow, the build (including the `floret-kit` submodule) and the
|
||||||
rules a change is reviewed against.
|
architectural rules changes are reviewed against.
|
||||||
|
|
||||||
## 🌍 Translations
|
## Translations
|
||||||
|
|
||||||
Calendula ships in English and German, with community translations in Arabic,
|
Calendula is written in English. Translators on Weblate have added Arabic,
|
||||||
Chinese, French, Italian, Polish, Portuguese, Russian, and Spanish at varying
|
Belarusian, Chinese (Simplified), Czech, French, German, Hungarian, Italian,
|
||||||
degrees of completeness — partial is fine, untranslated strings simply fall back
|
Japanese, Polish, Portuguese (Portugal and Brazil), Russian, Slovak and Spanish,
|
||||||
to English. You're warmly invited to add or finish your language. Translations
|
some of them partly. Untranslated strings fall back to English, so a partial
|
||||||
are managed on a self-hosted **Weblate**:
|
translation is fine. Translations are managed on a self-hosted Weblate:
|
||||||
|
|
||||||
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
[Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)
|
||||||
|
|
||||||
No coding needed — register on the Weblate server, pick (or request) a language,
|
You don't need to code. Register on the Weblate server, pick a language (or
|
||||||
and translate the strings in your browser. You can also reach this link in the
|
request one) and translate in your browser. The app links here from the top of
|
||||||
app from the top of **Settings → App language**.
|
*Settings → App language*.
|
||||||
|
|
||||||
## 📜 License
|
## Privacy
|
||||||
|
|
||||||
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
Calendula has no analytics, tracking, ads, crash-reporting SDK or third-party
|
||||||
|
services. It doesn't have the internet permission, so it can't send anything
|
||||||
|
anywhere. Your events stay in Android's own calendar store.
|
||||||
|
|
||||||
|
[Privacy policy](https://jeanlucmakiola.de/calendula/privacy), written in
|
||||||
|
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE), Jean-Luc Makiola, 2026
|
||||||
|
|||||||
+12
-8
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21904
|
versionCode = 22100
|
||||||
versionName = "2.19.4"
|
versionName = "2.21.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -113,12 +113,16 @@ android {
|
|||||||
lint {
|
lint {
|
||||||
// Community translations are expected to be partial — a missing string
|
// Community translations are expected to be partial — a missing string
|
||||||
// falls back to the English base at runtime — so don't fail the build on
|
// falls back to the English base at runtime — so don't fail the build on
|
||||||
// it. Likewise a translated <plurals> may not fill every CLDR quantity
|
// it. A translated <plurals> may likewise skip a CLDR quantity form its
|
||||||
// form its locale defines (e.g. Arabic needs "zero"); the missing form
|
// locale defines (e.g. Arabic "zero"): Android falls back to "other" for
|
||||||
// falls back to "other" at runtime, so MissingQuantity is informational
|
// any form it cannot find, so MissingQuantity is informational too.
|
||||||
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
// What a translation must NOT skip is "other" itself — nothing falls back
|
||||||
// check_translations.py guards the same invariants with clearer,
|
// for that one, not even the base locale, so it throws
|
||||||
// translator-facing messages.
|
// Resources$NotFoundException at runtime (Codeberg #297/#298).
|
||||||
|
// MissingQuantity doesn't tell the two cases apart, so that invariant is
|
||||||
|
// enforced by scripts/check_translations.py instead, with a clearer
|
||||||
|
// translator-facing message. Stale/extra keys (ExtraTranslation) stay
|
||||||
|
// fatal.
|
||||||
informational += listOf("MissingTranslation", "MissingQuantity")
|
informational += listOf("MissingTranslation", "MissingQuantity")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,13 +32,15 @@ class CalendulaApp : Application() {
|
|||||||
// respecting, on-device; the user submits the report by hand). The
|
// respecting, on-device; the user submits the report by hand). The
|
||||||
// capture/loop-detection/report machinery lives in floret-kit's
|
// capture/loop-detection/report machinery lives in floret-kit's
|
||||||
// core-crash; only the app label + issue-tracker URLs are app-specific.
|
// core-crash; only the app label + issue-tracker URLs are app-specific.
|
||||||
|
// The prefilled issue's own title and body are the kit's, in English:
|
||||||
|
// they are posted to the tracker, so they don't follow the device
|
||||||
|
// language the way the dialog around them does.
|
||||||
CrashReporter.install(
|
CrashReporter.install(
|
||||||
this,
|
this,
|
||||||
CrashConfig(
|
CrashConfig(
|
||||||
appLabel = getString(R.string.app_name),
|
appLabel = getString(R.string.app_name),
|
||||||
newIssueUrl = getString(R.string.report_issue_url),
|
newIssueUrl = getString(R.string.report_issue_url),
|
||||||
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||||
issueTitle = getString(R.string.crash_report_issue_title),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
reconcileAutoBackup()
|
reconcileAutoBackup()
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
@@ -113,11 +114,26 @@ class MainActivity : AppCompatActivity() {
|
|||||||
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||||
Configuration.UI_MODE_NIGHT_YES
|
Configuration.UI_MODE_NIGHT_YES
|
||||||
applyEdgeToEdge()
|
applyEdgeToEdge()
|
||||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
// Android hands the intent that started the task back to onCreate every
|
||||||
|
// time the activity is recreated — on a rotation, or on a restart after
|
||||||
|
// the system reclaimed the process. Re-running a launch that opens one
|
||||||
|
// specific event is what made a single reminder or widget tap re-open
|
||||||
|
// that event on every later launch, an error screen once the event had
|
||||||
|
// been deleted (#309); the user left it long ago, and the activity
|
||||||
|
// restores where they actually were along with its saved state.
|
||||||
|
//
|
||||||
|
// Only those channels are held back. An .ics import or a prefilled
|
||||||
|
// create form keeps no saved state of its own, so re-reading the intent
|
||||||
|
// is what carries one through a recreation.
|
||||||
|
val replaying = savedInstanceState != null || intent.isRelaunch()
|
||||||
|
if (!replaying) {
|
||||||
|
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||||
|
requestedEditKey = intent.editEventKeyOrNull()
|
||||||
|
}
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
|
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
requestedInsert = intent.insertRequestOrNull()
|
requestedInsert = intent.insertRequestOrNull()
|
||||||
requestedEditKey = intent.editEventKeyOrNull()
|
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
@@ -154,6 +170,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// weeks and switching between the week and day view.
|
// weeks and switching between the week and day view.
|
||||||
val timelineZoom = rememberTimelineZoom(
|
val timelineZoom = rememberTimelineZoom(
|
||||||
stored = settings.timelineScale,
|
stored = settings.timelineScale,
|
||||||
|
hours = settings.timelineHours,
|
||||||
onPersist = settingsViewModel::setTimelineScale,
|
onPersist = settingsViewModel::setTimelineScale,
|
||||||
)
|
)
|
||||||
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||||
@@ -171,8 +188,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalUse24HourFormat provides use24Hour,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
LocalShowHourLines provides settings.showHourLines,
|
LocalShowHourGrid provides settings.showHourGrid,
|
||||||
LocalTimelineZoom provides timelineZoom,
|
LocalTimelineZoom provides timelineZoom,
|
||||||
|
LocalMultiDayCount provides settings.multiDayCount,
|
||||||
LocalSoftenColors provides settings.softenColors,
|
LocalSoftenColors provides settings.softenColors,
|
||||||
) {
|
) {
|
||||||
RootScreen(
|
RootScreen(
|
||||||
@@ -242,6 +260,16 @@ class MainActivity : AppCompatActivity() {
|
|||||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this launch is the task being resumed rather than a fresh
|
||||||
|
* delivery — Android sets the flag when an activity is started from the
|
||||||
|
* recents list, where the intent it carries is the one the task was started
|
||||||
|
* with however long ago. `onNewIntent` is the channel a real new request
|
||||||
|
* arrives on while the task lives, so nothing is lost by ignoring these.
|
||||||
|
*/
|
||||||
|
private fun Intent.isRelaunch(): Boolean =
|
||||||
|
flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
|
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
|
||||||
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
||||||
|
|||||||
+28
-15
@@ -1,22 +1,35 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
|
* The colours offered when creating or editing a calendar; ARGB ints for a raw
|
||||||
* `EVENT_COLOR`. The named entries exist for callers that need one specific
|
* `CALENDAR_COLOR` / `EVENT_COLOR`.
|
||||||
* hue (the managed special-dates calendars), so they can't drift from the
|
*
|
||||||
* swatches offered in the colour picker.
|
* Hues rather than final fills: every surface that draws a calendar colour runs
|
||||||
|
* it through the app's own tone pass first, so these are chosen to stay
|
||||||
|
* distinguishable after it. Shared with Agendula, whose list palette these are —
|
||||||
|
* the two apps are the same family and a calendar and a task list picked from
|
||||||
|
* different-looking sets read as different products.
|
||||||
|
*
|
||||||
|
* The named entries exist for callers that need one specific hue (the managed
|
||||||
|
* special-dates calendars), so they can't drift from the swatches on offer.
|
||||||
*/
|
*/
|
||||||
object CalendarColorPalette {
|
object CalendarColorPalette {
|
||||||
val Red = 0xFFD50000.toInt()
|
val Mauve = 0xFF7A5C6B.toInt()
|
||||||
val Orange = 0xFFE67C00.toInt()
|
val Red = 0xFFD7484A.toInt()
|
||||||
val Amber = 0xFFF6BF26.toInt()
|
val Orange = 0xFFE8743B.toInt()
|
||||||
val Green = 0xFF33B679.toInt()
|
val Amber = 0xFFE0A32E.toInt()
|
||||||
val DarkGreen = 0xFF0B8043.toInt()
|
val Olive = 0xFF7CA83E.toInt()
|
||||||
val Blue = 0xFF039BE5.toInt()
|
val Green = 0xFF35A06A.toInt()
|
||||||
val Indigo = 0xFF3F51B5.toInt()
|
val Teal = 0xFF19938C.toInt()
|
||||||
val Purple = 0xFF8E24AA.toInt()
|
val Cyan = 0xFF2A9BC4.toInt()
|
||||||
val Graphite = 0xFF616161.toInt()
|
val Blue = 0xFF3C74C8.toInt()
|
||||||
|
val Indigo = 0xFF6A5CC0.toInt()
|
||||||
|
val Purple = 0xFF9455B8.toInt()
|
||||||
|
val Pink = 0xFFC94F8E.toInt()
|
||||||
|
|
||||||
/** The full palette, in swatch-row order. */
|
/** The full palette, in swatch-grid order. */
|
||||||
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
val all: List<Int> = listOf(
|
||||||
|
Mauve, Red, Orange, Amber, Olive, Green,
|
||||||
|
Teal, Cyan, Blue, Indigo, Purple, Pink,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
val name = displayName.trim()
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
@@ -400,7 +400,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
|
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
|
||||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
val name = displayName.trim()
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||||
put(CalendarContract.Calendars.NAME, name)
|
put(CalendarContract.Calendars.NAME, name)
|
||||||
@@ -464,7 +464,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
val name = displayName.trim()
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
|||||||
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
|
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||||
return CalendarSource(
|
return CalendarSource(
|
||||||
id = getLong(CalendarProjection.IDX_ID),
|
id = getLong(CalendarProjection.IDX_ID),
|
||||||
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
|
// Blank when the provider row has no name: the placeholder is a display
|
||||||
?: Fallbacks.UNNAMED_CALENDAR,
|
// string and belongs to the UI, where it can be localised (#329).
|
||||||
|
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME).orEmpty(),
|
||||||
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
||||||
accountType = accountType,
|
accountType = accountType,
|
||||||
color = getInt(CalendarProjection.IDX_COLOR),
|
color = getInt(CalendarProjection.IDX_COLOR),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
|||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||||
|
|
||||||
private const val TAG = "InstanceMapper"
|
private const val TAG = "InstanceMapper"
|
||||||
|
|
||||||
@@ -20,8 +21,9 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val rawTitle = getString(InstanceProjection.IDX_TITLE)
|
// Left blank when the event has no title: the placeholder is a display
|
||||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
// string, so it belongs to the UI layer where it can be localised (#321).
|
||||||
|
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
|
||||||
|
|
||||||
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
|
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
|
||||||
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
||||||
@@ -39,7 +41,17 @@ 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) ==
|
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe"
|
||||||
|
* counts as going, and everything that is not an open or refused invitation —
|
||||||
|
* your own events included — falls through to [EventResponse.Going].
|
||||||
|
*/
|
||||||
|
internal fun mapEventResponse(raw: Int): EventResponse = when (raw) {
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> EventResponse.Declined
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> EventResponse.Invited
|
||||||
|
else -> EventResponse.Going
|
||||||
|
}
|
||||||
|
|||||||
@@ -312,8 +312,3 @@ internal object ReminderProjection {
|
|||||||
const val IDX_MINUTES = 0
|
const val IDX_MINUTES = 0
|
||||||
const val IDX_METHOD = 1
|
const val IDX_METHOD = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object Fallbacks {
|
|
||||||
const val UNNAMED_CALENDAR = "(Unbenannter Kalender)"
|
|
||||||
const val UNTITLED_EVENT = "(Ohne Titel)"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import android.provider.CalendarContract
|
|
||||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
@@ -22,8 +21,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
|||||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||||
}.coerceAtLeast(dtStart)
|
}.coerceAtLeast(dtStart)
|
||||||
|
|
||||||
val rawTitle = getString(SearchProjection.IDX_TITLE)
|
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
|
||||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
|
||||||
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
||||||
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||||
} else {
|
} else {
|
||||||
@@ -43,7 +41,6 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
|||||||
location = getString(SearchProjection.IDX_LOCATION),
|
location = getString(SearchProjection.IDX_LOCATION),
|
||||||
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||||
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.storageValue
|
import de.jeanlucmakiola.calendula.ui.common.storageValue
|
||||||
@@ -212,14 +213,15 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the week/day timeline draws a faint separator line at each hour
|
* Whether the week/day timeline seats each hour in its own cell (v2.11, a
|
||||||
* (v2.11). Defaults to OFF — the historical clean look; users opt in.
|
* separator line until v2.20). Defaults to OFF — the historical flat column;
|
||||||
|
* users opt in. The stored key keeps its original name.
|
||||||
*/
|
*/
|
||||||
val showHourLines: Flow<Boolean> = store.data.map { prefs ->
|
val showHourGrid: Flow<Boolean> = store.data.map { prefs ->
|
||||||
prefs[SHOW_HOUR_LINES_KEY] ?: false
|
prefs[SHOW_HOUR_LINES_KEY] ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setShowHourLines(enabled: Boolean) {
|
suspend fun setShowHourGrid(enabled: Boolean) {
|
||||||
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +288,36 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many days the multi-day view shows side by side (#193). */
|
||||||
|
val multiDayCount: Flow<Int> = store.data.map { prefs ->
|
||||||
|
(prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setMultiDayCount(count: Int) {
|
||||||
|
store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hours the day, week and multi-day timelines fit to the screen when they
|
||||||
|
* open (#322). Off by default; the hours survive switching it off.
|
||||||
|
*/
|
||||||
|
val timelineHours: Flow<TimelineHours> = store.data.map { prefs ->
|
||||||
|
TimelineHours.of(
|
||||||
|
enabled = prefs[TIMELINE_HOURS_ENABLED_KEY] ?: false,
|
||||||
|
startHour = prefs[TIMELINE_HOURS_START_KEY] ?: TimelineHours.DEFAULT_START_HOUR,
|
||||||
|
endHour = prefs[TIMELINE_HOURS_END_KEY] ?: TimelineHours.DEFAULT_END_HOUR,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTimelineHours(hours: TimelineHours) {
|
||||||
|
val valid = TimelineHours.of(hours.enabled, hours.startHour, hours.endHour)
|
||||||
|
store.edit {
|
||||||
|
it[TIMELINE_HOURS_ENABLED_KEY] = valid.enabled
|
||||||
|
it[TIMELINE_HOURS_START_KEY] = valid.startHour
|
||||||
|
it[TIMELINE_HOURS_END_KEY] = valid.endHour
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
||||||
* historical layout, where it's an extended FAB that fades in above the "+"
|
* historical layout, where it's an extended FAB that fades in above the "+"
|
||||||
@@ -585,6 +617,24 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the wizard's calendar step has been answered (or skipped). */
|
||||||
|
val onboardingCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_CALENDARS_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setOnboardingCalendarsDone(done: Boolean = true) {
|
||||||
|
store.edit { it[ONBOARDING_CALENDARS_KEY] = done }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the wizard's hidden-calendars step has been answered (or skipped). */
|
||||||
|
val onboardingHiddenCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_HIDDEN_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setOnboardingHiddenCalendarsDone(done: Boolean = true) {
|
||||||
|
store.edit { it[ONBOARDING_HIDDEN_KEY] = done }
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether the wizard's backup step has been answered (or skipped). */
|
/** Whether the wizard's backup step has been answered (or skipped). */
|
||||||
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
||||||
@@ -929,10 +979,20 @@ class SettingsPrefs @Inject constructor(
|
|||||||
if (view in config.enabled) view.name else "!${view.name}"
|
if (view in config.enabled) view.name else "!${view.name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep the given order (de-duplicated), then append any views it omits. */
|
/** Keep stored order; insert the new multi-day view after Week on upgrades. */
|
||||||
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
|
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
|
||||||
val ordered = seen.distinct()
|
val ordered = seen.distinct().toMutableList()
|
||||||
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
|
IMPLEMENTED_VIEWS.forEach { view ->
|
||||||
|
if (view !in ordered) {
|
||||||
|
val index = if (view == CalendarView.MultiDay && CalendarView.Week in ordered) {
|
||||||
|
ordered.indexOf(CalendarView.Week) + 1
|
||||||
|
} else {
|
||||||
|
ordered.size
|
||||||
|
}
|
||||||
|
ordered.add(index, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ordered
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -976,6 +1036,10 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
||||||
|
internal val ONBOARDING_CALENDARS_KEY =
|
||||||
|
booleanPreferencesKey("onboarding_calendars_done")
|
||||||
|
internal val ONBOARDING_HIDDEN_KEY =
|
||||||
|
booleanPreferencesKey("onboarding_hidden_calendars_done")
|
||||||
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
||||||
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
||||||
internal val ONBOARDING_MONTH_STYLE_KEY =
|
internal val ONBOARDING_MONTH_STYLE_KEY =
|
||||||
@@ -994,8 +1058,14 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||||
private const val MINUTES_PER_DAY = 1_440
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
|
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
|
||||||
|
internal val MULTI_DAY_COUNT_KEY = intPreferencesKey("multi_day_count")
|
||||||
|
internal val TIMELINE_HOURS_ENABLED_KEY = booleanPreferencesKey("timeline_hours_enabled")
|
||||||
|
internal val TIMELINE_HOURS_START_KEY = intPreferencesKey("timeline_hours_start")
|
||||||
|
internal val TIMELINE_HOURS_END_KEY = intPreferencesKey("timeline_hours_end")
|
||||||
/** Default snooze delay for the notification "Snooze" action. */
|
/** Default snooze delay for the notification "Snooze" action. */
|
||||||
const val DEFAULT_SNOOZE_MINUTES = 10
|
const val DEFAULT_SNOOZE_MINUTES = 10
|
||||||
|
const val DEFAULT_MULTI_DAY_COUNT = 3
|
||||||
|
val MULTI_DAY_COUNTS = 3..6
|
||||||
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
|
||||||
stringPreferencesKey("per_calendar_reminder_override")
|
stringPreferencesKey("per_calendar_reminder_override")
|
||||||
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ class ReminderNotifier @Inject constructor(
|
|||||||
is24Hour = is24Hour,
|
is24Hour = is24Hour,
|
||||||
today = Instant.now().atZone(zone).toLocalDate(),
|
today = Instant.now().atZone(zone).toLocalDate(),
|
||||||
firstDayOfWeek = firstDayOfWeek,
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
|
todayLabel = context.getString(R.string.reminder_day_today),
|
||||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ import java.util.Locale
|
|||||||
* alone would be ambiguous)
|
* alone would be ambiguous)
|
||||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
|
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
|
||||||
* already unambiguous)
|
* already unambiguous)
|
||||||
* - all-day, one day: "11 Jun 2026"
|
* - all-day, one day: "Tomorrow" / "Thu" / "11 Jun 2026" (same relative rules,
|
||||||
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
* plus [todayLabel] — no time range to lean on)
|
||||||
|
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
|
||||||
*
|
*
|
||||||
* All-day instances already carry an explicit date, so they never gain a
|
* All-day instances store UTC midnights with an exclusive end, so they are read
|
||||||
* relative prefix. They store UTC midnights with an exclusive end, so they are
|
* in UTC and the end day is the last *covered* day (issue #308).
|
||||||
* read in UTC and the end day is the last *covered* day.
|
|
||||||
*/
|
*/
|
||||||
fun reminderTimeText(
|
fun reminderTimeText(
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
@@ -43,6 +43,7 @@ fun reminderTimeText(
|
|||||||
is24Hour: Boolean,
|
is24Hour: Boolean,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
firstDayOfWeek: DayOfWeek,
|
firstDayOfWeek: DayOfWeek,
|
||||||
|
todayLabel: String,
|
||||||
tomorrowLabel: String,
|
tomorrowLabel: String,
|
||||||
yesterdayLabel: String,
|
yesterdayLabel: String,
|
||||||
): String {
|
): String {
|
||||||
@@ -53,11 +54,16 @@ fun reminderTimeText(
|
|||||||
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
.minusDays(1)
|
.minusDays(1)
|
||||||
.coerceAtLeast(firstDay)
|
.coerceAtLeast(firstDay)
|
||||||
return if (lastDay == firstDay) {
|
val start = relativeDayPrefix(
|
||||||
dateFormat.format(firstDay)
|
day = firstDay,
|
||||||
} else {
|
today = today,
|
||||||
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
}
|
locale = locale,
|
||||||
|
dateFormat = dateFormat,
|
||||||
|
tomorrowLabel = tomorrowLabel,
|
||||||
|
yesterdayLabel = yesterdayLabel,
|
||||||
|
) ?: todayLabel
|
||||||
|
return if (lastDay == firstDay) start else start + RANGE + dateFormat.format(lastDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||||
|
|||||||
@@ -46,3 +46,19 @@ fun hasSystemHiddenCalendars(
|
|||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
pendingDisabledIds: Set<Long>,
|
pendingDisabledIds: Set<Long>,
|
||||||
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
|
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The failure a calendar list implies, or null when there is something to show.
|
||||||
|
* Distinguishes "the device has no calendars" from "every one of them is
|
||||||
|
* switched off" (#239) — both leave the grid empty, but only the second is
|
||||||
|
* fixed from Settings → Calendars.
|
||||||
|
*
|
||||||
|
* The filter sheet's own hidden set is deliberately not folded in: emptying it
|
||||||
|
* is a deliberate act taken moments earlier, in a sheet that is still one tap
|
||||||
|
* away.
|
||||||
|
*/
|
||||||
|
fun calendarListFailure(calendars: List<CalendarSource>): FailureReason? = when {
|
||||||
|
calendars.isEmpty() -> FailureReason.NoCalendarsConfigured
|
||||||
|
calendars.none { it.isVisibleInSystem } -> FailureReason.AllCalendarsHidden
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
|
||||||
|
/** Lower values appear first in lists and get the upper visible lanes in grids. */
|
||||||
|
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
|
||||||
|
isDeclined -> 3
|
||||||
|
spansMultipleDays(zone) -> 0
|
||||||
|
isAllDay -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
|
||||||
|
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
|
||||||
|
compareBy<EventInstance> { it.displayPriority(zone) }
|
||||||
|
.thenBy { it.start }
|
||||||
|
.thenBy { it.title }
|
||||||
|
.thenBy { it.instanceId }
|
||||||
@@ -68,14 +68,35 @@ data class EventInstance(
|
|||||||
*/
|
*/
|
||||||
val isRecurring: Boolean = false,
|
val isRecurring: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* This device user answered "no" to the invitation
|
* This device user's own answer to the invitation, as far as the grids care
|
||||||
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is
|
* (#180, #230).
|
||||||
* still an appointment someone expects an answer about — but every surface
|
|
||||||
* strikes it through, and it plans no reminders (#180).
|
|
||||||
*/
|
*/
|
||||||
val isDeclined: Boolean = false,
|
val response: EventResponse = EventResponse.Going,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How this device user stands towards an event's invitation
|
||||||
|
* (`Events.SELF_ATTENDEE_STATUS`), reduced to the three cases the calendar
|
||||||
|
* surfaces draw differently (#230).
|
||||||
|
*/
|
||||||
|
enum class EventResponse {
|
||||||
|
/** Your own event, or one you accepted — including a tentative "maybe". */
|
||||||
|
Going,
|
||||||
|
|
||||||
|
/** Invited, no answer given yet: drawn as an outline so it reads as still open. */
|
||||||
|
Invited,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* You answered "no". The event stays on the calendar — it is still an
|
||||||
|
* appointment someone expects an answer about — but every surface strikes it
|
||||||
|
* through, and it plans no reminders (#180).
|
||||||
|
*/
|
||||||
|
Declined,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shorthand for the declined case, which most surfaces test on its own (#180). */
|
||||||
|
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this event has finished relative to [now] — its end is at or before
|
* Whether this event has finished relative to [now] — its end is at or before
|
||||||
* the current instant. An in-progress event (already started but not yet ended)
|
* the current instant. An in-progress event (already started but not yet ended)
|
||||||
@@ -231,6 +252,14 @@ enum class RecurringWriteScope {
|
|||||||
enum class FailureReason {
|
enum class FailureReason {
|
||||||
PermissionRevoked,
|
PermissionRevoked,
|
||||||
NoCalendarsConfigured,
|
NoCalendarsConfigured,
|
||||||
|
AllCalendarsHidden,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendars exist and are switched on, but none can receive an event: every
|
||||||
|
* one is read-only, app-managed, or not synced to this device. Distinct from
|
||||||
|
* [AllCalendarsHidden], which a visibility switch fixes.
|
||||||
|
*/
|
||||||
|
NoImportTarget,
|
||||||
ProviderUnavailable,
|
ProviderUnavailable,
|
||||||
EventNotFound,
|
EventNotFound,
|
||||||
Unknown,
|
Unknown,
|
||||||
|
|||||||
@@ -166,14 +166,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
component != null -> {
|
component != null -> {
|
||||||
|
// A component whose END line never arrives ends at the next
|
||||||
|
// one's BEGIN, not at that one's END: reading to the far END
|
||||||
|
// would fold two events into one body, where the later
|
||||||
|
// properties overwrite the earlier and one event is lost.
|
||||||
val end = indexOfEnd(lines, i + 1, component)
|
val end = indexOfEnd(lines, i + 1, component)
|
||||||
|
val body = indexOfNextComponent(lines, i + 1, end)
|
||||||
parseComponent(
|
parseComponent(
|
||||||
body = lines.subList(i + 1, end),
|
body = lines.subList(i + 1, body),
|
||||||
fileCalendarName = calendarName,
|
fileCalendarName = calendarName,
|
||||||
warnings = warnings,
|
warnings = warnings,
|
||||||
isTask = component == "VTODO",
|
isTask = component == "VTODO",
|
||||||
)?.let(events::add)
|
)?.let(events::add)
|
||||||
i = end + 1
|
i = if (body < end) body else end + 1
|
||||||
}
|
}
|
||||||
line.isBegin("VTIMEZONE") -> {
|
line.isBegin("VTIMEZONE") -> {
|
||||||
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||||
@@ -445,6 +450,23 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||||
|
/**
|
||||||
|
* Where an unterminated component's body has to stop: the next top-level
|
||||||
|
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
|
||||||
|
* nested `VALARM` is part of the body and is not a boundary.
|
||||||
|
*/
|
||||||
|
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int {
|
||||||
|
var i = from
|
||||||
|
while (i < end) {
|
||||||
|
val line = parseContentLine(lines[i])
|
||||||
|
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
|
||||||
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||||
var i = from
|
var i = from
|
||||||
while (i < lines.size) {
|
while (i < lines.size) {
|
||||||
|
|||||||
@@ -1,24 +1,40 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui
|
package de.jeanlucmakiola.calendula.ui
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.PredictiveBackHandler
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
|
import androidx.compose.animation.core.SeekableTransitionState
|
||||||
|
import androidx.compose.animation.core.rememberTransition
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.listSaver
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.screen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInWindow
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
@@ -26,17 +42,39 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.StayingPut
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFab
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarTopBar
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ChromeHost
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ChromeSlot
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalChromeSlot
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
|
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScroll
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewFocus
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewFocusSaver
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
|
||||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.drillToWeek
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||||
@@ -44,16 +82,21 @@ import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
|||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
import de.jeanlucmakiola.calendula.ui.week.TimelineScreen
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the top-level view back stack (spec M1) and swaps between the calendar
|
* Holds the top-level view back stack (spec M1) and swaps between the calendar
|
||||||
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
* screens. Each screen owns its own ViewModel; they all open on the one focused
|
||||||
* pill in their top bars writes back here via [onSelectView].
|
* date held here ([ViewFocus]), and the view-switcher pill in their top bars
|
||||||
|
* writes back here via [onSelectView].
|
||||||
*
|
*
|
||||||
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
|
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
|
||||||
* A lateral switch (pill / drawer) builds a visit history so back retraces it
|
* A lateral switch (pill / drawer) builds a visit history so back retraces it
|
||||||
@@ -70,6 +113,7 @@ import kotlin.time.Clock
|
|||||||
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
||||||
* widget's view.)
|
* widget's view.)
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarHost(
|
fun CalendarHost(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -101,14 +145,25 @@ fun CalendarHost(
|
|||||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||||
mutableStateOf(listOf(defaultView))
|
mutableStateOf(listOf(defaultView))
|
||||||
}
|
}
|
||||||
|
var navigationVersion by remember { mutableIntStateOf(0) }
|
||||||
val view = viewStack.last()
|
val view = viewStack.last()
|
||||||
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
|
// The date every view opens on and carries along (#184); tapping a day
|
||||||
|
// anywhere focuses it and opens the day view there.
|
||||||
|
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
|
||||||
|
|
||||||
// Tapping a day in the month grid opens the day view anchored to that date.
|
val switchScope = rememberCoroutineScope()
|
||||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
|
||||||
|
navigationVersion++
|
||||||
|
viewStack = move(viewStack)
|
||||||
|
}
|
||||||
|
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
|
||||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||||
pendingDayIso = date.toString()
|
focus.navigateTo(date)
|
||||||
viewStack = viewStack.drillToDay()
|
switchTo { it.drillToDay() }
|
||||||
|
}
|
||||||
|
val onOpenWeek: (LocalDate) -> Unit = { date ->
|
||||||
|
focus.navigateTo(date)
|
||||||
|
switchTo { it.drillToWeek() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||||
@@ -196,10 +251,21 @@ fun CalendarHost(
|
|||||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||||
// keeps routing a single event straight into the prefilled create form.
|
// keeps routing a single event straight into the prefilled create form.
|
||||||
var importForceMany by remember { mutableStateOf(false) }
|
var importForceMany by remember { mutableStateOf(false) }
|
||||||
|
// Where a restore came from, so closing the import puts it back: the import
|
||||||
|
// overlays are declared under Backup & restore and the manager, so starting
|
||||||
|
// one has to close them — without this, finishing a restore drops you on the
|
||||||
|
// calendar and the next file means walking in through Settings again.
|
||||||
|
var backupAfterImport by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var calendarsAfterImport by rememberSaveable { mutableStateOf(false) }
|
||||||
|
// One import run. The import VM lives in the Activity's store, so this is
|
||||||
|
// what tells it a *re-import of the same file* is new work and not the run
|
||||||
|
// it already finished; a rotation keeps the number and keeps the result.
|
||||||
|
var importSession by rememberSaveable { mutableStateOf(0) }
|
||||||
LaunchedEffect(requestedImportUri) {
|
LaunchedEffect(requestedImportUri) {
|
||||||
if (requestedImportUri != null) {
|
if (requestedImportUri != null) {
|
||||||
importUri = requestedImportUri
|
importUri = requestedImportUri
|
||||||
importForceMany = false
|
importForceMany = false
|
||||||
|
importSession++
|
||||||
onImportConsumed()
|
onImportConsumed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,13 +313,14 @@ fun CalendarHost(
|
|||||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||||
// view so backing out returns there (then home), not to the default.
|
// view so backing out returns there (then home), not to the default.
|
||||||
LaunchedEffect(widgetNavRequest) {
|
LaunchedEffect(widgetNavRequest) {
|
||||||
|
if (widgetNavRequest != null) navigationVersion++
|
||||||
when (val req = widgetNavRequest) {
|
when (val req = widgetNavRequest) {
|
||||||
is WidgetNavRequest.OpenDate -> {
|
is WidgetNavRequest.OpenDate -> {
|
||||||
// Drill the day view in over the widget's view: drop any overlay
|
// Drill the day view in over the widget's view: drop any overlay
|
||||||
// that would cover it, so the open doesn't land under Settings/form.
|
// that would cover it, so the open doesn't land under Settings/form.
|
||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createDateIso = null
|
createDateIso = null
|
||||||
pendingDayIso = req.dateIso
|
focus.navigateTo(LocalDate.parse(req.dateIso))
|
||||||
// No widget source (an external date tap) roots over the default
|
// No widget source (an external date tap) roots over the default
|
||||||
// home view, so backing out of the day returns home then exits.
|
// home view, so backing out of the day returns home then exits.
|
||||||
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||||
@@ -277,7 +344,7 @@ fun CalendarHost(
|
|||||||
// default home — so backing out returns to the default, then exits.
|
// default home — so backing out returns to the default, then exits.
|
||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createDateIso = null
|
createDateIso = null
|
||||||
pendingDayIso = null
|
focus.navigateTo(localToday())
|
||||||
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||||
onWidgetNavConsumed()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
@@ -328,6 +395,9 @@ fun CalendarHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
// Overlays leave from the predictive-back preview (#184): shrinking on from
|
||||||
|
// where the gesture let go instead of snapping back and sliding off.
|
||||||
|
val overlayExit = predictiveBackExit()
|
||||||
|
|
||||||
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
||||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||||
@@ -335,71 +405,158 @@ fun CalendarHost(
|
|||||||
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
||||||
editKey != null || showSettings || showCalendars || showBackup ||
|
editKey != null || showSettings || showCalendars || showBackup ||
|
||||||
importUri != null || importForm != null
|
importUri != null || importForm != null
|
||||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
// The view switch runs on a seekable transition, so a back gesture can scrub
|
||||||
viewStack = viewStack.dropLast(1)
|
// it (#185): the previous view morphs in under the finger, releasing finishes
|
||||||
|
// the pop and cancelling springs back. Reduced motion keeps a plain back.
|
||||||
|
val viewTransition = remember { SeekableTransitionState(view) }
|
||||||
|
LaunchedEffect(view) { viewTransition.animateTo(view) }
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val viewBackEnabled = !anyOverlayVisible && viewStack.size > 1
|
||||||
|
if (reduceMotion) {
|
||||||
|
BackHandler(enabled = viewBackEnabled) {
|
||||||
|
navigationVersion++
|
||||||
|
viewStack = viewStack.dropLast(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PredictiveBackHandler(enabled = viewBackEnabled) { events ->
|
||||||
|
val previous = viewStack[viewStack.size - 2]
|
||||||
|
try {
|
||||||
|
events.collect { viewTransition.seekTo(it.progress, targetState = previous) }
|
||||||
|
navigationVersion++
|
||||||
|
viewStack = viewStack.dropLast(1)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
withContext(NonCancellable) { viewTransition.animateTo(viewTransition.currentState) }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
// Switching between the month, timeline and agenda screens is lateral
|
||||||
// navigation, so it fades through rather than sliding — paging *within* a
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
// view keeps the directional slide. What both views show morphs across
|
||||||
|
// (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade.
|
||||||
val viewSwitch = fadeThrough()
|
val viewSwitch = fadeThrough()
|
||||||
CompositionLocalProvider(LocalEventMove provides moveScope) {
|
val morphBounds = rememberViewMorphBoundsTransform()
|
||||||
AnimatedContent(
|
val morphFade = rememberCalendarFadeSpec()
|
||||||
targetState = view,
|
val morphClip = remember { BelowTopBarClip() }
|
||||||
|
val stayingPut = remember { StayingPut() }
|
||||||
|
val chromeHost = remember { ChromeHost() }
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) +
|
||||||
|
with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||||
|
val timelineScroll = remember { TimelineScrollMemory() }
|
||||||
|
// A timeline reached from another screen opens on the visible hours
|
||||||
|
// (#322); only a switch between timeline views carries the scroll over.
|
||||||
|
val timelineZoom = LocalTimelineZoom.current
|
||||||
|
LaunchedEffect(view.screen) {
|
||||||
|
if (view.screen != ViewScreen.Timeline && timelineZoom.hours.enabled) {
|
||||||
|
timelineScroll.midnightY = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalEventMove provides moveScope,
|
||||||
|
LocalTimelineScroll provides timelineScroll,
|
||||||
|
LocalViewFocus provides focus,
|
||||||
|
// The failure state's way out when every calendar is switched off
|
||||||
|
// (#239); nothing else in the calendar surfaces navigates here.
|
||||||
|
LocalManageCalendars provides remember { { showCalendars = true } },
|
||||||
|
) {
|
||||||
|
// One drawer, top bar and FAB for every screen (#184): a switch changes
|
||||||
|
// what they say, from what the screen on show publishes, rather than
|
||||||
|
// swapping them for another screen's.
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
// Open only via the menu button where edge-swipe would fight paging;
|
||||||
|
// the agenda has no paging to fight.
|
||||||
|
gesturesEnabled = drawerState.isOpen || view.screen == ViewScreen.Agenda,
|
||||||
|
drawerContent = {
|
||||||
|
CalendarDrawer(
|
||||||
|
currentView = view,
|
||||||
|
currentDate = chromeHost.chrome?.drawerDate ?: focus.date,
|
||||||
|
drawerState = drawerState,
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
|
onSelectView = { target ->
|
||||||
|
// Once the drawer is out of the way, so the switch is seen whole.
|
||||||
|
val requestVersion = ++navigationVersion
|
||||||
|
switchScope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
if (navigationVersion == requestVersion) onSelectView(target)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onJumpToDate = { target ->
|
||||||
|
chromeHost.actions?.onJumpToDate?.invoke(target)
|
||||||
|
switchScope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onSettings = {
|
||||||
|
onOpenSettings()
|
||||||
|
switchScope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
CalendarTopBar(
|
||||||
|
host = chromeHost,
|
||||||
|
selectedView = view,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
onCycleView = { onSelectView(viewStack.last().next(quickSwitchViews)) },
|
||||||
|
onOpenDrawer = { switchScope.launch { drawerState.open() } },
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
|
showTodayButton = todayInToolbar,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
|
||||||
|
) { innerPadding ->
|
||||||
|
SharedTransitionLayout(
|
||||||
|
Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y },
|
||||||
|
) {
|
||||||
|
// Keyed on the screen, not the view: the day, multi-day and week views
|
||||||
|
// are one timeline that resizes between them itself (#184), so switching
|
||||||
|
// among them must not replay this transition.
|
||||||
|
rememberTransition(viewTransition, label = "view-switch").AnimatedContent(
|
||||||
transitionSpec = { viewSwitch },
|
transitionSpec = { viewSwitch },
|
||||||
label = "view-switch",
|
contentKey = { it.screen },
|
||||||
) { currentView ->
|
) { currentView ->
|
||||||
when (currentView) {
|
val screen = currentView.screen
|
||||||
CalendarView.Week -> WeekScreen(
|
val sharedScope = this@SharedTransitionLayout
|
||||||
selectedView = currentView,
|
val visibilityScope = this@AnimatedContent
|
||||||
onSelectView = onSelectView,
|
val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) {
|
||||||
|
if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut)
|
||||||
|
}
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalViewMorph provides viewMorph,
|
||||||
|
LocalChromeSlot provides ChromeSlot(chromeHost, current = screen == view.screen),
|
||||||
|
) {
|
||||||
|
when (screen) {
|
||||||
|
ViewScreen.Timeline -> TimelineScreen(
|
||||||
|
view = currentView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onOpenSearch = onOpenSearch,
|
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
drawerViewOrder = drawerViewOrder,
|
|
||||||
todayInToolbar = todayInToolbar,
|
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
ViewScreen.Month -> MonthScreen(
|
||||||
selectedView = currentView,
|
onOpenDay = onOpenDay,
|
||||||
onSelectView = onSelectView,
|
onOpenWeek = onOpenWeek,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onOpenSearch = onOpenSearch,
|
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
initialDateIso = pendingDayIso,
|
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
drawerViewOrder = drawerViewOrder,
|
|
||||||
todayInToolbar = todayInToolbar,
|
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
ViewScreen.Agenda -> AgendaScreen(
|
||||||
selectedView = currentView,
|
|
||||||
onSelectView = onSelectView,
|
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onOpenSearch = onOpenSearch,
|
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
|
||||||
todayInToolbar = todayInToolbar,
|
|
||||||
)
|
|
||||||
CalendarView.Agenda -> AgendaScreen(
|
|
||||||
selectedView = currentView,
|
|
||||||
onSelectView = onSelectView,
|
|
||||||
onOpenDay = onOpenDay,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onOpenSearch = onOpenSearch,
|
|
||||||
onCreateEvent = onCreateEvent,
|
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
drawerViewOrder = drawerViewOrder,
|
|
||||||
todayInToolbar = todayInToolbar,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,11 +569,12 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showSearch,
|
visible = showSearch,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
SearchScreen(
|
SearchScreen(
|
||||||
onBack = { showSearch = false },
|
onBack = { showSearch = false },
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
visible = showSearch,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,7 +583,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = detailKey != null,
|
visible = detailKey != null,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
activeKey?.let { key ->
|
activeKey?.let { key ->
|
||||||
EventDetailScreen(
|
EventDetailScreen(
|
||||||
@@ -445,6 +603,7 @@ fun CalendarHost(
|
|||||||
importForm = form
|
importForm = form
|
||||||
detailKey = null
|
detailKey = null
|
||||||
},
|
},
|
||||||
|
visible = detailKey != null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -453,7 +612,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = createDateIso != null,
|
visible = createDateIso != null,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
(createDateIso ?: heldCreateIso)?.let { iso ->
|
(createDateIso ?: heldCreateIso)?.let { iso ->
|
||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
@@ -462,6 +621,7 @@ fun CalendarHost(
|
|||||||
onClose = { createDateIso = null },
|
onClose = { createDateIso = null },
|
||||||
onSaved = { createDateIso = null },
|
onSaved = { createDateIso = null },
|
||||||
onManageCalendars = { showCalendars = true },
|
onManageCalendars = { showCalendars = true },
|
||||||
|
visible = createDateIso != null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -470,7 +630,7 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = editKey != null,
|
visible = editKey != null,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
(editKey ?: heldEditKey)?.let { key ->
|
(editKey ?: heldEditKey)?.let { key ->
|
||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
@@ -482,6 +642,7 @@ fun CalendarHost(
|
|||||||
detailKey = null
|
detailKey = null
|
||||||
},
|
},
|
||||||
onManageCalendars = { showCalendars = true },
|
onManageCalendars = { showCalendars = true },
|
||||||
|
visible = editKey != null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,12 +651,13 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showSettings,
|
visible = showSettings,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBack = { showSettings = false },
|
onBack = { showSettings = false },
|
||||||
onManageCalendars = { showCalendars = true },
|
onManageCalendars = { showCalendars = true },
|
||||||
onOpenBackup = { showBackup = true },
|
onOpenBackup = { showBackup = true },
|
||||||
|
visible = showSettings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,11 +666,28 @@ fun CalendarHost(
|
|||||||
importUri?.let { uri ->
|
importUri?.let { uri ->
|
||||||
ImportScreen(
|
ImportScreen(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
|
session = importSession,
|
||||||
forceMany = importForceMany,
|
forceMany = importForceMany,
|
||||||
onClose = { importUri = null },
|
onClose = {
|
||||||
onManageCalendars = { showCalendars = true },
|
importUri = null
|
||||||
|
// Back to the surface the restore started from, ready for
|
||||||
|
// the next file.
|
||||||
|
showCalendars = calendarsAfterImport
|
||||||
|
showBackup = backupAfterImport
|
||||||
|
calendarsAfterImport = false
|
||||||
|
backupAfterImport = false
|
||||||
|
},
|
||||||
|
onManageCalendars = {
|
||||||
|
// The manager is where this leads, so it must not be
|
||||||
|
// reopened underneath on close.
|
||||||
|
calendarsAfterImport = false
|
||||||
|
backupAfterImport = false
|
||||||
|
showCalendars = true
|
||||||
|
},
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
|
calendarsAfterImport = false
|
||||||
|
backupAfterImport = false
|
||||||
importFormSource = ImportSource.File
|
importFormSource = ImportSource.File
|
||||||
importForm = form
|
importForm = form
|
||||||
},
|
},
|
||||||
@@ -530,11 +709,12 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showCalendars,
|
visible = showCalendars,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
CalendarsScreen(
|
CalendarsScreen(
|
||||||
onBack = { showCalendars = false },
|
onBack = { showCalendars = false },
|
||||||
onOpenBackup = { showBackup = true },
|
onOpenBackup = { showBackup = true },
|
||||||
|
visible = showCalendars,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,20 +722,36 @@ fun CalendarHost(
|
|||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showBackup,
|
visible = showBackup,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
BackupScreen(
|
// Settings can open this screen without the manager underneath, so
|
||||||
onBack = { showBackup = false },
|
// its failure states' "Manage calendars" way out has to open the
|
||||||
// Restore runs the normal .ics import, and both this screen and
|
// manager rather than just pop — popping alone lands on Settings
|
||||||
// the manager that can have opened it are declared above the
|
// (#304). Coming from the manager the flag is already set, so this
|
||||||
// import overlays — so both have to step aside.
|
// is the same pop-back there.
|
||||||
onImport = {
|
CompositionLocalProvider(
|
||||||
importUri = it
|
LocalManageCalendars provides {
|
||||||
importForceMany = true
|
showCalendars = true
|
||||||
showBackup = false
|
showBackup = false
|
||||||
showCalendars = false
|
|
||||||
},
|
},
|
||||||
)
|
) {
|
||||||
|
BackupScreen(
|
||||||
|
onBack = { showBackup = false },
|
||||||
|
// Restore runs the normal .ics import, and both this screen
|
||||||
|
// and the manager that can have opened it are declared above
|
||||||
|
// the import overlays — so both have to step aside.
|
||||||
|
onImport = {
|
||||||
|
importUri = it
|
||||||
|
importForceMany = true
|
||||||
|
importSession++
|
||||||
|
backupAfterImport = true
|
||||||
|
calendarsAfterImport = showCalendars
|
||||||
|
showBackup = false
|
||||||
|
showCalendars = false
|
||||||
|
},
|
||||||
|
visible = showBackup,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,3 +761,6 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
|||||||
save = { stack -> stack.map(CalendarView::ordinal) },
|
save = { stack -> stack.map(CalendarView::ordinal) },
|
||||||
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun localToday(): LocalDate =
|
||||||
|
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -13,34 +26,25 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Coffee
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
import androidx.compose.material.icons.filled.Menu
|
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
|
||||||
import androidx.compose.material3.rememberDrawerState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -51,16 +55,13 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
@@ -76,27 +77,22 @@ import kotlin.time.Instant
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AgendaScreen(
|
fun AgendaScreen(
|
||||||
selectedView: CalendarView,
|
|
||||||
onSelectView: (CalendarView) -> Unit,
|
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
|
||||||
onOpenSearch: () -> Unit,
|
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
|
||||||
todayInToolbar: Boolean = false,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AgendaViewModel = hiltViewModel(),
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
// Opens on the focused date and hands back the day scrolled to the top.
|
||||||
|
val focus = LocalViewFocus.current
|
||||||
|
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showRangePicker by remember { mutableStateOf(false) }
|
var showRangePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -106,92 +102,102 @@ fun AgendaScreen(
|
|||||||
}
|
}
|
||||||
val successState = state as? AgendaUiState.Success
|
val successState = state as? AgendaUiState.Success
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
// The agenda window always starts at the anchor, so moving through it is a
|
||||||
drawerState = drawerState,
|
// list scroll, not an anchor change — "today" has to bring the list back to
|
||||||
drawerContent = {
|
// the top as well, and stay offered while it is scrolled away (#305).
|
||||||
CalendarDrawer(
|
val listState = rememberLazyListState()
|
||||||
currentView = selectedView,
|
val scrolledAway by remember {
|
||||||
currentDate = anchor,
|
derivedStateOf {
|
||||||
drawerState = drawerState,
|
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
||||||
viewOrder = drawerViewOrder,
|
}
|
||||||
onSelectView = { view ->
|
}
|
||||||
onSelectView(view)
|
// A new window is rendered from its first day, so the list belongs at the
|
||||||
scope.launch { drawerState.close() }
|
// top. Keyed on the anchor the days arrived with, not the one just
|
||||||
},
|
// requested: scrolling before the new rows compose leaves the list keyed to
|
||||||
onJumpToDate = { target ->
|
// a row that reappears further down, and it follows it there.
|
||||||
viewModel.goToDate(target)
|
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
|
||||||
scope.launch { drawerState.close() }
|
val jumpToToday: () -> Unit = {
|
||||||
},
|
// Off today, moving the anchor is what resets the list (above); on today
|
||||||
onSettings = {
|
// the window doesn't change, so the scroll back is the whole action.
|
||||||
onOpenSettings()
|
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
|
||||||
scope.launch { drawerState.close() }
|
viewModel.goToToday()
|
||||||
},
|
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
|
||||||
)
|
}
|
||||||
},
|
|
||||||
) {
|
PublishChrome(
|
||||||
Scaffold(
|
ScreenChrome(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
title = stringResource(R.string.view_agenda),
|
||||||
topBar = {
|
titleDate = null,
|
||||||
AgendaTopBar(
|
drawerDate = anchor,
|
||||||
selectedView = selectedView,
|
awayFromToday = !isOnToday || (scrolledAway && successState != null),
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
todayText = stringResource(R.string.agenda_today_action),
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
),
|
||||||
onOpenSearch = onOpenSearch,
|
ScreenActions(
|
||||||
showTodayButton = todayInToolbar,
|
onToday = jumpToToday,
|
||||||
onToday = viewModel::goToToday,
|
onJumpToDate = { target ->
|
||||||
scrollBehavior = scrollBehavior,
|
focus?.date = target
|
||||||
)
|
viewModel.goToDate(target)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
onCreate = { onCreateEvent(anchor, null) },
|
||||||
CalendarFabColumn(
|
),
|
||||||
todayVisible = !isOnToday && !todayInToolbar,
|
)
|
||||||
todayText = stringResource(R.string.agenda_today_action),
|
|
||||||
onToday = viewModel::goToToday,
|
ScreenBody(modifier = modifier) { innerPadding ->
|
||||||
onCreate = { onCreateEvent(anchor, null) },
|
Column(
|
||||||
)
|
modifier = Modifier
|
||||||
},
|
.fillMaxSize()
|
||||||
) { innerPadding ->
|
.padding(innerPadding),
|
||||||
Column(
|
) {
|
||||||
modifier = Modifier
|
// One bar at the top: the "showing …" header on the left and the
|
||||||
.fillMaxSize()
|
// session range switcher on the right (one settings toggle).
|
||||||
.padding(innerPadding),
|
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||||
) {
|
// end lines the selector up with whatever ends the top bar:
|
||||||
// One bar at the top: the "showing …" header on the left and the
|
// the view switcher's background, or — once #150 hides it —
|
||||||
// session range switcher on the right (one settings toggle).
|
// the search icon's glyph.
|
||||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
|
||||||
Row(
|
AppBarSpacing.Inset
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
} else {
|
||||||
// end aligns the selector's right edge with the top-bar view
|
AppBarSpacing.IconTrailingInset
|
||||||
// switcher (its 8.dp margin + the app bar's 4.dp inset).
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
|
||||||
) {
|
|
||||||
AgendaRangeBanner(
|
|
||||||
range = s.range,
|
|
||||||
start = s.anchor,
|
|
||||||
end = s.rangeEnd,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
AgendaRangePill(
|
|
||||||
range = s.range,
|
|
||||||
isOverride = s.rangeIsOverride,
|
|
||||||
onClick = { showRangePicker = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
AgendaContent(
|
Row(
|
||||||
state = state,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
pastDisplay = pastDisplay,
|
|
||||||
showToday = showToday,
|
|
||||||
onRetry = viewModel::goToToday,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onOpenDay = onOpenDay,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxWidth()
|
||||||
.fillMaxWidth(),
|
.padding(
|
||||||
)
|
start = RANGE_BAR_TEXT_INSET,
|
||||||
|
end = selectorEnd,
|
||||||
|
top = 8.dp,
|
||||||
|
bottom = 8.dp,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
AgendaRangeBanner(
|
||||||
|
range = s.range,
|
||||||
|
start = s.anchor,
|
||||||
|
end = s.rangeEnd,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
AgendaRangePill(
|
||||||
|
range = s.range,
|
||||||
|
isOverride = s.rangeIsOverride,
|
||||||
|
onClick = { showRangePicker = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
AgendaContent(
|
||||||
|
state = state,
|
||||||
|
pastDisplay = pastDisplay,
|
||||||
|
showToday = showToday,
|
||||||
|
listState = listState,
|
||||||
|
onRetry = viewModel::goToToday,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
onTopDay = if (successState?.anchor == anchor) {
|
||||||
|
{ focus?.date = it }
|
||||||
|
} else null,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +251,9 @@ private fun AgendaRangePill(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Optical start inset for the range bar's text, set against the title above it. */
|
||||||
|
private val RANGE_BAR_TEXT_INSET = 28.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A header naming the concrete window currently shown under a "showing …" label,
|
* A header naming the concrete window currently shown under a "showing …" label,
|
||||||
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
||||||
@@ -284,6 +293,8 @@ internal fun AgendaContent(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
listState: LazyListState = rememberLazyListState(),
|
||||||
|
onTopDay: ((LocalDate) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
AgendaUiState.Loading -> Box(modifier)
|
AgendaUiState.Loading -> Box(modifier)
|
||||||
@@ -312,16 +323,21 @@ internal fun AgendaContent(
|
|||||||
enabled = showToday && state.anchor == state.today,
|
enabled = showToday && state.anchor == state.today,
|
||||||
)
|
)
|
||||||
if (days.isEmpty()) {
|
if (days.isEmpty()) {
|
||||||
|
// Nothing to scroll: drop the position a previous list left, so
|
||||||
|
// the today FAB doesn't linger over an empty screen.
|
||||||
|
LaunchedEffect(Unit) { listState.scrollToItem(0) }
|
||||||
AgendaEmpty(modifier)
|
AgendaEmpty(modifier)
|
||||||
} else {
|
} else {
|
||||||
AgendaList(
|
AgendaList(
|
||||||
days = days,
|
days = days,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
zone = state.zone,
|
zone = state.zone,
|
||||||
|
listState = listState,
|
||||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||||
now = now,
|
now = now,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onTopDay = onTopDay,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -335,14 +351,28 @@ private fun AgendaList(
|
|||||||
days: List<AgendaDay>,
|
days: List<AgendaDay>,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
|
listState: LazyListState,
|
||||||
dimPast: Boolean,
|
dimPast: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onTopDay: ((LocalDate) -> Unit)?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
if (onTopDay != null) {
|
||||||
|
val itemDates by rememberUpdatedState(agendaItemDates(days))
|
||||||
|
val currentOnTopDay by rememberUpdatedState(onTopDay)
|
||||||
|
// The day the list opened on is where the view already is; only a
|
||||||
|
// scroll moves it on.
|
||||||
|
LaunchedEffect(listState) {
|
||||||
|
snapshotFlow { listState.firstVisibleItemIndex }
|
||||||
|
.drop(1)
|
||||||
|
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) }
|
||||||
|
}
|
||||||
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
state = listState,
|
||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
@@ -372,7 +402,7 @@ private fun AgendaList(
|
|||||||
zone = zone,
|
zone = zone,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
dimmed = dimPast && event.hasEnded(now),
|
dimmed = dimPast && event.hasEnded(now),
|
||||||
modifier = animateItemMotion(),
|
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -382,6 +412,13 @@ private fun AgendaList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The day of each item [AgendaList] lays out for [days], in its order. */
|
||||||
|
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
|
||||||
|
days.flatMap { day ->
|
||||||
|
// Header, the placeholder or the events, then the gap.
|
||||||
|
List(2 + day.events.size.coerceAtLeast(1)) { day.date }
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||||
Column(
|
Column(
|
||||||
@@ -403,51 +440,3 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun AgendaTopBar(
|
|
||||||
selectedView: CalendarView,
|
|
||||||
onCycleView: () -> Unit,
|
|
||||||
onOpenDrawer: () -> Unit,
|
|
||||||
onOpenSearch: () -> Unit,
|
|
||||||
showTodayButton: Boolean,
|
|
||||||
onToday: () -> Unit,
|
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
|
||||||
) {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.view_agenda),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onOpenDrawer) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Menu,
|
|
||||||
contentDescription = stringResource(R.string.month_open_menu),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
TodayAction(show = showTodayButton, onToday = onToday)
|
|
||||||
IconButton(onClick = onOpenSearch) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Search,
|
|
||||||
contentDescription = stringResource(R.string.search_action),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ViewSwitcherPill(
|
|
||||||
current = selectedView,
|
|
||||||
onCycle = onCycleView,
|
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||||
@@ -86,11 +87,7 @@ fun groupAgendaDays(
|
|||||||
return byDay.map { (date, dayEvents) ->
|
return byDay.map { (date, dayEvents) ->
|
||||||
AgendaDay(
|
AgendaDay(
|
||||||
date = date,
|
date = date,
|
||||||
events = dayEvents.sortedWith(
|
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
|
||||||
compareByDescending<EventInstance> { it.isAllDay }
|
|
||||||
.thenBy { it.start }
|
|
||||||
.thenBy { it.title },
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -156,9 +157,7 @@ class AgendaViewModel @Inject constructor(
|
|||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
): AgendaUiState {
|
): AgendaUiState {
|
||||||
if (calendars.isEmpty()) {
|
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
|
||||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
|
||||||
}
|
|
||||||
val anchor = params.anchor
|
val anchor = params.anchor
|
||||||
val rangeEnd = anchor.plus(
|
val rangeEnd = anchor.plus(
|
||||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import android.net.Uri
|
|||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -18,6 +20,7 @@ import androidx.compose.material.icons.filled.Schedule
|
|||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
@@ -47,9 +50,10 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
@@ -78,20 +82,16 @@ private val RESTORE_MIME_TYPES = arrayOf(
|
|||||||
fun BackupScreen(
|
fun BackupScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onImport: (Uri) -> Unit,
|
onImport: (Uri) -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val state by viewModel.backupState.collectAsStateWithLifecycle()
|
||||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
// Export covers local calendars only; managed special-dates mirrors are
|
|
||||||
// rebuilt from contacts. Restore can target anything the import picker offers.
|
|
||||||
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
|
||||||
val canImport = calendars.any { it.isEventTarget }
|
|
||||||
|
|
||||||
// Exports everything eligible (null); the per-calendar selector owns its
|
// Exports everything eligible (null); the per-calendar selector owns its
|
||||||
// own launcher.
|
// own launcher.
|
||||||
val createBackup = rememberLauncherForActivityResult(
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
@@ -132,78 +132,43 @@ fun BackupScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_backup),
|
title = stringResource(R.string.settings_section_backup),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
// Loading and failure fill the screen and centre themselves, which they
|
||||||
|
// can only do in an unscrolled column — the scrolling one measures them
|
||||||
|
// against an unbounded height and leaves them hanging under the header.
|
||||||
|
scrollable = state is BackupUiState.Ready,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
HintText(stringResource(R.string.calendars_backup_hint))
|
when (val s = state) {
|
||||||
|
BackupUiState.Loading -> BackupLoading()
|
||||||
if (exportable.isNotEmpty()) {
|
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
|
||||||
GroupedRow(
|
is BackupUiState.Ready -> BackupContent(
|
||||||
title = stringResource(R.string.calendars_backup_action),
|
exportable = s.exportable,
|
||||||
position = Position.Top,
|
canImport = s.canImport,
|
||||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
autoBackup = autoBackup,
|
||||||
onClick = {
|
viewModel = viewModel,
|
||||||
// A single exportable calendar skips the selector.
|
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||||
if (exportable.size == 1) {
|
onPickFolder = { runCatching { pickFolder.launch(null) } },
|
||||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
onExportAll = {
|
||||||
} else {
|
runCatching { createBackup.launch(defaultBackupName()) }
|
||||||
showExportPicker = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
onExportPick = { showExportPicker = true },
|
||||||
GroupedRow(
|
onEditInterval = { showInterval = true },
|
||||||
title = stringResource(R.string.calendars_restore_action),
|
|
||||||
summary = stringResource(R.string.calendars_restore_hint),
|
|
||||||
position = Position.Middle,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
|
||||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup),
|
|
||||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
|
||||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
|
||||||
trailing = {
|
|
||||||
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
|
||||||
},
|
|
||||||
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
|
||||||
)
|
|
||||||
if (autoBackup.enabled) {
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
|
||||||
summary = rememberFolderName(autoBackup.folderUri)
|
|
||||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { runCatching { pickFolder.launch(null) } },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
|
||||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
|
||||||
position = Position.Bottom,
|
|
||||||
onClick = { showInterval = true },
|
|
||||||
)
|
|
||||||
HintText(backupStatusText(autoBackup.status))
|
|
||||||
}
|
|
||||||
} else if (canImport) {
|
|
||||||
// Nothing to back up, but restore is still possible — don't hide
|
|
||||||
// it behind export eligibility.
|
|
||||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
|
||||||
HintText(stringResource(R.string.calendars_restore_hint))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_restore_action),
|
|
||||||
position = Position.Alone,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
|
||||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showExportPicker) {
|
// Gated on Ready rather than resetting the flag when the state leaves it:
|
||||||
ExportCalendarPicker(
|
// flipping it here would be a write during composition.
|
||||||
calendars = exportable,
|
(state as? BackupUiState.Ready)?.let { ready ->
|
||||||
onExport = viewModel::exportBackup,
|
if (showExportPicker) {
|
||||||
onDismiss = { showExportPicker = false },
|
ExportCalendarPicker(
|
||||||
)
|
calendars = ready.exportable,
|
||||||
|
onExport = viewModel::exportBackup,
|
||||||
|
onDismiss = { showExportPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (showInterval) {
|
if (showInterval) {
|
||||||
BackupIntervalDialog(
|
BackupIntervalDialog(
|
||||||
@@ -214,6 +179,103 @@ fun BackupScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The screen's loading state, centred in the scaffold's content column. */
|
||||||
|
@Composable
|
||||||
|
private fun BackupLoading() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default file name for a one-shot export. */
|
||||||
|
private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The working screen: export (when there are local calendars to export), restore,
|
||||||
|
* and the automatic-backup block. Restore is always on screen — when nothing can
|
||||||
|
* receive the events it says so and routes to the calendar manager rather than
|
||||||
|
* disappearing, which is what #304 reported as an invisible button.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BackupContent(
|
||||||
|
exportable: List<CalendarSource>,
|
||||||
|
canImport: Boolean,
|
||||||
|
autoBackup: AutoBackupUiState,
|
||||||
|
viewModel: CalendarsViewModel,
|
||||||
|
onRestore: () -> Unit,
|
||||||
|
onPickFolder: () -> Unit,
|
||||||
|
onExportAll: () -> Unit,
|
||||||
|
onExportPick: () -> Unit,
|
||||||
|
onEditInterval: () -> Unit,
|
||||||
|
) {
|
||||||
|
val manageCalendars = LocalManageCalendars.current
|
||||||
|
// Restore never depends on export eligibility, and never disappears: when no
|
||||||
|
// calendar can receive events it explains that and offers the way to fix it.
|
||||||
|
val restoreSummary = if (canImport) {
|
||||||
|
stringResource(R.string.calendars_restore_hint)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.calendars_restore_unavailable)
|
||||||
|
}
|
||||||
|
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
|
||||||
|
|
||||||
|
if (exportable.isEmpty()) {
|
||||||
|
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||||
|
HintText(restoreSummary)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = onRestoreClick,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
HintText(stringResource(R.string.calendars_backup_hint))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
|
position = Position.Top,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
|
// A single exportable calendar skips the selector.
|
||||||
|
onClick = if (exportable.size == 1) onExportAll else onExportPick,
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
summary = restoreSummary,
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = onRestoreClick,
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup),
|
||||||
|
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||||
|
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||||
|
trailing = {
|
||||||
|
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
||||||
|
)
|
||||||
|
if (autoBackup.enabled) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||||
|
summary = rememberFolderName(autoBackup.folderUri)
|
||||||
|
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = onPickFolder,
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||||
|
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = onEditInterval,
|
||||||
|
)
|
||||||
|
HintText(backupStatusText(autoBackup.status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||||
@@ -252,7 +314,7 @@ private fun ExportCalendarPicker(
|
|||||||
calendars.forEachIndexed { index, calendar ->
|
calendars.forEachIndexed { index, calendar ->
|
||||||
val isSelected = calendar.id in selected
|
val isSelected = calendar.id in selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
summary = calendar.description,
|
summary = calendar.description,
|
||||||
position = positionOf(index, calendars.size),
|
position = positionOf(index, calendars.size),
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State of the Backup & restore screen (#69). Three states, because the calendar
|
||||||
|
* list it is derived from has all three: it arrives empty, it can throw, and
|
||||||
|
* "loaded but nothing to offer" is a real outcome the screen used to render as a
|
||||||
|
* blank page (#304).
|
||||||
|
*/
|
||||||
|
sealed interface BackupUiState {
|
||||||
|
data object Loading : BackupUiState
|
||||||
|
data class Failure(val reason: FailureReason) : BackupUiState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At least one half of the screen works. [exportable] may be empty (restore
|
||||||
|
* only) and [canImport] may be false (export only), but never both — that is
|
||||||
|
* a [Failure].
|
||||||
|
*/
|
||||||
|
data class Ready(
|
||||||
|
val exportable: List<CalendarSource>,
|
||||||
|
val canImport: Boolean,
|
||||||
|
) : BackupUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the screen can offer for this calendar list.
|
||||||
|
*
|
||||||
|
* Export covers the app's own local calendars; managed special-dates mirrors are
|
||||||
|
* rebuilt from contacts, so they are excluded. Restore can target anything the
|
||||||
|
* import picker offers ([isEventTarget]).
|
||||||
|
*
|
||||||
|
* A failure is raised only when *neither* is possible — deliberately not
|
||||||
|
* [calendarListFailure] on its own, which would call an all-hidden device a
|
||||||
|
* failure while its local calendars are still perfectly exportable. When both
|
||||||
|
* halves are dead the list itself usually says why (no calendars, everything
|
||||||
|
* switched off); [FailureReason.NoImportTarget] covers the remaining case, where
|
||||||
|
* calendars exist and are visible but every one of them is read-only, managed or
|
||||||
|
* not synced to the device.
|
||||||
|
*/
|
||||||
|
fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
|
||||||
|
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||||
|
val canImport = calendars.any { it.isEventTarget }
|
||||||
|
if (exportable.isEmpty() && !canImport) {
|
||||||
|
return BackupUiState.Failure(
|
||||||
|
calendarListFailure(calendars) ?: FailureReason.NoImportTarget,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return BackupUiState.Ready(exportable = exportable, canImport = canImport)
|
||||||
|
}
|
||||||
@@ -90,6 +90,7 @@ import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||||
@@ -97,11 +98,25 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
|||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||||
|
|
||||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||||
@@ -118,6 +133,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
|||||||
fun CalendarsScreen(
|
fun CalendarsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenBackup: () -> Unit,
|
onOpenBackup: () -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
@@ -162,6 +178,7 @@ fun CalendarsScreen(
|
|||||||
onConsumeError = viewModel::consumeError,
|
onConsumeError = viewModel::consumeError,
|
||||||
onOpenBackup = onOpenBackup,
|
onOpenBackup = onOpenBackup,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
visible = visible,
|
||||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||||
onSetVisible = viewModel::setCalendarVisible,
|
onSetVisible = viewModel::setCalendarVisible,
|
||||||
@@ -178,6 +195,7 @@ private fun CalendarsList(
|
|||||||
onConsumeError: () -> Unit,
|
onConsumeError: () -> Unit,
|
||||||
onOpenBackup: () -> Unit,
|
onOpenBackup: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
visible: Boolean,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onEdit: (CalendarSource) -> Unit,
|
onEdit: (CalendarSource) -> Unit,
|
||||||
onSetVisible: (Long, Boolean) -> Unit,
|
onSetVisible: (Long, Boolean) -> Unit,
|
||||||
@@ -203,6 +221,7 @@ private fun CalendarsList(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
// What the per-calendar / per-account switches below actually do.
|
// What the per-calendar / per-account switches below actually do.
|
||||||
HintText(stringResource(R.string.calendars_visibility_hint))
|
HintText(stringResource(R.string.calendars_visibility_hint))
|
||||||
@@ -231,7 +250,7 @@ private fun CalendarsList(
|
|||||||
local.forEachIndexed { index, calendar ->
|
local.forEachIndexed { index, calendar ->
|
||||||
val disabled = !calendar.isVisibleInSystem
|
val disabled = !calendar.isVisibleInSystem
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
summary = calendarRowSummary(calendar),
|
summary = calendarRowSummary(calendar),
|
||||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
@@ -239,7 +258,7 @@ private fun CalendarsList(
|
|||||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
trailing = {
|
trailing = {
|
||||||
EnableSwitch(
|
EnableSwitch(
|
||||||
calendarName = calendar.displayName,
|
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
enabled = !disabled,
|
enabled = !disabled,
|
||||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||||
)
|
)
|
||||||
@@ -316,7 +335,7 @@ private fun CalendarsList(
|
|||||||
ordered.forEachIndexed { index, calendar ->
|
ordered.forEachIndexed { index, calendar ->
|
||||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
summary = calendarRowSummary(calendar),
|
summary = calendarRowSummary(calendar),
|
||||||
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
@@ -325,7 +344,7 @@ private fun CalendarsList(
|
|||||||
trailing = if (calendar.hasVisibilitySwitch) {
|
trailing = if (calendar.hasVisibilitySwitch) {
|
||||||
{
|
{
|
||||||
EnableSwitch(
|
EnableSwitch(
|
||||||
calendarName = calendar.displayName,
|
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
enabled = calendar.isVisibleInSystem,
|
enabled = calendar.isVisibleInSystem,
|
||||||
onToggle = { enabled ->
|
onToggle = { enabled ->
|
||||||
onSetVisible(calendar.id, enabled)
|
onSetVisible(calendar.id, enabled)
|
||||||
@@ -343,9 +362,15 @@ private fun CalendarsList(
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
private const val SWATCHES_PER_ROW = 6
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or edit a local calendar: a name field over the palette of calendar
|
||||||
|
* colours, on the family's full-screen sheet with the commit in its title bar.
|
||||||
|
* The same sheet Agendula edits a task list in, so the two read as one product.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun CalendarEditor(
|
internal fun CalendarEditor(
|
||||||
sessionKey: Int,
|
sessionKey: Int,
|
||||||
isNew: Boolean,
|
isNew: Boolean,
|
||||||
initialName: String,
|
initialName: String,
|
||||||
@@ -360,121 +385,50 @@ private fun CalendarEditor(
|
|||||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||||
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
val dark = isSystemInDarkTheme()
|
val focusRequester = remember { FocusRequester() }
|
||||||
val soften = LocalSoftenColors.current
|
|
||||||
|
|
||||||
Scaffold(
|
val valid = name.isNotBlank()
|
||||||
modifier = Modifier
|
val commit = { if (valid) onSave(name.trim(), color, description.trim().ifEmpty { null }) }
|
||||||
.predictiveBack(onBack = onClose)
|
|
||||||
.fillMaxSize()
|
FullScreenPicker(
|
||||||
.background(MaterialTheme.colorScheme.surface),
|
title = stringResource(
|
||||||
topBar = {
|
if (isNew) R.string.calendars_new_title else R.string.calendars_edit_title,
|
||||||
TopAppBar(
|
),
|
||||||
title = {
|
onDismiss = onClose,
|
||||||
Text(
|
predictiveBack = true,
|
||||||
stringResource(
|
actions = {
|
||||||
if (isNew) R.string.calendars_new_title
|
Button(
|
||||||
else R.string.calendars_edit_title,
|
onClick = commit,
|
||||||
),
|
enabled = valid,
|
||||||
)
|
modifier = Modifier.padding(end = 12.dp),
|
||||||
},
|
) { Text(stringResource(R.string.event_edit_save)) }
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onClose) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Close,
|
|
||||||
contentDescription = stringResource(R.string.event_edit_close),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
if (!isNew) {
|
|
||||||
// Disabled rather than hidden while the special-dates
|
|
||||||
// sync owns this calendar; the card below says why.
|
|
||||||
IconButton(
|
|
||||||
onClick = { confirmDelete = true },
|
|
||||||
enabled = !deleteLocked,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Delete,
|
|
||||||
contentDescription = stringResource(R.string.event_detail_delete),
|
|
||||||
tint = if (deleteLocked) {
|
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.error
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Filled save button, matching the event editor's top bar.
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onSave(name.trim(), color, description.trim().ifEmpty { null })
|
|
||||||
},
|
|
||||||
enabled = name.isNotBlank(),
|
|
||||||
modifier = Modifier.padding(end = 12.dp),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.event_edit_save))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) {
|
||||||
Column(
|
if (deleteLocked) {
|
||||||
modifier = Modifier
|
ManagedNote()
|
||||||
.padding(innerPadding)
|
Spacer(Modifier.height(20.dp))
|
||||||
.fillMaxSize()
|
}
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
CalendarNameField(
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
name = name,
|
||||||
) {
|
color = color,
|
||||||
if (deleteLocked) {
|
focusRequester = focusRequester,
|
||||||
EditorCard(
|
onNameChange = { name = it },
|
||||||
icon = Icons.Default.Info,
|
onImeAction = commit,
|
||||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
)
|
||||||
iconAtTop = true,
|
// Inside the picker: it is a Dialog, so a request made from the caller's
|
||||||
) {
|
// composition can run before this field's node exists.
|
||||||
Text(
|
// A new calendar opens with the keyboard up: naming it is the whole task.
|
||||||
text = stringResource(R.string.calendars_managed_delete_locked),
|
LaunchedEffect(isNew) { if (isNew) focusRequester.requestFocus() }
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
Spacer(Modifier.height(20.dp))
|
||||||
)
|
EditorSectionLabel(stringResource(R.string.calendars_color_label))
|
||||||
}
|
CalendarColorGrid(selected = color, onSelect = { color = it })
|
||||||
}
|
|
||||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
|
Spacer(Modifier.height(20.dp))
|
||||||
InlineTextField(
|
EditorSectionLabel(stringResource(R.string.calendars_description_label))
|
||||||
value = name,
|
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
onValueChange = { name = it },
|
Box(Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(16.dp)) {
|
||||||
placeholder = stringResource(R.string.calendars_name_label),
|
|
||||||
textStyle = MaterialTheme.typography.titleLarge,
|
|
||||||
capitalization = KeyboardCapitalization.Sentences,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
EditorCard(
|
|
||||||
icon = Icons.Default.Palette,
|
|
||||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
iconAtTop = true,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.calendars_color_label),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
ColorSwatchRow(
|
|
||||||
colors = CalendarColorPalette.all,
|
|
||||||
selected = color,
|
|
||||||
onSelect = { color = it },
|
|
||||||
dark = dark,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
EditorCard(
|
|
||||||
icon = Icons.AutoMirrored.Filled.Notes,
|
|
||||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
iconAtTop = true,
|
|
||||||
) {
|
|
||||||
InlineTextField(
|
InlineTextField(
|
||||||
value = description,
|
value = description,
|
||||||
onValueChange = { description = it },
|
onValueChange = { description = it },
|
||||||
@@ -485,20 +439,21 @@ private fun CalendarEditor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isNew && !deleteLocked) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
DeleteCalendarRow(onClick = { confirmDelete = true })
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (confirmDelete) {
|
if (confirmDelete) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { confirmDelete = false },
|
onDismissRequest = { confirmDelete = false },
|
||||||
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
|
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
|
||||||
text = {
|
text = { Text(stringResource(R.string.calendars_delete_confirm_message, initialName)) },
|
||||||
Text(stringResource(R.string.calendars_delete_confirm_message, initialName))
|
|
||||||
},
|
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = { confirmDelete = false; onDelete() }) {
|
||||||
confirmDelete = false
|
|
||||||
onDelete()
|
|
||||||
}) {
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.event_detail_delete),
|
stringResource(R.string.event_detail_delete),
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
@@ -514,6 +469,205 @@ private fun CalendarEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The name, with the chosen colour beside it so the two read as one thing. */
|
||||||
|
@Composable
|
||||||
|
private fun CalendarNameField(
|
||||||
|
name: String,
|
||||||
|
color: Int,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
onNameChange: (String) -> Unit,
|
||||||
|
onImeAction: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CalendarMonth,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = eventAccent(color, dark, soften),
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InlineTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = onNameChange,
|
||||||
|
placeholder = stringResource(R.string.calendars_name_label),
|
||||||
|
capitalization = KeyboardCapitalization.Sentences,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
onImeAction = onImeAction,
|
||||||
|
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The palette as two rows of round swatches; the chosen one carries a check.
|
||||||
|
*
|
||||||
|
* Drawn through the app's own tone pass rather than raw, so a swatch is the
|
||||||
|
* colour the calendar's events will actually appear in — the user's "soften
|
||||||
|
* colours" preference included.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CalendarColorGrid(selected: Int, onSelect: (Int) -> Unit) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||||
|
.selectableGroup(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
CalendarColorPalette.all.chunked(SWATCHES_PER_ROW).forEach { row ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
row.forEach { swatch ->
|
||||||
|
ColorSwatch(
|
||||||
|
fill = eventFill(swatch, dark, soften),
|
||||||
|
label = stringResource(swatchLabel(swatch)),
|
||||||
|
selected = swatch == selected,
|
||||||
|
onClick = { onSelect(swatch) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Keeps a short final row's swatches the size a full row's
|
||||||
|
// are, rather than stretching them across the width.
|
||||||
|
repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColorSwatch(
|
||||||
|
fill: Color,
|
||||||
|
label: String,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(fill)
|
||||||
|
.then(
|
||||||
|
if (selected) {
|
||||||
|
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// selectable, not clickable: the swatch carries its chosen state in
|
||||||
|
// semantics too, so the check is not the only cue.
|
||||||
|
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
|
||||||
|
.semantics { contentDescription = label },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = eventInk(fill, alpha = 0.7f),
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Names the swatch for a screen reader — twelve circles are otherwise one label. */
|
||||||
|
@StringRes
|
||||||
|
private fun swatchLabel(argb: Int): Int = when (argb) {
|
||||||
|
CalendarColorPalette.Mauve -> R.string.color_name_mauve
|
||||||
|
CalendarColorPalette.Red -> R.string.color_name_red
|
||||||
|
CalendarColorPalette.Orange -> R.string.color_name_orange
|
||||||
|
CalendarColorPalette.Amber -> R.string.color_name_amber
|
||||||
|
CalendarColorPalette.Olive -> R.string.color_name_olive
|
||||||
|
CalendarColorPalette.Green -> R.string.color_name_green
|
||||||
|
CalendarColorPalette.Teal -> R.string.color_name_teal
|
||||||
|
CalendarColorPalette.Cyan -> R.string.color_name_cyan
|
||||||
|
CalendarColorPalette.Blue -> R.string.color_name_blue
|
||||||
|
CalendarColorPalette.Indigo -> R.string.color_name_indigo
|
||||||
|
CalendarColorPalette.Purple -> R.string.color_name_purple
|
||||||
|
else -> R.string.color_name_pink
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Said in the sheet rather than on a disabled button: the sync owns this one. */
|
||||||
|
@Composable
|
||||||
|
private fun ManagedNote() {
|
||||||
|
GroupedSurface(
|
||||||
|
position = Position.Alone,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Info, contentDescription = null)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_managed_delete_locked),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeleteCalendarRow(onClick: () -> Unit) {
|
||||||
|
GroupedSurface(
|
||||||
|
position = Position.Alone,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
onClick = onClick,
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_detail_delete),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EditorSectionLabel(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The row's supporting line: the states that make this calendar behave unlike a
|
* The row's supporting line: the states that make this calendar behave unlike a
|
||||||
* plain writable one (#76), then its own description.
|
* plain writable one (#76), then its own description.
|
||||||
@@ -556,36 +710,6 @@ private fun EnableSwitch(
|
|||||||
private fun dimIf(disabled: Boolean): Modifier =
|
private fun dimIf(disabled: Boolean): Modifier =
|
||||||
if (disabled) Modifier.alpha(0.38f) else Modifier
|
if (disabled) Modifier.alpha(0.38f) else Modifier
|
||||||
|
|
||||||
/** Tonal field card matching the event editor's design (icon + content). */
|
|
||||||
@Composable
|
|
||||||
private fun EditorCard(
|
|
||||||
icon: ImageVector,
|
|
||||||
iconTint: Color,
|
|
||||||
iconAtTop: Boolean = false,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = iconTint,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(top = if (iconAtTop) 2.dp else 0.dp)
|
|
||||||
.size(24.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
Column(modifier = Modifier.weight(1f)) { content() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One collapsible calendar group rendered as a connected card. The header row is
|
* One collapsible calendar group rendered as a connected card. The header row is
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -56,6 +58,22 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = emptyList(),
|
initialValue = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Backup & restore screen's own view of that list, with the loading and
|
||||||
|
* failure states [calendars] flattens away — it starts empty and catches to
|
||||||
|
* empty, which that screen used to render as a blank page (#304).
|
||||||
|
*/
|
||||||
|
val backupState: StateFlow<BackupUiState> =
|
||||||
|
repository.calendars()
|
||||||
|
.map { backupUiState(it) }
|
||||||
|
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = BackupUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||||
settingsPrefs.autoBackupEnabled,
|
settingsPrefs.autoBackupEnabled,
|
||||||
|
|||||||
@@ -56,12 +56,14 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
|||||||
* account comes from when another account shares the name (#77).
|
* account comes from when another account shares the name (#77).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun accountGroupTitle(group: CalendarAccountGroup): String =
|
fun accountGroupTitle(group: CalendarAccountGroup): String {
|
||||||
if (!group.ambiguous) {
|
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||||
group.label
|
return if (!group.ambiguous) {
|
||||||
|
label
|
||||||
} else {
|
} else {
|
||||||
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
|
stringResource(R.string.calendars_account_from_source, label, sourceAppName(group.accountType))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The human name of the app backing [accountType], falling back to the raw
|
* The human name of the app backing [accountType], falling back to the raw
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trailing-edge and title spacing shared by the four calendar app bars (#165).
|
||||||
|
*
|
||||||
|
* Only the trailing edge and the title's start are shared; the leading edge and
|
||||||
|
* the gaps between the action icons stay on M3's defaults, which derive both
|
||||||
|
* from the icon buttons' width.
|
||||||
|
*/
|
||||||
|
object AppBarSpacing {
|
||||||
|
|
||||||
|
/** Side inset shared by the bars' trailing edge and the month grid. */
|
||||||
|
val Inset = 8.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start inset on the title, taking it clear of the navigation icon M3 places
|
||||||
|
* it flush against. Real padding rather than a negative offset, which would
|
||||||
|
* swallow taps meant for the menu button.
|
||||||
|
*/
|
||||||
|
val TitleInset = 8.dp
|
||||||
|
|
||||||
|
/** M3's own padding around the actions row. */
|
||||||
|
internal val BarPadding = 4.dp
|
||||||
|
|
||||||
|
private val IconButtonSize = 48.dp
|
||||||
|
|
||||||
|
/** M3's icon slot inside an icon button; the today glyph fills the same box. */
|
||||||
|
internal val IconSize = 24.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End padding for a container-backed control that ends the bar, measured to
|
||||||
|
* its background. Coerced because [androidx.compose.foundation.layout.padding]
|
||||||
|
* throws on a negative value.
|
||||||
|
*/
|
||||||
|
val ContainerTrailingInset = (Inset - BarPadding).coerceAtLeast(0.dp)
|
||||||
|
|
||||||
|
/** Screen edge to the glyph of an icon button that ends the bar. */
|
||||||
|
val IconTrailingInset = BarPadding + (IconButtonSize - IconSize) / 2
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common
|
|||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.snap
|
import androidx.compose.animation.core.snap
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -10,24 +11,240 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.layout.ParentDataModifier
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** Gap a timed block leaves to its neighbours in the column. */
|
||||||
|
val BLOCK_OUTER_INSET = 1.dp
|
||||||
|
|
||||||
|
/** Padding between a timed block's edge and its text. */
|
||||||
|
val BLOCK_TEXT_PADDING = 4.dp
|
||||||
|
|
||||||
|
/** The same, above and below — what a block's height has to pay before any text. */
|
||||||
|
val BLOCK_TEXT_INSET = 2.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a timed block of a given height has to spend on text, and what each line
|
||||||
|
* of it costs.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class BlockTextMetrics(
|
||||||
|
/** Padding above and below the text — see [rememberBlockTextMetrics]. */
|
||||||
|
val inset: Dp,
|
||||||
|
/** Height left for text once [inset] is paid at both edges. */
|
||||||
|
val available: Dp,
|
||||||
|
/** What the first line of a title draws in. */
|
||||||
|
val titleLine: Dp,
|
||||||
|
/** What every title line after the first adds. */
|
||||||
|
val titleLeading: Dp,
|
||||||
|
/** What the time label's one line draws in. */
|
||||||
|
val timeLine: Dp,
|
||||||
|
) {
|
||||||
|
/** Whether the block can draw a title at all. */
|
||||||
|
val fitsTitle: Boolean get() = available >= titleLine
|
||||||
|
|
||||||
|
/** Height a title of [lines] lines occupies. */
|
||||||
|
fun titleHeight(lines: Int): Dp =
|
||||||
|
if (lines <= 0) 0.dp else titleLine + titleLeading * (lines - 1)
|
||||||
|
|
||||||
|
/** Title lines that fit [within], which may be none. */
|
||||||
|
fun titleBudget(within: Dp): Int =
|
||||||
|
if (within < titleLine) 0 else 1 + ((within - titleLine) / titleLeading).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The vertical padding a block [height] tall can afford around a title line of
|
||||||
|
* [titleLine].
|
||||||
|
*
|
||||||
|
* The inset is what the block gives up first: breathing room is worth having
|
||||||
|
* where there is room to breathe, but on a block down to its last few pixels a
|
||||||
|
* bare colour chip where a label would have fit reads as a rendering fault. It
|
||||||
|
* tapers rather than snapping, so a pinch closes the gap gradually instead of
|
||||||
|
* dropping it in one frame (#289).
|
||||||
|
*/
|
||||||
|
internal fun blockTextInset(height: Dp, titleLine: Dp): Dp =
|
||||||
|
minOf(BLOCK_TEXT_INSET, (height - titleLine) / 2).coerceAtLeast(0.dp)
|
||||||
|
|
||||||
|
/** Text metrics for a timed block [height] tall. */
|
||||||
|
@Composable
|
||||||
|
fun rememberBlockTextMetrics(height: Dp): BlockTextMetrics {
|
||||||
|
val titleStyle = MaterialTheme.typography.labelMedium
|
||||||
|
val titleLine = rememberTrimmedLineHeight(titleStyle)
|
||||||
|
// Packed, so a second line costs what the first did rather than a whole
|
||||||
|
// Material line box — the gap between two lines of a wrapped title is the
|
||||||
|
// one place a block pays that leading twice (#190).
|
||||||
|
val titleLeading = titleLine
|
||||||
|
val timeLine = rememberTrimmedLineHeight(MaterialTheme.typography.labelSmall.asEventTime())
|
||||||
|
val inset = blockTextInset(height, titleLine)
|
||||||
|
return BlockTextMetrics(
|
||||||
|
inset = inset,
|
||||||
|
available = height - inset * 2,
|
||||||
|
titleLine = titleLine,
|
||||||
|
titleLeading = titleLeading,
|
||||||
|
timeLine = timeLine,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lines [text] needs to render whole at [textWidth], capped at [max].
|
||||||
|
*
|
||||||
|
* Lets a block hand out its height by what the text actually asks for rather
|
||||||
|
* than by what would fit: a title that wants one line should not be given three
|
||||||
|
* that the time label could have used, and a week column is narrower than a
|
||||||
|
* "09:30–11:00" range so the range should not be assumed to want one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int {
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
|
||||||
|
return remember(text, style, widthPx, max, measurer) {
|
||||||
|
if (max <= 1 || widthPx <= 0) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
measurer.measure(
|
||||||
|
text = text,
|
||||||
|
style = style,
|
||||||
|
constraints = Constraints(maxWidth = widthPx),
|
||||||
|
).lineCount.coerceIn(1, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lines the time label may take at [textWidth], out of the [spare] height left
|
||||||
|
* once the title and the label's own first line are paid for.
|
||||||
|
*
|
||||||
|
* A week column is narrower than a "09:30–11:00" range — and a 12-hour
|
||||||
|
* "6:00 PM–7:00 PM" can want four lines — so the label wraps over as many as it
|
||||||
|
* needs rather than lose its end, but only out of lines the title measured
|
||||||
|
* itself as not needing, never one it would have filled.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
|
||||||
|
val timeLineHeight = rememberTrimmedLineHeight(
|
||||||
|
MaterialTheme.typography.labelSmall.asEventTime(),
|
||||||
|
)
|
||||||
|
return blockTextLines(
|
||||||
|
text = label,
|
||||||
|
// The style it is drawn in, or the budget measures a line the label
|
||||||
|
// never uses (#219).
|
||||||
|
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||||
|
textWidth = textWidth,
|
||||||
|
max = timeLinesWithin(spare, timeLineHeight),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lines a time label may take: its own first one, plus every whole [timeLine] in [spare]. */
|
||||||
|
internal fun timeLinesWithin(spare: Dp, timeLine: Dp): Int =
|
||||||
|
if (spare < timeLine || timeLine <= 0.dp) 1 else 1 + (spare / timeLine).toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time range as a timed block draws it.
|
||||||
|
*
|
||||||
|
* Each time stays whole where the column is wide enough for one, so a 12-hour
|
||||||
|
* label never parts "6:00" from its "PM": the range breaks at the dash, which
|
||||||
|
* rides on whichever side has room for it. Where not even one time fits, the
|
||||||
|
* text wraps wherever it can.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberBlockTimeRange(start: String, end: String, textWidth: Dp): String {
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val style = MaterialTheme.typography.labelSmall.asEventTime()
|
||||||
|
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
|
||||||
|
return remember(start, end, style, widthPx, measurer) {
|
||||||
|
blockTimeRange(start, end) {
|
||||||
|
measurer.measure(text = it, style = style, softWrap = false).size.width <= widthPx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun blockTimeRange(start: String, end: String, fits: (String) -> Boolean): String {
|
||||||
|
val from = start.replace(' ', NO_BREAK_SPACE)
|
||||||
|
val to = end.replace(' ', NO_BREAK_SPACE)
|
||||||
|
return when {
|
||||||
|
fits("$from–$to") -> "$from–$to"
|
||||||
|
!fits(from) || !fits(to) -> "$start–$ZERO_WIDTH_SPACE$end"
|
||||||
|
fits("$from–") -> "$from–\n$to"
|
||||||
|
fits("–$to") -> "$from\n–$to"
|
||||||
|
else -> "$from\n$to"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val NO_BREAK_SPACE = ' '
|
||||||
|
private const val ZERO_WIDTH_SPACE = ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A timed block's title, over at most [maxLines], breaking at word boundaries.
|
||||||
|
*
|
||||||
|
* #164 clipped the last line mid-glyph so none of a narrow chip's few
|
||||||
|
* characters went on an ellipsis. On a block that can wrap, whole words read
|
||||||
|
* better than full lines do: "Farmers Market" over two lines beats "Farmer" /
|
||||||
|
* "s Marke". A single line still clips, having nowhere to wrap to.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BlockTitle(
|
||||||
|
title: String,
|
||||||
|
maxLines: Int,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
textDecoration: TextDecoration? = null,
|
||||||
|
fontWeight: FontWeight? = null,
|
||||||
|
) {
|
||||||
|
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
modifier = modifier,
|
||||||
|
style = rememberPackedLines(
|
||||||
|
MaterialTheme.typography.labelMedium
|
||||||
|
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
|
||||||
|
),
|
||||||
|
maxLines = maxLines,
|
||||||
|
overflow = overflow.overflow,
|
||||||
|
softWrap = overflow.softWrap,
|
||||||
|
color = color,
|
||||||
|
textDecoration = textDecoration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A timed block's own time label, crossfaded rather than replaced — the block
|
* A timed block's own time label, crossfaded rather than replaced — the block
|
||||||
* slides to its new slot, so the label shouldn't change in a single frame.
|
* slides to its new slot, so the label shouldn't change in a single frame.
|
||||||
|
*
|
||||||
|
* Overflows like a title does (#164): the "…" costs two characters of a string
|
||||||
|
* that is nothing but characters, so the label clips at the block's edge
|
||||||
|
* instead. [maxLines] lets a narrow column spend spare height on the range
|
||||||
|
* rather than losing its end.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
fun BlockTimeLabel(
|
||||||
|
label: String,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
maxLines: Int = 1,
|
||||||
|
) {
|
||||||
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
||||||
snap()
|
snap()
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||||
}
|
}
|
||||||
|
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||||
|
// Regular weight against the title's medium above it (#219).
|
||||||
|
val style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime())
|
||||||
Crossfade(
|
Crossfade(
|
||||||
targetState = label,
|
targetState = label,
|
||||||
animationSpec = spec,
|
animationSpec = spec,
|
||||||
@@ -36,17 +253,22 @@ fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
|||||||
) { text ->
|
) { text ->
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = style,
|
||||||
maxLines = 1,
|
maxLines = maxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = overflow.overflow,
|
||||||
|
softWrap = overflow.softWrap,
|
||||||
color = color,
|
color = color,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where a timed block sits in its column, after tweening. */
|
/**
|
||||||
|
* Where a timed block sits in its column, after tweening: across as shares of
|
||||||
|
* the column's width, so a column resizing carries its blocks with it (#184),
|
||||||
|
* down in the timeline's own units.
|
||||||
|
*/
|
||||||
@Immutable
|
@Immutable
|
||||||
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y: Dp, val height: Dp)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A timed block's placement, tweened rather than jumped. Continuity comes from
|
* A timed block's placement, tweened rather than jumped. Continuity comes from
|
||||||
@@ -59,17 +281,49 @@ data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
|||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
|
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
|
||||||
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
|
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
|
||||||
LocalTimelineZoom.current.isPinching
|
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||||
) {
|
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||||
snap()
|
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
|
||||||
} else {
|
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
|
||||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
val animatedY by animateDpAsState(y, dpSpec, label = "block-y")
|
||||||
|
val animatedHeight by animateDpAsState(height, dpSpec, label = "block-height")
|
||||||
|
return BlockPlacement(animatedX, animatedWidth, animatedY, animatedHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seats a block across its [placement]'s share of a [LaneColumn]. */
|
||||||
|
fun Modifier.laneSlot(placement: BlockPlacement): Modifier =
|
||||||
|
then(LaneSlot(placement.xFraction, placement.widthFraction))
|
||||||
|
|
||||||
|
private data class LaneSlot(val x: Float, val width: Float) : ParentDataModifier {
|
||||||
|
override fun Density.modifyParentData(parentData: Any?): Any = this@LaneSlot
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A day column's blocks, each measured across the share of the column its
|
||||||
|
* [laneSlot] gives it; anything else fills the column. The shares are applied
|
||||||
|
* at layout time, so the column can change width every frame without its
|
||||||
|
* blocks recomposing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LaneColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||||
|
Layout(content = content, modifier = modifier) { measurables, constraints ->
|
||||||
|
val width = constraints.maxWidth
|
||||||
|
val loose = constraints.copy(minWidth = 0, minHeight = 0)
|
||||||
|
val placed = measurables.map { measurable ->
|
||||||
|
val slot = measurable.parentData as? LaneSlot
|
||||||
|
if (slot == null) {
|
||||||
|
measurable.measure(loose) to 0
|
||||||
|
} else {
|
||||||
|
val left = (slot.x * width).roundToInt()
|
||||||
|
val right = ((slot.x + slot.width) * width).roundToInt()
|
||||||
|
val laneWidth = (right - left).coerceAtLeast(0)
|
||||||
|
measurable.measure(loose.copy(minWidth = laneWidth, maxWidth = laneWidth)) to left
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layout(width, constraints.maxHeight) {
|
||||||
|
placed.forEach { (placeable, x) -> placeable.placeRelative(x, 0) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val animatedX by animateDpAsState(x, spec, label = "block-x")
|
|
||||||
val animatedY by animateDpAsState(y, spec, label = "block-y")
|
|
||||||
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
|
|
||||||
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
|
|
||||||
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a calendar screen shows in the top bar, the FAB and the drawer, which the
|
||||||
|
* host draws once for all of them (#184): switching screens changes what they
|
||||||
|
* say rather than swapping one bar for another.
|
||||||
|
*
|
||||||
|
* @param titleDate the date the title's picker opens on, or null for a title
|
||||||
|
* that is only a label.
|
||||||
|
* @param drawerDate the date the drawer's picker opens on.
|
||||||
|
* @param awayFromToday whether "today" is worth offering.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class ScreenChrome(
|
||||||
|
val title: String,
|
||||||
|
val shortTitle: String = title,
|
||||||
|
val titleDate: LocalDate?,
|
||||||
|
val drawerDate: LocalDate,
|
||||||
|
val awayFromToday: Boolean,
|
||||||
|
val todayText: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What the chrome does for the screen on show; read when used, so not observed. */
|
||||||
|
class ScreenActions(
|
||||||
|
val onToday: () -> Unit,
|
||||||
|
val onJumpToDate: (LocalDate) -> Unit,
|
||||||
|
val onCreate: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
|
||||||
|
@Stable
|
||||||
|
class ChromeHost {
|
||||||
|
var chrome by mutableStateOf<ScreenChrome?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var actions: ScreenActions? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
internal fun publish(chrome: ScreenChrome, actions: ScreenActions) {
|
||||||
|
this.chrome = chrome
|
||||||
|
this.actions = actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The host's chrome, and whether the screen reading it is the one on show rather than one leaving. */
|
||||||
|
class ChromeSlot(val host: ChromeHost, val current: Boolean)
|
||||||
|
|
||||||
|
val LocalChromeSlot = staticCompositionLocalOf<ChromeSlot?> { null }
|
||||||
|
|
||||||
|
/** Hands [chrome] and [actions] to the host, while this screen is the one on show. */
|
||||||
|
@Composable
|
||||||
|
fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
|
||||||
|
val slot = LocalChromeSlot.current ?: return
|
||||||
|
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The top bar every calendar screen shares. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CalendarTopBar(
|
||||||
|
host: ChromeHost,
|
||||||
|
selectedView: CalendarView,
|
||||||
|
quickSwitchViews: List<CalendarView>,
|
||||||
|
onCycleView: () -> Unit,
|
||||||
|
onOpenDrawer: () -> Unit,
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
|
showTodayButton: Boolean,
|
||||||
|
) {
|
||||||
|
val chrome = host.chrome
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
if (chrome != null) {
|
||||||
|
CalendarTitleButton(
|
||||||
|
title = chrome.title,
|
||||||
|
currentDate = chrome.titleDate,
|
||||||
|
onJumpToDate = { host.actions?.onJumpToDate?.invoke(it) },
|
||||||
|
shortTitle = chrome.shortTitle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onOpenDrawer) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Menu,
|
||||||
|
contentDescription = stringResource(R.string.month_open_menu),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
TodayAction(show = showTodayButton, onToday = { host.actions?.onToday?.invoke() })
|
||||||
|
IconButton(onClick = onOpenSearch) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = stringResource(R.string.search_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ViewSwitcherPill(
|
||||||
|
current = selectedView,
|
||||||
|
cycle = quickSwitchViews,
|
||||||
|
onCycle = onCycleView,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||||
|
// it, but here the bar meets the header on the same surface and the
|
||||||
|
// tint is what makes that seam look like a separate block (#186).
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The FAB column every calendar screen shares. */
|
||||||
|
@Composable
|
||||||
|
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
|
||||||
|
val chrome = host.chrome ?: return
|
||||||
|
CalendarFabColumn(
|
||||||
|
todayVisible = chrome.awayFromToday && !todayInToolbar,
|
||||||
|
todayText = chrome.todayText,
|
||||||
|
onToday = { host.actions?.onToday?.invoke() },
|
||||||
|
onCreate = { host.actions?.onCreate?.invoke() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A calendar screen's body under the shared chrome, on the ground the screen's
|
||||||
|
* own Scaffold used to give it; the host's Scaffold has already made room for
|
||||||
|
* the bars, so [content] gets no padding of its own.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ScreenBody(modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit) {
|
||||||
|
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
|
content(PaddingValues())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,7 +91,7 @@ fun CalendarDrawer(
|
|||||||
DrawerSectionHeader(stringResource(R.string.view_section))
|
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||||
viewOrder.forEachIndexed { index, view ->
|
viewOrder.forEachIndexed { index, view ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(view.labelRes),
|
title = view.label(),
|
||||||
position = positionOf(index, viewOrder.size),
|
position = positionOf(index, viewOrder.size),
|
||||||
selected = view == currentView,
|
selected = view == currentView,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
|
|||||||
@@ -1,17 +1,40 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.outlined.EditOff
|
||||||
|
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||||
|
import androidx.compose.material.icons.outlined.EventBusy
|
||||||
|
import androidx.compose.material.icons.outlined.Lock
|
||||||
|
import androidx.compose.material.icons.outlined.SyncProblem
|
||||||
|
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -20,37 +43,168 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen failure state shared by every calendar screen (spec §7).
|
* Full-screen failure state shared by every calendar screen (spec §7).
|
||||||
* One explanation line + one recovery action, never a toast.
|
* A tonal icon, one headline, one supporting line and one recovery action —
|
||||||
|
* never a toast.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
fun CalendarFailure(
|
||||||
val titleRes = when (reason) {
|
reason: FailureReason,
|
||||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
onRetry: () -> Unit,
|
||||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
modifier: Modifier = Modifier,
|
||||||
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
|
) {
|
||||||
FailureReason.Unknown,
|
val context = LocalContext.current
|
||||||
FailureReason.EventNotFound -> R.string.state_failure_unknown
|
val manageCalendars = LocalManageCalendars.current
|
||||||
}
|
val copy = failureCopy(reason)
|
||||||
val actionRes = when (reason) {
|
val onAction: () -> Unit = when (reason) {
|
||||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
|
FailureReason.NoCalendarsConfigured -> {
|
||||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
|
{ context.startCalendarSetup() }
|
||||||
else -> R.string.state_retry
|
}
|
||||||
|
FailureReason.AllCalendarsHidden,
|
||||||
|
FailureReason.NoImportTarget,
|
||||||
|
-> manageCalendars ?: onRetry
|
||||||
|
else -> onRetry
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(32.dp),
|
.padding(horizontal = 32.dp, vertical = 24.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
FailureIcon(icon = copy.icon, isError = copy.isError)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(titleRes),
|
text = stringResource(copy.title),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
copy.body?.let { body ->
|
||||||
FilledTonalButton(onClick = onRetry) {
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(stringResource(actionRes))
|
Text(
|
||||||
|
text = stringResource(body),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
FilledTonalButton(onClick = onAction) {
|
||||||
|
Text(stringResource(copy.action))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The state's mark: the reason's icon in a tonal circle above the headline. */
|
||||||
|
@Composable
|
||||||
|
private fun FailureIcon(icon: ImageVector, isError: Boolean) {
|
||||||
|
val container: Color
|
||||||
|
val content: Color
|
||||||
|
if (isError) {
|
||||||
|
container = MaterialTheme.colorScheme.errorContainer
|
||||||
|
content = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
} else {
|
||||||
|
container = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
content = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(72.dp)
|
||||||
|
.background(container, CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = content,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a reason says and offers. Headline and supporting line are separate so
|
||||||
|
* the explanation reads as body text instead of a headline that runs over three
|
||||||
|
* lines; [isError] tints the mark for the two reasons that are an actual fault
|
||||||
|
* rather than a calendar the user can switch back on.
|
||||||
|
*/
|
||||||
|
private data class FailureCopy(
|
||||||
|
val icon: ImageVector,
|
||||||
|
@StringRes val title: Int,
|
||||||
|
@StringRes val body: Int?,
|
||||||
|
@StringRes val action: Int,
|
||||||
|
val isError: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun failureCopy(reason: FailureReason): FailureCopy = when (reason) {
|
||||||
|
FailureReason.PermissionRevoked -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.Lock,
|
||||||
|
title = R.string.state_failure_permission,
|
||||||
|
body = R.string.state_failure_permission_body,
|
||||||
|
action = R.string.state_failure_permission_action,
|
||||||
|
)
|
||||||
|
FailureReason.NoCalendarsConfigured -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.CalendarMonth,
|
||||||
|
title = R.string.state_failure_no_calendars,
|
||||||
|
body = R.string.state_failure_no_calendars_body,
|
||||||
|
action = R.string.state_failure_no_calendars_action,
|
||||||
|
)
|
||||||
|
FailureReason.AllCalendarsHidden -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.VisibilityOff,
|
||||||
|
title = R.string.state_failure_all_hidden,
|
||||||
|
body = R.string.state_failure_all_hidden_body,
|
||||||
|
action = R.string.state_failure_all_hidden_action,
|
||||||
|
)
|
||||||
|
FailureReason.NoImportTarget -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.EditOff,
|
||||||
|
title = R.string.state_failure_no_import_target,
|
||||||
|
body = R.string.state_failure_no_import_target_body,
|
||||||
|
action = R.string.state_failure_all_hidden_action,
|
||||||
|
)
|
||||||
|
FailureReason.EventNotFound -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.EventBusy,
|
||||||
|
title = R.string.state_failure_event_not_found,
|
||||||
|
body = R.string.state_failure_event_not_found_body,
|
||||||
|
action = R.string.state_retry,
|
||||||
|
)
|
||||||
|
FailureReason.ProviderUnavailable -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.SyncProblem,
|
||||||
|
title = R.string.state_failure_provider,
|
||||||
|
body = R.string.state_failure_provider_body,
|
||||||
|
action = R.string.state_retry,
|
||||||
|
isError = true,
|
||||||
|
)
|
||||||
|
FailureReason.Unknown -> FailureCopy(
|
||||||
|
icon = Icons.Outlined.ErrorOutline,
|
||||||
|
title = R.string.state_failure_unknown,
|
||||||
|
body = null,
|
||||||
|
action = R.string.state_retry,
|
||||||
|
isError = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the headline and its supporting line at a readable measure. */
|
||||||
|
private val TEXT_MAX_WIDTH = 320.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens Settings → Calendars, the only screen that can switch a calendar back
|
||||||
|
* on. Null outside the calendar host — screens without it never raise the
|
||||||
|
* reason that needs it, and fall back to their own retry.
|
||||||
|
*/
|
||||||
|
val LocalManageCalendars = compositionLocalOf<(() -> Unit)?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the system screen for putting a calendar on the device: the account
|
||||||
|
* chooser filtered to calendar providers, falling back to account settings
|
||||||
|
* where no chooser resolves. Both are system components, so nothing here needs
|
||||||
|
* a `<queries>` entry.
|
||||||
|
*/
|
||||||
|
private fun Context.startCalendarSetup() {
|
||||||
|
val intents = listOf(
|
||||||
|
Intent(Settings.ACTION_ADD_ACCOUNT)
|
||||||
|
.putExtra(Settings.EXTRA_AUTHORITIES, arrayOf(CalendarContract.AUTHORITY)),
|
||||||
|
Intent(Settings.ACTION_SYNC_SETTINGS),
|
||||||
|
)
|
||||||
|
intents.firstOrNull { runCatching { startActivity(it) }.isSuccess }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.core.VectorConverter
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.lerp
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlin.math.floor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One height for every page of something that varies from page to page — the
|
||||||
|
* timelines' all-day strip, the split grid's rows — blended between the two
|
||||||
|
* pages a swipe sits between, so what sits below it moves with the finger rather
|
||||||
|
* than jumping on settle. It follows the swipe directly and only springs when a
|
||||||
|
* page's own height changes at rest. [fallback] stands in until the pages have
|
||||||
|
* reported theirs, so it doesn't open from zero.
|
||||||
|
*
|
||||||
|
* @param heights each composed page's own height, by page index.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun pagedHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp {
|
||||||
|
val currentFallback by rememberUpdatedState(fallback)
|
||||||
|
val target by remember(pagerState, heights) {
|
||||||
|
derivedStateOf {
|
||||||
|
val position = pagerState.currentPage + pagerState.currentPageOffsetFraction
|
||||||
|
val from = floor(position).toInt()
|
||||||
|
val a = heights[from]
|
||||||
|
val b = heights[from + 1]
|
||||||
|
lerp(a ?: b ?: currentFallback, b ?: a ?: currentFallback, position - from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val height = remember { Animatable(target, Dp.VectorConverter) }
|
||||||
|
LaunchedEffect(height) {
|
||||||
|
snapshotFlow { target to pagerState.isScrollInProgress }.collectLatest { (h, scrolling) ->
|
||||||
|
if (scrolling) height.snapTo(h) else height.animateTo(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return height.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A deliberate jump (Today, jump-to-date): animated, or snapped under reduced motion. */
|
||||||
|
suspend fun PagerState.jumpToPage(
|
||||||
|
page: Int,
|
||||||
|
reduceMotion: Boolean,
|
||||||
|
spec: FiniteAnimationSpec<Float>,
|
||||||
|
) {
|
||||||
|
if (reduceMotion) scrollToPage(page) else animateScrollToPage(page, animationSpec = spec)
|
||||||
|
}
|
||||||
@@ -116,7 +116,7 @@ private fun CalendarPickerGroup(
|
|||||||
calendars.forEachIndexed { index, calendar ->
|
calendars.forEachIndexed { index, calendar ->
|
||||||
val isSelected = calendar.id == selectedId
|
val isSelected = calendar.id == selectedId
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drag distance that turns a calendar page, shared by the month, week and day
|
|
||||||
* views so all three answer a swipe at the same point.
|
|
||||||
*
|
|
||||||
* It was 6dp once, which is inside the distance a tap wanders: brushing the grid
|
|
||||||
* changed the month, and a page that turns on an unintended gesture reads as the
|
|
||||||
* animation misfiring rather than as the gesture being over-eager.
|
|
||||||
*/
|
|
||||||
val CALENDAR_SWIPE_THRESHOLD = 24.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The whole-page horizontal swipe: one page per gesture, committed **the moment
|
|
||||||
* the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts.
|
|
||||||
*
|
|
||||||
* Waiting for the lift meant the page sat still under a finger that had already
|
|
||||||
* travelled far enough to ask for it, and the answer only arrived once you let
|
|
||||||
* go — which reads as the view being slow rather than as a deliberate commit.
|
|
||||||
* Firing on the threshold is what makes the gesture feel like it is being
|
|
||||||
* followed. The trade is that a drag can no longer be taken back by dragging the
|
|
||||||
* other way; in practice, once you have moved 24dp deliberately you meant it, and
|
|
||||||
* the page you land on is one swipe back.
|
|
||||||
*
|
|
||||||
* Deliberately **horizontal-only**. The week and day timelines scroll vertically
|
|
||||||
* underneath this, and a two-dimensional detector here would claim those drags
|
|
||||||
* before the inner scroll ever saw them. As it is, a horizontal drag crosses this
|
|
||||||
* detector's slop while a vertical one is consumed below, and the two coexist.
|
|
||||||
* (The month view's split style needs a vertical axis as well, so it keeps its
|
|
||||||
* own axis-locking detector rather than using this.)
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberCalendarPageSwipe(
|
|
||||||
onSwipeNext: () -> Unit,
|
|
||||||
onSwipePrev: () -> Unit,
|
|
||||||
): Modifier {
|
|
||||||
val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() }
|
|
||||||
return Modifier.pointerInput(onSwipeNext, onSwipePrev) {
|
|
||||||
var accum = 0f
|
|
||||||
// One page per gesture: without this a long drag would keep re-firing
|
|
||||||
// every time the accumulator crossed the threshold again.
|
|
||||||
var fired = false
|
|
||||||
detectHorizontalDragGestures(
|
|
||||||
onDragStart = {
|
|
||||||
accum = 0f
|
|
||||||
fired = false
|
|
||||||
},
|
|
||||||
onDragEnd = {
|
|
||||||
accum = 0f
|
|
||||||
fired = false
|
|
||||||
},
|
|
||||||
onDragCancel = {
|
|
||||||
accum = 0f
|
|
||||||
fired = false
|
|
||||||
},
|
|
||||||
onHorizontalDrag = { _, drag ->
|
|
||||||
accum += drag
|
|
||||||
if (!fired) {
|
|
||||||
val commit = when {
|
|
||||||
accum < -threshold -> onSwipeNext
|
|
||||||
accum > threshold -> onSwipePrev
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (commit != null) {
|
|
||||||
fired = true
|
|
||||||
commit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.text.style.LineHeightStyle
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
|
||||||
|
* a 16sp line box — room a label wants when it stands on its own, and close to
|
||||||
|
* a fifth of an event row when it doesn't. The outer edges only, so a wrapped
|
||||||
|
* title keeps its interior line spacing (#190).
|
||||||
|
*
|
||||||
|
* Trimmed rather than set to a smaller line height: the font picker can load a
|
||||||
|
* serif, a monospace or a file of the user's own, and a line height under a
|
||||||
|
* face's own ascent and descent overlaps its lines. There is nothing to trim
|
||||||
|
* below that, so this is safe whatever font is chosen.
|
||||||
|
*/
|
||||||
|
private val TrimmedLines = LineHeightStyle(
|
||||||
|
alignment = LineHeightStyle.Alignment.Center,
|
||||||
|
trim = LineHeightStyle.Trim.Both,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Ascenders and descenders both, so a line is measured at its full extent. */
|
||||||
|
private const val LINE_SAMPLE = "Ag"
|
||||||
|
|
||||||
|
/** [this] with Material's outer leading trimmed — see [TrimmedLines]. */
|
||||||
|
fun TextStyle.trimmedLines(): TextStyle = copy(lineHeightStyle = TrimmedLines)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [style] with its wrapped lines packed onto the face's own extent instead of
|
||||||
|
* Material's line box.
|
||||||
|
*
|
||||||
|
* [trimmedLines] takes the leading off the outer edges of a run of text; this
|
||||||
|
* takes it from between the lines as well, which is the half a wrapped event
|
||||||
|
* title pays for twice over. The line height is *measured* from the font rather
|
||||||
|
* than picked, so it lands exactly on the face's ascent-plus-descent and can
|
||||||
|
* never be short enough to overlap — whatever the font picker has loaded (#190).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberPackedLines(style: TextStyle): TextStyle {
|
||||||
|
val line = rememberTrimmedLineHeight(style)
|
||||||
|
return with(LocalDensity.current) { style.trimmedLines().copy(lineHeight = line.toSp()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What one trimmed line of [style] actually draws in. */
|
||||||
|
@Composable
|
||||||
|
fun rememberTrimmedLineHeight(style: TextStyle): Dp {
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
return remember(style, density, measurer) {
|
||||||
|
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -9,16 +18,19 @@ import androidx.compose.material.icons.filled.ArrowDropDown
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -29,19 +41,20 @@ import kotlinx.datetime.LocalDate
|
|||||||
* drawer uses and reporting the chosen day through [onJumpToDate].
|
* drawer uses and reporting the chosen day through [onJumpToDate].
|
||||||
*
|
*
|
||||||
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
||||||
* visible day, week start or month anchor).
|
* visible day, week start or month anchor); null makes the title a plain label,
|
||||||
|
* as the agenda's is, and the caret folds away.
|
||||||
*
|
*
|
||||||
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
|
* [shortTitle] replaces [title] when the full one does not fit the width the app
|
||||||
* start alignment: M3 places the title flush against the navigation icon's
|
* bar hands the title slot (#165). Either way the line clamps to one and
|
||||||
* trailing edge, and the title is hit-tested on top of it, so a negative offset
|
* ellipsises, so pass a [shortTitle] wherever there is something left to drop.
|
||||||
* would swallow taps meant for the menu button.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarTitleButton(
|
fun CalendarTitleButton(
|
||||||
title: String,
|
title: String,
|
||||||
currentDate: LocalDate,
|
currentDate: LocalDate?,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
shortTitle: String = title,
|
||||||
) {
|
) {
|
||||||
var showDatePicker by rememberSaveable { mutableStateOf(false) }
|
var showDatePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -50,25 +63,56 @@ fun CalendarTitleButton(
|
|||||||
.heightIn(min = 48.dp)
|
.heightIn(min = 48.dp)
|
||||||
.clip(MaterialTheme.shapes.large)
|
.clip(MaterialTheme.shapes.large)
|
||||||
.clickable(
|
.clickable(
|
||||||
|
enabled = currentDate != null,
|
||||||
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
||||||
role = Role.Button,
|
role = Role.Button,
|
||||||
) { showDatePicker = true }
|
) { showDatePicker = true }
|
||||||
.padding(horizontal = 8.dp),
|
.padding(horizontal = AppBarSpacing.TitleInset),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
val style = MaterialTheme.typography.titleLarge
|
||||||
text = title,
|
BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) {
|
||||||
style = MaterialTheme.typography.titleLarge,
|
val measurer = rememberTextMeasurer()
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
val shown = remember(title, shortTitle, style, constraints.maxWidth, measurer) {
|
||||||
)
|
titleFor(
|
||||||
Icon(
|
title = title,
|
||||||
imageVector = Icons.Default.ArrowDropDown,
|
shortTitle = shortTitle,
|
||||||
contentDescription = null,
|
titleWidth = measurer.measure(title, style).size.width,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
availableWidth = constraints.maxWidth,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
// Cross-fades, so switching views or paging turns the title over
|
||||||
|
// rather than snapping it (#184).
|
||||||
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = shown,
|
||||||
|
transitionSpec = {
|
||||||
|
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) { text ->
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = style,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = currentDate != null,
|
||||||
|
enter = fadeIn(rememberCalendarFadeSpec()) + expandHorizontally(),
|
||||||
|
exit = fadeOut(rememberCalendarFadeSpec()) + shrinkHorizontally(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDatePicker) {
|
if (showDatePicker && currentDate != null) {
|
||||||
CalendarDatePickerDialog(
|
CalendarDatePickerDialog(
|
||||||
initial = currentDate,
|
initial = currentDate,
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
@@ -79,3 +123,11 @@ fun CalendarTitleButton(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Falls back to [shortTitle] when [title] is wider than [availableWidth] (#165). */
|
||||||
|
internal fun titleFor(
|
||||||
|
title: String,
|
||||||
|
shortTitle: String,
|
||||||
|
titleWidth: Int,
|
||||||
|
availableWidth: Int,
|
||||||
|
): String = if (titleWidth <= availableWidth) title else shortTitle
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.ContentTransform
|
|
||||||
import androidx.compose.animation.SizeTransform
|
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -19,12 +13,12 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
* the reduce-motion check — now live in floret-kit's identity module
|
* the reduce-motion check — now live in floret-kit's identity module
|
||||||
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
|
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
|
||||||
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
|
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
|
||||||
* here is only what's specific to paging the calendar grid: the directional
|
* here is only what's specific to paging the calendar grid: the specs its
|
||||||
* month/week/day slide and the specs that feed it.
|
* pagers settle with, and that the view switch reuses.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The M3 Expressive spatial spring used for the month/week/day slide: the
|
* The M3 Expressive spatial spring the calendar's motion is built on: the
|
||||||
* *default* spring-physics spec from the active motion scheme, rather than a
|
* *default* spring-physics spec from the active motion scheme, rather than a
|
||||||
* fixed easing curve.
|
* fixed easing curve.
|
||||||
*
|
*
|
||||||
@@ -40,6 +34,12 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||||
MaterialTheme.motionScheme.defaultSpatialSpec()
|
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||||
|
|
||||||
|
/** The same spring as [rememberCalendarSlideSpec], for a pager settling onto its page. */
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
|
||||||
|
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The effects spec from the active motion scheme, for the opacity half of the
|
* The effects spec from the active motion scheme, for the opacity half of the
|
||||||
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||||
@@ -55,48 +55,17 @@ fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
|||||||
MaterialTheme.motionScheme.defaultEffectsSpec()
|
MaterialTheme.motionScheme.defaultEffectsSpec()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
|
* A switch between views (#184): the default spatial spring, a little softer, as
|
||||||
* outgoing page slides and fades one way while the incoming one arrives from the
|
* the whole screen reshapes rather than a page sliding by; softer still when
|
||||||
* other, position on a spring and opacity on an easing curve.
|
* [long], for a change the eye has further to follow.
|
||||||
*
|
|
||||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
|
||||||
* (e.g. "today"); a jump reuses the forward direction.
|
|
||||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
|
||||||
* @param fadeSpec effects spec for the opacity half, and for the whole
|
|
||||||
* transition under reduced motion; typically
|
|
||||||
* [rememberCalendarFadeSpec].
|
|
||||||
* @param reduceMotion when true, drop the movement and cross-fade alone.
|
|
||||||
*/
|
*/
|
||||||
fun calendarSlideTransition(
|
@Composable
|
||||||
slideDir: Int,
|
fun <T> rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec<T> =
|
||||||
spec: FiniteAnimationSpec<IntOffset>,
|
spring(
|
||||||
fadeSpec: FiniteAnimationSpec<Float>,
|
dampingRatio = VIEW_SWITCH_DAMPING,
|
||||||
reduceMotion: Boolean,
|
stiffness = if (long) LONG_VIEW_SWITCH_STIFFNESS else VIEW_SWITCH_STIFFNESS,
|
||||||
): ContentTransform {
|
|
||||||
if (reduceMotion) {
|
|
||||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
|
||||||
}
|
|
||||||
val dir = if (slideDir == 0) 1 else slideDir
|
|
||||||
return ContentTransform(
|
|
||||||
targetContentEnter =
|
|
||||||
slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
|
|
||||||
initialContentExit =
|
|
||||||
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
|
|
||||||
// AnimatedContent clips to the animating container by default, which
|
|
||||||
// shears the pages against the viewport edge as they pass. There is no
|
|
||||||
// size change here to contain — both pages are the same grid.
|
|
||||||
sizeTransform = SizeTransform(clip = false),
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
private const val VIEW_SWITCH_DAMPING = 0.9f
|
||||||
* How far a page travels, as a fraction of the container width.
|
private const val VIEW_SWITCH_STIFFNESS = 450f
|
||||||
*
|
private const val LONG_VIEW_SWITCH_STIFFNESS = 340f
|
||||||
* A full width was the obvious reading of "paging", but the two pages are
|
|
||||||
* stacked and both opaque, so a full-width slide showed one grid racing across
|
|
||||||
* another — the movement carried the whole transition and had a long way to go.
|
|
||||||
* Under M3's shared-axis pattern the offset only has to *hint* the direction
|
|
||||||
* while the cross-fade does the swapping, so a fifth of the width is plenty and
|
|
||||||
* leaves nothing skating past.
|
|
||||||
*/
|
|
||||||
private const val SLIDE_TRAVEL_DIVISOR = 5
|
|
||||||
|
|||||||
@@ -1,37 +1,53 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CalendarViewDay
|
import androidx.compose.material.icons.filled.CalendarViewDay
|
||||||
import androidx.compose.material.icons.filled.CalendarViewMonth
|
import androidx.compose.material.icons.filled.CalendarViewMonth
|
||||||
import androidx.compose.material.icons.filled.CalendarViewWeek
|
import androidx.compose.material.icons.filled.CalendarViewWeek
|
||||||
import androidx.compose.material.icons.filled.ViewAgenda
|
import androidx.compose.material.icons.filled.ViewAgenda
|
||||||
|
import androidx.compose.material.icons.filled.ViewColumn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
|
||||||
/** The top-level calendar views the user can switch between (spec M1). */
|
/** The top-level calendar views the user can switch between (spec M1). */
|
||||||
enum class CalendarView {
|
enum class CalendarView {
|
||||||
Month,
|
Month,
|
||||||
Week,
|
Week,
|
||||||
|
MultiDay,
|
||||||
Day,
|
Day,
|
||||||
Agenda,
|
Agenda,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
|
/** How many days the multi-day view shows (#193), provided once at the app root. */
|
||||||
@get:StringRes
|
val LocalMultiDayCount = staticCompositionLocalOf { SettingsPrefs.DEFAULT_MULTI_DAY_COUNT }
|
||||||
val CalendarView.labelRes: Int
|
|
||||||
get() = when (this) {
|
/**
|
||||||
CalendarView.Month -> R.string.view_month
|
* The view's name, shared by the top-bar pill, the drawer and Settings. The
|
||||||
CalendarView.Week -> R.string.view_week
|
* multi-day view is named after its day count ("3 days").
|
||||||
CalendarView.Day -> R.string.view_day
|
*/
|
||||||
CalendarView.Agenda -> R.string.view_agenda
|
@Composable
|
||||||
|
fun CalendarView.label(): String = when (this) {
|
||||||
|
CalendarView.Month -> stringResource(R.string.view_month)
|
||||||
|
CalendarView.Week -> stringResource(R.string.view_week)
|
||||||
|
CalendarView.MultiDay -> {
|
||||||
|
val count = LocalMultiDayCount.current
|
||||||
|
pluralStringResource(R.plurals.view_multi_day, count, count)
|
||||||
}
|
}
|
||||||
|
CalendarView.Day -> stringResource(R.string.view_day)
|
||||||
|
CalendarView.Agenda -> stringResource(R.string.view_agenda)
|
||||||
|
}
|
||||||
|
|
||||||
/** Leading icon for the view in the drawer's View section. */
|
/** Leading icon for the view in the drawer's View section. */
|
||||||
val CalendarView.icon: ImageVector
|
val CalendarView.icon: ImageVector
|
||||||
get() = when (this) {
|
get() = when (this) {
|
||||||
CalendarView.Month -> Icons.Filled.CalendarViewMonth
|
CalendarView.Month -> Icons.Filled.CalendarViewMonth
|
||||||
CalendarView.Week -> Icons.Filled.CalendarViewWeek
|
CalendarView.Week -> Icons.Filled.CalendarViewWeek
|
||||||
|
CalendarView.MultiDay -> Icons.Filled.ViewColumn
|
||||||
CalendarView.Day -> Icons.Filled.CalendarViewDay
|
CalendarView.Day -> Icons.Filled.CalendarViewDay
|
||||||
CalendarView.Agenda -> Icons.Filled.ViewAgenda
|
CalendarView.Agenda -> Icons.Filled.ViewAgenda
|
||||||
}
|
}
|
||||||
@@ -41,7 +57,13 @@ val CalendarView.icon: ImageVector
|
|||||||
* through these in order.
|
* through these in order.
|
||||||
*/
|
*/
|
||||||
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
val IMPLEMENTED_VIEWS: List<CalendarView> =
|
||||||
listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day, CalendarView.Agenda)
|
listOf(
|
||||||
|
CalendarView.Month,
|
||||||
|
CalendarView.Week,
|
||||||
|
CalendarView.MultiDay,
|
||||||
|
CalendarView.Day,
|
||||||
|
CalendarView.Agenda,
|
||||||
|
)
|
||||||
|
|
||||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||||
@@ -57,7 +79,8 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
|
|||||||
* implemented view — the settings screen reorders the whole set — while [cycle]
|
* implemented view — the settings screen reorders the whole set — while [cycle]
|
||||||
* is the subset the pill actually steps through, in [order]. The navigation
|
* is the subset the pill actually steps through, in [order]. The navigation
|
||||||
* drawer keeps its own separate order and always lists every view, so a view
|
* drawer keeps its own separate order and always lists every view, so a view
|
||||||
* disabled here stays reachable there.
|
* disabled here stays reachable there — including when [cycle] is emptied and
|
||||||
|
* the pill disappears altogether.
|
||||||
*/
|
*/
|
||||||
data class QuickSwitchConfig(
|
data class QuickSwitchConfig(
|
||||||
val order: List<CalendarView>,
|
val order: List<CalendarView>,
|
||||||
@@ -67,14 +90,15 @@ data class QuickSwitchConfig(
|
|||||||
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
/**
|
||||||
|
* Fewest views that keep the switch meaningful. A single target is not a
|
||||||
|
* switch, so below this the pill is hidden rather than special-cased (#150);
|
||||||
|
* the drawer still reaches every view.
|
||||||
|
*/
|
||||||
|
const val MIN_CYCLE = 2
|
||||||
|
|
||||||
/** All views, in default order, all enabled. */
|
/** All views, in default order, all enabled. */
|
||||||
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
||||||
|
|
||||||
/**
|
|
||||||
* Fewest views that keep the switch meaningful — a "switch" needs at
|
|
||||||
* least two targets, so the settings screen blocks disabling below this.
|
|
||||||
*/
|
|
||||||
const val MIN_ENABLED = 2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,3 +129,16 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarVie
|
|||||||
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
|
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
|
||||||
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
||||||
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
||||||
|
|
||||||
|
/** The screens the views are drawn by: the day, multi-day and week views share one timeline (#184). */
|
||||||
|
enum class ViewScreen { Month, Timeline, Agenda }
|
||||||
|
|
||||||
|
val CalendarView.screen: ViewScreen
|
||||||
|
get() = when (this) {
|
||||||
|
CalendarView.Month -> ViewScreen.Month
|
||||||
|
CalendarView.Agenda -> ViewScreen.Agenda
|
||||||
|
CalendarView.Week, CalendarView.MultiDay, CalendarView.Day -> ViewScreen.Timeline
|
||||||
|
}
|
||||||
|
/** Push the week view over the month after a week-number tap. */
|
||||||
|
fun List<CalendarView>.drillToWeek(): List<CalendarView> =
|
||||||
|
if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.onLongClick
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** Puts one labelled field on the clipboard. */
|
||||||
|
fun interface FieldCopier {
|
||||||
|
operator fun invoke(label: String, text: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [FieldCopier] that confirms the copy only where the system doesn't (#195).
|
||||||
|
*
|
||||||
|
* Android 13 raises its own clipboard chip for every copy, so a snackbar on top
|
||||||
|
* of it reads as the app having done the job twice. A failure is always worth a
|
||||||
|
* word, though: the clipboard can refuse a very long description outright.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val confirmation = stringResource(R.string.field_copied)
|
||||||
|
val failure = stringResource(R.string.field_copy_failed)
|
||||||
|
return remember(clipboard, scope, snackbarHostState, confirmation, failure) {
|
||||||
|
FieldCopier { label, text ->
|
||||||
|
scope.launch {
|
||||||
|
val message = runCatching {
|
||||||
|
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, text)))
|
||||||
|
}.fold(
|
||||||
|
onSuccess = {
|
||||||
|
confirmation.takeIf {
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { failure },
|
||||||
|
)
|
||||||
|
if (message != null) {
|
||||||
|
// Copying twice in a row shouldn't queue two four-second
|
||||||
|
// confirmations — the newest one wins.
|
||||||
|
snackbarHostState.currentSnackbarData?.dismiss()
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Long-press to copy [text] whole, filed on the clipboard under [label].
|
||||||
|
*
|
||||||
|
* [onTap] carries a field's existing tap action through; a field without one
|
||||||
|
* stays unclickable rather than growing a ripple that leads nowhere, and gets
|
||||||
|
* its long press announced through semantics instead. That branch merges the
|
||||||
|
* node it sits on, so a screen reader lands on the field itself and finds the
|
||||||
|
* action there — a bare container is never focused.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.copyOnLongPress(
|
||||||
|
label: String,
|
||||||
|
text: String,
|
||||||
|
copy: FieldCopier,
|
||||||
|
onTap: (() -> Unit)? = null,
|
||||||
|
): Modifier {
|
||||||
|
val actionLabel = stringResource(R.string.field_copy_action)
|
||||||
|
return if (onTap != null) {
|
||||||
|
combinedClickable(
|
||||||
|
onClick = onTap,
|
||||||
|
onLongClickLabel = actionLabel,
|
||||||
|
onLongClick = { copy(label, text) },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
pointerInput(label, text, copy) {
|
||||||
|
detectTapGestures(
|
||||||
|
onLongPress = {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
copy(label, text)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}.semantics(mergeDescendants = true) {
|
||||||
|
onLongClick(actionLabel) {
|
||||||
|
copy(label, text)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
|
|
||||||
|
/** Stroke an invitation you have not answered is outlined with. */
|
||||||
|
val EVENT_OUTLINE_WIDTH = 1.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The surface an event chip is drawn on, which an outlined one fills itself with
|
||||||
|
* so it reads as the background showing through rather than as a pale block laid
|
||||||
|
* over it. Defaults to the `surfaceContainer` the month cells and the timeline
|
||||||
|
* columns carry; the all-day strips sit on plain `surface` and a floating drag
|
||||||
|
* copy wants a lifted one, so both provide their own.
|
||||||
|
*/
|
||||||
|
val LocalChipGround = compositionLocalOf { Color.Unspecified }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which edges of a chip the event runs past. Those edges are squared by
|
||||||
|
* [monthBarShape] / [timedBlockShape] so the cut reads as "this carries on", and
|
||||||
|
* an outline has to leave them open for the same reason — a stroke all the way
|
||||||
|
* round would close a bar carried across a week boundary into two boxes.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class ChipCuts(
|
||||||
|
val start: Boolean = false,
|
||||||
|
val end: Boolean = false,
|
||||||
|
val top: Boolean = false,
|
||||||
|
val bottom: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** [ChipCuts] for a month or all-day bar, which is only ever cut left and right. */
|
||||||
|
fun monthBarCuts(continuesLeft: Boolean, continuesRight: Boolean): ChipCuts =
|
||||||
|
ChipCuts(start = continuesLeft, end = continuesRight)
|
||||||
|
|
||||||
|
/** [ChipCuts] for a timed block, which is only ever cut top and bottom. */
|
||||||
|
fun timedBlockCuts(continuesBefore: Boolean, continuesAfter: Boolean): ChipCuts =
|
||||||
|
ChipCuts(top = continuesBefore, bottom = continuesAfter)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How one event's chip or block is painted, which turns on your answer to its
|
||||||
|
* invitation (#180, #230).
|
||||||
|
*
|
||||||
|
* An invitation you have not answered is drawn as an outline: its calendar's
|
||||||
|
* colour on the border and on the title, over the surface the chip sits on. So
|
||||||
|
* it holds a chip's shape and a chip's weight without claiming the filled
|
||||||
|
* container an event you are going to gets. A declined one keeps the fill and
|
||||||
|
* its strike-through.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class EventPaint(
|
||||||
|
val fill: Color,
|
||||||
|
/** Null for a filled chip; the border colour when the chip is outlined. */
|
||||||
|
val outline: Color?,
|
||||||
|
val titleInk: Color,
|
||||||
|
val secondaryInk: Color,
|
||||||
|
val decoration: TextDecoration?,
|
||||||
|
/**
|
||||||
|
* Weight to set the title at, or null to keep whatever the surface's own
|
||||||
|
* text style carries.
|
||||||
|
*/
|
||||||
|
val titleWeight: FontWeight?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** [EventPaint] for [event] on a [dark] scheme. */
|
||||||
|
@Composable
|
||||||
|
fun eventPaint(event: EventInstance, dark: Boolean): EventPaint {
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
if (event.response == EventResponse.Invited) {
|
||||||
|
// Harmonised even when the setting is off. Raw mode exists so a filled
|
||||||
|
// container matches what the sync source paints, and an outlined chip
|
||||||
|
// has no container — what it has is coloured *text*, which needs the
|
||||||
|
// lightness pinned against the surface or a pale calendar goes
|
||||||
|
// unreadable (eventTone returns the raw colour verbatim otherwise).
|
||||||
|
val accent = eventAccent(event.color, dark, soften = true)
|
||||||
|
val ground = LocalChipGround.current.takeIf { it != Color.Unspecified }
|
||||||
|
?: MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
return EventPaint(
|
||||||
|
fill = ground,
|
||||||
|
outline = accent,
|
||||||
|
titleInk = accent,
|
||||||
|
// A neutral token rather than the accent faded: the time is the
|
||||||
|
// smallest text on the chip, and an alpha step off an accent that is
|
||||||
|
// itself only just clear of the surface is where legibility goes.
|
||||||
|
secondaryInk = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
decoration = null,
|
||||||
|
// The label styles' medium weight is set to carry ink on a filled
|
||||||
|
// container. In a calendar colour on a plain one it thickens into
|
||||||
|
// something harder to read, so step it back to regular.
|
||||||
|
titleWeight = FontWeight.Normal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val fill = eventFill(event.color, dark, soften)
|
||||||
|
return EventPaint(
|
||||||
|
fill = fill,
|
||||||
|
outline = null,
|
||||||
|
titleInk = eventInk(fill, alpha = TITLE_INK_ALPHA),
|
||||||
|
secondaryInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
|
decoration = declinedDecoration(event.isDeclined),
|
||||||
|
titleWeight = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [this] set at the weight [paint] asks a title for, for measuring and for drawing alike. */
|
||||||
|
fun TextStyle.withTitleWeight(paint: EventPaint): TextStyle =
|
||||||
|
if (paint.titleWeight == null) this else copy(fontWeight = paint.titleWeight)
|
||||||
|
|
||||||
|
/** Seats a chip or block on [shape]: its fill, plus the border when it has one. */
|
||||||
|
fun Modifier.eventSurface(
|
||||||
|
paint: EventPaint,
|
||||||
|
shape: Shape,
|
||||||
|
cuts: ChipCuts = ChipCuts(),
|
||||||
|
): Modifier {
|
||||||
|
val filled = background(paint.fill, shape)
|
||||||
|
val outline = paint.outline ?: return filled
|
||||||
|
return filled.clip(shape).drawBehind { drawChipOutline(outline, cuts) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The border, stroked as one round-rect whose edges run past the chip on every
|
||||||
|
* side the event continues over. Clipped to the chip's own shape, so those
|
||||||
|
* strokes — and the corners that would have turned back in — fall outside and
|
||||||
|
* the edge stays open.
|
||||||
|
*/
|
||||||
|
private fun DrawScope.drawChipOutline(color: Color, cuts: ChipCuts) {
|
||||||
|
val stroke = EVENT_OUTLINE_WIDTH.toPx()
|
||||||
|
val radius = EVENT_CHIP_CORNER.toPx()
|
||||||
|
// Far enough out that the corner arc clears the clip too, not just the edge.
|
||||||
|
val bleed = radius + stroke
|
||||||
|
val rtl = layoutDirection == LayoutDirection.Rtl
|
||||||
|
val leftCut = if (rtl) cuts.end else cuts.start
|
||||||
|
val rightCut = if (rtl) cuts.start else cuts.end
|
||||||
|
val left = if (leftCut) -bleed else stroke / 2f
|
||||||
|
val top = if (cuts.top) -bleed else stroke / 2f
|
||||||
|
val right = if (rightCut) size.width + bleed else size.width - stroke / 2f
|
||||||
|
val bottom = if (cuts.bottom) size.height + bleed else size.height - stroke / 2f
|
||||||
|
drawRoundRect(
|
||||||
|
color = color,
|
||||||
|
topLeft = Offset(left, top),
|
||||||
|
size = Size(right - left, bottom - top),
|
||||||
|
cornerRadius = CornerRadius(radius),
|
||||||
|
style = Stroke(width = stroke),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
|
||||||
|
private val TIME_WEIGHT = FontWeight.Normal
|
||||||
|
private val TIME_TRACKING = 0.sp
|
||||||
|
private val TIME_SPAN = SpanStyle(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ink for an event's title, against [SECONDARY_INK_ALPHA] for the time beside or
|
||||||
|
* beneath it. Full, so the pair separates on a typeface with no medium weight to
|
||||||
|
* step down from — Atkinson Hyperlegible and JetBrains Mono ship regular and bold
|
||||||
|
* only, and a user's imported font a single face, so [asEventTime] is a no-op
|
||||||
|
* there and the ink is the whole difference (#219).
|
||||||
|
*/
|
||||||
|
const val TITLE_INK_ALPHA = 1f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [this] set as an event's time rather than as its title: the label styles' medium
|
||||||
|
* weight and 0.5sp of tracking left a time reading as part of the title next to it.
|
||||||
|
*/
|
||||||
|
fun TextStyle.asEventTime(): TextStyle =
|
||||||
|
copy(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
|
||||||
|
|
||||||
|
/** Text reading [time], set apart in [timeInk], before [title]. */
|
||||||
|
fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedString =
|
||||||
|
buildAnnotatedString {
|
||||||
|
if (time != null) {
|
||||||
|
withStyle(TIME_SPAN.copy(color = timeInk)) { append(time) }
|
||||||
|
append(" ")
|
||||||
|
}
|
||||||
|
append(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The characters of title that have to survive a time prefix for it to be worth
|
||||||
|
* its place — lowercase Latin of average advance, priced at the surface's own
|
||||||
|
* text style rather than guessed in dp.
|
||||||
|
*/
|
||||||
|
private const val TITLE_SAMPLE = "notepad"
|
||||||
|
|
||||||
|
/** The widest wall-clock time in either convention: two-digit hour, and a meridiem in 12-hour. */
|
||||||
|
private const val SAMPLE_HOUR = 12
|
||||||
|
private const val SAMPLE_MINUTE = 45
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The narrowest run of [style] text that may carry a time in front of a title:
|
||||||
|
* wide enough for the widest time in the current convention plus [TITLE_SAMPLE].
|
||||||
|
*
|
||||||
|
* Measured rather than a device breakpoint, so it follows the font scale, the
|
||||||
|
* 12/24-hour setting, the locale's own time format and whatever else has
|
||||||
|
* already been taken off the width.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberInlineTimeWidth(style: TextStyle): Dp {
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale)
|
||||||
|
return remember(sample, style, density, measurer) {
|
||||||
|
val label = inlineTimeLabel(sample, TITLE_SAMPLE, Color.Unspecified)
|
||||||
|
with(density) { measurer.measure(label, style).size.width.toDp() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
|
||||||
|
/** How an event chip's title should overflow: what to pass to `Text`. */
|
||||||
|
data class EventTitleOverflow(val overflow: TextOverflow, val softWrap: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overflow for an event chip's title (#164). Chips are narrow enough that the
|
||||||
|
* "…" costs a couple of readable characters, so the title runs to the chip's
|
||||||
|
* edge and clips instead — mid-glyph on one line, at the last whole word it
|
||||||
|
* could fit on several.
|
||||||
|
*
|
||||||
|
* One case keeps the ellipsis: a single **[rtl]** line. With `softWrap` off
|
||||||
|
* Compose lays the line out at its full intrinsic width and clips to the node's
|
||||||
|
* left edge, which in RTL is the *end* of the string — an Arabic title would
|
||||||
|
* lose its beginning. The ellipsis truncates at the logical end in both
|
||||||
|
* directions. Wrapping needs `softWrap` on, which clips the right end either
|
||||||
|
* way, so more than one line needs no such exception.
|
||||||
|
*/
|
||||||
|
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
|
||||||
|
when {
|
||||||
|
!singleLine -> EventTitleOverflow(TextOverflow.Clip, softWrap = true)
|
||||||
|
rtl -> EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true)
|
||||||
|
else -> EventTitleOverflow(TextOverflow.Clip, softWrap = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [eventTitleOverflowFor] against the current layout direction. */
|
||||||
|
@Composable
|
||||||
|
fun eventTitleOverflow(singleLine: Boolean = true): EventTitleOverflow =
|
||||||
|
eventTitleOverflowFor(
|
||||||
|
rtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
|
||||||
|
singleLine = singleLine,
|
||||||
|
)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the week/day timeline seats each hour in its own cell, from the
|
||||||
|
* `show_hour_lines` preference. Provided once at the app root (like
|
||||||
|
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
|
||||||
|
* Defaults to off — the historical flat column.
|
||||||
|
*/
|
||||||
|
val LocalShowHourGrid = staticCompositionLocalOf { false }
|
||||||
|
|
||||||
|
/** Gap between two hour cells, matching the month grid's gap between day cells. */
|
||||||
|
val HOUR_CELL_GAP = 2.dp
|
||||||
|
|
||||||
|
/** Half a gap: what a cell — and a block seated in it — gives up at each edge. */
|
||||||
|
val HOUR_CELL_INSET = HOUR_CELL_GAP / 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The half-gap a block seated in the grid gives up at one of its ends, so an
|
||||||
|
* on-the-hour event fills its cell instead of overhanging the seam into its
|
||||||
|
* neighbours'. Zero with the grid off, and zero at an end [cut] at midnight:
|
||||||
|
* that edge is squared off against the column's own, and stopping a half-gap
|
||||||
|
* short of it would leave the block floating there rather than running off.
|
||||||
|
*/
|
||||||
|
fun hourCellBlockInset(show: Boolean, cut: Boolean): Dp =
|
||||||
|
if (show && !cut) HOUR_CELL_INSET else 0.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corner radius of an hour cell: the event chip's own, so the grid never rounds
|
||||||
|
* harder than the blocks it seats. The month grid's 12dp belongs to a cell many
|
||||||
|
* times the size — on an hour cell it out-rounds its own content.
|
||||||
|
*/
|
||||||
|
private val HOUR_CELL_CORNER = EVENT_CHIP_CORNER
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radius an hour cell of [cellHeight] by [cellWidth] pixels may round to. The
|
||||||
|
* hour pitch runs from a fit-the-day sliver up to [MAX_PINCH_HOUR_HEIGHT] and a
|
||||||
|
* week column is a seventh of the screen, so the radius is held to half the
|
||||||
|
* shorter side — the point past which the corners would meet and the cell turn
|
||||||
|
* into a lozenge.
|
||||||
|
*/
|
||||||
|
internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Float): Float =
|
||||||
|
minOf(maxRadius, cellHeight / 2f, cellWidth / 2f).coerceAtLeast(0f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seat each of the day's 24 hours in its own rounded cell when [show] is true,
|
||||||
|
* so the hour boundary reads as a seam between two surfaces rather than a line
|
||||||
|
* drawn across one. Applied to a day column's content, so the cells sit over the
|
||||||
|
* column background but beneath the event blocks — blocks stay in a continuous
|
||||||
|
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
|
||||||
|
* height; [color] is resolved by the caller from the theme.
|
||||||
|
*/
|
||||||
|
fun Modifier.hourGridCells(
|
||||||
|
show: Boolean,
|
||||||
|
hourHeightPx: Float,
|
||||||
|
color: Color,
|
||||||
|
): Modifier =
|
||||||
|
if (!show) {
|
||||||
|
this
|
||||||
|
} else {
|
||||||
|
drawBehind {
|
||||||
|
val inset = HOUR_CELL_INSET.toPx()
|
||||||
|
val cellHeight = hourHeightPx - inset * 2f
|
||||||
|
if (cellHeight <= 0f) return@drawBehind
|
||||||
|
val radius = CornerRadius(
|
||||||
|
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
|
||||||
|
)
|
||||||
|
val cellSize = Size(size.width, cellHeight)
|
||||||
|
val top = inset
|
||||||
|
for (hour in 0 until 24) {
|
||||||
|
drawRoundRect(
|
||||||
|
color = color,
|
||||||
|
topLeft = Offset(0f, hour * hourHeightPx + top),
|
||||||
|
size = cellSize,
|
||||||
|
cornerRadius = radius,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -72,6 +73,11 @@ fun HourGutter(
|
|||||||
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
||||||
label = "hourLabelAlpha",
|
label = "hourLabelAlpha",
|
||||||
)
|
)
|
||||||
|
// Each label straddles the boundary it names, so it lines up with the seam
|
||||||
|
// the hour grid leaves there instead of hanging below it. Derived from the
|
||||||
|
// label's own line height, so it holds at any font scale.
|
||||||
|
val labelStyle = MaterialTheme.typography.labelSmall
|
||||||
|
val labelLift = with(LocalDensity.current) { labelStyle.lineHeight.toDp() } / 2
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -90,12 +96,12 @@ fun HourGutter(
|
|||||||
if (h > 0) {
|
if (h > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = formatHourLabel(h, use24Hour, locale),
|
text = formatHourLabel(h, use24Hour, locale),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = labelStyle,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
.copy(alpha = hourAlpha),
|
.copy(alpha = hourAlpha),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.offset(y = (-6).dp),
|
.offset(y = -labelLift),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.drawBehind
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the week/day timeline draws an hour separator line, from the
|
|
||||||
* `showHourLines` preference. Provided once at the app root (like
|
|
||||||
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
|
|
||||||
* Defaults to off — the historical clean look.
|
|
||||||
*/
|
|
||||||
val LocalShowHourLines = staticCompositionLocalOf { false }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draw a faint separator line at the top of each hour (1..23) when [show] is
|
|
||||||
* true. Applied to a day column's content so each line sits over the column's
|
|
||||||
* background but beneath the event blocks. [hourHeightPx] is one hour's pixel
|
|
||||||
* height; [color] is resolved by the caller from the theme.
|
|
||||||
*/
|
|
||||||
fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
|
|
||||||
if (!show) {
|
|
||||||
this
|
|
||||||
} else {
|
|
||||||
drawBehind {
|
|
||||||
for (hour in 1 until 24) {
|
|
||||||
val y = hour * hourHeightPx
|
|
||||||
drawLine(
|
|
||||||
color = color,
|
|
||||||
start = Offset(0f, y),
|
|
||||||
end = Offset(size.width, y),
|
|
||||||
strokeWidth = 1f,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.onClick
|
||||||
|
import androidx.compose.ui.semantics.role
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** Thickness of the out-of-view edge bar. */
|
||||||
|
private val EDGE_BAR_THICKNESS = 3.dp
|
||||||
|
|
||||||
|
/** Band the bar sits in, and the room a revealed event keeps from the edge. */
|
||||||
|
private val EDGE_TOUCH_HEIGHT = 24.dp
|
||||||
|
|
||||||
|
/** How far from the viewport's edge a tap on empty column still hits the bar. */
|
||||||
|
private val EDGE_TAP_REACH = 16.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the bar sits in from the viewport's edges — enough to clear the
|
||||||
|
* timeline's rounded 16dp corners on the outer columns.
|
||||||
|
*/
|
||||||
|
private val EDGE_BAR_VERTICAL_INSET = 4.dp
|
||||||
|
private val EDGE_BAR_HORIZONTAL_INSET = 6.dp
|
||||||
|
|
||||||
|
/** Distance outside the viewport over which a bar fades before its event appears. */
|
||||||
|
private val EDGE_FADE_LEAD = 24.dp
|
||||||
|
|
||||||
|
/** The nearest timed blocks wholly above and below a column's viewport. */
|
||||||
|
@Immutable
|
||||||
|
data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) {
|
||||||
|
companion object {
|
||||||
|
val None = OutOfViewEvents(null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which edge of the viewport a hidden event sits past. */
|
||||||
|
enum class ViewportEdge { Top, Bottom }
|
||||||
|
|
||||||
|
/** Where [block]'s rendered bottom lands, in minutes — short blocks keep a floor. */
|
||||||
|
internal fun renderedEndMin(block: TimedBlock): Float =
|
||||||
|
maxOf(block.endMin.toFloat(), block.startMin + MIN_EVENT_FRACTION * 60f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin]
|
||||||
|
* (minutes of the day), nearest first. A block leaves this set as soon as any
|
||||||
|
* part of its rendered height enters the viewport.
|
||||||
|
*/
|
||||||
|
fun outOfViewEvents(
|
||||||
|
blocks: List<TimedBlock>,
|
||||||
|
topMin: Float,
|
||||||
|
bottomMin: Float,
|
||||||
|
): OutOfViewEvents {
|
||||||
|
val above = blocks
|
||||||
|
.filter { renderedEndMin(it) <= topMin }
|
||||||
|
.maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin }))
|
||||||
|
val below = blocks
|
||||||
|
.filter { it.startMin >= bottomMin }
|
||||||
|
.minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) }))
|
||||||
|
return OutOfViewEvents(above, below)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scroll offset that brings [block] into a [viewportPx]-tall viewport from
|
||||||
|
* past [edge], with [marginPx] of room so it clears the edge bar. An event above
|
||||||
|
* lands with its start at the top; one below lands whole at the bottom when it
|
||||||
|
* fits, and with its start at the top when it does not.
|
||||||
|
*/
|
||||||
|
fun revealScrollTarget(
|
||||||
|
block: TimedBlock,
|
||||||
|
edge: ViewportEdge,
|
||||||
|
hourPx: Float,
|
||||||
|
viewportPx: Int,
|
||||||
|
marginPx: Float,
|
||||||
|
maxScroll: Int,
|
||||||
|
): Int {
|
||||||
|
val startPx = block.startMin / 60f * hourPx
|
||||||
|
val endPx = renderedEndMin(block) / 60f * hourPx
|
||||||
|
val target = when (edge) {
|
||||||
|
ViewportEdge.Top -> startPx - marginPx
|
||||||
|
ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx)
|
||||||
|
}
|
||||||
|
return target.roundToInt().coerceIn(0, maxOf(maxScroll, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The edge whose bar a tap at [y] (content px) lands on, if that bar is showing
|
||||||
|
* and the tap is within [reachPx] of the viewport's edge.
|
||||||
|
*/
|
||||||
|
fun tappedEdge(
|
||||||
|
events: OutOfViewEvents,
|
||||||
|
y: Float,
|
||||||
|
viewportTop: Float,
|
||||||
|
viewportPx: Int,
|
||||||
|
reachPx: Float,
|
||||||
|
): ViewportEdge? = when {
|
||||||
|
events.above != null && y - viewportTop <= reachPx -> ViewportEdge.Top
|
||||||
|
events.below != null && viewportTop + viewportPx - y <= reachPx -> ViewportEdge.Bottom
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fade while the event is still outside; never draw its bar at the boundary. */
|
||||||
|
internal fun edgeBarAlpha(gapPx: Float, fadeLeadPx: Float): Float = when {
|
||||||
|
gapPx <= 0f -> 0f
|
||||||
|
fadeLeadPx <= 0f -> 1f
|
||||||
|
else -> (gapPx / fadeLeadPx).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The out-of-view state of one day column (#188): which events hide past its
|
||||||
|
* edges, and the scroll that reveals them.
|
||||||
|
*
|
||||||
|
* The bars take no touches themselves, so event blocks under them keep their
|
||||||
|
* taps and drags; the column's own tap handler offers taps to [tap] first.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class OutOfViewEdges internal constructor(
|
||||||
|
private val scrollState: ScrollState,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
internal var blocks by mutableStateOf(emptyList<TimedBlock>())
|
||||||
|
internal var hourPx by mutableFloatStateOf(0f)
|
||||||
|
internal var fadeLeadPx by mutableFloatStateOf(0f)
|
||||||
|
internal var marginPx by mutableFloatStateOf(0f)
|
||||||
|
internal var reachPx by mutableFloatStateOf(0f)
|
||||||
|
internal var reduceMotion = false
|
||||||
|
internal var revealSpec: FiniteAnimationSpec<Float>? = null
|
||||||
|
|
||||||
|
val events: OutOfViewEvents by derivedStateOf {
|
||||||
|
val viewport = scrollState.viewportSize
|
||||||
|
if (viewport <= 0 || hourPx <= 0f) {
|
||||||
|
OutOfViewEvents.None
|
||||||
|
} else {
|
||||||
|
val top = scrollState.value.toFloat()
|
||||||
|
outOfViewEvents(
|
||||||
|
blocks = blocks,
|
||||||
|
topMin = top / hourPx * 60f,
|
||||||
|
bottomMin = (top + viewport) / hourPx * 60f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scrolls the event hidden past [edge] into view, if there is one. */
|
||||||
|
fun reveal(edge: ViewportEdge) {
|
||||||
|
val block = if (edge == ViewportEdge.Top) events.above else events.below
|
||||||
|
block ?: return
|
||||||
|
val target = revealScrollTarget(
|
||||||
|
block = block,
|
||||||
|
edge = edge,
|
||||||
|
hourPx = hourPx,
|
||||||
|
viewportPx = scrollState.viewportSize,
|
||||||
|
marginPx = marginPx,
|
||||||
|
maxScroll = scrollState.maxValue,
|
||||||
|
)
|
||||||
|
val spec = revealSpec
|
||||||
|
scope.launch {
|
||||||
|
if (reduceMotion || spec == null) {
|
||||||
|
scrollState.scrollTo(target)
|
||||||
|
} else {
|
||||||
|
scrollState.animateScrollTo(target, spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
|
||||||
|
fun tap(y: Float): Boolean {
|
||||||
|
val edge = tappedEdge(
|
||||||
|
events = events,
|
||||||
|
y = y,
|
||||||
|
viewportTop = scrollState.value.toFloat(),
|
||||||
|
viewportPx = scrollState.viewportSize,
|
||||||
|
reachPx = reachPx,
|
||||||
|
) ?: return false
|
||||||
|
reveal(edge)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberOutOfViewEdges(
|
||||||
|
blocks: List<TimedBlock>,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
hourPx: Float,
|
||||||
|
): OutOfViewEdges {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val edges = remember(scrollState, scope) { OutOfViewEdges(scrollState, scope) }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val revealSpec = rememberCalendarPageSpec()
|
||||||
|
SideEffect {
|
||||||
|
edges.blocks = blocks
|
||||||
|
edges.hourPx = hourPx
|
||||||
|
edges.fadeLeadPx = with(density) { EDGE_FADE_LEAD.toPx() }
|
||||||
|
edges.marginPx = with(density) { EDGE_TOUCH_HEIGHT.roundToPx().toFloat() }
|
||||||
|
edges.reachPx = with(density) { EDGE_TAP_REACH.toPx() }
|
||||||
|
edges.reduceMotion = reduceMotion
|
||||||
|
edges.revealSpec = revealSpec
|
||||||
|
}
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin bars at the top and bottom of a day column's visible viewport, tinted in
|
||||||
|
* the colour of the nearest timed event hidden past that edge (#188).
|
||||||
|
*
|
||||||
|
* Lives inside the column's scrolling content and follows the viewport by
|
||||||
|
* offsetting against [scrollState] at placement rather than recomposing every
|
||||||
|
* frame. Opacity follows the gap to the hidden event and reaches zero before
|
||||||
|
* the event enters view. Taps reach it through [OutOfViewEdges.tap]; screen
|
||||||
|
* readers get a button.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BoxScope.OutOfViewEdgeBars(
|
||||||
|
edges: OutOfViewEdges,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
dark: Boolean,
|
||||||
|
) {
|
||||||
|
val touchPx = with(LocalDensity.current) { EDGE_TOUCH_HEIGHT.roundToPx() }
|
||||||
|
val events = edges.events
|
||||||
|
EdgeBar(
|
||||||
|
block = events.above,
|
||||||
|
edge = ViewportEdge.Top,
|
||||||
|
dark = dark,
|
||||||
|
onReveal = edges::reveal,
|
||||||
|
scrollState = scrollState,
|
||||||
|
hourPx = edges.hourPx,
|
||||||
|
fadeLeadPx = edges.fadeLeadPx,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.offset { IntOffset(0, scrollState.value) },
|
||||||
|
)
|
||||||
|
EdgeBar(
|
||||||
|
block = events.below,
|
||||||
|
edge = ViewportEdge.Bottom,
|
||||||
|
dark = dark,
|
||||||
|
onReveal = edges::reveal,
|
||||||
|
scrollState = scrollState,
|
||||||
|
hourPx = edges.hourPx,
|
||||||
|
fadeLeadPx = edges.fadeLeadPx,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EdgeBar(
|
||||||
|
block: TimedBlock?,
|
||||||
|
edge: ViewportEdge,
|
||||||
|
dark: Boolean,
|
||||||
|
onReveal: (ViewportEdge) -> Unit,
|
||||||
|
scrollState: ScrollState,
|
||||||
|
hourPx: Float,
|
||||||
|
fadeLeadPx: Float,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
block ?: return
|
||||||
|
val color = eventFill(block.event.color, dark, LocalSoftenColors.current)
|
||||||
|
val label = stringResource(
|
||||||
|
if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(EDGE_TOUCH_HEIGHT)
|
||||||
|
.graphicsLayer {
|
||||||
|
val gapPx = when (edge) {
|
||||||
|
ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx
|
||||||
|
ViewportEdge.Bottom -> block.startMin / 60f * hourPx -
|
||||||
|
(scrollState.value + scrollState.viewportSize)
|
||||||
|
}
|
||||||
|
alpha = edgeBarAlpha(gapPx, fadeLeadPx)
|
||||||
|
}
|
||||||
|
.semantics {
|
||||||
|
role = Role.Button
|
||||||
|
contentDescription = label
|
||||||
|
onClick {
|
||||||
|
onReveal(edge)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = if (edge == ViewportEdge.Top) Alignment.TopCenter else Alignment.BottomCenter,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
horizontal = EDGE_BAR_HORIZONTAL_INSET,
|
||||||
|
vertical = EDGE_BAR_VERTICAL_INSET,
|
||||||
|
)
|
||||||
|
.height(EDGE_BAR_THICKNESS)
|
||||||
|
.background(color, CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One shared state per pager page — a week, a day, a month — so a page and the
|
||||||
|
* screen's anchor read the same query, and the pages either side stay loaded
|
||||||
|
* across a swipe.
|
||||||
|
*
|
||||||
|
* Every entry shares on its own job, cancelled when [keep] lets it go: a
|
||||||
|
* `stateIn` on the ViewModel's scope would stay running, holding its last state,
|
||||||
|
* until the ViewModel itself was cleared. Main thread only.
|
||||||
|
*
|
||||||
|
* @param keep whether a cached key is still worth holding once [get] asks for another.
|
||||||
|
*/
|
||||||
|
class PageStateCache<K, S>(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val initial: S,
|
||||||
|
private val keep: (cached: K, requested: K) -> Boolean,
|
||||||
|
private val load: (K) -> Flow<S>,
|
||||||
|
) {
|
||||||
|
private class Entry<S>(val state: StateFlow<S>, val job: Job)
|
||||||
|
|
||||||
|
private val entries = HashMap<K, Entry<S>>()
|
||||||
|
|
||||||
|
/** The number of entries held, for tests. */
|
||||||
|
internal val size: Int get() = entries.size
|
||||||
|
|
||||||
|
fun get(key: K): StateFlow<S> {
|
||||||
|
val iterator = entries.iterator()
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
val (cached, entry) = iterator.next()
|
||||||
|
if (cached != key && !keep(cached, key)) {
|
||||||
|
entry.job.cancel()
|
||||||
|
iterator.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries.getOrPut(key) {
|
||||||
|
val job = SupervisorJob(scope.coroutineContext[Job])
|
||||||
|
Entry(
|
||||||
|
state = load(key).stateIn(scope + job, SharingStarted.WhileSubscribed(5_000L), initial),
|
||||||
|
job = job,
|
||||||
|
)
|
||||||
|
}.state
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import kotlin.time.Instant
|
|||||||
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
|
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
|
||||||
* the "dim completed events" setting is on, or `null` when it is off (nothing
|
* the "dim completed events" setting is on, or `null` when it is off (nothing
|
||||||
* dims). Provided per grid screen so only the event chips that read it recompose
|
* dims). Provided per grid screen so only the event chips that read it recompose
|
||||||
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
|
* as the minute ticks, mirroring [LocalShowHourGrid] / [LocalUse24HourFormat].
|
||||||
*/
|
*/
|
||||||
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.absoluteOffset
|
import androidx.compose.foundation.layout.absoluteOffset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -24,8 +25,9 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
import androidx.compose.ui.layout.boundsInRoot
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
@@ -34,7 +36,6 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
|||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -83,6 +84,13 @@ data class TimelineDrag(
|
|||||||
val eventSpanMin: Int,
|
val eventSpanMin: Int,
|
||||||
/** The event as the grid would draw it, one piece per day column it covers. */
|
/** The event as the grid would draw it, one piece per day column it covers. */
|
||||||
val pieces: List<TimelineDragPiece>,
|
val pieces: List<TimelineDragPiece>,
|
||||||
|
/**
|
||||||
|
* Lines the block the finger picked up drew its title over, zero for one too
|
||||||
|
* short to have drawn it at all. The floating copy is that block's size, so
|
||||||
|
* it has to spend its height the same way or the text re-wraps under the
|
||||||
|
* finger and snaps back on drop (#267).
|
||||||
|
*/
|
||||||
|
val titleLines: Int,
|
||||||
/**
|
/**
|
||||||
* True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
|
* True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
|
||||||
* every column this timeline shows. A hint under the finger, not where the
|
* every column this timeline shows. A hint under the finger, not where the
|
||||||
@@ -192,6 +200,32 @@ internal fun dragFloorMin(clipOffsetMin: Int, eventSpanMin: Int): Int =
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where one dragged piece's top edge sits below its column's top, and how tall
|
||||||
|
* it is drawn, at [hourPx] to the hour. [insetPx] is the half-gap a block seated
|
||||||
|
* in the hour grid gives up at each end, zero with the grid off — taken off the
|
||||||
|
* copy exactly as the grid takes it off the block, or the copy is a gap taller
|
||||||
|
* than the block it lifted off and spends the difference on a time label that
|
||||||
|
* block had no room for (#267). An end cut at midnight keeps none of it, as the
|
||||||
|
* block's own squared-off edge does.
|
||||||
|
*/
|
||||||
|
internal fun dragPieceBounds(
|
||||||
|
slice: DragSlice,
|
||||||
|
hourPx: Float,
|
||||||
|
insetPx: Float,
|
||||||
|
): DragPieceBounds {
|
||||||
|
val top = if (slice.continuesBefore) 0f else insetPx
|
||||||
|
val bottom = if (slice.continuesAfter) 0f else insetPx
|
||||||
|
val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
||||||
|
return DragPieceBounds(
|
||||||
|
top = slice.startMin / 60f * hourPx + top,
|
||||||
|
height = (full - top - bottom).coerceAtLeast(0f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A piece's placement within its column — see [dragPieceBounds]. */
|
||||||
|
internal data class DragPieceBounds(val top: Float, val height: Float)
|
||||||
|
|
||||||
/** One day's share of a dragged event, before it is placed on screen. */
|
/** One day's share of a dragged event, before it is placed on screen. */
|
||||||
internal data class DragSlice(
|
internal data class DragSlice(
|
||||||
val dayOffset: Int,
|
val dayOffset: Int,
|
||||||
@@ -235,6 +269,13 @@ class TimelineGeometry {
|
|||||||
var stepPx: Float = 0f
|
var stepPx: Float = 0f
|
||||||
var days: List<LocalDate> = emptyList()
|
var days: List<LocalDate> = emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The half-gap a block gives up at each uncut end when the hour grid seats
|
||||||
|
* it in a cell, zero with the grid off. The floating copy gives up the same,
|
||||||
|
* so it is the size of the block it lifted off (#267).
|
||||||
|
*/
|
||||||
|
var blockInsetPx: Float = 0f
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
||||||
* never mirrored but the grid is, so the mapping has to flip with it.
|
* never mirrored but the grid is, so the mapping has to flip with it.
|
||||||
@@ -243,8 +284,8 @@ class TimelineGeometry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hoisted drag state for one timeline (#68). It lives above the per-page
|
* Hoisted drag state for one timeline (#68). It lives above the pager, so a
|
||||||
* `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the
|
* page change mid-drag can't strand a ghost, and the
|
||||||
* block it renders is drawn in an overlay, clear of the day column's clip.
|
* block it renders is drawn in an overlay, clear of the day column's clip.
|
||||||
*/
|
*/
|
||||||
@Stable
|
@Stable
|
||||||
@@ -313,6 +354,9 @@ class TimelineDragController {
|
|||||||
*/
|
*/
|
||||||
private var clipOffsetMin = 0
|
private var clipOffsetMin = 0
|
||||||
|
|
||||||
|
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
|
||||||
|
private var titleLines = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The slot the block already occupied when it was picked up — not the same
|
* The slot the block already occupied when it was picked up — not the same
|
||||||
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
||||||
@@ -322,11 +366,13 @@ class TimelineDragController {
|
|||||||
fun begin(
|
fun begin(
|
||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
clipOffsetMin: Int,
|
clipOffsetMin: Int,
|
||||||
|
titleLines: Int,
|
||||||
pointerInRoot: Offset,
|
pointerInRoot: Offset,
|
||||||
blockInRoot: Offset,
|
blockInRoot: Offset,
|
||||||
) {
|
) {
|
||||||
source = block
|
source = block
|
||||||
this.clipOffsetMin = clipOffsetMin
|
this.clipOffsetMin = clipOffsetMin
|
||||||
|
this.titleLines = titleLines
|
||||||
settling = null
|
settling = null
|
||||||
isDragging = true
|
isDragging = true
|
||||||
liftedInstanceId = block.event.instanceId
|
liftedInstanceId = block.event.instanceId
|
||||||
@@ -345,6 +391,7 @@ class TimelineDragController {
|
|||||||
fun cancel() {
|
fun cancel() {
|
||||||
source = null
|
source = null
|
||||||
clipOffsetMin = 0
|
clipOffsetMin = 0
|
||||||
|
titleLines = 0
|
||||||
isDragging = false
|
isDragging = false
|
||||||
liftedInstanceId = null
|
liftedInstanceId = null
|
||||||
originSlot = null
|
originSlot = null
|
||||||
@@ -467,23 +514,22 @@ class TimelineDragController {
|
|||||||
startMin = startMin,
|
startMin = startMin,
|
||||||
eventStartMin = eventStartMin,
|
eventStartMin = eventStartMin,
|
||||||
eventSpanMin = eventSpan,
|
eventSpanMin = eventSpan,
|
||||||
|
titleLines = titleLines,
|
||||||
// Bounded to the columns this timeline actually shows: a day it
|
// Bounded to the columns this timeline actually shows: a day it
|
||||||
// doesn't has no piece to draw. The write is unaffected.
|
// doesn't has no piece to draw. The write is unaffected.
|
||||||
pieces = slices
|
pieces = slices
|
||||||
.map { slice ->
|
.map { slice ->
|
||||||
val day = dayIndex + slice.dayOffset
|
val day = dayIndex + slice.dayOffset
|
||||||
val col = if (geometry.isRtl) days.lastIndex - day else day
|
val col = if (geometry.isRtl) days.lastIndex - day else day
|
||||||
|
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
|
||||||
TimelineDragPiece(
|
TimelineDragPiece(
|
||||||
topLeftInRoot = Offset(
|
topLeftInRoot = Offset(
|
||||||
x = origin.x + col * columnPx,
|
x = origin.x + col * columnPx,
|
||||||
y = origin.y + slice.startMin / 60f * hourPx,
|
y = origin.y + bounds.top,
|
||||||
),
|
),
|
||||||
sizePx = IntSize(
|
sizePx = IntSize(
|
||||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||||
maxOf(
|
bounds.height.roundToInt(),
|
||||||
slice.spanMin / 60f * hourPx,
|
|
||||||
MIN_EVENT_FRACTION * hourPx,
|
|
||||||
).roundToInt(),
|
|
||||||
),
|
),
|
||||||
continuesBefore = slice.continuesBefore,
|
continuesBefore = slice.continuesBefore,
|
||||||
continuesAfter = slice.continuesAfter,
|
continuesAfter = slice.continuesAfter,
|
||||||
@@ -604,8 +650,8 @@ const val SETTLE_FADE_MILLIS: Int = 250
|
|||||||
@Composable
|
@Composable
|
||||||
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
||||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
val density = LocalDensity.current
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val soften = LocalSoftenColors.current
|
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -655,7 +701,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
|||||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||||
label = "drag-handover",
|
label = "drag-handover",
|
||||||
)
|
)
|
||||||
val fill = eventFill(drag.event.color, dark, soften)
|
val paint = eventPaint(drag.event, dark)
|
||||||
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
// Read off the event rather than the held block, so grabbing either half
|
// Read off the event rather than the held block, so grabbing either half
|
||||||
// of an event that crosses midnight names the same hours. An end past
|
// of an event that crosses midnight names the same hours. An end past
|
||||||
@@ -664,24 +710,63 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
|||||||
val startMin = drag.eventStartMin.mod(MINUTES_PER_DAY)
|
val startMin = drag.eventStartMin.mod(MINUTES_PER_DAY)
|
||||||
val rawEnd = startMin + drag.eventSpanMin
|
val rawEnd = startMin + drag.eventSpanMin
|
||||||
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
|
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
|
||||||
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}–" +
|
val range = formatMinuteOfDay(startMin, use24Hour, locale) to
|
||||||
formatMinuteOfDay(endMin, use24Hour, locale)
|
formatMinuteOfDay(endMin, use24Hour, locale)
|
||||||
// The tallest piece carries the range: on the smallest it would be
|
// The timeline's own bounds. The copy is drawn over the whole calendar
|
||||||
// clipped away, which is exactly the case when a short tail is held.
|
// so no column clip or rounded corner cuts it, but it still belongs to
|
||||||
val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height }
|
// the timeline: a piece a whole day tall reaches far past both ends of
|
||||||
drag.pieces.forEachIndexed { index, piece ->
|
// the viewport, and unclipped it paints over the headers above (#267).
|
||||||
DragCopy(
|
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
|
||||||
topLeftInRoot = piece.topLeftInRoot,
|
// How much of each piece the viewport actually shows. A multi-day event
|
||||||
overlayOrigin = origin,
|
// has a piece per day, and the widest one is a full 24 hours — taller
|
||||||
sizePx = piece.sizePx,
|
// than the screen, its top at a midnight scrolled out of sight. Picking
|
||||||
fill = fill,
|
// the range's piece by raw height put it there, off the top of the
|
||||||
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
|
// timeline; picking by *visible* height puts it where it can be read.
|
||||||
lift = lift,
|
val shown = drag.pieces.map { piece ->
|
||||||
alpha = copyAlpha,
|
if (port == null) {
|
||||||
title = title,
|
piece.sizePx.height.toFloat()
|
||||||
// Once for the whole event: repeated, it would name it per day.
|
} else {
|
||||||
label = label.takeIf { index == labelled },
|
val top = maxOf(piece.topLeftInRoot.y, port.top)
|
||||||
)
|
val bottom = minOf(piece.topLeftInRoot.y + piece.sizePx.height, port.bottom)
|
||||||
|
bottom - top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val labelled = shown.indices.maxByOrNull { shown[it] }
|
||||||
|
Box(
|
||||||
|
modifier = if (port == null) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
.absoluteOffset {
|
||||||
|
IntOffset(
|
||||||
|
(port.left - origin.x).roundToInt(),
|
||||||
|
(port.top - origin.y).roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.size(
|
||||||
|
width = with(density) { port.width.toDp() },
|
||||||
|
height = with(density) { port.height.toDp() },
|
||||||
|
)
|
||||||
|
.clipToBounds()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val pieceOrigin = port?.topLeft ?: origin
|
||||||
|
drag.pieces.forEachIndexed { index, piece ->
|
||||||
|
DragCopy(
|
||||||
|
topLeftInRoot = piece.topLeftInRoot,
|
||||||
|
overlayOrigin = pieceOrigin,
|
||||||
|
sizePx = piece.sizePx,
|
||||||
|
paint = paint,
|
||||||
|
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
|
||||||
|
cuts = timedBlockCuts(piece.continuesBefore, piece.continuesAfter),
|
||||||
|
lift = lift,
|
||||||
|
alpha = copyAlpha,
|
||||||
|
title = title,
|
||||||
|
titleLines = drag.titleLines,
|
||||||
|
// Once for the whole event: repeated, it would name it per day.
|
||||||
|
range = range.takeIf { index == labelled },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -692,14 +777,50 @@ private fun DragCopy(
|
|||||||
topLeftInRoot: Offset,
|
topLeftInRoot: Offset,
|
||||||
overlayOrigin: Offset,
|
overlayOrigin: Offset,
|
||||||
sizePx: IntSize,
|
sizePx: IntSize,
|
||||||
fill: Color,
|
paint: EventPaint,
|
||||||
shape: RoundedCornerShape,
|
shape: RoundedCornerShape,
|
||||||
|
cuts: ChipCuts,
|
||||||
lift: Float,
|
lift: Float,
|
||||||
alpha: Float,
|
alpha: Float,
|
||||||
title: String,
|
title: String,
|
||||||
label: String?,
|
titleLines: Int,
|
||||||
|
range: Pair<String, String>?,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val width = with(density) { sizePx.width.toDp() }
|
||||||
|
val height = with(density) { sizePx.height.toDp() }
|
||||||
|
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||||
|
// The block's size, spent the block's way — text sits at the top as it does
|
||||||
|
// on the block, so the copy hands back to the grid without shifting (#267),
|
||||||
|
// and it squeezes its inset on the same terms so a short block's title does
|
||||||
|
// not vanish the moment it is lifted (#289).
|
||||||
|
// The title is served in full first and the range lives off what is left:
|
||||||
|
// the hour gutter down the side still says where the copy sits, so the
|
||||||
|
// range is the half that can afford to go.
|
||||||
|
val metrics = rememberBlockTextMetrics(height)
|
||||||
|
val allowed = titleLines.coerceAtMost(metrics.titleBudget(metrics.available))
|
||||||
|
// Re-measured at the copy's own width rather than spent on the source's
|
||||||
|
// count: a block sharing its column with another is a lane wide where the
|
||||||
|
// copy is a whole column, so the source's second line is one the copy never
|
||||||
|
// draws — and reserving it took the range away with it (#267).
|
||||||
|
val lines = if (allowed <= 0) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
blockTextLines(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
|
||||||
|
textWidth = textWidth,
|
||||||
|
max = allowed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val left = metrics.available - metrics.titleHeight(lines)
|
||||||
|
val label = range?.let { rememberBlockTimeRange(it.first, it.second, textWidth) }
|
||||||
|
val showTime = label != null && left >= metrics.timeLine
|
||||||
|
val timeMaxLines = if (showTime) {
|
||||||
|
blockTimeLines(label!!, textWidth, left - metrics.timeLine)
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
// Absolute: these are root coordinates, and the direction-aware
|
// Absolute: these are root coordinates, and the direction-aware
|
||||||
@@ -710,12 +831,20 @@ private fun DragCopy(
|
|||||||
(topLeftInRoot.y - overlayOrigin.y).roundToInt(),
|
(topLeftInRoot.y - overlayOrigin.y).roundToInt(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.size(
|
// Required: the parent is the viewport's size, and a plain `size`
|
||||||
width = with(density) { sizePx.width.toDp() },
|
// would let it cap a piece a whole day tall at one screen — drawn
|
||||||
height = with(density) { sizePx.height.toDp() },
|
// from a midnight scrolled off the top, that ended the column a
|
||||||
)
|
// scroll offset short of its own bottom (#267). The parent clips it.
|
||||||
.padding(horizontal = 1.dp)
|
.requiredSize(width = width, height = height)
|
||||||
|
.padding(horizontal = BLOCK_OUTER_INSET)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
|
// Anchored at the top edge, not the middle: scaled about the
|
||||||
|
// centre, the 2% lift raises the top by 1% of the height — a few
|
||||||
|
// pixels on an hour, but a steady upward creep of the text as a
|
||||||
|
// dragged multi-day piece grows, and a shear against the
|
||||||
|
// timeline's clip. It snapped back when the lift animated out
|
||||||
|
// on drop (#267).
|
||||||
|
transformOrigin = TransformOrigin(0.5f, 0f)
|
||||||
scaleX = 1f + 0.02f * lift
|
scaleX = 1f + 0.02f * lift
|
||||||
scaleY = 1f + 0.02f * lift
|
scaleY = 1f + 0.02f * lift
|
||||||
shadowElevation = 8.dp.toPx() * lift
|
shadowElevation = 8.dp.toPx() * lift
|
||||||
@@ -723,24 +852,29 @@ private fun DragCopy(
|
|||||||
this.shape = shape
|
this.shape = shape
|
||||||
clip = false
|
clip = false
|
||||||
}
|
}
|
||||||
.background(fill, shape)
|
.eventSurface(paint, shape, cuts)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
if (lines > 0) {
|
||||||
text = title,
|
BlockTitle(
|
||||||
style = MaterialTheme.typography.labelMedium,
|
title = title,
|
||||||
maxLines = 1,
|
maxLines = lines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
color = paint.titleInk,
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
textDecoration = paint.decoration,
|
||||||
)
|
fontWeight = paint.titleWeight,
|
||||||
if (label != null) {
|
)
|
||||||
|
}
|
||||||
|
if (showTime) {
|
||||||
|
val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1)
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
// As the block it lifted off sets its own time (#219).
|
||||||
maxLines = 1,
|
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||||
overflow = TextOverflow.Ellipsis,
|
maxLines = timeMaxLines,
|
||||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
overflow = overflow.overflow,
|
||||||
|
softWrap = overflow.softWrap,
|
||||||
|
color = paint.secondaryInk,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hours the week and day timelines fit to the screen when they open (#322).
|
||||||
|
*
|
||||||
|
* Nothing outside the range is hidden: [TimelineScale.FitHours] only sizes the
|
||||||
|
* hour so that [startHour]..[endHour] fills the viewport, and the timeline opens
|
||||||
|
* scrolled to [startHour]. The hours are kept when [enabled] is switched off, so
|
||||||
|
* switching it back on restores the chosen range.
|
||||||
|
*/
|
||||||
|
data class TimelineHours(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val startHour: Int = DEFAULT_START_HOUR,
|
||||||
|
val endHour: Int = DEFAULT_END_HOUR,
|
||||||
|
) {
|
||||||
|
val span: Int get() = endHour - startHour
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_START_HOUR = 7
|
||||||
|
const val DEFAULT_END_HOUR = 22
|
||||||
|
|
||||||
|
/** Shortest range that can be chosen, in hours. */
|
||||||
|
const val MIN_SPAN = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A range with [startHour] and [endHour] held to the day and at least
|
||||||
|
* [MIN_SPAN] apart; the start gives way when the two collide.
|
||||||
|
*/
|
||||||
|
fun of(enabled: Boolean, startHour: Int, endHour: Int): TimelineHours {
|
||||||
|
val end = endHour.coerceIn(MIN_SPAN, 24)
|
||||||
|
val start = startHour.coerceIn(0, end - MIN_SPAN)
|
||||||
|
return TimelineHours(enabled, start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start hours that still leave [MIN_SPAN] before [endHour]. */
|
||||||
|
fun startOptions(endHour: Int): List<Int> = (0..endHour - MIN_SPAN).toList()
|
||||||
|
|
||||||
|
/** End hours at least [MIN_SPAN] after [startHour]; 24 is midnight. */
|
||||||
|
fun endOptions(startHour: Int): List<Int> = (startHour + MIN_SPAN..24).toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scroll offset that puts [startHour] at the top of a timeline laid out
|
||||||
|
* with [maxValue] scroll range over a [viewportPx] viewport, or null while that
|
||||||
|
* layout is not yet at [hourPx] per hour — the scale changes a frame before the
|
||||||
|
* content grows to match it, and scrolling against the old layout would land on
|
||||||
|
* the wrong hour.
|
||||||
|
*/
|
||||||
|
internal fun startHourScroll(maxValue: Int, viewportPx: Int, startHour: Int, hourPx: Float): Int? {
|
||||||
|
if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null
|
||||||
|
val laidOutHourPx = (maxValue + viewportPx) / 24f
|
||||||
|
if (abs(laidOutHourPx - hourPx) > 1f) return null
|
||||||
|
return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue)
|
||||||
|
}
|
||||||
@@ -38,6 +38,12 @@ sealed interface TimelineScale {
|
|||||||
/** A height the user pinched to. Build it through [custom], which clamps. */
|
/** A height the user pinched to. Build it through [custom], which clamps. */
|
||||||
data class Custom(val hourHeight: Dp) : TimelineScale
|
data class Custom(val hourHeight: Dp) : TimelineScale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [startHour]..[endHour] fills the viewport (#322). Applied when a timeline
|
||||||
|
* opens with [TimelineHours] on, never picked from the preset list.
|
||||||
|
*/
|
||||||
|
data class FitHours(val startHour: Int, val endHour: Int) : TimelineScale
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The named steps the settings picker offers, coarse to roomy. */
|
/** The named steps the settings picker offers, coarse to roomy. */
|
||||||
val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable)
|
val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable)
|
||||||
@@ -74,6 +80,9 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
|
|||||||
is TimelineScale.Custom -> this.hourHeight
|
is TimelineScale.Custom -> this.hourHeight
|
||||||
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
|
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
|
||||||
.coerceAtLeast(fillHourHeight(viewportHeight))
|
.coerceAtLeast(fillHourHeight(viewportHeight))
|
||||||
|
is TimelineScale.FitHours -> (viewportHeight / (endHour - startHour).coerceIn(1, 24).toFloat())
|
||||||
|
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
|
||||||
|
.coerceAtLeast(fillHourHeight(viewportHeight))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,17 +105,6 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
|
|||||||
*/
|
*/
|
||||||
const val MIN_EVENT_FRACTION = 26f / 60f
|
const val MIN_EVENT_FRACTION = 26f / 60f
|
||||||
|
|
||||||
/**
|
|
||||||
* Narrowest an event block may be and still wrap its title over several lines.
|
|
||||||
*
|
|
||||||
* Wrapping is driven by the block's height, so a tall block on a lane-split
|
|
||||||
* column would otherwise stack two or three characters per line — "Da/ily",
|
|
||||||
* "Fa/rmer/s…" — which reads worse than one ellipsised line. A full week column
|
|
||||||
* clears this on any phone; a split one never does, while the day view's much
|
|
||||||
* wider columns keep wrapping even several lanes deep.
|
|
||||||
*/
|
|
||||||
val MIN_TITLE_WRAP_WIDTH = 36.dp
|
|
||||||
|
|
||||||
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
|
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
|
||||||
val FIT_DAY_MIN = 24.dp
|
val FIT_DAY_MIN = 24.dp
|
||||||
|
|
||||||
@@ -145,6 +143,7 @@ val TimelineScale.labelRes: Int
|
|||||||
TimelineScale.Regular -> R.string.timeline_scale_regular
|
TimelineScale.Regular -> R.string.timeline_scale_regular
|
||||||
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
|
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
|
||||||
is TimelineScale.Custom -> R.string.timeline_scale_custom
|
is TimelineScale.Custom -> R.string.timeline_scale_custom
|
||||||
|
is TimelineScale.FitHours -> R.string.settings_visible_hours
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
@@ -155,11 +154,15 @@ val TimelineScale.descriptionRes: Int
|
|||||||
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
|
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
|
||||||
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
|
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
|
||||||
is TimelineScale.Custom -> R.string.timeline_scale_custom_summary
|
is TimelineScale.Custom -> R.string.timeline_scale_custom_summary
|
||||||
|
is TimelineScale.FitHours -> R.string.settings_visible_hours_summary
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Marks a stored custom height; the rest of the value is its dp. */
|
/** Marks a stored custom height; the rest of the value is its dp. */
|
||||||
private const val CUSTOM_PREFIX = "custom:"
|
private const val CUSTOM_PREFIX = "custom:"
|
||||||
|
|
||||||
|
/** Marks a stored hour range, as `start-end`. */
|
||||||
|
private const val HOURS_PREFIX = "hours:"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stored names for the presets. Spelled out rather than taken from `toString()`
|
* Stored names for the presets. Spelled out rather than taken from `toString()`
|
||||||
* so R8 can't rename them out from under an existing install — and they match
|
* so R8 can't rename them out from under an existing install — and they match
|
||||||
@@ -182,6 +185,7 @@ private val PRESET_NAMES: Map<TimelineScale, String> = mapOf(
|
|||||||
*/
|
*/
|
||||||
fun TimelineScale.storageValue(): String = when (this) {
|
fun TimelineScale.storageValue(): String = when (this) {
|
||||||
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
|
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
|
||||||
|
is TimelineScale.FitHours -> "$HOURS_PREFIX$startHour-$endHour"
|
||||||
TimelineScale.FitDay,
|
TimelineScale.FitDay,
|
||||||
TimelineScale.Compact,
|
TimelineScale.Compact,
|
||||||
TimelineScale.Regular,
|
TimelineScale.Regular,
|
||||||
@@ -199,5 +203,11 @@ fun parseTimelineScale(stored: String?): TimelineScale = when {
|
|||||||
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
|
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
|
||||||
?.let { TimelineScale.custom(it.dp) }
|
?.let { TimelineScale.custom(it.dp) }
|
||||||
?: TimelineScale.Regular
|
?: TimelineScale.Regular
|
||||||
|
stored.startsWith(HOURS_PREFIX) -> stored.removePrefix(HOURS_PREFIX).split('-')
|
||||||
|
.mapNotNull { it.toIntOrNull() }
|
||||||
|
.takeIf { it.size == 2 }
|
||||||
|
?.let { (start, end) -> TimelineHours.of(enabled = true, start, end) }
|
||||||
|
?.let { TimelineScale.FitHours(it.startHour, it.endHour) }
|
||||||
|
?: TimelineScale.Regular
|
||||||
else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular
|
else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where 00:00 sat in the last timeline shown, measured from the top of its
|
||||||
|
* view's content, so the next timeline view opens on the same hours (#184): an
|
||||||
|
* event in both keeps its place and only grows sideways.
|
||||||
|
*/
|
||||||
|
class TimelineScrollMemory {
|
||||||
|
var midnightY: Float? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalTimelineScroll = staticCompositionLocalOf<TimelineScrollMemory?> { null }
|
||||||
|
|
||||||
|
/** A timeline's scroll, and where it opens before it can scroll there. */
|
||||||
|
@Stable
|
||||||
|
class TimelineScroll(val state: ScrollState, noonPending: Boolean, private val openingHour: Int?) {
|
||||||
|
/** Whether it is still to be put on noon, which it only can be once laid out. */
|
||||||
|
var noonPending by mutableStateOf(noonPending)
|
||||||
|
internal set
|
||||||
|
|
||||||
|
/** The top of what it shows, or will once open, over [content] pixels in a [viewport] pixels high. */
|
||||||
|
fun top(viewport: Float, content: Float): Float =
|
||||||
|
if (noonPending) {
|
||||||
|
(openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f)
|
||||||
|
.coerceIn(0f, (content - viewport).coerceAtLeast(0f))
|
||||||
|
} else state.value.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalOpeningTimeline = staticCompositionLocalOf<TimelineScroll?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scroll state of a timeline whose scrolling area starts [timelineTop] below
|
||||||
|
* its view's content top. Opens where the last timeline left off, or on noon the
|
||||||
|
* first time.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
|
||||||
|
val memory = LocalTimelineScroll.current
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val zoom = LocalTimelineZoom.current
|
||||||
|
val topPx = with(density) { timelineTop.toPx() }
|
||||||
|
val remembered = memory?.midnightY
|
||||||
|
val scrollState = rememberScrollState(
|
||||||
|
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
|
||||||
|
)
|
||||||
|
val scroll = remember(scrollState) {
|
||||||
|
TimelineScroll(
|
||||||
|
scrollState,
|
||||||
|
noonPending = remembered == null,
|
||||||
|
openingHour = zoom.hours.takeIf { it.enabled }?.startHour,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val currentTop by rememberUpdatedState(topPx)
|
||||||
|
var firstOpen by remember(scrollState) { mutableStateOf(true) }
|
||||||
|
LaunchedEffect(scrollState, zoom, zoom.hours) {
|
||||||
|
if (firstOpen && remembered != null) {
|
||||||
|
firstOpen = false
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
firstOpen = false
|
||||||
|
val startHour = zoom.open()
|
||||||
|
if (startHour == null) {
|
||||||
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
|
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||||
|
} else {
|
||||||
|
// Fitted to the scroll viewport itself: the layout resolves scales
|
||||||
|
// against the viewport plus the all-day strip, which would push the
|
||||||
|
// end hour below the screen.
|
||||||
|
val viewport = snapshotFlow { scrollState.viewportSize }.first { it > 0 }
|
||||||
|
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
|
||||||
|
zoom.holdFit(hourHeight)
|
||||||
|
val hourPx = with(density) { hourHeight.toPx() }
|
||||||
|
val target = withTimeoutOrNull(FIT_LAYOUT_TIMEOUT_MILLIS) {
|
||||||
|
snapshotFlow {
|
||||||
|
startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx)
|
||||||
|
}.filterNotNull().first()
|
||||||
|
} ?: (startHour * (scrollState.maxValue + scrollState.viewportSize) / 24)
|
||||||
|
.coerceIn(0, scrollState.maxValue.coerceAtLeast(0))
|
||||||
|
scrollState.scrollTo(target)
|
||||||
|
}
|
||||||
|
scroll.noonPending = false
|
||||||
|
}
|
||||||
|
if (memory != null) {
|
||||||
|
LaunchedEffect(scrollState, memory) {
|
||||||
|
snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long an opening timeline waits to be laid out at its fitted hours before scrolling anyway. */
|
||||||
|
private const val FIT_LAYOUT_TIMEOUT_MILLIS = 500L
|
||||||
@@ -19,7 +19,6 @@ import androidx.compose.ui.input.pointer.PointerEventPass
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.ceil
|
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -50,13 +49,49 @@ class TimelineZoom(
|
|||||||
var isPinching: Boolean by mutableStateOf(false)
|
var isPinching: Boolean by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/** The visible-hours preference (#322); a timeline re-fits when it changes. */
|
||||||
|
var hours: TimelineHours by mutableStateOf(TimelineHours())
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var stored: TimelineScale = initial
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Take a value that came from the preference. Ignored mid-pinch: the stored
|
* Take a value that came from the preference. Ignored mid-pinch: the stored
|
||||||
* value is a frame or two behind the fingers there, and letting it land would
|
* value is a frame or two behind the fingers there, and letting it land would
|
||||||
* snap the timeline back while the user is still pinching.
|
* snap the timeline back while the user is still pinching. With visible
|
||||||
|
* hours on it is only remembered, so a late-arriving preference can't undo
|
||||||
|
* the fit a timeline just opened with.
|
||||||
*/
|
*/
|
||||||
fun adopt(stored: TimelineScale) {
|
fun adopt(stored: TimelineScale) {
|
||||||
if (!isPinching) scale = stored
|
this.stored = stored
|
||||||
|
if (!isPinching && !hours.enabled) scale = stored
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take the visible-hours preference; switching it off hands back the stored scale. */
|
||||||
|
fun adoptHours(hours: TimelineHours) {
|
||||||
|
val wasEnabled = this.hours.enabled
|
||||||
|
this.hours = hours
|
||||||
|
if (wasEnabled && !hours.enabled && !isPinching) scale = stored
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A timeline is opening. With visible hours on, the range replaces whatever
|
||||||
|
* scale is in force — a stored preset and a pinch alike — and its start hour
|
||||||
|
* is returned for the timeline to scroll to; otherwise null.
|
||||||
|
*/
|
||||||
|
fun open(): Int? {
|
||||||
|
val hours = hours
|
||||||
|
if (!hours.enabled) return null
|
||||||
|
if (!isPinching) scale = TimelineScale.FitHours(hours.startHour, hours.endHour)
|
||||||
|
return hours.startHour
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the height [open] fitted, so a viewport that changes afterwards (the
|
||||||
|
* all-day strip following a swipe) doesn't rescale the hours under the scroll.
|
||||||
|
*/
|
||||||
|
fun holdFit(hourHeight: Dp) {
|
||||||
|
if (!isPinching && scale is TimelineScale.FitHours) scale = TimelineScale.Custom(hourHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun beginPinch() {
|
fun beginPinch() {
|
||||||
@@ -67,9 +102,10 @@ class TimelineZoom(
|
|||||||
scale = TimelineScale.custom(hourHeight)
|
scale = TimelineScale.custom(hourHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Settles the pinch. With visible hours on it only lasts until the next [open]. */
|
||||||
fun endPinch() {
|
fun endPinch() {
|
||||||
isPinching = false
|
isPinching = false
|
||||||
persist(scale)
|
if (!hours.enabled) persist(scale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,16 +119,18 @@ private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) {
|
|||||||
val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom }
|
val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches
|
* The app-wide [TimelineZoom], seeded from [stored] and [hours] and writing
|
||||||
* back through [onPersist].
|
* settled pinches back through [onPersist].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberTimelineZoom(
|
fun rememberTimelineZoom(
|
||||||
stored: TimelineScale,
|
stored: TimelineScale,
|
||||||
|
hours: TimelineHours,
|
||||||
onPersist: (TimelineScale) -> Unit,
|
onPersist: (TimelineScale) -> Unit,
|
||||||
): TimelineZoom {
|
): TimelineZoom {
|
||||||
val persist by rememberUpdatedState(onPersist)
|
val persist by rememberUpdatedState(onPersist)
|
||||||
val zoom = remember { TimelineZoom(stored) { persist(it) } }
|
val zoom = remember { TimelineZoom(stored) { persist(it) } }
|
||||||
|
LaunchedEffect(hours) { zoom.adoptHours(hours) }
|
||||||
// Picking a preset in Settings has to reach the timelines, and so does the
|
// Picking a preset in Settings has to reach the timelines, and so does the
|
||||||
// stored value arriving after the first frame.
|
// stored value arriving after the first frame.
|
||||||
LaunchedEffect(stored) { zoom.adopt(stored) }
|
LaunchedEffect(stored) { zoom.adopt(stored) }
|
||||||
@@ -197,18 +235,26 @@ fun rememberTimelinePinchZoom(
|
|||||||
* names, jumping the whole column as a pinch drifts across each half pixel.
|
* names, jumping the whole column as a pinch drifts across each half pixel.
|
||||||
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
|
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
|
||||||
*
|
*
|
||||||
* The bounds themselves are pulled onto that grid too, each in the direction
|
* The ceiling is pulled onto that grid too, downwards, so it stays a height the
|
||||||
* that keeps its own promise — up for the fill floor, so no dead space opens
|
* pinch can actually land on.
|
||||||
* 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
|
* [fillPx] is deliberately *not* rounded (#290). It is the one height the whole
|
||||||
* the focal anchor a scroll correction on every frame the fingers sit still.
|
* day exactly fills the viewport at, and it is the same value
|
||||||
|
* [TimelineScale.FitDay] resolves to — rounding it up by the fraction of a pixel
|
||||||
|
* that 24 hours don't divide the viewport into leaves the timeline a pixel per
|
||||||
|
* hour taller than its own viewport, so a pinched-all-the-way-out day still
|
||||||
|
* scrolls a hair and bounces off Android's overscroll stretch, while the
|
||||||
|
* identical FitDay preset sits still. Being the clamp result rather than a bound
|
||||||
|
* the gesture is merely held against, it is a height the pinch does land on: the
|
||||||
|
* next frame reads it back unchanged and the focal anchor is handed nothing to
|
||||||
|
* correct.
|
||||||
*/
|
*/
|
||||||
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
|
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
|
||||||
// Filling the viewport wins over the ceiling: on a screen tall enough for
|
// Filling the viewport wins over the ceiling: on a screen tall enough for
|
||||||
// the two to disagree, dead space is the worse of the two failures.
|
// the two to disagree, dead space is the worse of the two failures.
|
||||||
target.roundToInt().toFloat()
|
target.roundToInt().toFloat()
|
||||||
.coerceAtMost(floor(maxPx))
|
.coerceAtMost(floor(maxPx))
|
||||||
.coerceAtLeast(ceil(fillPx))
|
.coerceAtLeast(fillPx)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The scroll offset that keeps the moment under [centroidY] under it after the
|
* The scroll offset that keeps the moment under [centroidY] under it after the
|
||||||
|
|||||||
@@ -1,26 +1,82 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.material.icons.filled.Today
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
|
private val GlyphShape = RoundedCornerShape(7.dp)
|
||||||
|
private val GlyphBorder = 1.5.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height of the day number. Expressed in dp and converted to sp at draw time so
|
||||||
|
* it does not follow the font scale: this is an icon glyph sharing the bar with
|
||||||
|
* stroke icons, and a two-digit day that grew with the text would push the bar
|
||||||
|
* past the width #165 measured it to. The button's content description carries
|
||||||
|
* the meaning for screen readers, so nothing is lost by holding it still.
|
||||||
|
*/
|
||||||
|
private val GlyphTextHeight = 12.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The top-bar "jump to today" icon button, shared by every calendar view's app
|
* The top-bar "jump to today" icon button, shared by every calendar view's app
|
||||||
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
|
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
|
||||||
* today control into the toolbar; renders nothing when [show] is false, so it
|
* today control into the toolbar; renders nothing when [show] is false, so it
|
||||||
* drops straight into an app bar's `actions` slot without a wrapping condition.
|
* drops straight into an app bar's `actions` slot without a wrapping condition.
|
||||||
|
*
|
||||||
|
* The glyph is the current day number in an outlined box rather than a generic
|
||||||
|
* calendar icon (#220), so the bar also says what day it is. Outlined rather
|
||||||
|
* than the month grid's filled circle, which reads as "this cell is today" and
|
||||||
|
* would sit heavier here than the stroke icons beside it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TodayAction(show: Boolean, onToday: () -> Unit) {
|
fun TodayAction(show: Boolean, onToday: () -> Unit) {
|
||||||
if (!show) return
|
if (!show) return
|
||||||
IconButton(onClick = onToday) {
|
val now = rememberCurrentMinute()
|
||||||
Icon(
|
// Only the day number is read, so the bar recomposes at midnight rather
|
||||||
imageVector = Icons.Default.Today,
|
// than on every minute tick.
|
||||||
contentDescription = stringResource(R.string.today_jump_action),
|
val day by remember {
|
||||||
)
|
derivedStateOf { now.value.toLocalDateTime(TimeZone.currentSystemDefault()).date.day }
|
||||||
|
}
|
||||||
|
val description = stringResource(R.string.today_jump_action)
|
||||||
|
IconButton(
|
||||||
|
onClick = onToday,
|
||||||
|
modifier = Modifier
|
||||||
|
.semantics { contentDescription = description },
|
||||||
|
) {
|
||||||
|
val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(AppBarSpacing.IconSize)
|
||||||
|
.border(GlyphBorder, LocalContentColor.current, GlyphShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = day.toString(),
|
||||||
|
fontSize = textSize,
|
||||||
|
lineHeight = textSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = LocalContentColor.current,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date every calendar view is looking at (#184), so switching views keeps
|
||||||
|
* the same days on screen and what is shown in both can travel between them.
|
||||||
|
*
|
||||||
|
* A view reads it once, as it comes on screen, and writes it back only when the
|
||||||
|
* user moves: a view settling where it opened must not nudge it, or switching
|
||||||
|
* back and forth would drift.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class ViewFocus(initial: LocalDate) {
|
||||||
|
var date by mutableStateOf(initial)
|
||||||
|
/** Explicit navigation, including a request for the view already on screen. */
|
||||||
|
var navigationVersion by mutableStateOf(0)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun navigateTo(target: LocalDate) {
|
||||||
|
date = target
|
||||||
|
navigationVersion++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ViewFocusSaver: Saver<ViewFocus, String> = Saver(
|
||||||
|
save = { it.date.toString() },
|
||||||
|
restore = { ViewFocus(LocalDate.parse(it)) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val LocalViewFocus = staticCompositionLocalOf<ViewFocus?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [enter] with the focused date once, as the view comes on screen, before
|
||||||
|
* it reads its own position: its first frame is then already on that date.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T : Any> EnterOnFocus(key: T, enter: (LocalDate) -> Unit) {
|
||||||
|
val focus = LocalViewFocus.current
|
||||||
|
remember(key) { focus?.date?.also(enter) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.BoundsTransform
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.mutableStateSetOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the views share when switching between them (#184): an event visible in
|
||||||
|
* both grows from where it sat into where it lands, a date label travels to its
|
||||||
|
* new seat, and everything else fades through.
|
||||||
|
*
|
||||||
|
* An element is tagged with the day it is drawn *from*, not just its instance: a
|
||||||
|
* multi-day event is drawn once per day, per week row or per timeline column,
|
||||||
|
* and only one element per key may take part. The segment that starts on the
|
||||||
|
* same day in both views is the one that travels; the rest fade where they are.
|
||||||
|
*/
|
||||||
|
sealed interface ViewMorphKey {
|
||||||
|
data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey
|
||||||
|
|
||||||
|
data class Day(val date: LocalDate) : ViewMorphKey
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** [event]'s occurrence as drawn from [day]. */
|
||||||
|
fun event(event: EventInstance, day: LocalDate): Event =
|
||||||
|
Event(event.eventId, event.start.toEpochMilliseconds(), day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The view switch's scopes and specs, captured where they exist. Null under reduced motion. */
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
class ViewMorphScope(
|
||||||
|
val shared: SharedTransitionScope,
|
||||||
|
val visibility: AnimatedVisibilityScope,
|
||||||
|
val boundsTransform: BoundsTransform,
|
||||||
|
val fadeSpec: FiniteAnimationSpec<Float>,
|
||||||
|
val clip: BelowTopBarClip,
|
||||||
|
val stayingPut: StayingPut,
|
||||||
|
/** Read while matching, so a tag can sit out without leaving the modifier chain. */
|
||||||
|
val enabled: () -> Boolean = { true },
|
||||||
|
) {
|
||||||
|
/** This scope with its tags switched off while [enabled] is false. */
|
||||||
|
fun gated(enabled: () -> Boolean): ViewMorphScope =
|
||||||
|
ViewMorphScope(shared, visibility, boundsTransform, fadeSpec, clip, stayingPut, enabled)
|
||||||
|
|
||||||
|
internal val config = object : SharedTransitionScope.SharedContentConfig {
|
||||||
|
override val SharedTransitionScope.SharedContentState.isEnabled: Boolean
|
||||||
|
get() = enabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The events a view has but keeps out of the switch, being scrolled out of
|
||||||
|
* sight: the other view leaves them untagged too, or its half would travel to
|
||||||
|
* nowhere and snap into place at the end.
|
||||||
|
*/
|
||||||
|
class StayingPut {
|
||||||
|
val keys = mutableStateSetOf<ViewMorphKey.Event>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks [key] as staying put while [stays], for as long as it is composed. */
|
||||||
|
@Composable
|
||||||
|
fun StayPut(key: ViewMorphKey.Event, stays: Boolean) {
|
||||||
|
val stayingPut = LocalViewMorph.current?.stayingPut ?: return
|
||||||
|
if (!stays) return
|
||||||
|
DisposableEffect(stayingPut, key) {
|
||||||
|
stayingPut.keys += key
|
||||||
|
onDispose { stayingPut.keys -= key }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps what travels below the top bar, which every view has at the same height:
|
||||||
|
* the overlay draws above the whole screen, so an event scrolled under the bar
|
||||||
|
* would otherwise cross it. [top] is the bar's bottom edge in the shared layout.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
class BelowTopBarClip : SharedTransitionScope.OverlayClip {
|
||||||
|
var top = 0f
|
||||||
|
|
||||||
|
override fun getClipPath(
|
||||||
|
sharedContentState: SharedTransitionScope.SharedContentState,
|
||||||
|
bounds: Rect,
|
||||||
|
layoutDirection: LayoutDirection,
|
||||||
|
density: Density,
|
||||||
|
): Path = Path().apply { addRect(Rect(-FAR, top, FAR, FAR)) }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FAR = 1e6f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalViewMorph = compositionLocalOf<ViewMorphScope?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a pager page may carry the morph tags: only the settled one. Its
|
||||||
|
* neighbours are composed ahead and repeat some of its dates, which would both
|
||||||
|
* claim its keys and fly in from off screen.
|
||||||
|
*/
|
||||||
|
fun morphPageActive(page: Int, settledPage: Int): Boolean = page == settledPage
|
||||||
|
|
||||||
|
/** Provides the view morph to [content] only while [active] — see [morphPageActive]. */
|
||||||
|
@Composable
|
||||||
|
fun ViewMorphPage(active: Boolean, content: @Composable () -> Unit) {
|
||||||
|
val morph = LocalViewMorph.current
|
||||||
|
CompositionLocalProvider(LocalViewMorph provides morph.takeIf { active }, content = content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags an event chip, bar, block or row. Its bounds are shared and the two faces
|
||||||
|
* cross-fade inside them; remeasured rather than scaled, so a title keeps its
|
||||||
|
* real size and is clipped while there is no room, instead of smearing.
|
||||||
|
*
|
||||||
|
* Goes after the element's offset and before its size, so the travelling bounds
|
||||||
|
* drive the measurement.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier {
|
||||||
|
val morph = LocalViewMorph.current ?: return this
|
||||||
|
if (key in morph.stayingPut.keys) return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphEvent.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(key, morph.config),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
enter = fadeIn(morph.fadeSpec),
|
||||||
|
exit = fadeOut(morph.fadeSpec),
|
||||||
|
boundsTransform = morph.boundsTransform,
|
||||||
|
clipInOverlayDuringTransition = morph.clip,
|
||||||
|
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||||
|
zIndexInOverlay = 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tags a day's number, which is small on both sides, so it scales on its way. */
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
fun Modifier.morphDay(date: LocalDate): Modifier {
|
||||||
|
val morph = LocalViewMorph.current ?: return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphDay.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(ViewMorphKey.Day(date), morph.config),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
enter = fadeIn(morph.fadeSpec),
|
||||||
|
exit = fadeOut(morph.fadeSpec),
|
||||||
|
boundsTransform = morph.boundsTransform,
|
||||||
|
clipInOverlayDuringTransition = morph.clip,
|
||||||
|
zIndexInOverlay = 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The view switch's travel, on the same spring as the timeline's columns resizing. */
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
fun rememberViewMorphBoundsTransform(): BoundsTransform {
|
||||||
|
val spec = rememberViewSwitchSpec<Rect>()
|
||||||
|
return remember(spec) { BoundsTransform { _, _ -> spec } }
|
||||||
|
}
|
||||||
@@ -1,27 +1,57 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.unit.Dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-bar pill that shows the current view and cycles to the next one on tap
|
* Top-bar pill that shows the current view and cycles to the next one on tap
|
||||||
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
|
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
|
||||||
|
*
|
||||||
|
* Renders nothing when [cycle] holds fewer than [QuickSwitchConfig.MIN_CYCLE]
|
||||||
|
* views (#150), so it drops straight into an app bar's `actions` slot without a
|
||||||
|
* wrapping condition — the same way [TodayAction] handles being turned off.
|
||||||
|
*
|
||||||
|
* [trailingInset] lands the pill's background on the bar's own rhythm; pass
|
||||||
|
* `0.dp` outside a top app bar, where M3's actions padding is not there to
|
||||||
|
* cancel (#165).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ViewSwitcherPill(
|
fun ViewSwitcherPill(
|
||||||
current: CalendarView,
|
current: CalendarView,
|
||||||
|
cycle: List<CalendarView>,
|
||||||
onCycle: () -> Unit,
|
onCycle: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
|
||||||
) {
|
) {
|
||||||
|
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
|
||||||
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = onCycle,
|
onClick = onCycle,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
modifier = modifier,
|
modifier = modifier
|
||||||
|
.padding(end = trailingInset),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(current.labelRes))
|
// The label cross-fades and the pill eases to its width, so cycling
|
||||||
|
// views turns it over rather than snapping it (#184).
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = current.label(),
|
||||||
|
transitionSpec = {
|
||||||
|
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { label ->
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.daysUntil
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
|
||||||
|
/** Pages the day pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
|
||||||
|
internal const val DAY_PAGE_COUNT: Int = 140_000
|
||||||
|
|
||||||
|
private const val EPOCH_PAGE = DAY_PAGE_COUNT / 2
|
||||||
|
|
||||||
|
/** The day at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||||
|
private val PAGE_EPOCH = LocalDate(2000, 1, 1)
|
||||||
|
|
||||||
|
/** The day shown on pager [page]. */
|
||||||
|
internal fun dayForPage(page: Int): LocalDate =
|
||||||
|
PAGE_EPOCH.plus(page - EPOCH_PAGE, DateTimeUnit.DAY)
|
||||||
|
|
||||||
|
/** The pager page showing [date]. */
|
||||||
|
internal fun dayPageFor(date: LocalDate): Int =
|
||||||
|
(EPOCH_PAGE + PAGE_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
|
||||||
@@ -1,838 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.ScrollState
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Menu
|
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.DrawerValue
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.rememberDrawerState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.key
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.customActions
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import java.util.Locale
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
|
||||||
|
|
||||||
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
|
||||||
internal fun DayUiState.Success.allDayStripHeight(): Dp {
|
|
||||||
if (allDay.isEmpty()) return 0.dp
|
|
||||||
val lanes = allDay.maxOf { it.lane } + 1
|
|
||||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun DayScreen(
|
|
||||||
selectedView: CalendarView,
|
|
||||||
onSelectView: (CalendarView) -> Unit,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
|
||||||
onOpenSettings: () -> Unit,
|
|
||||||
onOpenSearch: () -> Unit,
|
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
|
||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
|
||||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
|
||||||
todayInToolbar: Boolean = false,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
initialDateIso: String? = null,
|
|
||||||
viewModel: DayViewModel = hiltViewModel(),
|
|
||||||
) {
|
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
|
||||||
val date by viewModel.date.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
// When opened from the month grid, anchor to the tapped date.
|
|
||||||
LaunchedEffect(initialDateIso) {
|
|
||||||
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
// The all-day strip shares the app bar's scrolled colour so the whole top
|
|
||||||
// region elevates together once the timeline scrolls under it.
|
|
||||||
val topSectionColor by animateColorAsState(
|
|
||||||
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surface
|
|
||||||
},
|
|
||||||
label = "day-top-section-color",
|
|
||||||
)
|
|
||||||
|
|
||||||
val isOnToday = when (val s = state) {
|
|
||||||
is DayUiState.Success -> s.date == s.today
|
|
||||||
else -> true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drives whether the title carries the year. Falls back to the clock only
|
|
||||||
// while the first load is in flight, when there is no state to read today from.
|
|
||||||
val currentYear = when (val s = state) {
|
|
||||||
is DayUiState.Success -> s.today.year
|
|
||||||
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
|
||||||
var slideDir by remember { mutableIntStateOf(0) }
|
|
||||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
|
||||||
val goPrev = { slideDir = -1; viewModel.goToPrev() }
|
|
||||||
// Slide toward today: viewing the future → today comes in from the left
|
|
||||||
// (back), viewing the past → from the right (forward).
|
|
||||||
val jumpToToday = {
|
|
||||||
slideDir = when (val s = state) {
|
|
||||||
is DayUiState.Success -> if (s.today < s.date) -1 else 1
|
|
||||||
else -> 0
|
|
||||||
}
|
|
||||||
viewModel.goToToday()
|
|
||||||
}
|
|
||||||
// Drawer jump-to-date: slide from the side the target lies on.
|
|
||||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
|
||||||
slideDir = if (target < date) -1 else 1
|
|
||||||
viewModel.goToDate(target)
|
|
||||||
}
|
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
|
||||||
drawerState = drawerState,
|
|
||||||
// Open only via the menu button — edge-swipe would fight the day swipe.
|
|
||||||
gesturesEnabled = drawerState.isOpen,
|
|
||||||
drawerContent = {
|
|
||||||
CalendarDrawer(
|
|
||||||
currentView = selectedView,
|
|
||||||
currentDate = date,
|
|
||||||
drawerState = drawerState,
|
|
||||||
viewOrder = drawerViewOrder,
|
|
||||||
onSelectView = { view ->
|
|
||||||
onSelectView(view)
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
},
|
|
||||||
onJumpToDate = { target ->
|
|
||||||
jumpToDate(target)
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
},
|
|
||||||
onSettings = {
|
|
||||||
onOpenSettings()
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
|
||||||
topBar = {
|
|
||||||
DayTopBar(
|
|
||||||
date = date,
|
|
||||||
currentYear = currentYear,
|
|
||||||
selectedView = selectedView,
|
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
|
||||||
onOpenSearch = onOpenSearch,
|
|
||||||
onJumpToDate = jumpToDate,
|
|
||||||
showTodayButton = todayInToolbar,
|
|
||||||
onToday = jumpToToday,
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
floatingActionButton = {
|
|
||||||
CalendarFabColumn(
|
|
||||||
todayVisible = !isOnToday && !todayInToolbar,
|
|
||||||
todayText = stringResource(R.string.day_today_action),
|
|
||||||
onToday = jumpToToday,
|
|
||||||
onCreate = { onCreateEvent(date, null) },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
DayContent(
|
|
||||||
state = state,
|
|
||||||
slideDir = slideDir,
|
|
||||||
topSectionColor = topSectionColor,
|
|
||||||
onSwipeNext = goNext,
|
|
||||||
onSwipePrev = goPrev,
|
|
||||||
onRetry = jumpToToday,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(innerPadding)
|
|
||||||
.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DayContent(
|
|
||||||
state: DayUiState,
|
|
||||||
slideDir: Int,
|
|
||||||
topSectionColor: Color,
|
|
||||||
onSwipeNext: () -> Unit,
|
|
||||||
onSwipePrev: () -> Unit,
|
|
||||||
onRetry: () -> Unit,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
|
||||||
val reduceMotion = rememberReduceMotion()
|
|
||||||
|
|
||||||
// Hoisted above the per-day AnimatedContent so the vertical scroll position
|
|
||||||
// survives day-to-day swipes. We only centre on noon once, on first entry
|
|
||||||
// into the day view (i.e. when arriving from the month/week view).
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
|
||||||
// Half the scroll range *is* noon: the content spans a full 24 hours, so
|
|
||||||
// centring the range centres midday at whatever hour height is in force.
|
|
||||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
|
||||||
// day during a swipe, so the strip slides along but never jumps in height.
|
|
||||||
val targetAllDayHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp
|
|
||||||
val allDayHeight by animateDpAsState(
|
|
||||||
targetValue = targetAllDayHeight,
|
|
||||||
label = "day-all-day-strip-height",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Whole-page horizontal swipe, one level above the timeline's vertical
|
|
||||||
// scroll: a horizontal drag crosses this detector's slop, while a vertical
|
|
||||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
|
||||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
|
||||||
|
|
||||||
// Above the AnimatedContent: a page change mid-drag would strand the
|
|
||||||
// floating block inside the outgoing page.
|
|
||||||
val dragController = rememberTimelineDragController()
|
|
||||||
val move = LocalEventMove.current
|
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = state,
|
|
||||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
|
||||||
contentKey = { s ->
|
|
||||||
when (s) {
|
|
||||||
is DayUiState.Success -> "success-${s.date}"
|
|
||||||
is DayUiState.Failure -> "failure-${s.reason}"
|
|
||||||
DayUiState.Loading -> "loading"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
|
||||||
label = "day-transition",
|
|
||||||
) { s ->
|
|
||||||
when (s) {
|
|
||||||
DayUiState.Loading -> DayLoading()
|
|
||||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
|
||||||
is DayUiState.Success -> DaySuccess(
|
|
||||||
state = s,
|
|
||||||
topSectionColor = topSectionColor,
|
|
||||||
scrollState = scrollState,
|
|
||||||
allDayHeight = allDayHeight,
|
|
||||||
dragController = dragController,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onCreateAt = onCreateAt,
|
|
||||||
onDrop = { drop ->
|
|
||||||
move?.move(
|
|
||||||
MoveRequest(
|
|
||||||
eventId = drop.event.eventId,
|
|
||||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
|
||||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
|
||||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TimelineDragOverlay(dragController)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun DaySuccess(
|
|
||||||
state: DayUiState.Success,
|
|
||||||
topSectionColor: Color,
|
|
||||||
scrollState: ScrollState,
|
|
||||||
allDayHeight: Dp,
|
|
||||||
dragController: TimelineDragController,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
// All-day strip collapses to nothing when the day has no all-day events,
|
|
||||||
// so the timeline sits directly under the app bar.
|
|
||||||
AllDayStrip(
|
|
||||||
state = state,
|
|
||||||
height = allDayHeight,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(topSectionColor),
|
|
||||||
)
|
|
||||||
// Breathing room between the (colour-shifting) top section and the
|
|
||||||
// scrolling timeline below.
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Timeline(
|
|
||||||
state = state,
|
|
||||||
scrollState = scrollState,
|
|
||||||
dragController = dragController,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onCreateAt = onCreateAt,
|
|
||||||
onDrop = onDrop,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun DayTopBar(
|
|
||||||
date: LocalDate,
|
|
||||||
currentYear: Int,
|
|
||||||
selectedView: CalendarView,
|
|
||||||
onCycleView: () -> Unit,
|
|
||||||
onOpenDrawer: () -> Unit,
|
|
||||||
onOpenSearch: () -> Unit,
|
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
|
||||||
showTodayButton: Boolean,
|
|
||||||
onToday: () -> Unit,
|
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
|
||||||
) {
|
|
||||||
val locale = currentLocale()
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
CalendarTitleButton(
|
|
||||||
title = formatDayTitle(date, locale, currentYear),
|
|
||||||
currentDate = date,
|
|
||||||
onJumpToDate = onJumpToDate,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onOpenDrawer) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Menu,
|
|
||||||
contentDescription = stringResource(R.string.month_open_menu),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
TodayAction(show = showTodayButton, onToday = onToday)
|
|
||||||
IconButton(onClick = onOpenSearch) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Search,
|
|
||||||
contentDescription = stringResource(R.string.search_action),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ViewSwitcherPill(
|
|
||||||
current = selectedView,
|
|
||||||
onCycle = onCycleView,
|
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AllDayStrip(
|
|
||||||
state: DayUiState.Success,
|
|
||||||
height: Dp,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = modifier
|
|
||||||
// Height is hoisted + animated so it resizes smoothly; padding sits
|
|
||||||
// inside it so the content area is lanes * row height.
|
|
||||||
.height(height)
|
|
||||||
.padding(
|
|
||||||
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.
|
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
|
||||||
// Bars are positioned absolutely by lane (vertical stacking); each spans
|
|
||||||
// the full day-column width. clipToBounds keeps bars from spilling out
|
|
||||||
// while the height animates.
|
|
||||||
BoxWithConstraints(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.clipToBounds(),
|
|
||||||
) {
|
|
||||||
val barWidth = maxWidth
|
|
||||||
state.allDay.forEach { span ->
|
|
||||||
AllDayBar(
|
|
||||||
event = span.event,
|
|
||||||
dark = dark,
|
|
||||||
onClick = { onEventClick(span.event) },
|
|
||||||
modifier = Modifier
|
|
||||||
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
|
|
||||||
.width(barWidth)
|
|
||||||
.height(ALL_DAY_ROW_HEIGHT)
|
|
||||||
.padding(horizontal = 1.dp, vertical = 1.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AllDayBar(
|
|
||||||
event: EventInstance,
|
|
||||||
dark: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
|
||||||
val soften = LocalSoftenColors.current
|
|
||||||
val fill = eventFill(event.color, dark, soften)
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.background(fill, EventChipShape)
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
|
||||||
.semantics { contentDescription = title },
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = eventInk(fill),
|
|
||||||
textDecoration = declinedDecoration(event.isDeclined),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun Timeline(
|
|
||||||
state: DayUiState.Success,
|
|
||||||
scrollState: ScrollState,
|
|
||||||
dragController: TimelineDragController,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
) {
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
val zoom = LocalTimelineZoom.current
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
|
||||||
|
|
||||||
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
|
||||||
// timeline's own viewport height, which is only known here — below the top
|
|
||||||
// bar, date header and all-day strip.
|
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
|
||||||
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
|
||||||
val totalHeight = hourHeight * 24
|
|
||||||
// The pinch sits on the Row, above both scroll viewports: it has to
|
|
||||||
// outrank the vertical scroll, and it does that by watching the initial
|
|
||||||
// pass, which only reaches it if it is their ancestor.
|
|
||||||
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
|
||||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
|
||||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
|
||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
|
||||||
// soft corners are permanent at any scroll position.
|
|
||||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
|
||||||
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
|
||||||
// labels centre on the top bar hamburger, matching the week view.
|
|
||||||
HourGutter(
|
|
||||||
scrollState = scrollState,
|
|
||||||
hourHeight = hourHeight,
|
|
||||||
dragController = dragController,
|
|
||||||
)
|
|
||||||
// Day column: rounded, clipped scroll viewport (permanent corners).
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.verticalScroll(scrollState)
|
|
||||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
|
||||||
) {
|
|
||||||
DayColumnCard(
|
|
||||||
blocks = state.timed,
|
|
||||||
dark = dark,
|
|
||||||
date = state.date,
|
|
||||||
today = state.today,
|
|
||||||
hourHeight = hourHeight,
|
|
||||||
dragController = dragController,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onCreateAt = onCreateAt,
|
|
||||||
onDrop = onDrop,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(totalHeight)
|
|
||||||
// The scrolling content itself, so its root position
|
|
||||||
// already folds in the scroll offset.
|
|
||||||
.onGloballyPositioned { coords ->
|
|
||||||
dragController.geometry.let {
|
|
||||||
it.grid = coords
|
|
||||||
it.scroll = scrollState
|
|
||||||
it.hourPx = with(density) { hourHeight.toPx() }
|
|
||||||
it.columnGapPx = 0f
|
|
||||||
it.columnWidthPx = coords.size.width.toFloat()
|
|
||||||
it.days = listOf(state.date)
|
|
||||||
it.isRtl = isRtl
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DayColumnCard(
|
|
||||||
blocks: List<TimedBlock>,
|
|
||||||
dark: Boolean,
|
|
||||||
date: LocalDate,
|
|
||||||
today: LocalDate,
|
|
||||||
hourHeight: Dp,
|
|
||||||
dragController: TimelineDragController,
|
|
||||||
onEventClick: (EventInstance) -> Unit,
|
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
|
||||||
val showHourLines = LocalShowHourLines.current
|
|
||||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
|
||||||
// Tells a settled drop when this column has caught up with it.
|
|
||||||
LaunchedEffect(blocks, dragController.settling) {
|
|
||||||
dragController.noteGrid(date, blocks)
|
|
||||||
}
|
|
||||||
// The tap detector outlives the composition that installed it — a pinch or a
|
|
||||||
// Settings change moves the hour height without restarting it — so it reads
|
|
||||||
// the height and the callback through state handles instead of capturing
|
|
||||||
// them, or taps land on the scale the column had before the zoom (#148).
|
|
||||||
val currentHourPx = rememberUpdatedState(hourPx)
|
|
||||||
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
|
||||||
Card(
|
|
||||||
// Plain rectangular column — the soft corners come from the outer
|
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
|
||||||
shape = RectangleShape,
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
BoxWithConstraints(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
// Faint hour separators sit over the column background but under
|
|
||||||
// the event blocks (drawBehind paints before the children).
|
|
||||||
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
|
|
||||||
// Tap an empty slot to create an event there. Taps on event
|
|
||||||
// blocks are consumed by their own click handler first, so this
|
|
||||||
// only fires on the column background. Snaps to the tapped hour.
|
|
||||||
.pointerInput(date) {
|
|
||||||
detectTapGestures { offset ->
|
|
||||||
currentOnCreateAt.value(
|
|
||||||
date,
|
|
||||||
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
val colWidth = maxWidth
|
|
||||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
|
||||||
// Keyed by event, so a block that changes time or lane is the *same*
|
|
||||||
// composable afterwards and tweens there. The ordinal disambiguates
|
|
||||||
// a column holding two occurrences of one series.
|
|
||||||
val ordinals = mutableMapOf<Long, Int>()
|
|
||||||
blocks.forEach { block ->
|
|
||||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
|
||||||
key(block.event.eventId, ordinal) {
|
|
||||||
val laneWidth = colWidth / block.laneCount
|
|
||||||
val top = hourHeight * (block.startMin / 60f)
|
|
||||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
|
||||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
|
||||||
val place = animatedBlockPlacement(
|
|
||||||
x = laneWidth * block.lane,
|
|
||||||
y = top,
|
|
||||||
width = laneWidth,
|
|
||||||
height = height,
|
|
||||||
)
|
|
||||||
EventBlock(
|
|
||||||
block = block,
|
|
||||||
dark = dark,
|
|
||||||
height = place.height,
|
|
||||||
date = date,
|
|
||||||
dragController = dragController,
|
|
||||||
onClick = { onEventClick(block.event) },
|
|
||||||
onDrop = onDrop,
|
|
||||||
modifier = Modifier
|
|
||||||
.offset(x = place.x, y = place.y)
|
|
||||||
.width(place.width)
|
|
||||||
.height(place.height)
|
|
||||||
.padding(horizontal = 1.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Current-time line, on top of the events, only on today's column.
|
|
||||||
if (date == today) {
|
|
||||||
NowLine(date = date, hourHeight = hourHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EventBlock(
|
|
||||||
block: TimedBlock,
|
|
||||||
dark: Boolean,
|
|
||||||
height: Dp,
|
|
||||||
date: LocalDate,
|
|
||||||
dragController: TimelineDragController,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
|
||||||
val locale = currentLocale()
|
|
||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val titleLineHeight = with(density) {
|
|
||||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
|
||||||
}
|
|
||||||
val timeLineHeight = with(density) {
|
|
||||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
|
||||||
}
|
|
||||||
// What's left for text once the 2.dp top/bottom padding is paid for. A block
|
|
||||||
// that cannot afford both lines spends its space on the title, and one too
|
|
||||||
// short even for that drops the title rather than serving a sliced one.
|
|
||||||
val available = height - 4.dp
|
|
||||||
val showTime = block.endMin - block.startMin >= 45 &&
|
|
||||||
available >= titleLineHeight + timeLineHeight
|
|
||||||
val showTitle = available >= titleLineHeight
|
|
||||||
val soften = LocalSoftenColors.current
|
|
||||||
val fill = eventFill(block.event.color, dark, soften)
|
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
|
||||||
val moveAction = eventMoveAction(block.event)
|
|
||||||
val draggable = eventDragAllowed(block.event)
|
|
||||||
// The drop takes this offset back off, so a tail clipped at midnight lands
|
|
||||||
// where the event's own start belongs (#253).
|
|
||||||
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
|
||||||
val shape = remember(block, date, zone) {
|
|
||||||
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
|
|
||||||
}
|
|
||||||
val dragModifier = rememberEventDragSource(
|
|
||||||
enabled = draggable,
|
|
||||||
key = block.event.instanceId,
|
|
||||||
onPickUp = { pointer, blockRoot, _ ->
|
|
||||||
dragController.begin(block, clipOffset, pointer, blockRoot)
|
|
||||||
},
|
|
||||||
onMove = dragController::move,
|
|
||||||
onDrop = { dragController.finish()?.let(onDrop) },
|
|
||||||
onCancel = dragController::cancel,
|
|
||||||
)
|
|
||||||
val lifted = draggable && dragController.ghosts(block)
|
|
||||||
val ghost = ghostAlpha(lifted)
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
|
||||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
|
||||||
.background(fill, shape)
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
// After clickable, so it is the inner node and wins the main pass;
|
|
||||||
// the tap still works, since a drag consumes the up.
|
|
||||||
.then(dragModifier)
|
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = "$title, $timeLabel"
|
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
if (showTitle) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
maxLines = if (showTime) 1 else 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
|
||||||
textDecoration = declinedDecoration(block.event.isDeclined),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showTime) {
|
|
||||||
BlockTimeLabel(
|
|
||||||
label = timeLabel,
|
|
||||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DayLoading() {
|
|
||||||
val scale = LocalTimelineZoom.current.scale
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
|
||||||
// Same scale resolution as the loaded timeline, so the skeleton's column
|
|
||||||
// doesn't resize the moment the real day arrives.
|
|
||||||
val totalHeight = scale.hourHeight(maxHeight) * 24
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(end = TIMELINE_CONTENT_END_INSET)
|
|
||||||
.verticalScroll(scrollState),
|
|
||||||
) {
|
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.height(totalHeight)
|
|
||||||
.padding(horizontal = 2.dp)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
|
||||||
formatMinuteOfDay(min, is24Hour, locale)
|
|
||||||
|
|
||||||
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
|
|
||||||
formatCalendarTitle(
|
|
||||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
|
||||||
locale = locale,
|
|
||||||
currentYear = currentYear,
|
|
||||||
skeleton = "EEEdMMM",
|
|
||||||
)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.AllDaySpan
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The day view is a single-column slice of the week view (spec S3). It reuses the
|
|
||||||
* week's [TimedBlock] and [AllDaySpan] layout primitives — for one day, all-day
|
|
||||||
* spans collapse to a single column ([AllDaySpan.startCol] == [AllDaySpan.endCol]
|
|
||||||
* == 0) and only their [AllDaySpan.lane] (vertical stacking) matters.
|
|
||||||
*/
|
|
||||||
sealed interface DayUiState {
|
|
||||||
data object Loading : DayUiState
|
|
||||||
data class Failure(val reason: FailureReason) : DayUiState
|
|
||||||
data class Success(
|
|
||||||
val date: LocalDate,
|
|
||||||
val today: LocalDate,
|
|
||||||
/** All-day/multi-day events covering this day, stacked by lane. */
|
|
||||||
val allDay: List<AllDaySpan>,
|
|
||||||
/** Timed events clipped to this day with overlap lanes resolved. */
|
|
||||||
val timed: List<TimedBlock>,
|
|
||||||
) : DayUiState
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
|
||||||
import kotlinx.datetime.atTime
|
|
||||||
import kotlinx.datetime.minus
|
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Instant
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@HiltViewModel
|
|
||||||
class DayViewModel @Inject constructor(
|
|
||||||
private val repository: CalendarRepository,
|
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
|
||||||
|
|
||||||
private val _date = MutableStateFlow(todayDate)
|
|
||||||
val date: StateFlow<LocalDate> = _date
|
|
||||||
|
|
||||||
val state: StateFlow<DayUiState> = _date
|
|
||||||
.flatMapLatest { day ->
|
|
||||||
val range = dayRange(day, zone)
|
|
||||||
combine(
|
|
||||||
repository.calendars(),
|
|
||||||
repository.instances(range),
|
|
||||||
) { calendars, instances ->
|
|
||||||
buildState(day, calendars, instances)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
|
|
||||||
.flowOn(io)
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = DayUiState.Loading,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun goToPrev() {
|
|
||||||
_date.value = _date.value.minus(1, DateTimeUnit.DAY)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun goToNext() {
|
|
||||||
_date.value = _date.value.plus(1, DateTimeUnit.DAY)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun goToToday() {
|
|
||||||
_date.value = todayDate
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Jump to a specific date (e.g. when opened from the month grid). */
|
|
||||||
fun goToDate(date: LocalDate) {
|
|
||||||
_date.value = date
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildState(
|
|
||||||
day: LocalDate,
|
|
||||||
calendars: List<CalendarSource>,
|
|
||||||
instances: List<EventInstance>,
|
|
||||||
): DayUiState {
|
|
||||||
if (calendars.isEmpty()) {
|
|
||||||
return DayUiState.Failure(FailureReason.NoCalendarsConfigured)
|
|
||||||
}
|
|
||||||
val days = listOf(day)
|
|
||||||
val allDay = instances.filter { it.isAllDay }
|
|
||||||
val timed = instances.filterNot { it.isAllDay }
|
|
||||||
return DayUiState.Success(
|
|
||||||
date = day,
|
|
||||||
today = todayDate,
|
|
||||||
allDay = layoutAllDay(allDay, days, zone),
|
|
||||||
timed = layoutDay(timed, day, zone),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Half-open instant range covering the single calendar [date]. */
|
|
||||||
internal fun dayRange(date: LocalDate, zone: TimeZone): ClosedRange<Instant> {
|
|
||||||
val from = date.atStartOfDayIn(zone)
|
|
||||||
val to = date.atTime(23, 59, 59).toInstant(zone)
|
|
||||||
return from..to
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
|
||||||
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.snapshotFlow
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Clock
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A live, scaled-down Day view for the onboarding view chooser. Today's date is
|
|
||||||
* real; only the events are stand-ins.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun DayViewPreview(
|
|
||||||
height: Dp,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
|
||||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
|
||||||
val state = remember(today, zone) { sampleDayState(today, zone) }
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
|
||||||
// Half the scroll range is noon — the same centring the live view does.
|
|
||||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
ScaledViewPreview(height = height, modifier = modifier) {
|
|
||||||
DaySuccess(
|
|
||||||
state = state,
|
|
||||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
|
||||||
scrollState = scrollState,
|
|
||||||
allDayHeight = state.allDayStripHeight(),
|
|
||||||
dragController = rememberTimelineDragController(),
|
|
||||||
onEventClick = {},
|
|
||||||
onCreateAt = { _, _ -> },
|
|
||||||
onDrop = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sample day state, laid out through the same helpers the live view uses. */
|
|
||||||
private fun sampleDayState(today: LocalDate, zone: TimeZone): DayUiState.Success {
|
|
||||||
val days = listOf(today)
|
|
||||||
val events = sampleTimelineEvents(days, today, zone)
|
|
||||||
return DayUiState.Success(
|
|
||||||
date = today,
|
|
||||||
today = today,
|
|
||||||
allDay = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
|
||||||
timed = layoutDay(events, today, zone),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import android.net.Uri
|
|||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -98,6 +97,9 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
|||||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
@@ -138,6 +140,7 @@ fun EventDetailScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
onDuplicate: (EventForm) -> Unit,
|
onDuplicate: (EventForm) -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||||
@@ -150,6 +153,7 @@ fun EventDetailScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val copyField = rememberFieldCopier(snackbarHostState)
|
||||||
|
|
||||||
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
|
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
|
||||||
// an .ics in the cache and hands back a content Uri for the chooser.
|
// an .ics in the cache and hands back a content Uri for the chooser.
|
||||||
@@ -224,7 +228,7 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.predictiveBack(onBack = onBack),
|
modifier = Modifier.predictiveBack(onBack = onBack, visible = visible),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -299,8 +303,9 @@ fun EventDetailScreen(
|
|||||||
is EventDetailUiState.Failure -> CalendarFailure(
|
is EventDetailUiState.Failure -> CalendarFailure(
|
||||||
reason = s.reason,
|
reason = s.reason,
|
||||||
onRetry = viewModel::retry,
|
onRetry = viewModel::retry,
|
||||||
|
modifier = contentModifier,
|
||||||
)
|
)
|
||||||
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
|
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +387,11 @@ private fun DeleteEventDialog(
|
|||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
|
private fun EventDetailContent(
|
||||||
|
state: EventDetailUiState.Success,
|
||||||
|
copyField: FieldCopier,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
val detail = state.detail
|
val detail = state.detail
|
||||||
val instance = detail.instance
|
val instance = detail.instance
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -399,6 +408,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
// event, so it's left implicit — only Free is worth surfacing. A
|
// event, so it's left implicit — only Free is worth surfacing. A
|
||||||
// cancelled event strikes through its title.
|
// cancelled event strikes through its title.
|
||||||
Row(verticalAlignment = Alignment.Top) {
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
val titleLabel = stringResource(R.string.event_detail_title)
|
||||||
Text(
|
Text(
|
||||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
@@ -408,7 +418,17 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.then(
|
||||||
|
// Nothing to copy off an untitled event — the placeholder
|
||||||
|
// isn't the event's own text.
|
||||||
|
if (instance.title.isNotBlank()) {
|
||||||
|
Modifier.copyOnLongPress(titleLabel, instance.title, copyField)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if (detail.availability == Availability.Free) {
|
if (detail.availability == Availability.Free) {
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
@@ -508,7 +528,8 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = state.calendarName ?: stringResource(R.string.event_detail_calendar_unknown),
|
text = state.calendarName?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||||
|
?: stringResource(R.string.event_detail_calendar_unknown),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -516,10 +537,11 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
// Location (conditional, tap → maps).
|
// Location (conditional, tap → maps).
|
||||||
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val locationLabel = stringResource(R.string.event_detail_location)
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.Default.Place,
|
icon = Icons.Default.Place,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
iconContentDescription = locationLabel,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = location,
|
text = location,
|
||||||
@@ -527,7 +549,12 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { openInMaps(context, location) }
|
.copyOnLongPress(
|
||||||
|
label = locationLabel,
|
||||||
|
text = location,
|
||||||
|
copy = copyField,
|
||||||
|
onTap = { openInMaps(context, location) },
|
||||||
|
)
|
||||||
.padding(vertical = 2.dp),
|
.padding(vertical = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -535,10 +562,15 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
|
|
||||||
// Description (conditional). URLs are auto-linked.
|
// Description (conditional). URLs are auto-linked.
|
||||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||||
|
val descriptionLabel = stringResource(R.string.event_detail_description)
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.AutoMirrored.Filled.Notes,
|
icon = Icons.AutoMirrored.Filled.Notes,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_description),
|
iconContentDescription = descriptionLabel,
|
||||||
|
// The gesture sits on the card so the icon and padding answer
|
||||||
|
// it too: every linkified URL owns the pointer over its own
|
||||||
|
// glyphs, which leaves the text itself a patchy target.
|
||||||
|
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||||
@@ -611,13 +643,14 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
private fun DetailCard(
|
private fun DetailCard(
|
||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
iconContentDescription: String?,
|
iconContentDescription: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@@ -90,6 +91,7 @@ import androidx.compose.ui.graphics.isSpecified
|
|||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
@@ -101,6 +103,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -200,6 +203,7 @@ fun EventEditScreen(
|
|||||||
initialForm: EventForm? = null,
|
initialForm: EventForm? = null,
|
||||||
initialFormSource: ImportSource = ImportSource.File,
|
initialFormSource: ImportSource = ImportSource.File,
|
||||||
onManageCalendars: (() -> Unit)? = null,
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
|
visible: Boolean = true,
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||||
@@ -282,7 +286,7 @@ fun EventEditScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.predictiveBack(onBack = close),
|
modifier = Modifier.predictiveBack(onBack = close, visible = visible),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -617,7 +621,9 @@ private fun EventEditContent(
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(
|
text = stringResource(
|
||||||
R.string.event_edit_managed_hint,
|
R.string.event_edit_managed_hint,
|
||||||
selectedCalendar?.displayName.orEmpty(),
|
selectedCalendar?.displayName
|
||||||
|
?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||||
|
.orEmpty(),
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -716,6 +722,7 @@ private fun EventEditContent(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = selectedCalendar?.displayName
|
text = selectedCalendar?.displayName
|
||||||
|
?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||||
?: stringResource(R.string.event_edit_error_no_calendar),
|
?: stringResource(R.string.event_edit_error_no_calendar),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = if (selectedCalendar == null) {
|
color = if (selectedCalendar == null) {
|
||||||
@@ -788,38 +795,59 @@ private fun EventEditContent(
|
|||||||
EditCard(
|
EditCard(
|
||||||
icon = Icons.Default.Place,
|
icon = Icons.Default.Place,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||||
|
iconAtTop = true,
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
InlineField(
|
InlineField(
|
||||||
value = form.location,
|
value = form.location,
|
||||||
onValueChange = viewModel::setLocation,
|
onValueChange = viewModel::setLocation,
|
||||||
placeholder = stringResource(R.string.event_detail_location),
|
placeholder = stringResource(R.string.event_detail_location),
|
||||||
|
// A location is as often a meeting URL as an address,
|
||||||
|
// and "Zoom.us/j/123" reads wrong (#146).
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
// Multi-line so a long address or meeting URL wraps and
|
||||||
|
// the card grows instead of scrolling off one line
|
||||||
|
// (#273). The field holds no newlines of its own, so
|
||||||
|
// the IME's action key closes the keyboard rather than
|
||||||
|
// inserting a break setLocation would only fold away.
|
||||||
|
singleLine = false,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
|
.animateContentSizeMotion()
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
)
|
)
|
||||||
IconButton(
|
// The box is exactly one text line tall, so the button
|
||||||
onClick = {
|
// centres on the first line without making a single-line
|
||||||
runCatching {
|
// card taller than its text; requiredSize keeps the
|
||||||
pickContactAddress.launch(
|
// button's own 40dp ripple.
|
||||||
Intent(
|
Box(
|
||||||
Intent.ACTION_PICK,
|
modifier = Modifier.height(firstLineHeight()),
|
||||||
ContactsContract.CommonDataKinds.StructuredPostal
|
contentAlignment = Alignment.Center,
|
||||||
.CONTENT_URI,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(40.dp),
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
IconButton(
|
||||||
imageVector = Icons.Default.Contacts,
|
onClick = {
|
||||||
contentDescription = stringResource(
|
runCatching {
|
||||||
R.string.event_edit_location_from_contacts,
|
pickContactAddress.launch(
|
||||||
),
|
Intent(
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
Intent.ACTION_PICK,
|
||||||
modifier = Modifier.size(22.dp),
|
ContactsContract.CommonDataKinds.StructuredPostal
|
||||||
)
|
.CONTENT_URI,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.requiredSize(40.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Contacts,
|
||||||
|
contentDescription = stringResource(
|
||||||
|
R.string.event_edit_location_from_contacts,
|
||||||
|
),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1989,8 +2017,8 @@ private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>?
|
|||||||
/**
|
/**
|
||||||
* Read the formatted postal address from a contact-pick result URI. No
|
* Read the formatted postal address from a contact-pick result URI. No
|
||||||
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
|
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
|
||||||
* The provider's formatted address is multi-line; collapse it to one line so
|
* The provider's formatted address is multi-line; collapse it to one comma-
|
||||||
* it sits cleanly in the single-line location field.
|
* separated line, which is what the location field holds.
|
||||||
*/
|
*/
|
||||||
private fun readContactAddress(context: Context, uri: Uri): String? =
|
private fun readContactAddress(context: Context, uri: Uri): String? =
|
||||||
context.contentResolver.query(
|
context.contentResolver.query(
|
||||||
@@ -2174,14 +2202,19 @@ private fun EditCard(
|
|||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
|
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
// A top-aligned icon centres on the first text line, whatever
|
||||||
|
// that line measures at the user's font scale.
|
||||||
|
val iconOffset = if (iconAtTop) {
|
||||||
|
((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp)
|
||||||
|
} else {
|
||||||
|
0.dp
|
||||||
|
}
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = iconContentDescription,
|
contentDescription = iconContentDescription,
|
||||||
tint = iconTint,
|
tint = iconTint,
|
||||||
// 4dp mirrors InlineField's vertical padding, so a
|
|
||||||
// top-aligned icon (24dp) centres on the ~24sp first line.
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = if (iconAtTop) 4.dp else 0.dp)
|
.padding(top = iconOffset)
|
||||||
.size(24.dp),
|
.size(24.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
@@ -2223,7 +2256,9 @@ private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Lo
|
|||||||
/**
|
/**
|
||||||
* Borderless text input used inside the cards (and as the headline title).
|
* Borderless text input used inside the cards (and as the headline title).
|
||||||
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
||||||
* the app share one input style.
|
* the app share one input style. Sentence-case by default, as floret-kit is;
|
||||||
|
* a field holding an identifier rather than prose passes
|
||||||
|
* [KeyboardCapitalization.None].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun InlineField(
|
private fun InlineField(
|
||||||
@@ -2235,6 +2270,8 @@ private fun InlineField(
|
|||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
|
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
|
||||||
|
imeAction: ImeAction = ImeAction.Default,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
@@ -2249,10 +2286,25 @@ private fun InlineField(
|
|||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
keyboardType = keyboardType,
|
keyboardType = keyboardType,
|
||||||
capitalization = KeyboardCapitalization.None,
|
capitalization = capitalization,
|
||||||
|
imeAction = imeAction,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height of one [InlineField] line: the resolved [TextStyle.lineHeight] plus the
|
||||||
|
* field's 4dp vertical padding. In dp, so it tracks the user's font scale
|
||||||
|
* instead of assuming the 24sp line of a 1.0 scale (#165).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun firstLineHeight(
|
||||||
|
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||||
|
): Dp {
|
||||||
|
val lineHeight = textStyle.lineHeight
|
||||||
|
if (!lineHeight.isSp) return 32.dp
|
||||||
|
return with(LocalDensity.current) { lineHeight.toDp() } + 8.dp
|
||||||
|
}
|
||||||
|
|
||||||
/** One schedule row: label, then tappable date and (unless all-day) time. */
|
/** One schedule row: label, then tappable date and (unless all-day) time. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScheduleRow(
|
private fun ScheduleRow(
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
private const val TAG = "EventEdit"
|
private const val TAG = "EventEdit"
|
||||||
|
|
||||||
|
/** Any line break, with the whitespace around it, as one match. */
|
||||||
|
private val LINE_BREAK = Regex("""[ \t]*\R[ \t]*""")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
||||||
* want different reminder handling (#49), and differ in whether they own the
|
* want different reminder handling (#49), and differ in whether they own the
|
||||||
@@ -532,7 +535,11 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// paste would introduce, so it never reaches the provider's TITLE column.
|
// paste would introduce, so it never reaches the provider's TITLE column.
|
||||||
fun setTitle(value: String) =
|
fun setTitle(value: String) =
|
||||||
update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
|
update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
|
||||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
// The location wraps too (#273) but is likewise one logical line. A pasted
|
||||||
|
// multi-line address joins the way the contact picker's does (#146), so it
|
||||||
|
// never runs together into "12 Main St10115 Berlin".
|
||||||
|
fun setLocation(value: String) =
|
||||||
|
update { it.copy(location = value.replace(LINE_BREAK, ", ")) }
|
||||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) {
|
fun setAllDay(value: Boolean) {
|
||||||
// Going all-day drops any pinned zone: the times become bare dates that
|
// Going all-day drops any pinned zone: the times become bare dates that
|
||||||
|
|||||||
@@ -62,15 +62,16 @@ private fun FilterList(
|
|||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
groups.forEach { group ->
|
groups.forEach { group ->
|
||||||
|
val account = group.account.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||||
Text(
|
Text(
|
||||||
text = if (group.ambiguous) {
|
text = if (group.ambiguous) {
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.calendars_account_from_source,
|
R.string.calendars_account_from_source,
|
||||||
group.account,
|
account,
|
||||||
sourceAppName(group.accountType),
|
sourceAppName(group.accountType),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
group.account
|
account
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -83,7 +84,7 @@ private fun FilterList(
|
|||||||
)
|
)
|
||||||
group.calendars.forEachIndexed { index, cal ->
|
group.calendars.forEachIndexed { index, cal ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = cal.displayName,
|
title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
position = positionOf(index, group.calendars.size),
|
position = positionOf(index, group.calendars.size),
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { CalendarColorChip(cal.color) },
|
leading = { CalendarColorChip(cal.color) },
|
||||||
@@ -125,6 +126,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
|
|||||||
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
|
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
|
||||||
val msg = when (reason) {
|
val msg = when (reason) {
|
||||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||||
|
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
|
||||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||||
else -> R.string.state_failure_provider
|
else -> R.string.state_failure_provider
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,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.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -35,8 +36,9 @@ class FilterViewModel @Inject constructor(
|
|||||||
// Calendars switched off device-wide don't belong in the drawer's
|
// Calendars switched off device-wide don't belong in the drawer's
|
||||||
// hide/show list; they live only in Settings → Calendars.
|
// hide/show list; they live only in Settings → Calendars.
|
||||||
val enabled = calendars.filter { it.isVisibleInSystem }
|
val enabled = calendars.filter { it.isVisibleInSystem }
|
||||||
if (enabled.isEmpty()) {
|
val failure = calendarListFailure(calendars)
|
||||||
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
if (failure != null) {
|
||||||
|
FilterUiState.Failure(failure)
|
||||||
} else {
|
} else {
|
||||||
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
|
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,19 +73,20 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
|||||||
@Composable
|
@Composable
|
||||||
fun ImportScreen(
|
fun ImportScreen(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
|
session: Int,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onOpenSingle: (EventForm) -> Unit,
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
forceMany: Boolean = false,
|
forceMany: Boolean = false,
|
||||||
onManageCalendars: (() -> Unit)? = null,
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
// Key the VM by the file uri. This screen has no nav backstack, so an
|
visible: Boolean = true,
|
||||||
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
viewModel: ImportViewModel = hiltViewModel(),
|
||||||
// across imports — its one-shot `load` guard would then show the *previous*
|
|
||||||
// file's parsed state on the next import (a second restore, export→restore,
|
|
||||||
// etc.). Keying per uri hands each distinct file a fresh VM (fresh Loading
|
|
||||||
// state), while the same uri (rotation) reuses it and holds the result.
|
|
||||||
viewModel: ImportViewModel = hiltViewModel(key = uri.toString()),
|
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(uri) { viewModel.load(uri, forceMany) }
|
// hiltViewModel() resolves to the Activity's store and is retained across
|
||||||
|
// imports, so the reload is driven by [session] rather than by a fresh VM:
|
||||||
|
// keying per uri looked right but handed a *re-import of the same file* the
|
||||||
|
// previous run's finished state, importing nothing (#304). A rotation keeps
|
||||||
|
// the session and so keeps the result.
|
||||||
|
LaunchedEffect(session) { viewModel.load(uri, forceMany, session) }
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// A single event isn't shown here — it opens the create form for review.
|
// A single event isn't shown here — it opens the create form for review.
|
||||||
@@ -103,7 +104,7 @@ fun ImportScreen(
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.predictiveBack(onBack = onClose)
|
.predictiveBack(onBack = onClose, visible = visible)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
|||||||
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -69,18 +70,26 @@ class ImportViewModel @Inject constructor(
|
|||||||
private val parser = IcsParser()
|
private val parser = IcsParser()
|
||||||
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
|
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
|
||||||
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
||||||
private var started = false
|
private var loadedSession: Int? = null
|
||||||
|
private var loadJob: Job? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read + parse [uri] once; subsequent calls (recomposition) are ignored.
|
* Read + parse [uri] once per [session]; recomposition (and a rotation,
|
||||||
|
* which keeps the session) re-calls this and keeps the result. A new import
|
||||||
|
* of the *same* file is a new session, and has to parse and run again —
|
||||||
|
* keying on the uri alone showed the previous run's summary and imported
|
||||||
|
* nothing (#304).
|
||||||
|
*
|
||||||
* When [forceMany] is set (an in-app restore), a single-event file still goes
|
* When [forceMany] is set (an in-app restore), a single-event file still goes
|
||||||
* through the bulk picker + summary rather than the prefilled create form —
|
* through the bulk picker + summary rather than the prefilled create form —
|
||||||
* a restore is "bring back a backup", not "add this one event".
|
* a restore is "bring back a backup", not "add this one event".
|
||||||
*/
|
*/
|
||||||
fun load(uri: Uri, forceMany: Boolean = false) {
|
fun load(uri: Uri, forceMany: Boolean = false, session: Int = 0) {
|
||||||
if (started) return
|
if (loadedSession == session) return
|
||||||
started = true
|
loadedSession = session
|
||||||
viewModelScope.launch {
|
loadJob?.cancel()
|
||||||
|
_state.value = ImportUiState.Loading
|
||||||
|
loadJob = viewModelScope.launch {
|
||||||
val parsed = withContext(io) {
|
val parsed = withContext(io) {
|
||||||
importer.readText(uri)?.let(parser::parse)
|
importer.readText(uri)?.let(parser::parse)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.licences
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Third-party attribution.
|
||||||
|
*
|
||||||
|
* Reachable from Settings rather than buried, because Apache-2.0 §4(a) is about
|
||||||
|
* what the *recipient of the binary* is told. Each row opens the project's source.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun LicencesScreen(onBack: () -> Unit, visible: Boolean = true) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.licences_title),
|
||||||
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.licences_intro),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
OpenSourceLicences.ALL.forEachIndexed { index, attribution ->
|
||||||
|
GroupedRow(
|
||||||
|
title = attribution.name,
|
||||||
|
summary = "${attribution.copyright} · ${attribution.licence.spdxId}",
|
||||||
|
position = positionOf(index, OpenSourceLicences.ALL.size),
|
||||||
|
onClick = { uriHandler.openUri(attribution.sourceUrl) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.licences_footer),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.licences
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The third-party code Calendula ships, and what each licence obliges us to say.
|
||||||
|
*
|
||||||
|
* This screen is a **licence obligation, not an about-page nicety**:
|
||||||
|
* **Apache-2.0 §4(a)** requires the licence to travel with the work and **§4(d)**
|
||||||
|
* propagates any `NOTICE` file. What discharges that is what the recipient of the
|
||||||
|
* APK is shown — a notice only visible to someone reading the repository does not
|
||||||
|
* count. Nearly everything below is Apache-2.0.
|
||||||
|
*
|
||||||
|
* Hand-maintained on purpose. Generators read POM metadata, and POM metadata is
|
||||||
|
* routinely wrong — the licence name is often not an SPDX id and the licence URL
|
||||||
|
* frequently 404s, which produces confidently incomplete output. A short honest
|
||||||
|
* list beats a long generated one that omits the entry that mattered.
|
||||||
|
*
|
||||||
|
* **When adding a dependency, add it here.** There is no automated check that can
|
||||||
|
* tell you that you forgot. Test-only dependencies (JUnit, Truth, Turbine) are not
|
||||||
|
* shipped in the APK and stay off the list.
|
||||||
|
*/
|
||||||
|
data class Attribution(
|
||||||
|
val name: String,
|
||||||
|
val copyright: String,
|
||||||
|
val licence: Licence,
|
||||||
|
/** Where the source can actually be obtained; what each row links to. */
|
||||||
|
val sourceUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class Licence(val spdxId: String, val url: String) {
|
||||||
|
APACHE_2("Apache-2.0", "https://www.apache.org/licenses/LICENSE-2.0"),
|
||||||
|
MIT("MIT", "https://opensource.org/license/mit"),
|
||||||
|
}
|
||||||
|
|
||||||
|
object OpenSourceLicences {
|
||||||
|
|
||||||
|
val ALL: List<Attribution> = listOf(
|
||||||
|
Attribution(
|
||||||
|
name = "AndroidX, Jetpack Compose, Glance, WorkManager, DataStore, DocumentFile",
|
||||||
|
copyright = "© The Android Open Source Project",
|
||||||
|
licence = Licence.APACHE_2,
|
||||||
|
sourceUrl = "https://cs.android.com/androidx/platform/frameworks/support",
|
||||||
|
),
|
||||||
|
Attribution(
|
||||||
|
name = "Kotlin, kotlinx.coroutines, kotlinx.datetime",
|
||||||
|
copyright = "© JetBrains s.r.o. and Kotlin Programming Language contributors",
|
||||||
|
licence = Licence.APACHE_2,
|
||||||
|
sourceUrl = "https://github.com/JetBrains/kotlin",
|
||||||
|
),
|
||||||
|
Attribution(
|
||||||
|
name = "Dagger and Hilt",
|
||||||
|
copyright = "© Google LLC and The Dagger Authors",
|
||||||
|
licence = Licence.APACHE_2,
|
||||||
|
sourceUrl = "https://github.com/google/dagger",
|
||||||
|
),
|
||||||
|
Attribution(
|
||||||
|
name = "Material Design icons",
|
||||||
|
copyright = "© Google LLC",
|
||||||
|
licence = Licence.APACHE_2,
|
||||||
|
sourceUrl = "https://github.com/google/material-design-icons",
|
||||||
|
),
|
||||||
|
Attribution(
|
||||||
|
name = "floret-kit",
|
||||||
|
copyright = "© Jean-Luc Makiola",
|
||||||
|
licence = Licence.MIT,
|
||||||
|
sourceUrl = "https://codeberg.org/jlmakiola/floret-kit",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
// The month grid's chip geometry, together in one file: these derive from each
|
||||||
|
// other, and a top-level val reading one across a file boundary would resolve
|
||||||
|
// against whichever class the JVM happened to initialise first.
|
||||||
|
|
||||||
|
/** Gap between a day cell and its neighbours. */
|
||||||
|
internal val CELL_GAP = 2.dp
|
||||||
|
|
||||||
|
/** Padding between a month chip's edge and its text. */
|
||||||
|
internal val MONTH_CHIP_TEXT_PADDING = 3.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chip's own inset inside its day cell. The cell's gap and no more: a day
|
||||||
|
* column on a phone is around fifty dp, and the chip was spending a quarter of
|
||||||
|
* it on chrome before a single glyph. The cells keep their full separation from
|
||||||
|
* each other — what the grid reads as breathing room — and only the chip inside
|
||||||
|
* one takes the width back (#212).
|
||||||
|
*/
|
||||||
|
internal val MONTH_CHIP_INSET = CELL_GAP
|
||||||
|
|
||||||
|
/** Horizontal space a chip spends on chrome rather than on text, both sides. */
|
||||||
|
internal val MONTH_CHIP_CHROME = (MONTH_CHIP_INSET + MONTH_CHIP_TEXT_PADDING) * 2f
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The start time a chip shows before its title (#219), or null when it has none
|
||||||
|
* to show: an all-day chip never carries a time, and neither does a bar carried
|
||||||
|
* in from an earlier week, whose start is not in this segment.
|
||||||
|
*/
|
||||||
|
internal fun monthChipTime(
|
||||||
|
event: EventInstance,
|
||||||
|
continuesLeft: Boolean,
|
||||||
|
zone: TimeZone,
|
||||||
|
is24Hour: Boolean,
|
||||||
|
locale: Locale,
|
||||||
|
): String? {
|
||||||
|
if (event.isAllDay || continuesLeft) return null
|
||||||
|
val start = event.start.toLocalDateTime(zone).time
|
||||||
|
return formatTimeOfDay(start.hour, start.minute, is24Hour, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */
|
||||||
|
@Composable
|
||||||
|
internal fun rememberMonthTimeChipWidth(): Dp =
|
||||||
|
rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + MONTH_CHIP_CHROME
|
||||||
@@ -97,6 +97,13 @@ data class MonthChipDrag(
|
|||||||
val targetDate: LocalDate?,
|
val targetDate: LocalDate?,
|
||||||
val topLeftInRoot: Offset,
|
val topLeftInRoot: Offset,
|
||||||
val sizePx: IntSize,
|
val sizePx: IntSize,
|
||||||
|
/**
|
||||||
|
* The start time of the chip this lifted off, already formatted (#219).
|
||||||
|
* Carried rather than re-derived: the overlay has no row to ask whether the
|
||||||
|
* bar was carried in from the previous week, and no zone the grid laid out
|
||||||
|
* in. Null where the source chip had no time to show.
|
||||||
|
*/
|
||||||
|
val time: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
|
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
|
||||||
@@ -172,6 +179,7 @@ class MonthDragController {
|
|||||||
|
|
||||||
private var event: EventInstance? = null
|
private var event: EventInstance? = null
|
||||||
private var grabDate: LocalDate? = null
|
private var grabDate: LocalDate? = null
|
||||||
|
private var time: String? = null
|
||||||
private var grab = Offset.Zero
|
private var grab = Offset.Zero
|
||||||
private var pointer = Offset.Zero
|
private var pointer = Offset.Zero
|
||||||
private var sizePx = IntSize.Zero
|
private var sizePx = IntSize.Zero
|
||||||
@@ -190,9 +198,11 @@ class MonthDragController {
|
|||||||
pointerInRoot: Offset,
|
pointerInRoot: Offset,
|
||||||
chipInRoot: Offset,
|
chipInRoot: Offset,
|
||||||
size: IntSize,
|
size: IntSize,
|
||||||
|
time: String? = null,
|
||||||
) {
|
) {
|
||||||
this.event = event
|
this.event = event
|
||||||
this.grabDate = grabDate
|
this.grabDate = grabDate
|
||||||
|
this.time = time
|
||||||
settling = null
|
settling = null
|
||||||
isDragging = true
|
isDragging = true
|
||||||
liftedInstanceId = event.instanceId
|
liftedInstanceId = event.instanceId
|
||||||
@@ -210,6 +220,7 @@ class MonthDragController {
|
|||||||
fun cancel() {
|
fun cancel() {
|
||||||
event = null
|
event = null
|
||||||
grabDate = null
|
grabDate = null
|
||||||
|
time = null
|
||||||
isDragging = false
|
isDragging = false
|
||||||
liftedInstanceId = null
|
liftedInstanceId = null
|
||||||
drag = null
|
drag = null
|
||||||
@@ -331,6 +342,7 @@ class MonthDragController {
|
|||||||
targetDate = resolved ?: drag?.targetDate,
|
targetDate = resolved ?: drag?.targetDate,
|
||||||
topLeftInRoot = pointer - grab,
|
topLeftInRoot = pointer - grab,
|
||||||
sizePx = sizePx,
|
sizePx = sizePx,
|
||||||
|
time = time,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -74,7 +74,7 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
|||||||
*/
|
*/
|
||||||
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
||||||
if (col !in days.indices || lane !in 0 until laneCap) return null
|
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 }
|
spanAt(col, lane)?.let { return it.event }
|
||||||
val occupied = spans
|
val occupied = spans
|
||||||
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||||
.map { it.lane }
|
.map { it.lane }
|
||||||
@@ -91,8 +91,11 @@ fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
|||||||
* [col] for a single-day chip, and for an empty slot no one should be asking
|
* [col] for a single-day chip, and for an empty slot no one should be asking
|
||||||
* about.
|
* about.
|
||||||
*/
|
*/
|
||||||
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int =
|
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int = spanAt(col, lane)?.startCol ?: col
|
||||||
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.startCol ?: col
|
|
||||||
|
/** The bar covering lane [lane] of column [col], or null where a pill sits there. */
|
||||||
|
fun MonthWeek.spanAt(col: Int, lane: Int): MonthSpan? =
|
||||||
|
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -136,6 +139,8 @@ sealed interface ContinuousMonthUiState {
|
|||||||
val monthsByIndex: Map<Int, List<MonthWeek>>,
|
val monthsByIndex: Map<Int, List<MonthWeek>>,
|
||||||
val weeksByIndex: Map<Int, MonthWeek>,
|
val weeksByIndex: Map<Int, MonthWeek>,
|
||||||
val weekStart: DayOfWeek,
|
val weekStart: DayOfWeek,
|
||||||
|
/** As [MonthUiState.Success.zone], and for the same reason. */
|
||||||
|
val zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||||
) : ContinuousMonthUiState
|
) : ContinuousMonthUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PageStateCache
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
|
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||||
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
||||||
@@ -23,6 +27,9 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.filterNot
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
@@ -42,6 +49,9 @@ import kotlin.time.Clock
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/** How many months either side of the requested one [MonthViewModel.month] keeps cached. */
|
||||||
|
private const val MONTH_CACHE_SPAN = 2
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MonthViewModel @Inject constructor(
|
class MonthViewModel @Inject constructor(
|
||||||
@@ -85,6 +95,21 @@ class MonthViewModel @Inject constructor(
|
|||||||
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
|
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
|
||||||
val month: StateFlow<YearMonth> = _month
|
val month: StateFlow<YearMonth> = _month
|
||||||
|
|
||||||
|
/** The pager page the anchor month sits on, for the paged and split styles. */
|
||||||
|
val anchorPage: StateFlow<Int> = _month
|
||||||
|
.map { monthIndexOf(it) }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = monthIndexOf(_month.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The anchor month's state. Once loaded it never falls back to
|
||||||
|
* [MonthUiState.Loading]: moving the anchor keeps the last result until the
|
||||||
|
* new month arrives, since the pages draw their own months and this feeds
|
||||||
|
* the failure screen and the split style's day pane.
|
||||||
|
*/
|
||||||
val state: StateFlow<MonthUiState> =
|
val state: StateFlow<MonthUiState> =
|
||||||
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
|
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
|
||||||
.flatMapLatest { (ym, ws, style) ->
|
.flatMapLatest { (ym, ws, style) ->
|
||||||
@@ -92,22 +117,48 @@ class MonthViewModel @Inject constructor(
|
|||||||
// Split draw, so under a scrolling style this query is a month
|
// Split draw, so under a scrolling style this query is a month
|
||||||
// of provider work for a view that isn't on screen.
|
// of provider work for a view that isn't on screen.
|
||||||
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
|
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
|
||||||
val range = monthGridRange(ym, ws, zone)
|
month(ym, ws).filterNot { it is MonthUiState.Loading }
|
||||||
combine(
|
|
||||||
repository.calendars(),
|
|
||||||
repository.instances(range),
|
|
||||||
) { calendars, instances ->
|
|
||||||
buildState(ym, ws, calendars, instances)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
.flowOn(io)
|
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = MonthUiState.Loading,
|
initialValue = MonthUiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val months = PageStateCache<Pair<YearMonth, DayOfWeek>, MonthUiState>(
|
||||||
|
scope = viewModelScope,
|
||||||
|
initial = MonthUiState.Loading,
|
||||||
|
// Far enough to cover the pages either side and a swipe back, so paging
|
||||||
|
// through a year doesn't keep a year of queries around. A week-start
|
||||||
|
// change lays every month out afresh.
|
||||||
|
keep = { (cached, cachedWs), (requested, ws) ->
|
||||||
|
cachedWs == ws && abs(monthIndexOf(cached) - monthIndexOf(requested)) <= MONTH_CACHE_SPAN
|
||||||
|
},
|
||||||
|
) { (ym, ws) ->
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
repository.instances(monthGridRange(ym, ws, zone)),
|
||||||
|
) { calendars, instances ->
|
||||||
|
buildState(ym, ws, calendars, instances)
|
||||||
|
}
|
||||||
|
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Month [ym] laid out with weeks starting on [ws], shared between its pager
|
||||||
|
* page and [state]. Main thread only.
|
||||||
|
*/
|
||||||
|
fun month(ym: YearMonth, ws: DayOfWeek): StateFlow<MonthUiState> = months.get(ym to ws)
|
||||||
|
|
||||||
|
/** The pager came to rest on [page]; follow it unless it is already the anchor's. */
|
||||||
|
fun onPageSettled(page: Int) {
|
||||||
|
if (monthIndexOf(_month.value) == page) return
|
||||||
|
_month.value = yearMonthForIndex(page)
|
||||||
|
realignSelection()
|
||||||
|
}
|
||||||
|
|
||||||
// --- Continuous + Dense styles (#38) ----------------------------------
|
// --- Continuous + Dense styles (#38) ----------------------------------
|
||||||
//
|
//
|
||||||
// These scroll through every month there is, so they can't load "a month" —
|
// These scroll through every month there is, so they can't load "a month" —
|
||||||
@@ -207,9 +258,7 @@ class MonthViewModel @Inject constructor(
|
|||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
): ContinuousMonthUiState {
|
): ContinuousMonthUiState {
|
||||||
if (calendars.isEmpty()) {
|
calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) }
|
||||||
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
|
||||||
}
|
|
||||||
// Bucketed once for the whole window: a row then takes the handful of
|
// Bucketed once for the whole window: a row then takes the handful of
|
||||||
// events on its seven days instead of re-scanning a year of them.
|
// events on its seven days instead of re-scanning a year of them.
|
||||||
val byDay = DayIndex(instances, zone)
|
val byDay = DayIndex(instances, zone)
|
||||||
@@ -238,17 +287,16 @@ class MonthViewModel @Inject constructor(
|
|||||||
monthsByIndex = months,
|
monthsByIndex = months,
|
||||||
weeksByIndex = weeks,
|
weeksByIndex = weeks,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
|
zone = zone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Split style (#53) ------------------------------------------------
|
// --- Split style (#53) ------------------------------------------------
|
||||||
//
|
//
|
||||||
// The first selected-day concept in the app: the other views drill straight
|
// Null means the full-month agenda; a date narrows the pane to that day.
|
||||||
// into a date, while the split style keeps one selected and lists it below
|
|
||||||
// the grid.
|
|
||||||
|
|
||||||
private val _selectedDate = MutableStateFlow(todayDate)
|
private val _selectedDate = MutableStateFlow<LocalDate?>(null)
|
||||||
val selectedDate: StateFlow<LocalDate> = _selectedDate
|
val selectedDate: StateFlow<LocalDate?> = _selectedDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select [date], following it to its month if it sits in the grid's leading
|
* Select [date], following it to its month if it sits in the grid's leading
|
||||||
@@ -256,33 +304,33 @@ class MonthViewModel @Inject constructor(
|
|||||||
* silently list a date the grid isn't pointing at.
|
* silently list a date the grid isn't pointing at.
|
||||||
*/
|
*/
|
||||||
fun selectDate(date: LocalDate) {
|
fun selectDate(date: LocalDate) {
|
||||||
_selectedDate.value = date
|
|
||||||
val target = YearMonth(date.year, date.month)
|
val target = YearMonth(date.year, date.month)
|
||||||
|
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
|
||||||
if (target != _month.value) _month.value = target
|
if (target != _month.value) _month.value = target
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun clearSelection() { _selectedDate.value = null }
|
||||||
* Move the selection with the month: today if the new month holds it, else
|
|
||||||
* its 1st. Leaving the old date selected would list a day the grid no longer
|
/** A pane swipe walks days when filtered, months when showing the agenda. */
|
||||||
* shows.
|
fun stepSplitPane(days: Int) {
|
||||||
*/
|
val selected = _selectedDate.value
|
||||||
|
if (selected == null) {
|
||||||
|
_month.value = yearMonthForIndex(monthIndexOf(_month.value) + days)
|
||||||
|
} else {
|
||||||
|
val next = selected.plus(days, DateTimeUnit.DAY)
|
||||||
|
_selectedDate.value = next
|
||||||
|
_month.value = YearMonth(next.year, next.month)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moving the grid to another month restores its full agenda. */
|
||||||
private fun realignSelection() {
|
private fun realignSelection() {
|
||||||
_selectedDate.value = selectionForMonth(_month.value, todayDate)
|
_selectedDate.value = null
|
||||||
}
|
|
||||||
|
|
||||||
fun goToPrev() {
|
|
||||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
|
||||||
realignSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun goToNext() {
|
|
||||||
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
|
||||||
realignSelection()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToToday() {
|
fun goToToday() {
|
||||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||||
_selectedDate.value = todayDate
|
_selectedDate.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -291,13 +339,12 @@ class MonthViewModel @Inject constructor(
|
|||||||
* otherwise sit wherever paged navigation last left it (today's month, on a
|
* otherwise sit wherever paged navigation last left it (today's month, on a
|
||||||
* fresh open) — and a switch to a paged style, or a reseed of the other
|
* fresh open) — and a switch to a paged style, or a reseed of the other
|
||||||
* scrolling style's list, would jump there instead of holding position. The
|
* scrolling style's list, would jump there instead of holding position. The
|
||||||
* selection is realigned alongside it so that landing on the Split style
|
* selection is cleared alongside it so Split opens the visible month's agenda.
|
||||||
* shows a live day in the month on screen rather than a stale, off-month one.
|
|
||||||
*/
|
*/
|
||||||
fun syncScrollMonth(ym: YearMonth) {
|
fun syncScrollMonth(ym: YearMonth) {
|
||||||
if (_month.value == ym) return
|
if (_month.value == ym) return
|
||||||
_month.value = ym
|
_month.value = ym
|
||||||
_selectedDate.value = selectionForMonth(ym, todayDate)
|
_selectedDate.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||||
@@ -312,9 +359,7 @@ class MonthViewModel @Inject constructor(
|
|||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
): MonthUiState {
|
): MonthUiState {
|
||||||
if (calendars.isEmpty()) {
|
calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) }
|
||||||
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
|
||||||
}
|
|
||||||
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
||||||
return MonthUiState.Success(
|
return MonthUiState.Success(
|
||||||
month = ym,
|
month = ym,
|
||||||
@@ -438,14 +483,18 @@ internal fun layoutCalendarWeek(
|
|||||||
days = days,
|
days = days,
|
||||||
spans = spans,
|
spans = spans,
|
||||||
timedByDay = days.associateWith { d ->
|
timedByDay = days.associateWith { d ->
|
||||||
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
|
// Declined last, so a day that overflows drops the events you said
|
||||||
|
// no to before the ones you are actually going to (#230).
|
||||||
|
singles.filter { it.coversDay(d, zone) }
|
||||||
|
.sortedWith(compareBy<EventInstance> { it.isDeclined }.thenBy { it.start })
|
||||||
},
|
},
|
||||||
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every event touching each of [days], all-day first then by start time. Unlike
|
* Every event touching each of [days], declined ones last (#230), then all-day
|
||||||
|
* first and by start time within each group. Unlike
|
||||||
* [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date
|
* [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date
|
||||||
* they cover and applies no display cap, so the split style's day pane can list a
|
* they cover and applies no display cap, so the split style's day pane can list a
|
||||||
* date in full without querying the provider again.
|
* date in full without querying the provider again.
|
||||||
@@ -458,7 +507,7 @@ internal fun instancesByDay(
|
|||||||
days.associateWith { day ->
|
days.associateWith { day ->
|
||||||
instances
|
instances
|
||||||
.filter { it.coversDay(day, zone) }
|
.filter { it.coversDay(day, zone) }
|
||||||
.sortedWith(compareByDescending<EventInstance> { it.isAllDay }.thenBy { it.start })
|
.sortedWith(eventDisplayComparator(zone))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -499,11 +548,10 @@ private const val WINDOW_EDGE = 2
|
|||||||
internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0)
|
internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which day the split style should select when the grid lands on [month]:
|
* First day shown in the split agenda: today in the current month, otherwise
|
||||||
* [today] when the month holds it, otherwise the 1st. Pure so the rule can be
|
* the first of the visible month.
|
||||||
* tested without standing up a view model and a provider behind it.
|
|
||||||
*/
|
*/
|
||||||
internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||||
if (YearMonth(today.year, today.month) == month) {
|
if (YearMonth(today.year, today.month) == month) {
|
||||||
today
|
today
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.CloudSync
|
||||||
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarEditor
|
||||||
|
import de.jeanlucmakiola.calendula.ui.settings.openUrl
|
||||||
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
|
||||||
|
/** Where the sync branch sends someone who needs a CalDAV client (#287). */
|
||||||
|
private const val DAVX5_URL = "https://www.davx5.com/"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard step shown only when the device has no calendar at all (#287): the app
|
||||||
|
* would otherwise finish onboarding onto an empty grid with nothing saying why.
|
||||||
|
*
|
||||||
|
* Two ways out. Calendula writes to the system calendar provider and syncs
|
||||||
|
* nothing itself, so anything beyond a device-only calendar means pointing at
|
||||||
|
* whatever does the syncing.
|
||||||
|
*
|
||||||
|
* [creation] switches the step to the outcome of its own create: confirming
|
||||||
|
* the calendar — the one place to say that more of them live in Settings — or
|
||||||
|
* reporting that the provider refused it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun CalendarsStep(
|
||||||
|
onCreateLocal: (name: String, color: Int, description: String?) -> Unit,
|
||||||
|
onSkip: () -> Unit,
|
||||||
|
creation: CalendarCreation?,
|
||||||
|
onFinished: () -> Unit,
|
||||||
|
onClearCreation: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
// The same editor Settings opens, rather than a calendar conjured out of
|
||||||
|
// nowhere: "create a calendar" should let you name and colour it, and the
|
||||||
|
// step is the first place anyone meets one.
|
||||||
|
var creating by rememberSaveable { mutableStateOf(false) }
|
||||||
|
// The insert is a suspend write: the editor stays up until it lands, so the
|
||||||
|
// step can't flash its "create" button back for a second press.
|
||||||
|
var submitted by rememberSaveable(creating) { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(creation) { if (creation is CalendarCreation.Failed) creating = false }
|
||||||
|
|
||||||
|
if (creation is CalendarCreation.Created) {
|
||||||
|
CalendarCreated(
|
||||||
|
name = creation.name,
|
||||||
|
onContinue = onFinished,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creating) {
|
||||||
|
CalendarEditor(
|
||||||
|
sessionKey = 0,
|
||||||
|
isNew = true,
|
||||||
|
// Empty, not a suggestion: a name already in the field reads as one
|
||||||
|
// the app has chosen, and the first thing to do is clear it.
|
||||||
|
initialName = "",
|
||||||
|
initialColor = CalendarColorPalette.all.first(),
|
||||||
|
initialDescription = "",
|
||||||
|
onSave = { name, color, description ->
|
||||||
|
if (!submitted) {
|
||||||
|
submitted = true
|
||||||
|
onCreateLocal(name, color, description)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDelete = {},
|
||||||
|
onClose = { creating = false },
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
OnboardingScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
topSpacing = OnboardingSpace.lg,
|
||||||
|
hero = { IconHero(Icons.Filled.CalendarMonth) },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onClearCreation()
|
||||||
|
creating = true
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_calendars_local_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { openUrl(context, DAVX5_URL) },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.onboarding_calendars_caldav_button))
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = onSkip,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.onboarding_calendars_skip_button))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_calendars_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_calendars_body),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (creation is CalendarCreation.Failed) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_calendars_create_failed),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||||
|
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.PhoneAndroid,
|
||||||
|
title = stringResource(R.string.onboarding_calendars_benefit_local_title),
|
||||||
|
body = stringResource(R.string.onboarding_calendars_benefit_local_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.CloudSync,
|
||||||
|
title = stringResource(R.string.onboarding_calendars_benefit_sync_title),
|
||||||
|
body = stringResource(R.string.onboarding_calendars_benefit_sync_body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the step shows once the calendar exists: that it does, and where the next one comes from. */
|
||||||
|
@Composable
|
||||||
|
private fun CalendarCreated(
|
||||||
|
name: String,
|
||||||
|
onContinue: () -> 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.CheckCircle) },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = onContinue,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_calendars_created_continue),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_calendars_created_title, name),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_calendars_created_body),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||||
|
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.Settings,
|
||||||
|
title = stringResource(R.string.onboarding_calendars_created_more_title),
|
||||||
|
body = stringResource(R.string.onboarding_calendars_created_more_body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
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.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard step for a device whose every calendar is switched off (#287), which
|
||||||
|
* would otherwise open the app on a blank grid with nothing saying why.
|
||||||
|
*
|
||||||
|
* The calendars are listed rather than summarised: which ones exist and whose
|
||||||
|
* account they came from is the whole of what there is to decide, and switching
|
||||||
|
* them on shows them in every other calendar app too — not a choice to make on
|
||||||
|
* the app's word. Each row carries the same switch Settings does; the button
|
||||||
|
* above them does all of it at once.
|
||||||
|
*
|
||||||
|
* Declining leads into the calendar step behind this one, which offers to make a
|
||||||
|
* calendar instead — the same offer a device with none at all gets.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun HiddenCalendarsStep(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
onSetVisible: (id: Long, visible: Boolean) -> Unit,
|
||||||
|
onSwitchAllOn: () -> Unit,
|
||||||
|
onFinished: () -> Unit,
|
||||||
|
writeFailed: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val anyVisible = calendars.any { it.isVisibleInSystem }
|
||||||
|
OnboardingScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
topSpacing = OnboardingSpace.sm,
|
||||||
|
contentPadding = 0.dp,
|
||||||
|
// The list is as long as the device's calendars; pinned actions would
|
||||||
|
// eat the room it needs and hide that there is more below.
|
||||||
|
scrollingActions = true,
|
||||||
|
hero = { IconHero(Icons.Filled.VisibilityOff) },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = if (anyVisible) onFinished else onSwitchAllOn,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = GroupedListInset)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (anyVisible) {
|
||||||
|
R.string.onboarding_view_continue_button
|
||||||
|
} else {
|
||||||
|
R.string.onboarding_hidden_switch_all_button
|
||||||
|
},
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Only while nothing is on: once something is, the step has been
|
||||||
|
// answered and the one button left to press says so.
|
||||||
|
if (!anyVisible) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onFinished,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = GroupedListInset)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.onboarding_hidden_skip_button))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(horizontal = GroupedListInset)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_hidden_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_hidden_body),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
if (writeFailed) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_hidden_failed),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.md))
|
||||||
|
|
||||||
|
calendars.forEachIndexed { index, calendar ->
|
||||||
|
val off = !calendar.isVisibleInSystem
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
|
summary = calendar.accountName.takeIf { it.isNotBlank() },
|
||||||
|
position = when {
|
||||||
|
calendars.size == 1 -> Position.Alone
|
||||||
|
index == 0 -> Position.Top
|
||||||
|
index == calendars.lastIndex -> Position.Bottom
|
||||||
|
else -> Position.Middle
|
||||||
|
},
|
||||||
|
dimmed = off,
|
||||||
|
trailing = if (calendar.hasVisibilitySwitch) {
|
||||||
|
{
|
||||||
|
CalendarSwitch(
|
||||||
|
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
|
enabled = !off,
|
||||||
|
onToggle = { on -> onSetVisible(calendar.id, on) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.md))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The per-row switch, writing the device's own `Calendars.VISIBLE` as Settings does. */
|
||||||
|
@Composable
|
||||||
|
private fun CalendarSwitch(
|
||||||
|
calendarName: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
onToggle: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
|
||||||
|
Switch(
|
||||||
|
checked = enabled,
|
||||||
|
onCheckedChange = onToggle,
|
||||||
|
modifier = Modifier.semantics { contentDescription = label },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,19 @@ enum class OnboardingStep {
|
|||||||
/** Required: nothing works without the calendar grant. */
|
/** Required: nothing works without the calendar grant. */
|
||||||
Permission,
|
Permission,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only when every calendar on the device is switched off, so the app would
|
||||||
|
* open on a blank grid: lists them and offers to switch them back on (#287).
|
||||||
|
*/
|
||||||
|
HiddenCalendars,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only when the device has nothing to put an event in — no calendar at all,
|
||||||
|
* or none left showing after the step above. The app can make a local one,
|
||||||
|
* or send you to whatever would do the syncing (#287).
|
||||||
|
*/
|
||||||
|
Calendars,
|
||||||
|
|
||||||
/** Whether Calendula delivers reminder notifications itself. */
|
/** Whether Calendula delivers reminder notifications itself. */
|
||||||
Reminders,
|
Reminders,
|
||||||
|
|
||||||
@@ -67,6 +80,18 @@ data class OnboardingPlan(
|
|||||||
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
||||||
* counter had not accounted for.
|
* counter had not accounted for.
|
||||||
*
|
*
|
||||||
|
* [calendarsApplies] carries the same contract for the calendar step, with one
|
||||||
|
* wrinkle: for the backup step an empty list means "could not read", but here
|
||||||
|
* an empty list *is* the condition. The two are indistinguishable at this
|
||||||
|
* layer and both want the step, so it turns false only once a calendar the app
|
||||||
|
* could actually show has arrived.
|
||||||
|
*
|
||||||
|
* [hiddenApplies] is the step in front of it (#287): the device has calendars
|
||||||
|
* but every one of them is switched off. It comes first because switching one
|
||||||
|
* back on is the lighter remedy — and where it is declined, the calendar step
|
||||||
|
* behind it still offers to make one, the same offer a device with no calendars
|
||||||
|
* at all gets.
|
||||||
|
*
|
||||||
* The month-style step is *not* conditional on Month being the chosen view:
|
* 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
|
* 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
|
* and went as the view is picked would move the counter under the user on the
|
||||||
@@ -75,7 +100,9 @@ data class OnboardingPlan(
|
|||||||
* [visibilityArmed] carries the one-time notice that the device is hiding some
|
* [visibilityArmed] carries the one-time notice that the device is hiding some
|
||||||
* calendars (#75) — the only optional step an *existing* install can be given,
|
* 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
|
* which is why it is not gated on `fresh`, and on its own it earns no closing
|
||||||
* screen.
|
* screen. It stays a pure announcement: the all-hidden *state* is
|
||||||
|
* [hiddenApplies]'s business, up at the front of the flow where something can
|
||||||
|
* still be done about it.
|
||||||
*/
|
*/
|
||||||
fun onboardingPlan(
|
fun onboardingPlan(
|
||||||
hasPermission: Boolean,
|
hasPermission: Boolean,
|
||||||
@@ -85,6 +112,10 @@ fun onboardingPlan(
|
|||||||
viewDone: Boolean,
|
viewDone: Boolean,
|
||||||
monthStyleDone: Boolean,
|
monthStyleDone: Boolean,
|
||||||
backupApplies: Boolean?,
|
backupApplies: Boolean?,
|
||||||
|
hiddenDone: Boolean = false,
|
||||||
|
hiddenApplies: Boolean? = false,
|
||||||
|
calendarsDone: Boolean = false,
|
||||||
|
calendarsApplies: Boolean? = null,
|
||||||
visibilityArmed: Boolean = false,
|
visibilityArmed: Boolean = false,
|
||||||
visibilityDone: Boolean = false,
|
visibilityDone: Boolean = false,
|
||||||
doneShown: Boolean = false,
|
doneShown: Boolean = false,
|
||||||
@@ -92,6 +123,8 @@ fun onboardingPlan(
|
|||||||
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
||||||
val steps = buildList {
|
val steps = buildList {
|
||||||
if (!hasPermission || fresh) add(OnboardingStep.Permission)
|
if (!hasPermission || fresh) add(OnboardingStep.Permission)
|
||||||
|
if (fresh && hiddenApplies != false) add(OnboardingStep.HiddenCalendars)
|
||||||
|
if (fresh && calendarsApplies != false) add(OnboardingStep.Calendars)
|
||||||
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
|
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
|
||||||
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
||||||
if (fresh) {
|
if (fresh) {
|
||||||
@@ -104,6 +137,8 @@ fun onboardingPlan(
|
|||||||
val current = steps.firstOrNull { step ->
|
val current = steps.firstOrNull { step ->
|
||||||
when (step) {
|
when (step) {
|
||||||
OnboardingStep.Permission -> !hasPermission
|
OnboardingStep.Permission -> !hasPermission
|
||||||
|
OnboardingStep.HiddenCalendars -> !hiddenDone
|
||||||
|
OnboardingStep.Calendars -> !calendarsDone
|
||||||
OnboardingStep.Reminders -> !remindersDone
|
OnboardingStep.Reminders -> !remindersDone
|
||||||
OnboardingStep.Backup -> !backupDone
|
OnboardingStep.Backup -> !backupDone
|
||||||
OnboardingStep.View -> !viewDone
|
OnboardingStep.View -> !viewDone
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ fun OnboardingSteps(
|
|||||||
// Collected for every step, not just the two that draw a preview: starting
|
// 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.
|
// it when the picker composes would leave that step blank as it slides in.
|
||||||
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
|
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
|
||||||
|
val creation by viewModel.creation.collectAsStateWithLifecycle()
|
||||||
|
val hiddenCalendars by viewModel.hiddenCalendars.collectAsStateWithLifecycle()
|
||||||
|
val hiddenWriteFailed by viewModel.hiddenWriteFailed.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Coerced because the outgoing half of a transition may be a step the live
|
// 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
|
// plan has since dropped — the backup step goes once the calendars say it
|
||||||
@@ -67,6 +70,26 @@ fun OnboardingSteps(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
progress = progress,
|
progress = progress,
|
||||||
)
|
)
|
||||||
|
OnboardingStep.HiddenCalendars -> HiddenCalendarsStep(
|
||||||
|
calendars = hiddenCalendars,
|
||||||
|
onSetVisible = viewModel::setHiddenCalendarVisible,
|
||||||
|
onSwitchAllOn = viewModel::switchAllCalendarsOn,
|
||||||
|
onFinished = viewModel::finishHiddenCalendars,
|
||||||
|
writeFailed = hiddenWriteFailed,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
|
OnboardingStep.Calendars -> CalendarsStep(
|
||||||
|
onCreateLocal = viewModel::createLocalCalendar,
|
||||||
|
onSkip = viewModel::skipCalendars,
|
||||||
|
creation = creation,
|
||||||
|
onFinished = viewModel::finishCalendars,
|
||||||
|
onClearCreation = viewModel::clearCalendarCreation,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
OnboardingStep.Reminders -> ReminderStep(
|
OnboardingStep.Reminders -> ReminderStep(
|
||||||
onFinished = viewModel::finishReminders,
|
onFinished = viewModel::finishReminders,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|||||||
+235
-10
@@ -16,6 +16,8 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||||
|
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -74,6 +77,27 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
initialValue = null,
|
initialValue = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar list every derived step reads, collected once: one per step
|
||||||
|
* would put a provider query each on every visibility tick, and let the
|
||||||
|
* steps judge different snapshots of the same moment. Null is "no list to
|
||||||
|
* judge" — before the grant, and on a read that failed.
|
||||||
|
*/
|
||||||
|
private val calendars: StateFlow<List<CalendarSource>?> = hasPermission
|
||||||
|
.flatMapLatest { granted ->
|
||||||
|
if (granted != true) {
|
||||||
|
flowOf(null)
|
||||||
|
} else {
|
||||||
|
val list: Flow<List<CalendarSource>?> = repository.calendars()
|
||||||
|
list.catch { emit(null) }.flowOn(io)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the backup step applies: something of yours is worth exporting and
|
* Whether the backup step applies: something of yours is worth exporting and
|
||||||
* nothing you write to is synced anywhere. Null while the calendar list
|
* nothing you write to is synced anywhere. Null while the calendar list
|
||||||
@@ -81,17 +105,90 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
* an empty list is also how a null cursor arrives, and guessing false there
|
* an empty list is also how a null cursor arrives, and guessing false there
|
||||||
* would drop the step for the whole session.
|
* would drop the step for the whole session.
|
||||||
*/
|
*/
|
||||||
private val backupApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
|
private val backupApplies: Flow<Boolean?> = calendars.map { list ->
|
||||||
if (granted != true) {
|
if (list.isNullOrEmpty()) null else list.backupApplies()
|
||||||
flowOf(null)
|
|
||||||
} else {
|
|
||||||
repository.calendars()
|
|
||||||
.map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() }
|
|
||||||
.catch { emit(null) }
|
|
||||||
.flowOn(io)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How the step's own create attempt ended, while it is still saying so (#287). */
|
||||||
|
private val _creation = MutableStateFlow<CalendarCreation?>(null)
|
||||||
|
val creation: StateFlow<CalendarCreation?> = _creation.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the step made a calendar here. Latched for the session and never
|
||||||
|
* cleared: the new calendar answers [calendarsApplies] with false, which
|
||||||
|
* would drop the step out of the plan the moment it is used — first from
|
||||||
|
* under the message that it worked, and then from the step count, which
|
||||||
|
* would fall by one just as the user pressed Continue.
|
||||||
|
*/
|
||||||
|
private val calendarCreatedHere = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the calendar step applies: the device has nothing to show and so
|
||||||
|
* nothing to put events in. Unlike [backupApplies] an empty list is the
|
||||||
|
* condition itself, not a failed read — the two look identical here and both
|
||||||
|
* want the step, so this only resolves once a calendar the app can actually
|
||||||
|
* show has arrived. Calendars that exist but are all switched off leave it
|
||||||
|
* unresolved too: the step in front (#287) offers to switch them on, and
|
||||||
|
* declining that lands here, on the same offer a device with none gets.
|
||||||
|
*/
|
||||||
|
private val calendarsApplies: Flow<Boolean?> = combine(
|
||||||
|
calendars.map { list -> if (list?.any { it.isVisibleInSystem } == true) false else null },
|
||||||
|
calendarCreatedHere,
|
||||||
|
) { applies, latched -> if (latched) null else applies }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set when the step starts a write of its own, and never cleared: from that
|
||||||
|
* moment the step holds its place in the plan until it is answered. Set
|
||||||
|
* *before* the write rather than after it, because a successful one answers
|
||||||
|
* [hiddenApplies] with false — the step would drop out between the provider
|
||||||
|
* taking the change and the latch seeing it.
|
||||||
|
*/
|
||||||
|
private val hiddenTouched = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/** A visibility write the provider did not take, while the step says so (#287). */
|
||||||
|
private val _hiddenWriteFailed = MutableStateFlow(false)
|
||||||
|
val hiddenWriteFailed: StateFlow<Boolean> = _hiddenWriteFailed.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the device is showing nothing at all right now: calendars exist,
|
||||||
|
* none is visible, and at least one of them has a switch the step can flip
|
||||||
|
* (#287). Null only while there is no list to judge — before the grant, and
|
||||||
|
* on a read that failed — which keeps the step in the plan until the answer
|
||||||
|
* arrives, the same shrink-only contract [backupApplies] keeps.
|
||||||
|
*
|
||||||
|
* An empty list answers false rather than null, unlike [calendarsApplies]:
|
||||||
|
* a phone with no calendars at all is the next step's business, and this one
|
||||||
|
* would be listing calendars it cannot see. [hiddenTouched] holds it at null
|
||||||
|
* from the step's first write onwards.
|
||||||
|
*/
|
||||||
|
private val hiddenApplies: Flow<Boolean?> = combine(
|
||||||
|
calendars.map { list -> list?.allHidden() },
|
||||||
|
hiddenTouched,
|
||||||
|
) { hidden, touched -> if (touched) null else hidden }
|
||||||
|
|
||||||
|
/** Both calendar steps' inputs: what each applies to, and whether it is answered. */
|
||||||
|
private val calendarsFlags: Flow<CalendarsFlags> = combine(
|
||||||
|
calendarsApplies,
|
||||||
|
prefs.onboardingCalendarsDone,
|
||||||
|
hiddenApplies,
|
||||||
|
prefs.onboardingHiddenCalendarsDone,
|
||||||
|
::CalendarsFlags,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendars the hidden-calendars step lists, so the user can judge which
|
||||||
|
* to switch on rather than take the app's word that there are some. Live: a
|
||||||
|
* row follows its own switch, and the ones without one stay listed anyway —
|
||||||
|
* being told a calendar exists is half the point.
|
||||||
|
*/
|
||||||
|
val hiddenCalendars: StateFlow<List<CalendarSource>> = calendars
|
||||||
|
.map { it.orEmpty().orderedForManager() }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
private val flags: Flow<OnboardingFlags> = combine(
|
private val flags: Flow<OnboardingFlags> = combine(
|
||||||
prefs.reminderOnboardingDone,
|
prefs.reminderOnboardingDone,
|
||||||
prefs.onboardingWizardArmed,
|
prefs.onboardingWizardArmed,
|
||||||
@@ -110,7 +207,13 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val plan: StateFlow<OnboardingPlan?> =
|
val plan: StateFlow<OnboardingPlan?> =
|
||||||
combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup ->
|
combine(
|
||||||
|
hasPermission,
|
||||||
|
flags,
|
||||||
|
closingFlags,
|
||||||
|
backupApplies,
|
||||||
|
calendarsFlags,
|
||||||
|
) { granted, stored, closing, backup, calendars ->
|
||||||
granted?.let {
|
granted?.let {
|
||||||
onboardingPlan(
|
onboardingPlan(
|
||||||
hasPermission = it,
|
hasPermission = it,
|
||||||
@@ -120,6 +223,10 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
viewDone = stored.viewDone,
|
viewDone = stored.viewDone,
|
||||||
monthStyleDone = stored.monthStyleDone,
|
monthStyleDone = stored.monthStyleDone,
|
||||||
backupApplies = backup,
|
backupApplies = backup,
|
||||||
|
hiddenDone = calendars.hiddenDone,
|
||||||
|
hiddenApplies = calendars.hiddenApplies,
|
||||||
|
calendarsDone = calendars.done,
|
||||||
|
calendarsApplies = calendars.applies,
|
||||||
visibilityArmed = closing.visibilityArmed,
|
visibilityArmed = closing.visibilityArmed,
|
||||||
visibilityDone = closing.visibilityDone,
|
visibilityDone = closing.visibilityDone,
|
||||||
doneShown = closing.doneShown,
|
doneShown = closing.doneShown,
|
||||||
@@ -186,6 +293,8 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
fun goBack() {
|
fun goBack() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (plan.value?.previous) {
|
when (plan.value?.previous) {
|
||||||
|
OnboardingStep.HiddenCalendars -> prefs.setOnboardingHiddenCalendarsDone(false)
|
||||||
|
OnboardingStep.Calendars -> prefs.setOnboardingCalendarsDone(false)
|
||||||
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
||||||
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
||||||
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
||||||
@@ -202,6 +311,53 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the device-only calendar the step's editor just described. The step
|
||||||
|
* stays up on [creation] either way — to confirm the calendar and say where
|
||||||
|
* to make more, or to say the provider refused it. Answering the step for
|
||||||
|
* them would leave them where the step exists to stop them being: past it,
|
||||||
|
* with nowhere to put an event.
|
||||||
|
*/
|
||||||
|
fun createLocalCalendar(displayName: String, color: Int, description: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val name = displayName.trim()
|
||||||
|
_creation.value = runCatching {
|
||||||
|
repository.createLocalCalendar(
|
||||||
|
displayName = name,
|
||||||
|
color = color,
|
||||||
|
description = description,
|
||||||
|
)
|
||||||
|
}.fold(
|
||||||
|
onSuccess = {
|
||||||
|
calendarCreatedHere.value = true
|
||||||
|
CalendarCreation.Created(name)
|
||||||
|
},
|
||||||
|
onFailure = { CalendarCreation.Failed },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the calendar step once its confirmation has been read. */
|
||||||
|
fun finishCalendars() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setOnboardingCalendarsDone()
|
||||||
|
_creation.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the failed attempt so the step offers itself again. */
|
||||||
|
fun clearCalendarCreation() {
|
||||||
|
_creation.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the calendar step without making one. An empty app is a legitimate
|
||||||
|
* choice, and so is going off to set up an account and coming back.
|
||||||
|
*/
|
||||||
|
fun skipCalendars() {
|
||||||
|
viewModelScope.launch { prefs.setOnboardingCalendarsDone() }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turn automatic backup on, writing to the folder the user just picked
|
* Turn automatic backup on, writing to the folder the user just picked
|
||||||
* (taking a durable write grant so background runs can keep writing), and
|
* (taking a durable write grant so background runs can keep writing), and
|
||||||
@@ -246,6 +402,52 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
|
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Switch one listed calendar on or off, from the hidden-calendars step (#287). */
|
||||||
|
fun setHiddenCalendarVisible(id: Long, visible: Boolean) = visibilityWrite {
|
||||||
|
repository.setCalendarsVisible(listOf(id), visible)
|
||||||
|
calendarList().any { it.id == id && it.isVisibleInSystem == visible }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch every hidden calendar back on — the step's one-tap way out (#287). */
|
||||||
|
fun switchAllCalendarsOn() = visibilityWrite {
|
||||||
|
val hidden = calendarList().filter { it.hasVisibilitySwitch && !it.isVisibleInSystem }
|
||||||
|
// Nothing to write is not a write that landed: a list that came back
|
||||||
|
// empty (a failed read) would otherwise report success and leave the
|
||||||
|
// step sitting there with no rows and no explanation.
|
||||||
|
if (hidden.isEmpty()) return@visibilityWrite false
|
||||||
|
repository.setCalendarsVisible(hidden.map { it.id }, true)
|
||||||
|
calendarList().any { it.isVisibleInSystem }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one of the step's visibility writes, whose lambda answers whether it
|
||||||
|
* took. Checked rather than assumed: with read-only calendar access the
|
||||||
|
* choice is kept app-side (#75) and nothing the grid shows would change.
|
||||||
|
*/
|
||||||
|
private fun visibilityWrite(landed: suspend () -> Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
hiddenTouched.value = true
|
||||||
|
_hiddenWriteFailed.value = !runCatching { landed() }.getOrDefault(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun calendarList(): List<CalendarSource> = repository.calendars().first()
|
||||||
|
|
||||||
|
/** Close the hidden-calendars step, switched on or left as they were (#287). */
|
||||||
|
fun finishHiddenCalendars() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setOnboardingHiddenCalendarsDone()
|
||||||
|
// The error belongs to the attempt, not to the step: stepping back
|
||||||
|
// into it later must not find a refusal nothing has asked for yet.
|
||||||
|
_hiddenWriteFailed.value = false
|
||||||
|
val list = calendarList()
|
||||||
|
// Switching them all back on answers the one-time notice too (#75) —
|
||||||
|
// it would otherwise announce, at the end of this same flow, that the
|
||||||
|
// device is hiding calendars it no longer hides.
|
||||||
|
if (list.isNotEmpty() && list.all { it.isVisibleInSystem }) retireVisibilityNotice()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Acknowledge the calendar-visibility notice (#75). */
|
/** Acknowledge the calendar-visibility notice (#75). */
|
||||||
fun finishVisibility() {
|
fun finishVisibility() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -295,6 +497,20 @@ private data class OnboardingFlags(
|
|||||||
val monthStyleDone: Boolean,
|
val monthStyleDone: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** How the calendar step's create attempt ended (#287). */
|
||||||
|
sealed interface CalendarCreation {
|
||||||
|
data class Created(val name: String) : CalendarCreation
|
||||||
|
data object Failed : CalendarCreation
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both calendar steps' inputs (#287). */
|
||||||
|
private data class CalendarsFlags(
|
||||||
|
val applies: Boolean?,
|
||||||
|
val done: Boolean,
|
||||||
|
val hiddenApplies: Boolean?,
|
||||||
|
val hiddenDone: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
/** The tail of the flow: the visibility notice and the closing screen. */
|
/** The tail of the flow: the visibility notice and the closing screen. */
|
||||||
private data class ClosingFlags(
|
private data class ClosingFlags(
|
||||||
val visibilityArmed: Boolean,
|
val visibilityArmed: Boolean,
|
||||||
@@ -309,6 +525,15 @@ data class ViewChoice(
|
|||||||
val weekStart: WeekStartPref,
|
val weekStart: WeekStartPref,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this device would show nothing and the step could change that: no
|
||||||
|
* calendar is visible, and at least one of them has a switch worth flipping —
|
||||||
|
* a non-syncing calendar holds no events on the device, so switching it on
|
||||||
|
* leaves the grid exactly as empty (#287).
|
||||||
|
*/
|
||||||
|
private fun List<CalendarSource>.allHidden(): Boolean =
|
||||||
|
any { it.hasVisibilitySwitch } && none { it.isVisibleInSystem }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when this device holds events that live nowhere else: at least one local
|
* 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.
|
* calendar worth exporting, and no writable synced calendar to carry them.
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
|
||||||
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.LocalMultiDayCount
|
||||||
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.common.ViewPreviewFrame
|
||||||
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.label
|
||||||
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
|
|
||||||
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
|
||||||
@@ -67,14 +67,19 @@ internal fun ViewStep(
|
|||||||
height = PREVIEW_HEIGHT,
|
height = PREVIEW_HEIGHT,
|
||||||
)
|
)
|
||||||
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
|
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||||
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
|
CalendarView.MultiDay -> WeekViewPreview(
|
||||||
|
weekStart = weekStart,
|
||||||
|
height = PREVIEW_HEIGHT,
|
||||||
|
dayCount = LocalMultiDayCount.current,
|
||||||
|
)
|
||||||
|
CalendarView.Day -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT, dayCount = 1)
|
||||||
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||||
val isSelected = view == choice.defaultView
|
val isSelected = view == choice.defaultView
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(view.labelRes),
|
title = view.label(),
|
||||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
leading = {
|
leading = {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import de.jeanlucmakiola.calendula.domain.MatchSpan
|
|||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchHit
|
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
@@ -125,6 +126,7 @@ fun SearchScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
visible: Boolean = true,
|
||||||
viewModel: SearchViewModel = hiltViewModel(),
|
viewModel: SearchViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||||
@@ -183,7 +185,7 @@ fun SearchScreen(
|
|||||||
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
|
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
|
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection, visible = visible),
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
topBar = {
|
topBar = {
|
||||||
SearchTopBar(
|
SearchTopBar(
|
||||||
@@ -508,6 +510,9 @@ private fun SearchResultRow(
|
|||||||
onLongClick: (() -> Unit)? = null,
|
onLongClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val event = hit.event
|
val event = hit.event
|
||||||
|
// Spans are empty for a blank title — the query never matches the
|
||||||
|
// placeholder — so marking the substitute is safe.
|
||||||
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
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
|
// On a picked row the headline is already recoloured for the secondary
|
||||||
@@ -520,7 +525,7 @@ private fun SearchResultRow(
|
|||||||
// Faded like a past event anywhere else in the app — search reaches back
|
// Faded like a past event anywhere else in the app — search reaches back
|
||||||
// through the whole history.
|
// through the whole history.
|
||||||
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined),
|
title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isDeclined),
|
||||||
summary = searchSummary(hit, highlight),
|
summary = searchSummary(hit, highlight),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ internal fun AppearanceScreen(
|
|||||||
state: SettingsUiState,
|
state: SettingsUiState,
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
) {
|
) {
|
||||||
var showTheme by remember { mutableStateOf(false) }
|
var showTheme by remember { mutableStateOf(false) }
|
||||||
var showBrandFont by remember { mutableStateOf(false) }
|
var showBrandFont by remember { mutableStateOf(false) }
|
||||||
@@ -96,6 +97,7 @@ internal fun AppearanceScreen(
|
|||||||
title = stringResource(R.string.settings_section_appearance),
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
// Theme & colour
|
// Theme & colour
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ internal fun EventFormScreen(
|
|||||||
state: SettingsUiState,
|
state: SettingsUiState,
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
) {
|
) {
|
||||||
var showDefaultDuration by remember { mutableStateOf(false) }
|
var showDefaultDuration by remember { mutableStateOf(false) }
|
||||||
// The calendar whose duration-override picker is open, if any.
|
// The calendar whose duration-override picker is open, if any.
|
||||||
@@ -53,6 +54,7 @@ internal fun EventFormScreen(
|
|||||||
title = stringResource(R.string.settings_section_event_form),
|
title = stringResource(R.string.settings_section_event_form),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
SettingsHint(stringResource(R.string.settings_form_fields_hint))
|
SettingsHint(stringResource(R.string.settings_form_fields_hint))
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -143,7 +145,7 @@ internal fun EventFormScreen(
|
|||||||
durationCalendars.forEachIndexed { index, calendar ->
|
durationCalendars.forEachIndexed { index, calendar ->
|
||||||
val override = state.perCalendarEventDuration[calendar.id]
|
val override = state.perCalendarEventDuration[calendar.id]
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
summary = override?.let { durationLabel(it) }
|
summary = override?.let { durationLabel(it) }
|
||||||
?: stringResource(
|
?: stringResource(
|
||||||
R.string.settings_calendar_duration_inherits,
|
R.string.settings_calendar_duration_inherits,
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ internal fun NotificationsScreen(
|
|||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenSpecialDates: () -> Unit,
|
onOpenSpecialDates: () -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
@@ -92,6 +93,7 @@ internal fun NotificationsScreen(
|
|||||||
title = stringResource(R.string.settings_section_notifications),
|
title = stringResource(R.string.settings_section_notifications),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_reminders),
|
title = stringResource(R.string.settings_reminders),
|
||||||
@@ -181,7 +183,7 @@ internal fun NotificationsScreen(
|
|||||||
// own section — link there instead.
|
// own section — link there instead.
|
||||||
if (calendar.id in state.managedCalendarIds) {
|
if (calendar.id in state.managedCalendarIds) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
@@ -198,7 +200,7 @@ internal fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
val expanded = calendar.id in expandedCalendars
|
val expanded = calendar.id in expandedCalendars
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||||
position = if (expanded) Position.Top else Position.Alone,
|
position = if (expanded) Position.Top else Position.Alone,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
trailing = {
|
trailing = {
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ package de.jeanlucmakiola.calendula.ui.settings
|
|||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.foundation.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
|
||||||
@@ -21,6 +19,7 @@ import androidx.compose.material.icons.filled.Backup
|
|||||||
import androidx.compose.material.icons.filled.BugReport
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
import androidx.compose.material.icons.filled.Cake
|
import androidx.compose.material.icons.filled.Cake
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Code
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
import androidx.compose.material.icons.filled.Gavel
|
import androidx.compose.material.icons.filled.Gavel
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
@@ -54,6 +53,7 @@ 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.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.licences.LicencesScreen
|
||||||
import de.jeanlucmakiola.floret.components.AboutCard
|
import de.jeanlucmakiola.floret.components.AboutCard
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
@@ -64,10 +64,13 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
|||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.floret.crash.openIssueTracker
|
import de.jeanlucmakiola.floret.crash.openIssueTracker
|
||||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
|
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
private enum class SettingsSection { Appearance, Views, EventForm, Notifications, SpecialDates, Widgets }
|
private enum class SettingsSection {
|
||||||
|
Appearance, Views, EventForm, Notifications, SpecialDates, Widgets, Licences
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings hub: three labelled groups of category rows, each opening a
|
* Settings hub: three labelled groups of category rows, each opening a
|
||||||
@@ -81,11 +84,14 @@ fun SettingsScreen(
|
|||||||
onManageCalendars: () -> Unit,
|
onManageCalendars: () -> Unit,
|
||||||
onOpenBackup: () -> Unit,
|
onOpenBackup: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
visible: Boolean = true,
|
||||||
viewModel: SettingsViewModel = hiltViewModel(),
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
|
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
// Leaves from the predictive-back preview rather than sliding off at full size.
|
||||||
|
val overlayExit = predictiveBackExit()
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -97,54 +103,63 @@ fun SettingsScreen(
|
|||||||
onOpenSection = { section = it },
|
onOpenSection = { section = it },
|
||||||
onManageCalendars = onManageCalendars,
|
onManageCalendars = onManageCalendars,
|
||||||
onOpenBackup = onOpenBackup,
|
onOpenBackup = onOpenBackup,
|
||||||
|
visible = visible,
|
||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.Appearance,
|
visible = section == SettingsSection.Appearance,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Appearance)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.Views,
|
visible = section == SettingsSection.Views,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Views)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.EventForm,
|
visible = section == SettingsSection.EventForm,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.EventForm)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.Notifications,
|
visible = section == SettingsSection.Notifications,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
NotificationsScreen(
|
NotificationsScreen(
|
||||||
state = state,
|
state = state,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onBack = { section = null },
|
onBack = { section = null },
|
||||||
onOpenSpecialDates = { section = SettingsSection.SpecialDates },
|
onOpenSpecialDates = { section = SettingsSection.SpecialDates },
|
||||||
|
visible = section == SettingsSection.Notifications,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.SpecialDates,
|
visible = section == SettingsSection.SpecialDates,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null })
|
SpecialDatesScreen(viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.SpecialDates)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.Widgets,
|
visible = section == SettingsSection.Widgets,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = section == SettingsSection.Licences,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = overlayExit,
|
||||||
|
) {
|
||||||
|
LicencesScreen(onBack = { section = null }, visible = section == SettingsSection.Licences)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,8 +174,14 @@ private fun SettingsHub(
|
|||||||
onOpenSection: (SettingsSection) -> Unit,
|
onOpenSection: (SettingsSection) -> Unit,
|
||||||
onManageCalendars: () -> Unit,
|
onManageCalendars: () -> Unit,
|
||||||
onOpenBackup: () -> Unit,
|
onOpenBackup: () -> Unit,
|
||||||
|
visible: Boolean,
|
||||||
) {
|
) {
|
||||||
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) {
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_title),
|
||||||
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
|
) {
|
||||||
// Card and support row are one grouped block, so the call to action
|
// Card and support row are one grouped block, so the call to action
|
||||||
// continues the container instead of sitting inside it as a button.
|
// continues the container instead of sitting inside it as a button.
|
||||||
Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() }
|
Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() }
|
||||||
@@ -253,6 +274,13 @@ private fun SettingsHub(
|
|||||||
icon = Icons.Default.Gavel,
|
icon = Icons.Default.Gavel,
|
||||||
position = Position.Middle,
|
position = Position.Middle,
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_licences),
|
||||||
|
summary = stringResource(R.string.settings_licences_subtitle),
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Code, ChipAccent.Neutral) },
|
||||||
|
onClick = { onOpenSection(SettingsSection.Licences) },
|
||||||
|
)
|
||||||
AboutLinkRow(
|
AboutLinkRow(
|
||||||
label = stringResource(R.string.settings_about_privacy),
|
label = stringResource(R.string.settings_about_privacy),
|
||||||
url = stringResource(R.string.about_privacy_url),
|
url = stringResource(R.string.about_privacy_url),
|
||||||
|
|||||||
@@ -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.TimelineHours
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
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
|
||||||
@@ -32,8 +33,10 @@ data class SettingsUiState(
|
|||||||
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
||||||
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
|
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
|
||||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||||
/** Whether the week/day timeline draws an hour separator line (v2.11). */
|
/** Whether the week/day timeline seats each hour in its own cell (v2.11). */
|
||||||
val showHourLines: Boolean = false,
|
val showHourGrid: Boolean = false,
|
||||||
|
/** Days the multi-day view shows side by side (#193). */
|
||||||
|
val multiDayCount: Int = SettingsPrefs.DEFAULT_MULTI_DAY_COUNT,
|
||||||
/** How the Agenda screen treats events that already ended today. */
|
/** How the Agenda screen treats events that already ended today. */
|
||||||
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
||||||
/** Whether the month/week grids fade events that have already finished. */
|
/** Whether the month/week grids fade events that have already finished. */
|
||||||
@@ -62,6 +65,7 @@ data class SettingsUiState(
|
|||||||
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
||||||
/** How tall an hour is drawn in the week and day timelines (#56). */
|
/** How tall an hour is drawn in the week and day timelines (#56). */
|
||||||
val timelineScale: TimelineScale = TimelineScale.Regular,
|
val timelineScale: TimelineScale = TimelineScale.Regular,
|
||||||
|
val timelineHours: TimelineHours = TimelineHours(),
|
||||||
/** 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"). */
|
||||||
|
|||||||
@@ -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.TimelineHours
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
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
|
||||||
@@ -141,29 +142,31 @@ class SettingsViewModel @Inject constructor(
|
|||||||
// Display toggles folded into one flow so they fit this group —
|
// Display toggles folded into one flow so they fit this group —
|
||||||
// the outer combine is already at its five-arg limit.
|
// the outer combine is already at its five-arg limit.
|
||||||
combine(
|
combine(
|
||||||
prefs.showHourLines,
|
prefs.showHourGrid,
|
||||||
prefs.showWeekNumbers,
|
prefs.showWeekNumbers,
|
||||||
prefs.agendaShowToday,
|
prefs.agendaShowToday,
|
||||||
prefs.softenCalendarColors,
|
prefs.softenCalendarColors,
|
||||||
combine(
|
combine(
|
||||||
prefs.todayButtonInToolbar,
|
prefs.todayButtonInToolbar,
|
||||||
prefs.dragToReschedule,
|
prefs.dragToReschedule,
|
||||||
::Pair,
|
prefs.multiDayCount,
|
||||||
|
::Triple,
|
||||||
),
|
),
|
||||||
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove) ->
|
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove, multiDay) ->
|
||||||
DisplayToggles(
|
DisplayToggles(
|
||||||
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove,
|
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove, multiDay,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||||
ViewSettings(
|
ViewSettings(
|
||||||
view, screenRange, widgetRange, timeFormat,
|
view, screenRange, widgetRange, timeFormat,
|
||||||
showHourLines = toggles.showHourLines,
|
showHourGrid = toggles.showHourGrid,
|
||||||
showWeekNumbers = toggles.showWeekNumbers,
|
showWeekNumbers = toggles.showWeekNumbers,
|
||||||
agendaShowToday = toggles.agendaShowToday,
|
agendaShowToday = toggles.agendaShowToday,
|
||||||
softenColors = toggles.softenColors,
|
softenColors = toggles.softenColors,
|
||||||
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||||
dragToReschedule = toggles.dragToReschedule,
|
dragToReschedule = toggles.dragToReschedule,
|
||||||
|
multiDayCount = toggles.multiDayCount,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
@@ -178,9 +181,11 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.drawerViewOrder,
|
prefs.drawerViewOrder,
|
||||||
prefs.monthViewStyle,
|
prefs.monthViewStyle,
|
||||||
prefs.widgetSize,
|
prefs.widgetSize,
|
||||||
prefs.timelineScale,
|
combine(prefs.timelineScale, prefs.timelineHours, ::Pair),
|
||||||
) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale ->
|
) { quickSwitch, drawer, monthStyle, widgetSize, (timelineScale, timelineHours) ->
|
||||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale)
|
ViewCustomization(
|
||||||
|
quickSwitch, drawer, monthStyle, widgetSize, timelineScale, timelineHours,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||||
@@ -191,12 +196,13 @@ class SettingsViewModel @Inject constructor(
|
|||||||
agendaScreenRange = views.agendaScreenRange,
|
agendaScreenRange = views.agendaScreenRange,
|
||||||
agendaWidgetRange = views.agendaWidgetRange,
|
agendaWidgetRange = views.agendaWidgetRange,
|
||||||
timeFormat = views.timeFormat,
|
timeFormat = views.timeFormat,
|
||||||
showHourLines = views.showHourLines,
|
showHourGrid = views.showHourGrid,
|
||||||
showWeekNumbers = views.showWeekNumbers,
|
showWeekNumbers = views.showWeekNumbers,
|
||||||
agendaShowToday = views.agendaShowToday,
|
agendaShowToday = views.agendaShowToday,
|
||||||
softenColors = views.softenColors,
|
softenColors = views.softenColors,
|
||||||
todayButtonInToolbar = views.todayButtonInToolbar,
|
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||||
dragToReschedule = views.dragToReschedule,
|
dragToReschedule = views.dragToReschedule,
|
||||||
|
multiDayCount = views.multiDayCount,
|
||||||
agendaShowRangeBar = misc.showRangeBar,
|
agendaShowRangeBar = misc.showRangeBar,
|
||||||
autofocusEventTitle = misc.autofocusEventTitle,
|
autofocusEventTitle = misc.autofocusEventTitle,
|
||||||
pastEventDisplay = misc.pastEventDisplay,
|
pastEventDisplay = misc.pastEventDisplay,
|
||||||
@@ -206,6 +212,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||||
widgetSize = misc.viewCustomization.widgetSize,
|
widgetSize = misc.viewCustomization.widgetSize,
|
||||||
timelineScale = misc.viewCustomization.timelineScale,
|
timelineScale = misc.viewCustomization.timelineScale,
|
||||||
|
timelineHours = misc.viewCustomization.timelineHours,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -292,21 +299,23 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val agendaScreenRange: AgendaRange,
|
val agendaScreenRange: AgendaRange,
|
||||||
val agendaWidgetRange: AgendaRange,
|
val agendaWidgetRange: AgendaRange,
|
||||||
val timeFormat: TimeFormatPref,
|
val timeFormat: TimeFormatPref,
|
||||||
val showHourLines: Boolean,
|
val showHourGrid: Boolean,
|
||||||
val showWeekNumbers: Boolean,
|
val showWeekNumbers: Boolean,
|
||||||
val agendaShowToday: Boolean,
|
val agendaShowToday: Boolean,
|
||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
val todayButtonInToolbar: Boolean,
|
val todayButtonInToolbar: Boolean,
|
||||||
val dragToReschedule: Boolean,
|
val dragToReschedule: Boolean,
|
||||||
|
val multiDayCount: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class DisplayToggles(
|
private data class DisplayToggles(
|
||||||
val showHourLines: Boolean,
|
val showHourGrid: Boolean,
|
||||||
val showWeekNumbers: Boolean,
|
val showWeekNumbers: Boolean,
|
||||||
val agendaShowToday: Boolean,
|
val agendaShowToday: Boolean,
|
||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
val todayButtonInToolbar: Boolean,
|
val todayButtonInToolbar: Boolean,
|
||||||
val dragToReschedule: Boolean,
|
val dragToReschedule: Boolean,
|
||||||
|
val multiDayCount: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class MiscSettings(
|
private data class MiscSettings(
|
||||||
@@ -323,6 +332,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val monthViewStyle: MonthViewStyle,
|
val monthViewStyle: MonthViewStyle,
|
||||||
val widgetSize: WidgetSize,
|
val widgetSize: WidgetSize,
|
||||||
val timelineScale: TimelineScale,
|
val timelineScale: TimelineScale,
|
||||||
|
val timelineHours: TimelineHours,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 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. */
|
||||||
@@ -532,8 +542,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setShowHourLines(enabled: Boolean) {
|
fun setMultiDayCount(count: Int) {
|
||||||
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
viewModelScope.launch { prefs.setMultiDayCount(count) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShowHourGrid(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setShowHourGrid(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setShowWeekNumbers(enabled: Boolean) {
|
fun setShowWeekNumbers(enabled: Boolean) {
|
||||||
@@ -591,15 +605,15 @@ class SettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable or disable [view] in the quick-switch cycle, via an atomic
|
* Enable or disable [view] in the quick-switch cycle, via an atomic
|
||||||
* read-modify-write so a concurrent reorder can't clobber it. The
|
* read-modify-write so a concurrent reorder can't clobber it. Any number of
|
||||||
* MIN_ENABLED floor is re-checked inside the transform, because the screen's
|
* views may be turned off; below two the pill hides itself (#150).
|
||||||
* own guard reads an async-echoed snapshot.
|
|
||||||
*/
|
*/
|
||||||
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.updateQuickSwitch { config ->
|
prefs.updateQuickSwitch { config ->
|
||||||
val next = if (enabled) config.enabled + view else config.enabled - view
|
config.copy(
|
||||||
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
|
enabled = if (enabled) config.enabled + view else config.enabled - view,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -617,6 +631,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setTimelineScale(scale) }
|
viewModelScope.launch { prefs.setTimelineScale(scale) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTimelineHours(hours: TimelineHours) {
|
||||||
|
viewModelScope.launch { prefs.setTimelineHours(hours) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
|||||||
internal fun SpecialDatesScreen(
|
internal fun SpecialDatesScreen(
|
||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
visible: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
|
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
|
||||||
val settings by viewModel.state.collectAsStateWithLifecycle()
|
val settings by viewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -76,6 +77,7 @@ internal fun SpecialDatesScreen(
|
|||||||
title = stringResource(R.string.settings_section_special_dates),
|
title = stringResource(R.string.settings_section_special_dates),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
// Paused banner: the permission was revoked after enabling.
|
// Paused banner: the permission was revoked after enabling.
|
||||||
if (state.enabled && state.stalledPermission) {
|
if (state.enabled && state.stalledPermission) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user