Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -37,6 +37,12 @@ jobs:
|
||||
- name: Reproducible-release invariant
|
||||
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
|
||||
|
||||
# Decide whether anything that affects the app build changed. Docs, store
|
||||
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
||||
# the SDK + Gradle work below but still report a green `ci`.
|
||||
|
||||
+216
@@ -7,6 +7,193 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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]).
|
||||
|
||||
### Fixed
|
||||
- **Multi-day events stay above shorter events.** Month and Week bars, Split day lists, and Agenda now place multi-day events before one-day all-day events, then timed events; declined events remain last ([#291]).
|
||||
|
||||
## [2.21.0] — 2026-09-23
|
||||
|
||||
### Added
|
||||
- **Setup makes sure you have a calendar to write to.** A fresh install could
|
||||
finish onboarding and land on an empty grid with nothing saying why — either
|
||||
because the device had no calendar at all, or because every calendar it had
|
||||
was switched off. Setup now covers both: it offers to create a calendar on
|
||||
this device or to add an account that syncs one, and it lists the calendars
|
||||
that exist but are switched off so you can turn them back on ([#287]).
|
||||
|
||||
### Changed
|
||||
- **The month grid gives its days more room.** Week numbers are set smaller and
|
||||
their gutter is measured from the widest number it has to seat instead of
|
||||
holding a fixed 40dp; event chips spend less of their width on padding; and
|
||||
the labels in both are tracked more tightly, which is worth roughly a
|
||||
character per chip ([#189], [#190], [#212], [#213]).
|
||||
- **A month day shows as many events as it has height for.** The limit was a
|
||||
flat three at every size, so a tall week row threw away space it could have
|
||||
filled, while a cramped one drew a third chip its band could not hold and put
|
||||
the "more" marker below the clip, where nothing showed the day held more at
|
||||
all. It is measured per week row now, and a tablet or a landscape phone seats
|
||||
more of them.
|
||||
- **Split view no longer pads a short month out to six rows.** A five-row month
|
||||
was handing the day pane a blank row and its gap — a sixth of the grid. The
|
||||
grid now draws only the rows its month spans and the pane grows into the rest
|
||||
([#162]).
|
||||
|
||||
### Fixed
|
||||
- **Short events show their title in week and day view.** A block needed 20dp
|
||||
of height before it drew any title at all, which at fit-the-whole-day put the
|
||||
cutoff around the half hour. The padding now tapers as the block shrinks, so
|
||||
a short event keeps its name ([#289]).
|
||||
- **A fully zoomed-out timeline sits still.** Rounding its height up left it as
|
||||
much as a pixel per hour taller than the viewport those hours are meant to
|
||||
fill — some 24px of leftover scroll, enough to bounce off Android's
|
||||
overscroll stretch ([#290]).
|
||||
- **Backup & restore says what is going on instead of coming up blank.** It
|
||||
built its rows straight from the calendar list, so loading, a provider error,
|
||||
and a device with nothing to export all rendered as an empty page with no
|
||||
buttons on it ([#304]).
|
||||
- **An event with no title reads in your language.** It was labelled "(Ohne
|
||||
Titel)" on every screen but the event's own, whatever language the phone was
|
||||
set to: the German text was chosen before the translated one could be
|
||||
([#321]).
|
||||
- **A calendar the system gives no name reads in your language.** The same
|
||||
cause, in the calendar lists and pickers — and because the calendar editor
|
||||
starts from that name, saving such a calendar wrote the German text into it
|
||||
for real ([#329]).
|
||||
|
||||
## [2.20.4] — 2026-09-20
|
||||
|
||||
### Fixed
|
||||
- **The app no longer reopens the event you last came in on.** Opening an event
|
||||
from a reminder or a home-screen widget made the app return to that same
|
||||
event on every later launch — and show an error screen once the event had
|
||||
been deleted. Android hands an app the launch it was started with again each
|
||||
time it is rebuilt, and Calendula was acting on it every time instead of
|
||||
once ([#309]).
|
||||
- **"Today" works in the agenda again.** Scrolling a few days ahead and tapping
|
||||
today did nothing, because the agenda is browsed by scrolling rather than by
|
||||
moving its date, and the button only moved the date. It now brings the list
|
||||
back to today, and it stays offered while the list is scrolled away — before,
|
||||
it was hidden in exactly the situation it is for ([#305]).
|
||||
- **Calendula is filed under "Calendar & Agenda" in the self-hosted repo.**
|
||||
Browsing by category in an F-Droid client set to prefer that repo listed the
|
||||
app under "Time" instead ([#294]).
|
||||
|
||||
## [2.20.3] — 2026-09-12
|
||||
|
||||
### Fixed
|
||||
- **A custom agenda range crashed the app in French.** Choosing a custom range
|
||||
for the agenda — or simply typing a number into that field — closed the app on
|
||||
any phone set to French. The French translation of the "%d days" label was
|
||||
missing the wording French uses for counts of two and above, and Android has
|
||||
nothing to fall back on when that particular form is absent, so every value
|
||||
except one day was affected ([#297], [#298]).
|
||||
|
||||
## [2.20.2] — 2026-09-12
|
||||
|
||||
### Fixed
|
||||
- **Crash reports are filed in English.** Reporting a crash from a phone set to
|
||||
another language prefilled the issue — title and body — in that language, so
|
||||
a French crash arrived on Codeberg as a French issue ([#297], [#298]). The
|
||||
text that is *posted* is now English for everyone; the dialog that asks you
|
||||
to send it, and everything else you see in the app, is still translated.
|
||||
|
||||
## [2.20.1] — 2026-09-07
|
||||
|
||||
### Fixed
|
||||
- **An empty calendar now explains itself.** "No calendars configured" offered
|
||||
an "Open system calendar settings" button that opened nothing — every screen
|
||||
wired it to the same reload that had just produced the empty state. It now
|
||||
opens the system's account chooser, filtered to the providers that sync
|
||||
calendars ([#239]).
|
||||
- **Calendars that are all switched off no longer look like an empty month.**
|
||||
When every calendar on the device is hidden at system level there is nothing
|
||||
to draw, but the app said nothing at all: it only ever checked whether the
|
||||
device had *no* calendars, so a device that had them and hid them fell
|
||||
through to a blank grid with no hint. That case now says so, and points at
|
||||
Settings → Calendars — the one screen that can switch a calendar back on. The
|
||||
navigation drawer's calendar list drew the same conflation and is fixed with
|
||||
it ([#239]).
|
||||
|
||||
## [2.20.0] — 2026-09-07
|
||||
|
||||
### Added
|
||||
- **Long-press to copy.** Holding down the title, the location or the
|
||||
description on an event puts that whole field on the clipboard. Tapping still
|
||||
does what it always did — the location opens maps, a link in the description
|
||||
opens — and the times, calendar, repeat rule and guests stay uncopyable. Below
|
||||
Android 13 a short confirmation appears; from Android 13 on the system shows
|
||||
its own clipboard chip ([#195]).
|
||||
- **The open-source projects Calendula ships** are now listed under Settings →
|
||||
About, each with its copyright holder and licence, and each row tapping
|
||||
through to that project's source ([#281]).
|
||||
|
||||
### Changed
|
||||
- Event titles no longer end in an ellipsis. The "…" took the width of a couple
|
||||
of characters and told you nothing you couldn't already see, so the title now
|
||||
simply runs to the edge of its chip in the month, week and day views — a few
|
||||
more letters per event, which is often the difference between two events you
|
||||
can tell apart and two you can't. Right-to-left languages keep the ellipsis,
|
||||
where clipping would cut the beginning of a title rather than its end
|
||||
([#164]).
|
||||
- Month-view events now show their start time before the title, where the chip
|
||||
is wide enough to keep the title readable — in landscape, on a foldable and on
|
||||
a tablet, and never at the cost of a narrow phone column. All-day events show
|
||||
no time, and neither does a bar carried in from the previous week, whose start
|
||||
is not in that row. Event times across the month, week and day views are also
|
||||
set in a regular weight against the title's, so a time reads as a time rather
|
||||
than as part of the name next to it ([#219]).
|
||||
- **The hour lines in week and day view are now an hour grid.** The setting
|
||||
that drew a faint separator line at each hour now seats every hour in its own
|
||||
rounded cell, so an hour boundary reads as the seam between two surfaces
|
||||
rather than a line drawn across one — the same negative space that separates
|
||||
the month grid's days and the week's columns. An event that starts on the hour
|
||||
fills its cell instead of overhanging the seam, and one that runs past
|
||||
midnight still meets the edge of its column ([#113]).
|
||||
- **An invitation you haven't answered now reads as an outline.** An event still
|
||||
waiting on your reply is drawn with the calendar's colour on its border and
|
||||
its title and nothing behind it, so it stands apart from the ones you have
|
||||
accepted — on the month chips, the all-day bars and the week and day blocks
|
||||
alike. Declined events keep their filled, struck-through chip and now sort
|
||||
last in the month view as well, so a day with more events than fit drops the
|
||||
ones you said no to first ([#230]).
|
||||
- The calendar titles now shorten instead of being cut off. When the full month
|
||||
name doesn't fit the top bar, the month and week views fall back to its
|
||||
three-letter form and the day view drops the weekday, rather than trailing off
|
||||
mid-word at large font sizes. The view switcher, the title and the agenda's
|
||||
range bar also line up with the grid underneath them ([#165]).
|
||||
- The jump-to-today button in the toolbar now shows today's date. It drew a
|
||||
generic calendar icon, which told you nothing you didn't already know from
|
||||
tapping it; it now carries the current day number in an outlined box, so the
|
||||
bar says what day it is as well as taking you there. It rolls over at midnight
|
||||
on its own ([#220]).
|
||||
- The quick-switch pill disappears when there is nothing left to switch between,
|
||||
and you can now turn calendar views off all the way down to a single one — the
|
||||
setting used to hold two open purely to keep the pill fed ([#150]).
|
||||
|
||||
### Fixed
|
||||
- **A long location no longer runs off the edge of its field.** The location on
|
||||
the edit screen sat on one line, so a full postal address or a long meeting
|
||||
link scrolled sideways out of sight as you typed it. It now wraps and the card
|
||||
grows to fit, with the pin and the contacts button staying level with the first
|
||||
line at any font size. A multi-line address you paste in is joined with commas
|
||||
— the way one picked from your contacts always has been — instead of running
|
||||
together into a single word ([#273]).
|
||||
- **The event form capitalizes sentences again.** The title, description and
|
||||
guest fields had stopped starting a sentence with a capital letter; they
|
||||
follow the keyboard's usual behaviour once more. The location is deliberately
|
||||
left alone, since it is as often a URL as an address ([#146]).
|
||||
- **A block you pick up no longer loses its second line.** Dragging an event
|
||||
whose time or title wrapped to two lines clipped the floating copy to one line
|
||||
for as long as you held it, and it snapped back on drop. The copy now measures
|
||||
its text exactly as the block does ([#267]).
|
||||
|
||||
## [2.19.4] — 2026-09-01
|
||||
|
||||
### Fixed
|
||||
@@ -1560,6 +1747,8 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
||||
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164
|
||||
[#165]: https://codeberg.org/jlmakiola/calendula/issues/165
|
||||
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
||||
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
||||
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
||||
@@ -1569,5 +1758,32 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
|
||||
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
||||
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
||||
[#219]: https://codeberg.org/jlmakiola/calendula/issues/219
|
||||
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
||||
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
|
||||
[#273]: https://codeberg.org/jlmakiola/calendula/issues/273
|
||||
[#113]: https://codeberg.org/jlmakiola/calendula/issues/113
|
||||
[#220]: https://codeberg.org/jlmakiola/calendula/issues/220
|
||||
[#195]: https://codeberg.org/jlmakiola/calendula/issues/195
|
||||
[#281]: https://codeberg.org/jlmakiola/calendula/issues/281
|
||||
[#230]: https://codeberg.org/jlmakiola/calendula/issues/230
|
||||
[#150]: https://codeberg.org/jlmakiola/calendula/issues/150
|
||||
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
|
||||
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
|
||||
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
|
||||
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
|
||||
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
|
||||
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
|
||||
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
|
||||
[#294]: https://codeberg.org/jlmakiola/calendula/issues/294
|
||||
[#287]: https://codeberg.org/jlmakiola/calendula/issues/287
|
||||
[#189]: https://codeberg.org/jlmakiola/calendula/issues/189
|
||||
[#190]: https://codeberg.org/jlmakiola/calendula/issues/190
|
||||
[#212]: https://codeberg.org/jlmakiola/calendula/issues/212
|
||||
[#213]: https://codeberg.org/jlmakiola/calendula/issues/213
|
||||
[#162]: https://codeberg.org/jlmakiola/calendula/issues/162
|
||||
[#289]: https://codeberg.org/jlmakiola/calendula/issues/289
|
||||
[#290]: https://codeberg.org/jlmakiola/calendula/issues/290
|
||||
[#304]: https://codeberg.org/jlmakiola/calendula/issues/304
|
||||
[#321]: https://codeberg.org/jlmakiola/calendula/issues/321
|
||||
[#329]: https://codeberg.org/jlmakiola/calendula/issues/329
|
||||
|
||||
@@ -157,6 +157,8 @@ reinstalling. Google Play is the exception — see above.</sub>
|
||||
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
||||
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
|
||||
- **[Privacy policy](docs/PRIVACY.md)** — the source of the page published at
|
||||
[jeanlucmakiola.de/calendula/privacy](https://jeanlucmakiola.de/calendula/privacy)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
@@ -187,6 +189,16 @@ No coding needed — register on the Weblate server, pick (or request) a languag
|
||||
and translate the strings in your browser. You can also reach this link in the
|
||||
app from the top of **Settings → App language**.
|
||||
|
||||
## 🔒 Privacy
|
||||
|
||||
No analytics, no tracking, no advertising, no crash-reporting SDK, no third-party
|
||||
services — and **no internet permission at all**, so the app is technically
|
||||
incapable of transmitting anything anywhere. Your events stay in Android's own
|
||||
calendar store, where they already were.
|
||||
|
||||
**→ [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 +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 21904
|
||||
versionName = "2.19.4"
|
||||
versionCode = 22100
|
||||
versionName = "2.21.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -113,12 +113,16 @@ android {
|
||||
lint {
|
||||
// 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
|
||||
// it. Likewise a translated <plurals> may not fill every CLDR quantity
|
||||
// form its locale defines (e.g. Arabic needs "zero"); the missing form
|
||||
// falls back to "other" at runtime, so MissingQuantity is informational
|
||||
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
||||
// check_translations.py guards the same invariants with clearer,
|
||||
// translator-facing messages.
|
||||
// it. A translated <plurals> may likewise skip a CLDR quantity form its
|
||||
// locale defines (e.g. Arabic "zero"): Android falls back to "other" for
|
||||
// any form it cannot find, so MissingQuantity is informational too.
|
||||
// What a translation must NOT skip is "other" itself — nothing falls back
|
||||
// for that one, not even the base locale, so it throws
|
||||
// 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")
|
||||
}
|
||||
|
||||
|
||||
@@ -32,13 +32,15 @@ class CalendulaApp : Application() {
|
||||
// respecting, on-device; the user submits the report by hand). The
|
||||
// capture/loop-detection/report machinery lives in floret-kit's
|
||||
// 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(
|
||||
this,
|
||||
CrashConfig(
|
||||
appLabel = getString(R.string.app_name),
|
||||
newIssueUrl = getString(R.string.report_issue_url),
|
||||
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||
issueTitle = getString(R.string.crash_report_issue_title),
|
||||
),
|
||||
)
|
||||
reconcileAutoBackup()
|
||||
|
||||
@@ -31,7 +31,8 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||
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.rememberTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
@@ -113,11 +114,26 @@ class MainActivity : AppCompatActivity() {
|
||||
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||
Configuration.UI_MODE_NIGHT_YES
|
||||
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()
|
||||
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
requestedInsert = intent.insertRequestOrNull()
|
||||
requestedEditKey = intent.editEventKeyOrNull()
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
setContent {
|
||||
// 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.
|
||||
val timelineZoom = rememberTimelineZoom(
|
||||
stored = settings.timelineScale,
|
||||
hours = settings.timelineHours,
|
||||
onPersist = settingsViewModel::setTimelineScale,
|
||||
)
|
||||
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||
@@ -171,8 +188,9 @@ class MainActivity : AppCompatActivity() {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
LocalShowHourGrid provides settings.showHourGrid,
|
||||
LocalTimelineZoom provides timelineZoom,
|
||||
LocalMultiDayCount provides settings.multiDayCount,
|
||||
LocalSoftenColors provides settings.softenColors,
|
||||
) {
|
||||
RootScreen(
|
||||
@@ -242,6 +260,16 @@ class MainActivity : AppCompatActivity() {
|
||||
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`)
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
|
||||
* `EVENT_COLOR`. The named entries exist for callers that need one specific
|
||||
* hue (the managed special-dates calendars), so they can't drift from the
|
||||
* swatches offered in the colour picker.
|
||||
* The colours offered when creating or editing a calendar; ARGB ints for a raw
|
||||
* `CALENDAR_COLOR` / `EVENT_COLOR`.
|
||||
*
|
||||
* 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 {
|
||||
val Red = 0xFFD50000.toInt()
|
||||
val Orange = 0xFFE67C00.toInt()
|
||||
val Amber = 0xFFF6BF26.toInt()
|
||||
val Green = 0xFF33B679.toInt()
|
||||
val DarkGreen = 0xFF0B8043.toInt()
|
||||
val Blue = 0xFF039BE5.toInt()
|
||||
val Indigo = 0xFF3F51B5.toInt()
|
||||
val Purple = 0xFF8E24AA.toInt()
|
||||
val Graphite = 0xFF616161.toInt()
|
||||
val Mauve = 0xFF7A5C6B.toInt()
|
||||
val Red = 0xFFD7484A.toInt()
|
||||
val Orange = 0xFFE8743B.toInt()
|
||||
val Amber = 0xFFE0A32E.toInt()
|
||||
val Olive = 0xFF7CA83E.toInt()
|
||||
val Green = 0xFF35A06A.toInt()
|
||||
val Teal = 0xFF19938C.toInt()
|
||||
val Cyan = 0xFF2A9BC4.toInt()
|
||||
val Blue = 0xFF3C74C8.toInt()
|
||||
val Indigo = 0xFF6A5CC0.toInt()
|
||||
val Purple = 0xFF9455B8.toInt()
|
||||
val Pink = 0xFFC94F8E.toInt()
|
||||
|
||||
/** The full palette, in swatch-row order. */
|
||||
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
||||
/** The full palette, in swatch-grid order. */
|
||||
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()
|
||||
|
||||
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 {
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||
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?) {
|
||||
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||
val name = displayName.trim()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||
put(CalendarContract.Calendars.NAME, name)
|
||||
@@ -464,7 +464,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
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 {
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||
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
|
||||
return CalendarSource(
|
||||
id = getLong(CalendarProjection.IDX_ID),
|
||||
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
|
||||
?: Fallbacks.UNNAMED_CALENDAR,
|
||||
// Blank when the provider row has no name: the placeholder is a display
|
||||
// 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(),
|
||||
accountType = accountType,
|
||||
color = getInt(CalendarProjection.IDX_COLOR),
|
||||
|
||||
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||
|
||||
private const val TAG = "InstanceMapper"
|
||||
|
||||
@@ -20,8 +21,9 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
||||
return null
|
||||
}
|
||||
|
||||
val rawTitle = getString(InstanceProjection.IDX_TITLE)
|
||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||
// Left blank when the event has no title: the placeholder is a display
|
||||
// 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)) {
|
||||
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
||||
@@ -39,7 +41,17 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
||||
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
|
||||
color = color,
|
||||
location = getString(InstanceProjection.IDX_LOCATION),
|
||||
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
||||
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe"
|
||||
* counts as going, and everything that is not an open or refused invitation —
|
||||
* your own events included — falls through to [EventResponse.Going].
|
||||
*/
|
||||
internal fun mapEventResponse(raw: Int): EventResponse = when (raw) {
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> EventResponse.Declined
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> EventResponse.Invited
|
||||
else -> EventResponse.Going
|
||||
}
|
||||
|
||||
@@ -312,8 +312,3 @@ internal object ReminderProjection {
|
||||
const val IDX_MINUTES = 0
|
||||
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
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
@@ -22,8 +21,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||
}.coerceAtLeast(dtStart)
|
||||
|
||||
val rawTitle = getString(SearchProjection.IDX_TITLE)
|
||||
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
|
||||
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
||||
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||
} else {
|
||||
@@ -43,7 +41,6 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
location = getString(SearchProjection.IDX_LOCATION),
|
||||
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
||||
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
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.parseTimelineScale
|
||||
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
|
||||
* (v2.11). Defaults to OFF — the historical clean look; users opt in.
|
||||
* Whether the week/day timeline seats each hour in its own cell (v2.11, a
|
||||
* 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
|
||||
}
|
||||
|
||||
suspend fun setShowHourLines(enabled: Boolean) {
|
||||
suspend fun setShowHourGrid(enabled: Boolean) {
|
||||
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
||||
}
|
||||
|
||||
@@ -286,6 +288,36 @@ class SettingsPrefs @Inject constructor(
|
||||
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
|
||||
* 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). */
|
||||
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
||||
@@ -929,10 +979,20 @@ class SettingsPrefs @Inject constructor(
|
||||
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> {
|
||||
val ordered = seen.distinct()
|
||||
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
|
||||
val ordered = seen.distinct().toMutableList()
|
||||
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 {
|
||||
@@ -976,6 +1036,10 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
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_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
||||
internal val ONBOARDING_MONTH_STYLE_KEY =
|
||||
@@ -994,8 +1058,14 @@ class SettingsPrefs @Inject constructor(
|
||||
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
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. */
|
||||
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 =
|
||||
stringPreferencesKey("per_calendar_reminder_override")
|
||||
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
|
||||
|
||||
@@ -88,6 +88,7 @@ class ReminderNotifier @Inject constructor(
|
||||
is24Hour = is24Hour,
|
||||
today = Instant.now().atZone(zone).toLocalDate(),
|
||||
firstDayOfWeek = firstDayOfWeek,
|
||||
todayLabel = context.getString(R.string.reminder_day_today),
|
||||
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||
)
|
||||
|
||||
@@ -27,12 +27,12 @@ import java.util.Locale
|
||||
* alone would be ambiguous)
|
||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
|
||||
* already unambiguous)
|
||||
* - all-day, one day: "11 Jun 2026"
|
||||
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
||||
* - all-day, one day: "Tomorrow" / "Thu" / "11 Jun 2026" (same relative rules,
|
||||
* 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
|
||||
* relative prefix. They store UTC midnights with an exclusive end, so they are
|
||||
* read in UTC and the end day is the last *covered* day.
|
||||
* All-day instances store UTC midnights with an exclusive end, so they are read
|
||||
* in UTC and the end day is the last *covered* day (issue #308).
|
||||
*/
|
||||
fun reminderTimeText(
|
||||
beginMillis: Long,
|
||||
@@ -43,6 +43,7 @@ fun reminderTimeText(
|
||||
is24Hour: Boolean,
|
||||
today: LocalDate,
|
||||
firstDayOfWeek: DayOfWeek,
|
||||
todayLabel: String,
|
||||
tomorrowLabel: String,
|
||||
yesterdayLabel: String,
|
||||
): String {
|
||||
@@ -53,11 +54,16 @@ fun reminderTimeText(
|
||||
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
.minusDays(1)
|
||||
.coerceAtLeast(firstDay)
|
||||
return if (lastDay == firstDay) {
|
||||
dateFormat.format(firstDay)
|
||||
} else {
|
||||
dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
|
||||
}
|
||||
val start = relativeDayPrefix(
|
||||
day = firstDay,
|
||||
today = today,
|
||||
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)
|
||||
|
||||
@@ -46,3 +46,19 @@ fun hasSystemHiddenCalendars(
|
||||
calendars: List<CalendarSource>,
|
||||
pendingDisabledIds: Set<Long>,
|
||||
): 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,
|
||||
/**
|
||||
* This device user answered "no" to the invitation
|
||||
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is
|
||||
* still an appointment someone expects an answer about — but every surface
|
||||
* strikes it through, and it plans no reminders (#180).
|
||||
* This device user's own answer to the invitation, as far as the grids care
|
||||
* (#180, #230).
|
||||
*/
|
||||
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
|
||||
* the current instant. An in-progress event (already started but not yet ended)
|
||||
@@ -231,6 +252,14 @@ enum class RecurringWriteScope {
|
||||
enum class FailureReason {
|
||||
PermissionRevoked,
|
||||
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,
|
||||
EventNotFound,
|
||||
Unknown,
|
||||
|
||||
@@ -166,14 +166,19 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
}
|
||||
when {
|
||||
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 body = indexOfNextComponent(lines, i + 1, end)
|
||||
parseComponent(
|
||||
body = lines.subList(i + 1, end),
|
||||
body = lines.subList(i + 1, body),
|
||||
fileCalendarName = calendarName,
|
||||
warnings = warnings,
|
||||
isTask = component == "VTODO",
|
||||
)?.let(events::add)
|
||||
i = end + 1
|
||||
i = if (body < end) body else end + 1
|
||||
}
|
||||
line.isBegin("VTIMEZONE") -> {
|
||||
// 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. */
|
||||
/**
|
||||
* 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 {
|
||||
var i = from
|
||||
while (i < lines.size) {
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
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.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
@@ -26,17 +42,38 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
||||
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.predictiveBackExit
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.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.TimelineScrollMemory
|
||||
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.drillToWeek
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||
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.edit.EventEditScreen
|
||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||
@@ -44,16 +81,21 @@ import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||
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.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* 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
|
||||
* pill in their top bars writes back here via [onSelectView].
|
||||
* screens. Each screen owns its own ViewModel; they all open on the one focused
|
||||
* 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.
|
||||
* A lateral switch (pill / drawer) builds a visit history so back retraces it
|
||||
@@ -70,6 +112,7 @@ import kotlin.time.Clock
|
||||
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
||||
* widget's view.)
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun CalendarHost(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -101,14 +144,25 @@ fun CalendarHost(
|
||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||
mutableStateOf(listOf(defaultView))
|
||||
}
|
||||
var navigationVersion by remember { mutableIntStateOf(0) }
|
||||
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.
|
||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val switchScope = rememberCoroutineScope()
|
||||
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 ->
|
||||
pendingDayIso = date.toString()
|
||||
viewStack = viewStack.drillToDay()
|
||||
focus.navigateTo(date)
|
||||
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
|
||||
@@ -196,10 +250,21 @@ fun CalendarHost(
|
||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||
// keeps routing a single event straight into the prefilled create form.
|
||||
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) {
|
||||
if (requestedImportUri != null) {
|
||||
importUri = requestedImportUri
|
||||
importForceMany = false
|
||||
importSession++
|
||||
onImportConsumed()
|
||||
}
|
||||
}
|
||||
@@ -247,13 +312,14 @@ fun CalendarHost(
|
||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||
// view so backing out returns there (then home), not to the default.
|
||||
LaunchedEffect(widgetNavRequest) {
|
||||
if (widgetNavRequest != null) navigationVersion++
|
||||
when (val req = widgetNavRequest) {
|
||||
is WidgetNavRequest.OpenDate -> {
|
||||
// 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.
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
pendingDayIso = req.dateIso
|
||||
focus.navigateTo(LocalDate.parse(req.dateIso))
|
||||
// No widget source (an external date tap) roots over the default
|
||||
// home view, so backing out of the day returns home then exits.
|
||||
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||
@@ -277,7 +343,7 @@ fun CalendarHost(
|
||||
// default home — so backing out returns to the default, then exits.
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
pendingDayIso = null
|
||||
focus.navigateTo(localToday())
|
||||
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
@@ -328,6 +394,9 @@ fun CalendarHost(
|
||||
}
|
||||
|
||||
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
|
||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||
@@ -335,71 +404,150 @@ fun CalendarHost(
|
||||
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
||||
editKey != null || showSettings || showCalendars || showBackup ||
|
||||
importUri != null || importForm != null
|
||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
||||
viewStack = viewStack.dropLast(1)
|
||||
// The view switch runs on a seekable transition, so a back gesture can scrub
|
||||
// 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()) {
|
||||
// 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
|
||||
// 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()
|
||||
CompositionLocalProvider(LocalEventMove provides moveScope) {
|
||||
AnimatedContent(
|
||||
targetState = view,
|
||||
val morphBounds = rememberViewMorphBoundsTransform()
|
||||
val morphFade = rememberCalendarFadeSpec()
|
||||
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() }
|
||||
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 },
|
||||
label = "view-switch",
|
||||
contentKey = { it.screen },
|
||||
) { currentView ->
|
||||
when (currentView) {
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
val screen = currentView.screen
|
||||
val sharedScope = this@SharedTransitionLayout
|
||||
val visibilityScope = this@AnimatedContent
|
||||
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,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
ViewScreen.Month -> MonthScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenWeek = onOpenWeek,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
initialDateIso = pendingDayIso,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
ViewScreen.Agenda -> AgendaScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
todayInToolbar = todayInToolbar,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,11 +560,12 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = showSearch,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SearchScreen(
|
||||
onBack = { showSearch = false },
|
||||
onEventClick = onEventClick,
|
||||
visible = showSearch,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -425,7 +574,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = detailKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
activeKey?.let { key ->
|
||||
EventDetailScreen(
|
||||
@@ -445,6 +594,7 @@ fun CalendarHost(
|
||||
importForm = form
|
||||
detailKey = null
|
||||
},
|
||||
visible = detailKey != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -453,7 +603,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = createDateIso != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
(createDateIso ?: heldCreateIso)?.let { iso ->
|
||||
EventEditScreen(
|
||||
@@ -462,6 +612,7 @@ fun CalendarHost(
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = createDateIso != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -470,7 +621,7 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = editKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
(editKey ?: heldEditKey)?.let { key ->
|
||||
EventEditScreen(
|
||||
@@ -482,6 +633,7 @@ fun CalendarHost(
|
||||
detailKey = null
|
||||
},
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = editKey != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -490,12 +642,13 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = showSettings,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SettingsScreen(
|
||||
onBack = { showSettings = false },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
onOpenBackup = { showBackup = true },
|
||||
visible = showSettings,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -504,11 +657,28 @@ fun CalendarHost(
|
||||
importUri?.let { uri ->
|
||||
ImportScreen(
|
||||
uri = uri,
|
||||
session = importSession,
|
||||
forceMany = importForceMany,
|
||||
onClose = { importUri = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
onClose = {
|
||||
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 ->
|
||||
importUri = null
|
||||
calendarsAfterImport = false
|
||||
backupAfterImport = false
|
||||
importFormSource = ImportSource.File
|
||||
importForm = form
|
||||
},
|
||||
@@ -530,11 +700,12 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = showCalendars,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
CalendarsScreen(
|
||||
onBack = { showCalendars = false },
|
||||
onOpenBackup = { showBackup = true },
|
||||
visible = showCalendars,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -542,20 +713,36 @@ fun CalendarHost(
|
||||
AnimatedVisibility(
|
||||
visible = showBackup,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
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
|
||||
// Settings can open this screen without the manager underneath, so
|
||||
// its failure states' "Manage calendars" way out has to open the
|
||||
// manager rather than just pop — popping alone lands on Settings
|
||||
// (#304). Coming from the manager the flag is already set, so this
|
||||
// is the same pop-back there.
|
||||
CompositionLocalProvider(
|
||||
LocalManageCalendars provides {
|
||||
showCalendars = true
|
||||
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 +752,6 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||
save = { stack -> stack.map(CalendarView::ordinal) },
|
||||
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 de.jeanlucmakiola.calendula.R
|
||||
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.declinedTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
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.layout.Arrangement
|
||||
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.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.DrawerValue
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
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.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
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.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.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.hasEnded
|
||||
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.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.CalendarView
|
||||
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.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
@@ -76,27 +77,22 @@ import kotlin.time.Instant
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AgendaScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onOpenDay: (LocalDate) -> 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,
|
||||
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 anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var showRangePicker by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -106,92 +102,102 @@ fun AgendaScreen(
|
||||
}
|
||||
val successState = state as? AgendaUiState.Success
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = anchor,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onJumpToDate = { target ->
|
||||
viewModel.goToDate(target)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onSettings = {
|
||||
onOpenSettings()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
AgendaTopBar(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = viewModel::goToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
// The agenda window always starts at the anchor, so moving through it is a
|
||||
// list scroll, not an anchor change — "today" has to bring the list back to
|
||||
// the top as well, and stay offered while it is scrolled away (#305).
|
||||
val listState = rememberLazyListState()
|
||||
val scrolledAway by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
||||
}
|
||||
}
|
||||
// A new window is rendered from its first day, so the list belongs at the
|
||||
// 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
|
||||
// a row that reappears further down, and it follows it there.
|
||||
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
|
||||
val jumpToToday: () -> Unit = {
|
||||
// Off today, moving the anchor is what resets the list (above); on today
|
||||
// the window doesn't change, so the scroll back is the whole action.
|
||||
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
|
||||
viewModel.goToToday()
|
||||
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
|
||||
}
|
||||
|
||||
PublishChrome(
|
||||
ScreenChrome(
|
||||
title = stringResource(R.string.view_agenda),
|
||||
titleDate = null,
|
||||
drawerDate = anchor,
|
||||
awayFromToday = !isOnToday || (scrolledAway && successState != null),
|
||||
todayText = stringResource(R.string.agenda_today_action),
|
||||
),
|
||||
ScreenActions(
|
||||
onToday = jumpToToday,
|
||||
onJumpToDate = { target ->
|
||||
focus?.date = target
|
||||
viewModel.goToDate(target)
|
||||
},
|
||||
floatingActionButton = {
|
||||
CalendarFabColumn(
|
||||
todayVisible = !isOnToday && !todayInToolbar,
|
||||
todayText = stringResource(R.string.agenda_today_action),
|
||||
onToday = viewModel::goToToday,
|
||||
onCreate = { onCreateEvent(anchor, null) },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
// One bar at the top: the "showing …" header on the left and the
|
||||
// session range switcher on the right (one settings toggle).
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// end aligns the selector's right edge with the top-bar view
|
||||
// switcher (its 8.dp margin + the app bar's 4.dp inset).
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
start = s.anchor,
|
||||
end = s.rangeEnd,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AgendaRangePill(
|
||||
range = s.range,
|
||||
isOverride = s.rangeIsOverride,
|
||||
onClick = { showRangePicker = true },
|
||||
)
|
||||
}
|
||||
onCreate = { onCreateEvent(anchor, null) },
|
||||
),
|
||||
)
|
||||
|
||||
ScreenBody(modifier = modifier) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
// One bar at the top: the "showing …" header on the left and the
|
||||
// session range switcher on the right (one settings toggle).
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
// end lines the selector up with whatever ends the top bar:
|
||||
// the view switcher's background, or — once #150 hides it —
|
||||
// the search icon's glyph.
|
||||
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
|
||||
AppBarSpacing.Inset
|
||||
} else {
|
||||
AppBarSpacing.IconTrailingInset
|
||||
}
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
showToday = showToday,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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,
|
||||
* 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,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
onTopDay: ((LocalDate) -> Unit)? = null,
|
||||
) {
|
||||
when (state) {
|
||||
AgendaUiState.Loading -> Box(modifier)
|
||||
@@ -312,16 +323,21 @@ internal fun AgendaContent(
|
||||
enabled = showToday && state.anchor == state.today,
|
||||
)
|
||||
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)
|
||||
} else {
|
||||
AgendaList(
|
||||
days = days,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
listState = listState,
|
||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||
now = now,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onTopDay = onTopDay,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -335,14 +351,28 @@ private fun AgendaList(
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
listState: LazyListState,
|
||||
dimPast: Boolean,
|
||||
now: Instant,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onTopDay: ((LocalDate) -> Unit)?,
|
||||
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(
|
||||
modifier = modifier,
|
||||
state = listState,
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
@@ -372,7 +402,7 @@ private fun AgendaList(
|
||||
zone = zone,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = animateItemMotion(),
|
||||
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
|
||||
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
|
||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
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
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||
@@ -86,11 +87,7 @@ fun groupAgendaDays(
|
||||
return byDay.map { (date, dayEvents) ->
|
||||
AgendaDay(
|
||||
date = date,
|
||||
events = dayEvents.sortedWith(
|
||||
compareByDescending<EventInstance> { it.isAllDay }
|
||||
.thenBy { it.start }
|
||||
.thenBy { it.title },
|
||||
),
|
||||
events = dayEvents.sortedWith(eventDisplayComparator(zone)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -156,9 +157,7 @@ class AgendaViewModel @Inject constructor(
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): AgendaUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
|
||||
val anchor = params.anchor
|
||||
val rangeEnd = anchor.plus(
|
||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||
|
||||
@@ -4,9 +4,11 @@ import android.net.Uri
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
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.SettingsPrefs
|
||||
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.CalendarFailure
|
||||
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.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
@@ -78,20 +82,16 @@ private val RESTORE_MIME_TYPES = arrayOf(
|
||||
fun BackupScreen(
|
||||
onBack: () -> Unit,
|
||||
onImport: (Uri) -> Unit,
|
||||
visible: Boolean = true,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val state by viewModel.backupState.collectAsStateWithLifecycle()
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||
|
||||
val context = LocalContext.current
|
||||
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
|
||||
// own launcher.
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
@@ -132,78 +132,43 @@ fun BackupScreen(
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_backup),
|
||||
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) },
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
|
||||
if (exportable.isNotEmpty()) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
onClick = {
|
||||
// A single exportable calendar skips the selector.
|
||||
if (exportable.size == 1) {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
} else {
|
||||
showExportPicker = true
|
||||
}
|
||||
when (val s = state) {
|
||||
BackupUiState.Loading -> BackupLoading()
|
||||
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
|
||||
is BackupUiState.Ready -> BackupContent(
|
||||
exportable = s.exportable,
|
||||
canImport = s.canImport,
|
||||
autoBackup = autoBackup,
|
||||
viewModel = viewModel,
|
||||
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
onPickFolder = { runCatching { pickFolder.launch(null) } },
|
||||
onExportAll = {
|
||||
runCatching { createBackup.launch(defaultBackupName()) }
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
summary = stringResource(R.string.calendars_restore_hint),
|
||||
position = Position.Middle,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup),
|
||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||
trailing = {
|
||||
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
||||
},
|
||||
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
||||
)
|
||||
if (autoBackup.enabled) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||
summary = rememberFolderName(autoBackup.folderUri)
|
||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||
position = Position.Middle,
|
||||
onClick = { runCatching { pickFolder.launch(null) } },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { showInterval = true },
|
||||
)
|
||||
HintText(backupStatusText(autoBackup.status))
|
||||
}
|
||||
} else if (canImport) {
|
||||
// Nothing to back up, but restore is still possible — don't hide
|
||||
// it behind export eligibility.
|
||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||
HintText(stringResource(R.string.calendars_restore_hint))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
onExportPick = { showExportPicker = true },
|
||||
onEditInterval = { showInterval = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showExportPicker) {
|
||||
ExportCalendarPicker(
|
||||
calendars = exportable,
|
||||
onExport = viewModel::exportBackup,
|
||||
onDismiss = { showExportPicker = false },
|
||||
)
|
||||
// Gated on Ready rather than resetting the flag when the state leaves it:
|
||||
// flipping it here would be a write during composition.
|
||||
(state as? BackupUiState.Ready)?.let { ready ->
|
||||
if (showExportPicker) {
|
||||
ExportCalendarPicker(
|
||||
calendars = ready.exportable,
|
||||
onExport = viewModel::exportBackup,
|
||||
onDismiss = { showExportPicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showInterval) {
|
||||
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
|
||||
* 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 ->
|
||||
val isSelected = calendar.id in selected
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, calendars.size),
|
||||
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.eventAccent
|
||||
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.SourceLogo
|
||||
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.expandEnter
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
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". */
|
||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||
@@ -118,6 +133,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||
fun CalendarsScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
@@ -162,6 +178,7 @@ fun CalendarsScreen(
|
||||
onConsumeError = viewModel::consumeError,
|
||||
onOpenBackup = onOpenBackup,
|
||||
onBack = onBack,
|
||||
visible = visible,
|
||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||
onSetVisible = viewModel::setCalendarVisible,
|
||||
@@ -178,6 +195,7 @@ private fun CalendarsList(
|
||||
onConsumeError: () -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean,
|
||||
onAdd: () -> Unit,
|
||||
onEdit: (CalendarSource) -> Unit,
|
||||
onSetVisible: (Long, Boolean) -> Unit,
|
||||
@@ -203,6 +221,7 @@ private fun CalendarsList(
|
||||
onBack = onBack,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
// What the per-calendar / per-account switches below actually do.
|
||||
HintText(stringResource(R.string.calendars_visibility_hint))
|
||||
@@ -231,7 +250,7 @@ private fun CalendarsList(
|
||||
local.forEachIndexed { index, calendar ->
|
||||
val disabled = !calendar.isVisibleInSystem
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
@@ -239,7 +258,7 @@ private fun CalendarsList(
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = {
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||
)
|
||||
@@ -316,7 +335,7 @@ private fun CalendarsList(
|
||||
ordered.forEachIndexed { index, calendar ->
|
||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
@@ -325,7 +344,7 @@ private fun CalendarsList(
|
||||
trailing = if (calendar.hasVisibilitySwitch) {
|
||||
{
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
enabled = calendar.isVisibleInSystem,
|
||||
onToggle = { 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
|
||||
private fun CalendarEditor(
|
||||
internal fun CalendarEditor(
|
||||
sessionKey: Int,
|
||||
isNew: Boolean,
|
||||
initialName: String,
|
||||
@@ -360,121 +385,50 @@ private fun CalendarEditor(
|
||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.predictiveBack(onBack = onClose)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (isNew) R.string.calendars_new_title
|
||||
else R.string.calendars_edit_title,
|
||||
),
|
||||
)
|
||||
},
|
||||
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,
|
||||
),
|
||||
)
|
||||
val valid = name.isNotBlank()
|
||||
val commit = { if (valid) onSave(name.trim(), color, description.trim().ifEmpty { null }) }
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(
|
||||
if (isNew) R.string.calendars_new_title else R.string.calendars_edit_title,
|
||||
),
|
||||
onDismiss = onClose,
|
||||
predictiveBack = true,
|
||||
actions = {
|
||||
Button(
|
||||
onClick = commit,
|
||||
enabled = valid,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
) { Text(stringResource(R.string.event_edit_save)) }
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (deleteLocked) {
|
||||
EditorCard(
|
||||
icon = Icons.Default.Info,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
iconAtTop = true,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_managed_delete_locked),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
|
||||
InlineTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
placeholder = stringResource(R.string.calendars_name_label),
|
||||
textStyle = MaterialTheme.typography.titleLarge,
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
)
|
||||
}
|
||||
EditorCard(
|
||||
icon = Icons.Default.Palette,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
iconAtTop = true,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_color_label),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
ColorSwatchRow(
|
||||
colors = CalendarColorPalette.all,
|
||||
selected = color,
|
||||
onSelect = { color = it },
|
||||
dark = dark,
|
||||
)
|
||||
}
|
||||
EditorCard(
|
||||
icon = Icons.AutoMirrored.Filled.Notes,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
iconAtTop = true,
|
||||
) {
|
||||
) {
|
||||
if (deleteLocked) {
|
||||
ManagedNote()
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
CalendarNameField(
|
||||
name = name,
|
||||
color = color,
|
||||
focusRequester = focusRequester,
|
||||
onNameChange = { name = it },
|
||||
onImeAction = commit,
|
||||
)
|
||||
// Inside the picker: it is a Dialog, so a request made from the caller's
|
||||
// composition can run before this field's node exists.
|
||||
// A new calendar opens with the keyboard up: naming it is the whole task.
|
||||
LaunchedEffect(isNew) { if (isNew) focusRequester.requestFocus() }
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
EditorSectionLabel(stringResource(R.string.calendars_color_label))
|
||||
CalendarColorGrid(selected = color, onSelect = { color = it })
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
EditorSectionLabel(stringResource(R.string.calendars_description_label))
|
||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Box(Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(16.dp)) {
|
||||
InlineTextField(
|
||||
value = description,
|
||||
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) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = false },
|
||||
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
|
||||
text = {
|
||||
Text(stringResource(R.string.calendars_delete_confirm_message, initialName))
|
||||
},
|
||||
text = { Text(stringResource(R.string.calendars_delete_confirm_message, initialName)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
confirmDelete = false
|
||||
onDelete()
|
||||
}) {
|
||||
TextButton(onClick = { confirmDelete = false; onDelete() }) {
|
||||
Text(
|
||||
stringResource(R.string.event_detail_delete),
|
||||
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
|
||||
* plain writable one (#76), then its own description.
|
||||
@@ -556,36 +710,6 @@ private fun EnableSwitch(
|
||||
private fun dimIf(disabled: Boolean): 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
|
||||
|
||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -56,6 +58,22 @@ class CalendarsViewModel @Inject constructor(
|
||||
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. */
|
||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||
settingsPrefs.autoBackupEnabled,
|
||||
|
||||
@@ -56,12 +56,14 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
||||
* account comes from when another account shares the name (#77).
|
||||
*/
|
||||
@Composable
|
||||
fun accountGroupTitle(group: CalendarAccountGroup): String =
|
||||
if (!group.ambiguous) {
|
||||
group.label
|
||||
fun accountGroupTitle(group: CalendarAccountGroup): String {
|
||||
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
return if (!group.ambiguous) {
|
||||
label
|
||||
} 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
|
||||
|
||||
@@ -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.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -10,24 +11,207 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
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 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,
|
||||
)
|
||||
}
|
||||
|
||||
/** Most lines a time label may wrap over before it is worth more than a title line. */
|
||||
const val MAX_TIME_LINES = 2
|
||||
|
||||
/**
|
||||
* Lines [text] needs to render whole at [textWidth], capped at [max].
|
||||
*
|
||||
* Lets a block hand out its height by what the text actually asks for rather
|
||||
* than by what would fit: a title that wants one line should not be given three
|
||||
* that the time label could have used, and a week column is narrower than a
|
||||
* "09: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, so the label takes a
|
||||
* second line rather than lose its end — but only out of a line the title
|
||||
* measured itself as not needing, never one it would have filled.
|
||||
*/
|
||||
@Composable
|
||||
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
|
||||
val timeLineHeight = rememberTrimmedLineHeight(
|
||||
MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
)
|
||||
return if (spare >= timeLineHeight) {
|
||||
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 = MAX_TIME_LINES,
|
||||
)
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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)
|
||||
@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()) {
|
||||
snap()
|
||||
} else {
|
||||
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(
|
||||
targetState = label,
|
||||
animationSpec = spec,
|
||||
@@ -36,17 +220,22 @@ fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = style,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
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
|
||||
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
|
||||
@@ -59,17 +248,49 @@ data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
|
||||
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
|
||||
LocalTimelineZoom.current.isPinching
|
||||
) {
|
||||
snap()
|
||||
} else {
|
||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
|
||||
val still = rememberReduceMotion() || 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()
|
||||
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
|
||||
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
|
||||
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))
|
||||
viewOrder.forEachIndexed { index, view ->
|
||||
GroupedRow(
|
||||
title = stringResource(view.labelRes),
|
||||
title = view.label(),
|
||||
position = positionOf(index, viewOrder.size),
|
||||
selected = view == currentView,
|
||||
minHeight = 56.dp,
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
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.text.style.TextAlign
|
||||
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).
|
||||
* 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
|
||||
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
||||
val titleRes = when (reason) {
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
|
||||
FailureReason.Unknown,
|
||||
FailureReason.EventNotFound -> R.string.state_failure_unknown
|
||||
}
|
||||
val actionRes = when (reason) {
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
|
||||
else -> R.string.state_retry
|
||||
fun CalendarFailure(
|
||||
reason: FailureReason,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val manageCalendars = LocalManageCalendars.current
|
||||
val copy = failureCopy(reason)
|
||||
val onAction: () -> Unit = when (reason) {
|
||||
FailureReason.NoCalendarsConfigured -> {
|
||||
{ context.startCalendarSetup() }
|
||||
}
|
||||
FailureReason.AllCalendarsHidden,
|
||||
FailureReason.NoImportTarget,
|
||||
-> manageCalendars ?: onRetry
|
||||
else -> onRetry
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
.padding(horizontal = 32.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
FailureIcon(icon = copy.icon, isError = copy.isError)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(titleRes),
|
||||
text = stringResource(copy.title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
FilledTonalButton(onClick = onRetry) {
|
||||
Text(stringResource(actionRes))
|
||||
copy.body?.let { body ->
|
||||
Spacer(Modifier.height(12.dp))
|
||||
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 ->
|
||||
val isSelected = calendar.id == selectedId
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||
selected = isSelected,
|
||||
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
|
||||
|
||||
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.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.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.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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 de.jeanlucmakiola.calendula.R
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -29,19 +41,20 @@ import kotlinx.datetime.LocalDate
|
||||
* drawer uses and reporting the chosen day through [onJumpToDate].
|
||||
*
|
||||
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
||||
* visible day, week start or month anchor).
|
||||
* 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
|
||||
* start alignment: M3 places the title flush against the navigation icon's
|
||||
* trailing edge, and the title is hit-tested on top of it, so a negative offset
|
||||
* would swallow taps meant for the menu button.
|
||||
* [shortTitle] replaces [title] when the full one does not fit the width the app
|
||||
* bar hands the title slot (#165). Either way the line clamps to one and
|
||||
* ellipsises, so pass a [shortTitle] wherever there is something left to drop.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarTitleButton(
|
||||
title: String,
|
||||
currentDate: LocalDate,
|
||||
currentDate: LocalDate?,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
shortTitle: String = title,
|
||||
) {
|
||||
var showDatePicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
@@ -50,25 +63,56 @@ fun CalendarTitleButton(
|
||||
.heightIn(min = 48.dp)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.clickable(
|
||||
enabled = currentDate != null,
|
||||
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
||||
role = Role.Button,
|
||||
) { showDatePicker = true }
|
||||
.padding(horizontal = 8.dp),
|
||||
.padding(horizontal = AppBarSpacing.TitleInset),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val style = MaterialTheme.typography.titleLarge
|
||||
BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) {
|
||||
val measurer = rememberTextMeasurer()
|
||||
val shown = remember(title, shortTitle, style, constraints.maxWidth, measurer) {
|
||||
titleFor(
|
||||
title = title,
|
||||
shortTitle = shortTitle,
|
||||
titleWidth = measurer.measure(title, style).size.width,
|
||||
availableWidth = constraints.maxWidth,
|
||||
)
|
||||
}
|
||||
// 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(
|
||||
initial = currentDate,
|
||||
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
|
||||
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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
|
||||
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
|
||||
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
|
||||
* here is only what's specific to paging the calendar grid: the directional
|
||||
* month/week/day slide and the specs that feed it.
|
||||
* here is only what's specific to paging the calendar grid: the specs its
|
||||
* 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
|
||||
* fixed easing curve.
|
||||
*
|
||||
@@ -40,6 +34,12 @@ import androidx.compose.ui.unit.IntOffset
|
||||
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||
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
|
||||
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||
@@ -55,48 +55,17 @@ fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
||||
MaterialTheme.motionScheme.defaultEffectsSpec()
|
||||
|
||||
/**
|
||||
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
|
||||
* outgoing page slides and fades one way while the incoming one arrives from the
|
||||
* other, position on a spring and opacity on an easing curve.
|
||||
*
|
||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||
* (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.
|
||||
* A switch between views (#184): the default spatial spring, a little softer, as
|
||||
* the whole screen reshapes rather than a page sliding by; softer still when
|
||||
* [long], for a change the eye has further to follow.
|
||||
*/
|
||||
fun calendarSlideTransition(
|
||||
slideDir: Int,
|
||||
spec: FiniteAnimationSpec<IntOffset>,
|
||||
fadeSpec: FiniteAnimationSpec<Float>,
|
||||
reduceMotion: Boolean,
|
||||
): ContentTransform {
|
||||
if (reduceMotion) {
|
||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||
}
|
||||
val dir = if (slideDir == 0) 1 else slideDir
|
||||
return ContentTransform(
|
||||
targetContentEnter =
|
||||
slideInHorizontally(spec) { w -> dir * w / 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),
|
||||
@Composable
|
||||
fun <T> rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec<T> =
|
||||
spring(
|
||||
dampingRatio = VIEW_SWITCH_DAMPING,
|
||||
stiffness = if (long) LONG_VIEW_SWITCH_STIFFNESS else VIEW_SWITCH_STIFFNESS,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a page travels, as a fraction of the container width.
|
||||
*
|
||||
* A full width was the obvious reading of "paging", but the two pages are
|
||||
* stacked and both opaque, so a full-width slide showed one grid racing across
|
||||
* another — the movement carried the whole transition and had a long way to go.
|
||||
* Under M3's shared-axis pattern the offset only has to *hint* the direction
|
||||
* while the cross-fade does the swapping, so a fifth of the width is plenty and
|
||||
* leaves nothing skating past.
|
||||
*/
|
||||
private const val SLIDE_TRAVEL_DIVISOR = 5
|
||||
private const val VIEW_SWITCH_DAMPING = 0.9f
|
||||
private const val VIEW_SWITCH_STIFFNESS = 450f
|
||||
private const val LONG_VIEW_SWITCH_STIFFNESS = 340f
|
||||
|
||||
@@ -1,37 +1,53 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CalendarViewDay
|
||||
import androidx.compose.material.icons.filled.CalendarViewMonth
|
||||
import androidx.compose.material.icons.filled.CalendarViewWeek
|
||||
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.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
|
||||
/** The top-level calendar views the user can switch between (spec M1). */
|
||||
enum class CalendarView {
|
||||
Month,
|
||||
Week,
|
||||
MultiDay,
|
||||
Day,
|
||||
Agenda,
|
||||
}
|
||||
|
||||
/** Switcher label, shared by the top-bar pill and the drawer's View section. */
|
||||
@get:StringRes
|
||||
val CalendarView.labelRes: Int
|
||||
get() = when (this) {
|
||||
CalendarView.Month -> R.string.view_month
|
||||
CalendarView.Week -> R.string.view_week
|
||||
CalendarView.Day -> R.string.view_day
|
||||
CalendarView.Agenda -> R.string.view_agenda
|
||||
/** How many days the multi-day view shows (#193), provided once at the app root. */
|
||||
val LocalMultiDayCount = staticCompositionLocalOf { SettingsPrefs.DEFAULT_MULTI_DAY_COUNT }
|
||||
|
||||
/**
|
||||
* The view's name, shared by the top-bar pill, the drawer and Settings. The
|
||||
* multi-day view is named after its day count ("3 days").
|
||||
*/
|
||||
@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. */
|
||||
val CalendarView.icon: ImageVector
|
||||
get() = when (this) {
|
||||
CalendarView.Month -> Icons.Filled.CalendarViewMonth
|
||||
CalendarView.Week -> Icons.Filled.CalendarViewWeek
|
||||
CalendarView.MultiDay -> Icons.Filled.ViewColumn
|
||||
CalendarView.Day -> Icons.Filled.CalendarViewDay
|
||||
CalendarView.Agenda -> Icons.Filled.ViewAgenda
|
||||
}
|
||||
@@ -41,7 +57,13 @@ val CalendarView.icon: ImageVector
|
||||
* through these in order.
|
||||
*/
|
||||
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. */
|
||||
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]
|
||||
* 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
|
||||
* disabled here stays reachable there.
|
||||
* disabled here stays reachable there — including when [cycle] is emptied and
|
||||
* the pill disappears altogether.
|
||||
*/
|
||||
data class QuickSwitchConfig(
|
||||
val order: List<CalendarView>,
|
||||
@@ -67,14 +90,15 @@ data class QuickSwitchConfig(
|
||||
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
||||
|
||||
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. */
|
||||
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). */
|
||||
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
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,
|
||||
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(
|
||||
modifier = modifier
|
||||
@@ -90,12 +96,12 @@ fun HourGutter(
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
style = labelStyle,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
.copy(alpha = hourAlpha),
|
||||
modifier = Modifier
|
||||
.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
|
||||
* 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
|
||||
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
|
||||
* as the minute ticks, mirroring [LocalShowHourGrid] / [LocalUse24HourFormat].
|
||||
*/
|
||||
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.absoluteOffset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -24,8 +25,9 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
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.layout.LayoutCoordinates
|
||||
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.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -83,6 +84,13 @@ data class TimelineDrag(
|
||||
val eventSpanMin: Int,
|
||||
/** The event as the grid would draw it, one piece per day column it covers. */
|
||||
val pieces: List<TimelineDragPiece>,
|
||||
/**
|
||||
* Lines the block the finger picked up drew its title over, zero for one too
|
||||
* short to have drawn it at all. The floating copy is that block's size, so
|
||||
* it has to spend its height the same way or the text re-wraps under the
|
||||
* finger and snaps back on drop (#267).
|
||||
*/
|
||||
val titleLines: Int,
|
||||
/**
|
||||
* True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
|
||||
* every column this timeline shows. A hint under the finger, not where the
|
||||
@@ -192,6 +200,32 @@ internal fun dragFloorMin(clipOffsetMin: Int, eventSpanMin: Int): Int =
|
||||
0
|
||||
}
|
||||
|
||||
/**
|
||||
* Where one dragged piece's top edge sits below its column's top, and how tall
|
||||
* it is drawn, at [hourPx] to the hour. [insetPx] is the half-gap a block seated
|
||||
* in the hour grid gives up at each end, zero with the grid off — taken off the
|
||||
* copy exactly as the grid takes it off the block, or the copy is a gap taller
|
||||
* than the block it lifted off and spends the difference on a time label that
|
||||
* block had no room for (#267). An end cut at midnight keeps none of it, as the
|
||||
* block's own squared-off edge does.
|
||||
*/
|
||||
internal fun dragPieceBounds(
|
||||
slice: DragSlice,
|
||||
hourPx: Float,
|
||||
insetPx: Float,
|
||||
): DragPieceBounds {
|
||||
val top = if (slice.continuesBefore) 0f else insetPx
|
||||
val bottom = if (slice.continuesAfter) 0f else insetPx
|
||||
val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
||||
return DragPieceBounds(
|
||||
top = slice.startMin / 60f * hourPx + top,
|
||||
height = (full - top - bottom).coerceAtLeast(0f),
|
||||
)
|
||||
}
|
||||
|
||||
/** A piece's placement within its column — see [dragPieceBounds]. */
|
||||
internal data class DragPieceBounds(val top: Float, val height: Float)
|
||||
|
||||
/** One day's share of a dragged event, before it is placed on screen. */
|
||||
internal data class DragSlice(
|
||||
val dayOffset: Int,
|
||||
@@ -235,6 +269,13 @@ class TimelineGeometry {
|
||||
var stepPx: Float = 0f
|
||||
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
|
||||
* 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
|
||||
* `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the
|
||||
* Hoisted drag state for one timeline (#68). It lives above the pager, so a
|
||||
* page change mid-drag can't strand a ghost, and the
|
||||
* block it renders is drawn in an overlay, clear of the day column's clip.
|
||||
*/
|
||||
@Stable
|
||||
@@ -313,6 +354,9 @@ class TimelineDragController {
|
||||
*/
|
||||
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
|
||||
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
||||
@@ -322,11 +366,13 @@ class TimelineDragController {
|
||||
fun begin(
|
||||
block: TimedBlock,
|
||||
clipOffsetMin: Int,
|
||||
titleLines: Int,
|
||||
pointerInRoot: Offset,
|
||||
blockInRoot: Offset,
|
||||
) {
|
||||
source = block
|
||||
this.clipOffsetMin = clipOffsetMin
|
||||
this.titleLines = titleLines
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = block.event.instanceId
|
||||
@@ -345,6 +391,7 @@ class TimelineDragController {
|
||||
fun cancel() {
|
||||
source = null
|
||||
clipOffsetMin = 0
|
||||
titleLines = 0
|
||||
isDragging = false
|
||||
liftedInstanceId = null
|
||||
originSlot = null
|
||||
@@ -467,23 +514,22 @@ class TimelineDragController {
|
||||
startMin = startMin,
|
||||
eventStartMin = eventStartMin,
|
||||
eventSpanMin = eventSpan,
|
||||
titleLines = titleLines,
|
||||
// Bounded to the columns this timeline actually shows: a day it
|
||||
// doesn't has no piece to draw. The write is unaffected.
|
||||
pieces = slices
|
||||
.map { slice ->
|
||||
val day = dayIndex + slice.dayOffset
|
||||
val col = if (geometry.isRtl) days.lastIndex - day else day
|
||||
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
|
||||
TimelineDragPiece(
|
||||
topLeftInRoot = Offset(
|
||||
x = origin.x + col * columnPx,
|
||||
y = origin.y + slice.startMin / 60f * hourPx,
|
||||
y = origin.y + bounds.top,
|
||||
),
|
||||
sizePx = IntSize(
|
||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||
maxOf(
|
||||
slice.spanMin / 60f * hourPx,
|
||||
MIN_EVENT_FRACTION * hourPx,
|
||||
).roundToInt(),
|
||||
bounds.height.roundToInt(),
|
||||
),
|
||||
continuesBefore = slice.continuesBefore,
|
||||
continuesAfter = slice.continuesAfter,
|
||||
@@ -604,8 +650,8 @@ const val SETTLE_FADE_MILLIS: Int = 250
|
||||
@Composable
|
||||
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val density = LocalDensity.current
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
@@ -655,7 +701,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
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) }
|
||||
// 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
|
||||
@@ -666,22 +712,61 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
|
||||
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}–" +
|
||||
formatMinuteOfDay(endMin, use24Hour, locale)
|
||||
// The tallest piece carries the range: on the smallest it would be
|
||||
// clipped away, which is exactly the case when a short tail is held.
|
||||
val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height }
|
||||
drag.pieces.forEachIndexed { index, piece ->
|
||||
DragCopy(
|
||||
topLeftInRoot = piece.topLeftInRoot,
|
||||
overlayOrigin = origin,
|
||||
sizePx = piece.sizePx,
|
||||
fill = fill,
|
||||
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
|
||||
lift = lift,
|
||||
alpha = copyAlpha,
|
||||
title = title,
|
||||
// Once for the whole event: repeated, it would name it per day.
|
||||
label = label.takeIf { index == labelled },
|
||||
)
|
||||
// The timeline's own bounds. The copy is drawn over the whole calendar
|
||||
// so no column clip or rounded corner cuts it, but it still belongs to
|
||||
// the timeline: a piece a whole day tall reaches far past both ends of
|
||||
// the viewport, and unclipped it paints over the headers above (#267).
|
||||
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
|
||||
// How much of each piece the viewport actually shows. A multi-day event
|
||||
// has a piece per day, and the widest one is a full 24 hours — taller
|
||||
// than the screen, its top at a midnight scrolled out of sight. Picking
|
||||
// the range's piece by raw height put it there, off the top of the
|
||||
// timeline; picking by *visible* height puts it where it can be read.
|
||||
val shown = drag.pieces.map { piece ->
|
||||
if (port == null) {
|
||||
piece.sizePx.height.toFloat()
|
||||
} else {
|
||||
val top = maxOf(piece.topLeftInRoot.y, port.top)
|
||||
val bottom = minOf(piece.topLeftInRoot.y + piece.sizePx.height, port.bottom)
|
||||
bottom - top
|
||||
}
|
||||
}
|
||||
val labelled = shown.indices.maxByOrNull { shown[it] }
|
||||
Box(
|
||||
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.
|
||||
label = label.takeIf { index == labelled },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -692,14 +777,49 @@ private fun DragCopy(
|
||||
topLeftInRoot: Offset,
|
||||
overlayOrigin: Offset,
|
||||
sizePx: IntSize,
|
||||
fill: Color,
|
||||
paint: EventPaint,
|
||||
shape: RoundedCornerShape,
|
||||
cuts: ChipCuts,
|
||||
lift: Float,
|
||||
alpha: Float,
|
||||
title: String,
|
||||
titleLines: Int,
|
||||
label: String?,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val width = with(density) { sizePx.width.toDp() }
|
||||
val height = with(density) { sizePx.height.toDp() }
|
||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||
// The block's size, spent the block's way — text sits at the top as it does
|
||||
// on the block, so the copy hands back to the grid without shifting (#267),
|
||||
// and it squeezes its inset on the same terms so a short block's title does
|
||||
// not vanish the moment it is lifted (#289).
|
||||
// The title is served in full first and the range lives off what is left:
|
||||
// the hour gutter down the side still says where the copy sits, so the
|
||||
// range is the half that can afford to go.
|
||||
val metrics = rememberBlockTextMetrics(height)
|
||||
val allowed = titleLines.coerceAtMost(metrics.titleBudget(metrics.available))
|
||||
// Re-measured at the copy's own width rather than spent on the source's
|
||||
// count: a block sharing its column with another is a lane wide where the
|
||||
// copy is a whole column, so the source's second line is one the copy never
|
||||
// draws — and reserving it took the range away with it (#267).
|
||||
val lines = if (allowed <= 0) {
|
||||
0
|
||||
} else {
|
||||
blockTextLines(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
|
||||
textWidth = textWidth,
|
||||
max = allowed,
|
||||
)
|
||||
}
|
||||
val left = metrics.available - metrics.titleHeight(lines)
|
||||
val showTime = label != null && left >= metrics.timeLine
|
||||
val timeMaxLines = if (showTime) {
|
||||
blockTimeLines(label!!, textWidth, left - metrics.timeLine)
|
||||
} else {
|
||||
1
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
@@ -710,12 +830,20 @@ private fun DragCopy(
|
||||
(topLeftInRoot.y - overlayOrigin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.size(
|
||||
width = with(density) { sizePx.width.toDp() },
|
||||
height = with(density) { sizePx.height.toDp() },
|
||||
)
|
||||
.padding(horizontal = 1.dp)
|
||||
// Required: the parent is the viewport's size, and a plain `size`
|
||||
// would let it cap a piece a whole day tall at one screen — drawn
|
||||
// from a midnight scrolled off the top, that ended the column a
|
||||
// scroll offset short of its own bottom (#267). The parent clips it.
|
||||
.requiredSize(width = width, height = height)
|
||||
.padding(horizontal = BLOCK_OUTER_INSET)
|
||||
.graphicsLayer {
|
||||
// Anchored at the top edge, not the middle: scaled about the
|
||||
// centre, the 2% lift raises the top by 1% of the height — a few
|
||||
// pixels on an hour, but a steady upward creep of the text as a
|
||||
// dragged multi-day piece grows, and a shear against the
|
||||
// timeline's clip. It snapped back when the lift animated out
|
||||
// on drop (#267).
|
||||
transformOrigin = TransformOrigin(0.5f, 0f)
|
||||
scaleX = 1f + 0.02f * lift
|
||||
scaleY = 1f + 0.02f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
@@ -723,24 +851,29 @@ private fun DragCopy(
|
||||
this.shape = shape
|
||||
clip = false
|
||||
}
|
||||
.background(fill, shape)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
.eventSurface(paint, shape, cuts)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
if (label != null) {
|
||||
if (lines > 0) {
|
||||
BlockTitle(
|
||||
title = title,
|
||||
maxLines = lines,
|
||||
color = paint.titleInk,
|
||||
textDecoration = paint.decoration,
|
||||
fontWeight = paint.titleWeight,
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
// As the block it lifted off sets its own time (#219).
|
||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
maxLines = timeMaxLines,
|
||||
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. */
|
||||
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 {
|
||||
/** The named steps the settings picker offers, coarse to roomy. */
|
||||
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
|
||||
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
|
||||
.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
|
||||
|
||||
/**
|
||||
* Narrowest an event block may be and still wrap its title over several lines.
|
||||
*
|
||||
* Wrapping is driven by the block's height, so a tall block on a lane-split
|
||||
* column would otherwise stack two or three characters per line — "Da/ily",
|
||||
* "Fa/rmer/s…" — which reads worse than one ellipsised line. A full week column
|
||||
* clears this on any phone; a split one never does, while the day view's much
|
||||
* wider columns keep wrapping even several lanes deep.
|
||||
*/
|
||||
val MIN_TITLE_WRAP_WIDTH = 36.dp
|
||||
|
||||
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
|
||||
val FIT_DAY_MIN = 24.dp
|
||||
|
||||
@@ -145,6 +143,7 @@ val TimelineScale.labelRes: Int
|
||||
TimelineScale.Regular -> R.string.timeline_scale_regular
|
||||
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
|
||||
is TimelineScale.Custom -> R.string.timeline_scale_custom
|
||||
is TimelineScale.FitHours -> R.string.settings_visible_hours
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
@@ -155,11 +154,15 @@ val TimelineScale.descriptionRes: Int
|
||||
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
|
||||
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
|
||||
is TimelineScale.Custom -> R.string.timeline_scale_custom_summary
|
||||
is TimelineScale.FitHours -> R.string.settings_visible_hours_summary
|
||||
}
|
||||
|
||||
/** Marks a stored custom height; the rest of the value is its dp. */
|
||||
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()`
|
||||
* 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) {
|
||||
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
|
||||
is TimelineScale.FitHours -> "$HOURS_PREFIX$startHour-$endHour"
|
||||
TimelineScale.FitDay,
|
||||
TimelineScale.Compact,
|
||||
TimelineScale.Regular,
|
||||
@@ -199,5 +203,11 @@ fun parseTimelineScale(stored: String?): TimelineScale = when {
|
||||
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
|
||||
?.let { TimelineScale.custom(it.dp) }
|
||||
?: 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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 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 {
|
||||
val (target, hourHeight) = snapshotFlow {
|
||||
val viewport = scrollState.viewportSize
|
||||
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
|
||||
val hourPx = with(density) { hourHeight.toPx() }
|
||||
startHourScroll(scrollState.maxValue, viewport, startHour, hourPx)?.let { it to hourHeight }
|
||||
}.filterNotNull().first()
|
||||
scrollState.scrollTo(target)
|
||||
zoom.holdFit(hourHeight)
|
||||
}
|
||||
scroll.noonPending = false
|
||||
}
|
||||
if (memory != null) {
|
||||
LaunchedEffect(scrollState, memory) {
|
||||
snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it }
|
||||
}
|
||||
}
|
||||
return scroll
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -50,13 +49,49 @@ class TimelineZoom(
|
||||
var isPinching: Boolean by mutableStateOf(false)
|
||||
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
|
||||
* 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) {
|
||||
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() {
|
||||
@@ -67,9 +102,10 @@ class TimelineZoom(
|
||||
scale = TimelineScale.custom(hourHeight)
|
||||
}
|
||||
|
||||
/** Settles the pinch. With visible hours on it only lasts until the next [open]. */
|
||||
fun endPinch() {
|
||||
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 }
|
||||
|
||||
/**
|
||||
* The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches
|
||||
* back through [onPersist].
|
||||
* The app-wide [TimelineZoom], seeded from [stored] and [hours] and writing
|
||||
* settled pinches back through [onPersist].
|
||||
*/
|
||||
@Composable
|
||||
fun rememberTimelineZoom(
|
||||
stored: TimelineScale,
|
||||
hours: TimelineHours,
|
||||
onPersist: (TimelineScale) -> Unit,
|
||||
): TimelineZoom {
|
||||
val persist by rememberUpdatedState(onPersist)
|
||||
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
|
||||
// stored value arriving after the first frame.
|
||||
LaunchedEffect(stored) { zoom.adopt(stored) }
|
||||
@@ -197,18 +235,26 @@ fun rememberTimelinePinchZoom(
|
||||
* names, jumping the whole column as a pinch drifts across each half pixel.
|
||||
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
|
||||
*
|
||||
* The bounds themselves are pulled onto that grid too, each in the direction
|
||||
* that keeps its own promise — up for the fill floor, so no dead space opens
|
||||
* under midnight, down for the ceiling. A fractional bound would be a height the
|
||||
* pinch can be held against but never actually land on, and the difference feeds
|
||||
* the focal anchor a scroll correction on every frame the fingers sit still.
|
||||
* The ceiling is pulled onto that grid too, downwards, so it stays a height the
|
||||
* pinch can actually land on.
|
||||
*
|
||||
* [fillPx] is deliberately *not* rounded (#290). It is the one height the whole
|
||||
* 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 =
|
||||
// Filling the viewport wins over the ceiling: on a screen tall enough for
|
||||
// the two to disagree, dead space is the worse of the two failures.
|
||||
target.roundToInt().toFloat()
|
||||
.coerceAtMost(floor(maxPx))
|
||||
.coerceAtLeast(ceil(fillPx))
|
||||
.coerceAtLeast(fillPx)
|
||||
|
||||
/**
|
||||
* The scroll offset that keeps the moment under [centroidY] under it after the
|
||||
|
||||
@@ -1,26 +1,82 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Today
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.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.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 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
|
||||
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
|
||||
* today control into the toolbar; renders nothing when [show] is false, so it
|
||||
* drops straight into an app bar's `actions` slot without a wrapping condition.
|
||||
*
|
||||
* 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
|
||||
fun TodayAction(show: Boolean, onToday: () -> Unit) {
|
||||
if (!show) return
|
||||
IconButton(onClick = onToday) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Today,
|
||||
contentDescription = stringResource(R.string.today_jump_action),
|
||||
)
|
||||
val now = rememberCurrentMinute()
|
||||
// Only the day number is read, so the bar recomposes at midnight rather
|
||||
// than on every minute tick.
|
||||
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,166 @@
|
||||
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,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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),
|
||||
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)),
|
||||
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
|
||||
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
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
|
||||
* (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
|
||||
fun ViewSwitcherPill(
|
||||
current: CalendarView,
|
||||
cycle: List<CalendarView>,
|
||||
onCycle: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
|
||||
) {
|
||||
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
FilledTonalButton(
|
||||
onClick = onCycle,
|
||||
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.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -98,6 +97,9 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
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.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
@@ -138,6 +140,7 @@ fun EventDetailScreen(
|
||||
onBack: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDuplicate: (EventForm) -> Unit,
|
||||
visible: Boolean = true,
|
||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||
@@ -150,6 +153,7 @@ fun EventDetailScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val copyField = rememberFieldCopier(snackbarHostState)
|
||||
|
||||
// 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.
|
||||
@@ -224,7 +228,7 @@ fun EventDetailScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.predictiveBack(onBack = onBack),
|
||||
modifier = Modifier.predictiveBack(onBack = onBack, visible = visible),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -299,8 +303,9 @@ fun EventDetailScreen(
|
||||
is EventDetailUiState.Failure -> CalendarFailure(
|
||||
reason = s.reason,
|
||||
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
|
||||
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 instance = detail.instance
|
||||
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
|
||||
// cancelled event strikes through its title.
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
val titleLabel = stringResource(R.string.event_detail_title)
|
||||
Text(
|
||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
@@ -408,7 +418,17 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
} else {
|
||||
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) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
@@ -508,7 +528,8 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -516,10 +537,11 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
// Location (conditional, tap → maps).
|
||||
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||
val context = LocalContext.current
|
||||
val locationLabel = stringResource(R.string.event_detail_location)
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.Place,
|
||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||
iconContentDescription = locationLabel,
|
||||
) {
|
||||
Text(
|
||||
text = location,
|
||||
@@ -527,7 +549,12 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { openInMaps(context, location) }
|
||||
.copyOnLongPress(
|
||||
label = locationLabel,
|
||||
text = location,
|
||||
copy = copyField,
|
||||
onTap = { openInMaps(context, location) },
|
||||
)
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -535,10 +562,15 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
|
||||
// Description (conditional). URLs are auto-linked.
|
||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
val descriptionLabel = stringResource(R.string.event_detail_description)
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
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 = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||
@@ -611,13 +643,14 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
private fun DetailCard(
|
||||
icon: ImageVector,
|
||||
iconContentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
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.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
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.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -200,6 +203,7 @@ fun EventEditScreen(
|
||||
initialForm: EventForm? = null,
|
||||
initialFormSource: ImportSource = ImportSource.File,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
visible: Boolean = true,
|
||||
viewModel: EventEditViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||
@@ -282,7 +286,7 @@ fun EventEditScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.predictiveBack(onBack = close),
|
||||
modifier = Modifier.predictiveBack(onBack = close, visible = visible),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -617,7 +621,9 @@ private fun EventEditContent(
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.event_edit_managed_hint,
|
||||
selectedCalendar?.displayName.orEmpty(),
|
||||
selectedCalendar?.displayName
|
||||
?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
.orEmpty(),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -716,6 +722,7 @@ private fun EventEditContent(
|
||||
) {
|
||||
Text(
|
||||
text = selectedCalendar?.displayName
|
||||
?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
?: stringResource(R.string.event_edit_error_no_calendar),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (selectedCalendar == null) {
|
||||
@@ -788,38 +795,59 @@ private fun EventEditContent(
|
||||
EditCard(
|
||||
icon = Icons.Default.Place,
|
||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||
iconAtTop = true,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
InlineField(
|
||||
value = form.location,
|
||||
onValueChange = viewModel::setLocation,
|
||||
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
|
||||
.weight(1f)
|
||||
.animateContentSizeMotion()
|
||||
.padding(vertical = 4.dp),
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
pickContactAddress.launch(
|
||||
Intent(
|
||||
Intent.ACTION_PICK,
|
||||
ContactsContract.CommonDataKinds.StructuredPostal
|
||||
.CONTENT_URI,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(40.dp),
|
||||
// The box is exactly one text line tall, so the button
|
||||
// centres on the first line without making a single-line
|
||||
// card taller than its text; requiredSize keeps the
|
||||
// button's own 40dp ripple.
|
||||
Box(
|
||||
modifier = Modifier.height(firstLineHeight()),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Contacts,
|
||||
contentDescription = stringResource(
|
||||
R.string.event_edit_location_from_contacts,
|
||||
),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
pickContactAddress.launch(
|
||||
Intent(
|
||||
Intent.ACTION_PICK,
|
||||
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_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
|
||||
* The provider's formatted address is multi-line; collapse it to one line so
|
||||
* it sits cleanly in the single-line location field.
|
||||
* The provider's formatted address is multi-line; collapse it to one comma-
|
||||
* separated line, which is what the location field holds.
|
||||
*/
|
||||
private fun readContactAddress(context: Context, uri: Uri): String? =
|
||||
context.contentResolver.query(
|
||||
@@ -2174,14 +2202,19 @@ private fun EditCard(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
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(
|
||||
imageVector = icon,
|
||||
contentDescription = iconContentDescription,
|
||||
tint = iconTint,
|
||||
// 4dp mirrors InlineField's vertical padding, so a
|
||||
// top-aligned icon (24dp) centres on the ~24sp first line.
|
||||
modifier = Modifier
|
||||
.padding(top = if (iconAtTop) 4.dp else 0.dp)
|
||||
.padding(top = iconOffset)
|
||||
.size(24.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).
|
||||
* 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
|
||||
private fun InlineField(
|
||||
@@ -2235,6 +2270,8 @@ private fun InlineField(
|
||||
minLines: Int = 1,
|
||||
enabled: Boolean = true,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
|
||||
imeAction: ImeAction = ImeAction.Default,
|
||||
modifier: Modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
@@ -2249,10 +2286,25 @@ private fun InlineField(
|
||||
minLines = minLines,
|
||||
enabled = enabled,
|
||||
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. */
|
||||
@Composable
|
||||
private fun ScheduleRow(
|
||||
|
||||
@@ -59,6 +59,9 @@ import javax.inject.Inject
|
||||
|
||||
private const val TAG = "EventEdit"
|
||||
|
||||
/** Any line break, with the whitespace around it, as one match. */
|
||||
private val LINE_BREAK = Regex("""[ \t]*\R[ \t]*""")
|
||||
|
||||
/**
|
||||
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
||||
* 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.
|
||||
fun setTitle(value: String) =
|
||||
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 setAllDay(value: Boolean) {
|
||||
// Going all-day drops any pinned zone: the times become bare dates that
|
||||
|
||||
@@ -62,15 +62,16 @@ private fun FilterList(
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
groups.forEach { group ->
|
||||
val account = group.account.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
Text(
|
||||
text = if (group.ambiguous) {
|
||||
stringResource(
|
||||
R.string.calendars_account_from_source,
|
||||
group.account,
|
||||
account,
|
||||
sourceAppName(group.accountType),
|
||||
)
|
||||
} else {
|
||||
group.account
|
||||
account
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
@@ -83,7 +84,7 @@ private fun FilterList(
|
||||
)
|
||||
group.calendars.forEachIndexed { index, cal ->
|
||||
GroupedRow(
|
||||
title = cal.displayName,
|
||||
title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
position = positionOf(index, group.calendars.size),
|
||||
minHeight = 56.dp,
|
||||
leading = { CalendarColorChip(cal.color) },
|
||||
@@ -125,6 +126,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
|
||||
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
|
||||
val msg = when (reason) {
|
||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
|
||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||
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.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
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
|
||||
// hide/show list; they live only in Settings → Calendars.
|
||||
val enabled = calendars.filter { it.isVisibleInSystem }
|
||||
if (enabled.isEmpty()) {
|
||||
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
val failure = calendarListFailure(calendars)
|
||||
if (failure != null) {
|
||||
FilterUiState.Failure(failure)
|
||||
} else {
|
||||
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
|
||||
}
|
||||
|
||||
@@ -73,19 +73,20 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
@Composable
|
||||
fun ImportScreen(
|
||||
uri: Uri,
|
||||
session: Int,
|
||||
onClose: () -> Unit,
|
||||
onOpenSingle: (EventForm) -> Unit,
|
||||
forceMany: Boolean = false,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
// Key the VM by the file uri. This screen has no nav backstack, so an
|
||||
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
||||
// 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()),
|
||||
visible: Boolean = true,
|
||||
viewModel: ImportViewModel = hiltViewModel(),
|
||||
) {
|
||||
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()
|
||||
|
||||
// A single event isn't shown here — it opens the create form for review.
|
||||
@@ -103,7 +104,7 @@ fun ImportScreen(
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.predictiveBack(onBack = onClose)
|
||||
.predictiveBack(onBack = onClose, visible = visible)
|
||||
.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
|
||||
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -69,18 +70,26 @@ class ImportViewModel @Inject constructor(
|
||||
private val parser = IcsParser()
|
||||
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
|
||||
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
|
||||
* through the bulk picker + summary rather than the prefilled create form —
|
||||
* a restore is "bring back a backup", not "add this one event".
|
||||
*/
|
||||
fun load(uri: Uri, forceMany: Boolean = false) {
|
||||
if (started) return
|
||||
started = true
|
||||
viewModelScope.launch {
|
||||
fun load(uri: Uri, forceMany: Boolean = false, session: Int = 0) {
|
||||
if (loadedSession == session) return
|
||||
loadedSession = session
|
||||
loadJob?.cancel()
|
||||
_state.value = ImportUiState.Loading
|
||||
loadJob = viewModelScope.launch {
|
||||
val parsed = withContext(io) {
|
||||
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 topLeftInRoot: Offset,
|
||||
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. */
|
||||
@@ -172,6 +179,7 @@ class MonthDragController {
|
||||
|
||||
private var event: EventInstance? = null
|
||||
private var grabDate: LocalDate? = null
|
||||
private var time: String? = null
|
||||
private var grab = Offset.Zero
|
||||
private var pointer = Offset.Zero
|
||||
private var sizePx = IntSize.Zero
|
||||
@@ -190,9 +198,11 @@ class MonthDragController {
|
||||
pointerInRoot: Offset,
|
||||
chipInRoot: Offset,
|
||||
size: IntSize,
|
||||
time: String? = null,
|
||||
) {
|
||||
this.event = event
|
||||
this.grabDate = grabDate
|
||||
this.time = time
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = event.instanceId
|
||||
@@ -210,6 +220,7 @@ class MonthDragController {
|
||||
fun cancel() {
|
||||
event = null
|
||||
grabDate = null
|
||||
time = null
|
||||
isDragging = false
|
||||
liftedInstanceId = null
|
||||
drag = null
|
||||
@@ -331,6 +342,7 @@ class MonthDragController {
|
||||
targetDate = resolved ?: drag?.targetDate,
|
||||
topLeftInRoot = pointer - grab,
|
||||
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? {
|
||||
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
|
||||
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||
.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
|
||||
* about.
|
||||
*/
|
||||
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int =
|
||||
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.startCol ?: col
|
||||
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int = spanAt(col, lane)?.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
|
||||
@@ -136,6 +139,8 @@ sealed interface ContinuousMonthUiState {
|
||||
val monthsByIndex: Map<Int, List<MonthWeek>>,
|
||||
val weeksByIndex: Map<Int, MonthWeek>,
|
||||
val weekStart: DayOfWeek,
|
||||
/** As [MonthUiState.Success.zone], and for the same reason. */
|
||||
val zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||
) : ContinuousMonthUiState
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
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.layoutAllDay
|
||||
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.flowOf
|
||||
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.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
@@ -42,6 +49,9 @@ import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
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)
|
||||
@HiltViewModel
|
||||
class MonthViewModel @Inject constructor(
|
||||
@@ -85,6 +95,21 @@ class MonthViewModel @Inject constructor(
|
||||
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.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> =
|
||||
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(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
|
||||
// of provider work for a view that isn't on screen.
|
||||
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
|
||||
val range = monthGridRange(ym, ws, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
) { calendars, instances ->
|
||||
buildState(ym, ws, calendars, instances)
|
||||
}
|
||||
month(ym, ws).filterNot { it is MonthUiState.Loading }
|
||||
}
|
||||
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
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) ----------------------------------
|
||||
//
|
||||
// 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>,
|
||||
instances: List<EventInstance>,
|
||||
): ContinuousMonthUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) }
|
||||
// Bucketed once for the whole window: a row then takes the handful of
|
||||
// events on its seven days instead of re-scanning a year of them.
|
||||
val byDay = DayIndex(instances, zone)
|
||||
@@ -238,17 +287,16 @@ class MonthViewModel @Inject constructor(
|
||||
monthsByIndex = months,
|
||||
weeksByIndex = weeks,
|
||||
weekStart = weekStart,
|
||||
zone = zone,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Split style (#53) ------------------------------------------------
|
||||
//
|
||||
// The first selected-day concept in the app: the other views drill straight
|
||||
// into a date, while the split style keeps one selected and lists it below
|
||||
// the grid.
|
||||
// Null means the full-month agenda; a date narrows the pane to that day.
|
||||
|
||||
private val _selectedDate = MutableStateFlow(todayDate)
|
||||
val selectedDate: StateFlow<LocalDate> = _selectedDate
|
||||
private val _selectedDate = MutableStateFlow<LocalDate?>(null)
|
||||
val selectedDate: StateFlow<LocalDate?> = _selectedDate
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
fun selectDate(date: LocalDate) {
|
||||
_selectedDate.value = date
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the selection with the month: today if the new month holds it, else
|
||||
* its 1st. Leaving the old date selected would list a day the grid no longer
|
||||
* shows.
|
||||
*/
|
||||
fun clearSelection() { _selectedDate.value = null }
|
||||
|
||||
/** A pane swipe walks days when filtered, months when showing the agenda. */
|
||||
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() {
|
||||
_selectedDate.value = selectionForMonth(_month.value, todayDate)
|
||||
}
|
||||
|
||||
fun goToPrev() {
|
||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
||||
realignSelection()
|
||||
}
|
||||
|
||||
fun goToNext() {
|
||||
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
||||
realignSelection()
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_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
|
||||
* fresh open) — and a switch to a paged style, or a reseed of the other
|
||||
* scrolling style's list, would jump there instead of holding position. The
|
||||
* selection is realigned alongside it so that landing on the Split style
|
||||
* shows a live day in the month on screen rather than a stale, off-month one.
|
||||
* selection is cleared alongside it so Split opens the visible month's agenda.
|
||||
*/
|
||||
fun syncScrollMonth(ym: YearMonth) {
|
||||
if (_month.value == ym) return
|
||||
_month.value = ym
|
||||
_selectedDate.value = selectionForMonth(ym, todayDate)
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||
@@ -312,9 +359,7 @@ class MonthViewModel @Inject constructor(
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): MonthUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) }
|
||||
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
||||
return MonthUiState.Success(
|
||||
month = ym,
|
||||
@@ -438,14 +483,18 @@ internal fun layoutCalendarWeek(
|
||||
days = days,
|
||||
spans = spans,
|
||||
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) } },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
@@ -458,7 +507,7 @@ internal fun instancesByDay(
|
||||
days.associateWith { day ->
|
||||
instances
|
||||
.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)
|
||||
|
||||
/**
|
||||
* Which day the split style should select when the grid lands on [month]:
|
||||
* [today] when the month holds it, otherwise the 1st. Pure so the rule can be
|
||||
* tested without standing up a view model and a provider behind it.
|
||||
* First day shown in the split agenda: today in the current month, otherwise
|
||||
* the first of the visible month.
|
||||
*/
|
||||
internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||
internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||
if (YearMonth(today.year, today.month) == month) {
|
||||
today
|
||||
} 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. */
|
||||
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. */
|
||||
Reminders,
|
||||
|
||||
@@ -67,6 +80,18 @@ data class OnboardingPlan(
|
||||
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
||||
* 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:
|
||||
* 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
|
||||
@@ -75,7 +100,9 @@ data class OnboardingPlan(
|
||||
* [visibilityArmed] carries the one-time notice that the device is hiding some
|
||||
* calendars (#75) — the only optional step an *existing* install can be given,
|
||||
* which is why it is not gated on `fresh`, and on its own it earns no closing
|
||||
* screen.
|
||||
* 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(
|
||||
hasPermission: Boolean,
|
||||
@@ -85,6 +112,10 @@ fun onboardingPlan(
|
||||
viewDone: Boolean,
|
||||
monthStyleDone: Boolean,
|
||||
backupApplies: Boolean?,
|
||||
hiddenDone: Boolean = false,
|
||||
hiddenApplies: Boolean? = false,
|
||||
calendarsDone: Boolean = false,
|
||||
calendarsApplies: Boolean? = null,
|
||||
visibilityArmed: Boolean = false,
|
||||
visibilityDone: Boolean = false,
|
||||
doneShown: Boolean = false,
|
||||
@@ -92,6 +123,8 @@ fun onboardingPlan(
|
||||
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
||||
val steps = buildList {
|
||||
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 (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
||||
if (fresh) {
|
||||
@@ -104,6 +137,8 @@ fun onboardingPlan(
|
||||
val current = steps.firstOrNull { step ->
|
||||
when (step) {
|
||||
OnboardingStep.Permission -> !hasPermission
|
||||
OnboardingStep.HiddenCalendars -> !hiddenDone
|
||||
OnboardingStep.Calendars -> !calendarsDone
|
||||
OnboardingStep.Reminders -> !remindersDone
|
||||
OnboardingStep.Backup -> !backupDone
|
||||
OnboardingStep.View -> !viewDone
|
||||
|
||||
@@ -28,6 +28,9 @@ fun OnboardingSteps(
|
||||
// Collected for every step, not just the two that draw a preview: starting
|
||||
// it when the picker composes would leave that step blank as it slides in.
|
||||
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
|
||||
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
|
||||
// plan has since dropped — the backup step goes once the calendars say it
|
||||
@@ -67,6 +70,26 @@ fun OnboardingSteps(
|
||||
modifier = modifier,
|
||||
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(
|
||||
onFinished = viewModel::finishReminders,
|
||||
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.reminders.ReminderScanner
|
||||
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.month.MonthViewStyle
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -74,6 +77,27 @@ class OnboardingViewModel @Inject constructor(
|
||||
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
|
||||
* 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
|
||||
* would drop the step for the whole session.
|
||||
*/
|
||||
private val backupApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
|
||||
if (granted != true) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
repository.calendars()
|
||||
.map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() }
|
||||
.catch { emit(null) }
|
||||
.flowOn(io)
|
||||
}
|
||||
private val backupApplies: Flow<Boolean?> = calendars.map { list ->
|
||||
if (list.isNullOrEmpty()) null else list.backupApplies()
|
||||
}
|
||||
|
||||
/** 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(
|
||||
prefs.reminderOnboardingDone,
|
||||
prefs.onboardingWizardArmed,
|
||||
@@ -110,7 +207,13 @@ class OnboardingViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
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 {
|
||||
onboardingPlan(
|
||||
hasPermission = it,
|
||||
@@ -120,6 +223,10 @@ class OnboardingViewModel @Inject constructor(
|
||||
viewDone = stored.viewDone,
|
||||
monthStyleDone = stored.monthStyleDone,
|
||||
backupApplies = backup,
|
||||
hiddenDone = calendars.hiddenDone,
|
||||
hiddenApplies = calendars.hiddenApplies,
|
||||
calendarsDone = calendars.done,
|
||||
calendarsApplies = calendars.applies,
|
||||
visibilityArmed = closing.visibilityArmed,
|
||||
visibilityDone = closing.visibilityDone,
|
||||
doneShown = closing.doneShown,
|
||||
@@ -186,6 +293,8 @@ class OnboardingViewModel @Inject constructor(
|
||||
fun goBack() {
|
||||
viewModelScope.launch {
|
||||
when (plan.value?.previous) {
|
||||
OnboardingStep.HiddenCalendars -> prefs.setOnboardingHiddenCalendarsDone(false)
|
||||
OnboardingStep.Calendars -> prefs.setOnboardingCalendarsDone(false)
|
||||
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
||||
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
||||
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
||||
@@ -202,6 +311,53 @@ class OnboardingViewModel @Inject constructor(
|
||||
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
|
||||
* (taking a durable write grant so background runs can keep writing), and
|
||||
@@ -246,6 +402,52 @@ class OnboardingViewModel @Inject constructor(
|
||||
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). */
|
||||
fun finishVisibility() {
|
||||
viewModelScope.launch {
|
||||
@@ -295,6 +497,20 @@ private data class OnboardingFlags(
|
||||
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. */
|
||||
private data class ClosingFlags(
|
||||
val visibilityArmed: Boolean,
|
||||
@@ -309,6 +525,15 @@ data class ViewChoice(
|
||||
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
|
||||
* 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.common.CalendarView
|
||||
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.ViewPreviewFrame
|
||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.label
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||
@@ -67,14 +67,19 @@ internal fun ViewStep(
|
||||
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)
|
||||
}
|
||||
}
|
||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||
val isSelected = view == choice.defaultView
|
||||
GroupedRow(
|
||||
title = stringResource(view.labelRes),
|
||||
title = view.label(),
|
||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||
selected = isSelected,
|
||||
leading = {
|
||||
|
||||
@@ -82,6 +82,7 @@ import de.jeanlucmakiola.calendula.domain.MatchSpan
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
@@ -125,6 +126,7 @@ fun SearchScreen(
|
||||
onBack: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
visible: Boolean = true,
|
||||
viewModel: SearchViewModel = hiltViewModel(),
|
||||
) {
|
||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||
@@ -183,7 +185,7 @@ fun SearchScreen(
|
||||
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
|
||||
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection, visible = visible),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
topBar = {
|
||||
SearchTopBar(
|
||||
@@ -508,6 +510,9 @@ private fun SearchResultRow(
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
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 soften = LocalSoftenColors.current
|
||||
// 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
|
||||
// through the whole history.
|
||||
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),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
|
||||
@@ -76,6 +76,7 @@ internal fun AppearanceScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
var showTheme by remember { mutableStateOf(false) }
|
||||
var showBrandFont by remember { mutableStateOf(false) }
|
||||
@@ -96,6 +97,7 @@ internal fun AppearanceScreen(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
// Theme & colour
|
||||
GroupedRow(
|
||||
|
||||
@@ -41,6 +41,7 @@ internal fun EventFormScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
var showDefaultDuration by remember { mutableStateOf(false) }
|
||||
// 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),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
SettingsHint(stringResource(R.string.settings_form_fields_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -143,7 +145,7 @@ internal fun EventFormScreen(
|
||||
durationCalendars.forEachIndexed { index, calendar ->
|
||||
val override = state.perCalendarEventDuration[calendar.id]
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = override?.let { durationLabel(it) }
|
||||
?: stringResource(
|
||||
R.string.settings_calendar_duration_inherits,
|
||||
|
||||
@@ -63,6 +63,7 @@ internal fun NotificationsScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
onOpenSpecialDates: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
@@ -92,6 +93,7 @@ internal fun NotificationsScreen(
|
||||
title = stringResource(R.string.settings_section_notifications),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_reminders),
|
||||
@@ -181,7 +183,7 @@ internal fun NotificationsScreen(
|
||||
// own section — link there instead.
|
||||
if (calendar.id in state.managedCalendarIds) {
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||
position = Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
@@ -198,7 +200,7 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
val expanded = calendar.id in expandedCalendars
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
position = if (expanded) Position.Top else Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
|
||||
@@ -2,9 +2,7 @@ package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
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.Cake
|
||||
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.Gavel
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
@@ -54,6 +53,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
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.CollapsingScaffold
|
||||
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.openIssueTracker
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
|
||||
/** 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
|
||||
@@ -81,11 +84,14 @@ fun SettingsScreen(
|
||||
onManageCalendars: () -> Unit,
|
||||
onOpenBackup: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
visible: Boolean = true,
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
// Leaves from the predictive-back preview rather than sliding off at full size.
|
||||
val overlayExit = predictiveBackExit()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -97,54 +103,63 @@ fun SettingsScreen(
|
||||
onOpenSection = { section = it },
|
||||
onManageCalendars = onManageCalendars,
|
||||
onOpenBackup = onOpenBackup,
|
||||
visible = visible,
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Appearance,
|
||||
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(
|
||||
visible = section == SettingsSection.Views,
|
||||
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(
|
||||
visible = section == SettingsSection.EventForm,
|
||||
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(
|
||||
visible = section == SettingsSection.Notifications,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
NotificationsScreen(
|
||||
state = state,
|
||||
viewModel = viewModel,
|
||||
onBack = { section = null },
|
||||
onOpenSpecialDates = { section = SettingsSection.SpecialDates },
|
||||
visible = section == SettingsSection.Notifications,
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.SpecialDates,
|
||||
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(
|
||||
visible = section == SettingsSection.Widgets,
|
||||
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,
|
||||
onManageCalendars: () -> 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
|
||||
// continues the container instead of sitting inside it as a button.
|
||||
Box(Modifier.padding(horizontal = GroupedListInset)) { AboutCard() }
|
||||
@@ -253,6 +274,13 @@ private fun SettingsHub(
|
||||
icon = Icons.Default.Gavel,
|
||||
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(
|
||||
label = stringResource(R.string.settings_about_privacy),
|
||||
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.IMPLEMENTED_VIEWS
|
||||
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.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
@@ -32,8 +33,10 @@ data class SettingsUiState(
|
||||
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
||||
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
|
||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||
/** Whether the week/day timeline draws an hour separator line (v2.11). */
|
||||
val showHourLines: Boolean = false,
|
||||
/** Whether the week/day timeline seats each hour in its own cell (v2.11). */
|
||||
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. */
|
||||
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
||||
/** Whether the month/week grids fade events that have already finished. */
|
||||
@@ -62,6 +65,7 @@ data class SettingsUiState(
|
||||
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
||||
/** How tall an hour is drawn in the week and day timelines (#56). */
|
||||
val timelineScale: TimelineScale = TimelineScale.Regular,
|
||||
val timelineHours: TimelineHours = TimelineHours(),
|
||||
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
/** 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.common.CalendarView
|
||||
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.month.MonthViewStyle
|
||||
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 —
|
||||
// the outer combine is already at its five-arg limit.
|
||||
combine(
|
||||
prefs.showHourLines,
|
||||
prefs.showHourGrid,
|
||||
prefs.showWeekNumbers,
|
||||
prefs.agendaShowToday,
|
||||
prefs.softenCalendarColors,
|
||||
combine(
|
||||
prefs.todayButtonInToolbar,
|
||||
prefs.dragToReschedule,
|
||||
::Pair,
|
||||
prefs.multiDayCount,
|
||||
::Triple,
|
||||
),
|
||||
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove) ->
|
||||
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove, multiDay) ->
|
||||
DisplayToggles(
|
||||
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove,
|
||||
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove, multiDay,
|
||||
)
|
||||
},
|
||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||
ViewSettings(
|
||||
view, screenRange, widgetRange, timeFormat,
|
||||
showHourLines = toggles.showHourLines,
|
||||
showHourGrid = toggles.showHourGrid,
|
||||
showWeekNumbers = toggles.showWeekNumbers,
|
||||
agendaShowToday = toggles.agendaShowToday,
|
||||
softenColors = toggles.softenColors,
|
||||
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||
dragToReschedule = toggles.dragToReschedule,
|
||||
multiDayCount = toggles.multiDayCount,
|
||||
)
|
||||
},
|
||||
combine(
|
||||
@@ -178,9 +181,11 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.drawerViewOrder,
|
||||
prefs.monthViewStyle,
|
||||
prefs.widgetSize,
|
||||
prefs.timelineScale,
|
||||
) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale ->
|
||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale)
|
||||
combine(prefs.timelineScale, prefs.timelineHours, ::Pair),
|
||||
) { quickSwitch, drawer, monthStyle, widgetSize, (timelineScale, timelineHours) ->
|
||||
ViewCustomization(
|
||||
quickSwitch, drawer, monthStyle, widgetSize, timelineScale, timelineHours,
|
||||
)
|
||||
},
|
||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||
@@ -191,12 +196,13 @@ class SettingsViewModel @Inject constructor(
|
||||
agendaScreenRange = views.agendaScreenRange,
|
||||
agendaWidgetRange = views.agendaWidgetRange,
|
||||
timeFormat = views.timeFormat,
|
||||
showHourLines = views.showHourLines,
|
||||
showHourGrid = views.showHourGrid,
|
||||
showWeekNumbers = views.showWeekNumbers,
|
||||
agendaShowToday = views.agendaShowToday,
|
||||
softenColors = views.softenColors,
|
||||
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||
dragToReschedule = views.dragToReschedule,
|
||||
multiDayCount = views.multiDayCount,
|
||||
agendaShowRangeBar = misc.showRangeBar,
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
pastEventDisplay = misc.pastEventDisplay,
|
||||
@@ -206,6 +212,7 @@ class SettingsViewModel @Inject constructor(
|
||||
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||
widgetSize = misc.viewCustomization.widgetSize,
|
||||
timelineScale = misc.viewCustomization.timelineScale,
|
||||
timelineHours = misc.viewCustomization.timelineHours,
|
||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||
defaultReminderMinutes = defaults.defaultReminder,
|
||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||
@@ -292,21 +299,23 @@ class SettingsViewModel @Inject constructor(
|
||||
val agendaScreenRange: AgendaRange,
|
||||
val agendaWidgetRange: AgendaRange,
|
||||
val timeFormat: TimeFormatPref,
|
||||
val showHourLines: Boolean,
|
||||
val showHourGrid: Boolean,
|
||||
val showWeekNumbers: Boolean,
|
||||
val agendaShowToday: Boolean,
|
||||
val softenColors: Boolean,
|
||||
val todayButtonInToolbar: Boolean,
|
||||
val dragToReschedule: Boolean,
|
||||
val multiDayCount: Int,
|
||||
)
|
||||
|
||||
private data class DisplayToggles(
|
||||
val showHourLines: Boolean,
|
||||
val showHourGrid: Boolean,
|
||||
val showWeekNumbers: Boolean,
|
||||
val agendaShowToday: Boolean,
|
||||
val softenColors: Boolean,
|
||||
val todayButtonInToolbar: Boolean,
|
||||
val dragToReschedule: Boolean,
|
||||
val multiDayCount: Int,
|
||||
)
|
||||
|
||||
private data class MiscSettings(
|
||||
@@ -323,6 +332,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val monthViewStyle: MonthViewStyle,
|
||||
val widgetSize: WidgetSize,
|
||||
val timelineScale: TimelineScale,
|
||||
val timelineHours: TimelineHours,
|
||||
)
|
||||
|
||||
/** 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) }
|
||||
}
|
||||
|
||||
fun setShowHourLines(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
||||
fun setMultiDayCount(count: Int) {
|
||||
viewModelScope.launch { prefs.setMultiDayCount(count) }
|
||||
}
|
||||
|
||||
fun setShowHourGrid(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setShowHourGrid(enabled) }
|
||||
}
|
||||
|
||||
fun setShowWeekNumbers(enabled: Boolean) {
|
||||
@@ -591,15 +605,15 @@ class SettingsViewModel @Inject constructor(
|
||||
|
||||
/**
|
||||
* Enable or disable [view] in the quick-switch cycle, via an atomic
|
||||
* read-modify-write so a concurrent reorder can't clobber it. The
|
||||
* MIN_ENABLED floor is re-checked inside the transform, because the screen's
|
||||
* own guard reads an async-echoed snapshot.
|
||||
* read-modify-write so a concurrent reorder can't clobber it. Any number of
|
||||
* views may be turned off; below two the pill hides itself (#150).
|
||||
*/
|
||||
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.updateQuickSwitch { config ->
|
||||
val next = if (enabled) config.enabled + view else config.enabled - view
|
||||
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
|
||||
config.copy(
|
||||
enabled = if (enabled) config.enabled + view else config.enabled - view,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -617,6 +631,10 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setTimelineScale(scale) }
|
||||
}
|
||||
|
||||
fun setTimelineHours(hours: TimelineHours) {
|
||||
viewModelScope.launch { prefs.setTimelineHours(hours) }
|
||||
}
|
||||
|
||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
internal fun SpecialDatesScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
val state by viewModel.specialDatesState.collectAsStateWithLifecycle()
|
||||
val settings by viewModel.state.collectAsStateWithLifecycle()
|
||||
@@ -76,6 +77,7 @@ internal fun SpecialDatesScreen(
|
||||
title = stringResource(R.string.settings_section_special_dates),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
// Paused banner: the permission was revoked after enabling.
|
||||
if (state.enabled && state.stalledPermission) {
|
||||
|
||||
@@ -21,10 +21,12 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
@@ -32,12 +34,15 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||
import de.jeanlucmakiola.calendula.ui.common.label
|
||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
@@ -55,14 +60,15 @@ import java.time.format.TextStyle as JavaTextStyle
|
||||
* it belongs to, plus the two cross-view ordering lists (#24, #69).
|
||||
*
|
||||
* The quick-switch cycle and the drawer list are independent orders — a view
|
||||
* off in the cycle is still reachable from the drawer. The cycle needs at least
|
||||
* [QuickSwitchConfig.MIN_ENABLED] targets.
|
||||
* off in the cycle is still reachable from the drawer, including when the cycle
|
||||
* is emptied and the pill disappears (#150).
|
||||
*/
|
||||
@Composable
|
||||
internal fun ViewsScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
var showMonthStyle by remember { mutableStateOf(false) }
|
||||
var showDefaultView by remember { mutableStateOf(false) }
|
||||
@@ -71,11 +77,16 @@ internal fun ViewsScreen(
|
||||
var showPastEvents by remember { mutableStateOf(false) }
|
||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||
var showTimelineScale by remember { mutableStateOf(false) }
|
||||
var showMultiDayCount by remember { mutableStateOf(false) }
|
||||
var showHoursStart by remember { mutableStateOf(false) }
|
||||
var showHoursEnd by remember { mutableStateOf(false) }
|
||||
val hours = state.timelineHours
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_views),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
val config = state.quickSwitchConfig
|
||||
|
||||
@@ -83,7 +94,7 @@ internal fun ViewsScreen(
|
||||
SectionHeader(stringResource(R.string.settings_views_all_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
summary = stringResource(state.defaultView.labelRes),
|
||||
summary = state.defaultView.label(),
|
||||
position = Position.Top,
|
||||
onClick = { showDefaultView = true },
|
||||
)
|
||||
@@ -160,22 +171,67 @@ internal fun ViewsScreen(
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_week_day_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_timeline_scale),
|
||||
summary = stringResource(state.timelineScale.labelRes),
|
||||
title = stringResource(R.string.settings_multi_day_count),
|
||||
summary = multiDayLabel(state.multiDayCount),
|
||||
position = Position.Top,
|
||||
onClick = { showTimelineScale = true },
|
||||
onClick = { showMultiDayCount = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_timeline_scale),
|
||||
summary = if (hours.enabled) {
|
||||
stringResource(R.string.settings_timeline_scale_by_visible_hours)
|
||||
} else {
|
||||
stringResource(state.timelineScale.labelRes)
|
||||
},
|
||||
position = Position.Middle,
|
||||
dimmed = hours.enabled,
|
||||
onClick = if (hours.enabled) null else ({ showTimelineScale = true }),
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_visible_hours),
|
||||
summary = if (hours.enabled) {
|
||||
stringResource(
|
||||
R.string.settings_visible_hours_on_summary,
|
||||
hourLabel(hours.startHour),
|
||||
hourLabel(hours.endHour),
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.settings_visible_hours_summary)
|
||||
},
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = hours.enabled,
|
||||
onCheckedChange = { viewModel.setTimelineHours(hours.copy(enabled = it)) },
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setTimelineHours(hours.copy(enabled = !hours.enabled)) },
|
||||
)
|
||||
if (hours.enabled) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_visible_hours_start),
|
||||
summary = hourLabel(hours.startHour),
|
||||
position = Position.Middle,
|
||||
onClick = { showHoursStart = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_visible_hours_end),
|
||||
summary = hourLabel(hours.endHour),
|
||||
position = Position.Middle,
|
||||
onClick = { showHoursEnd = true },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_hour_lines),
|
||||
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.showHourLines,
|
||||
onCheckedChange = viewModel::setShowHourLines,
|
||||
checked = state.showHourGrid,
|
||||
onCheckedChange = viewModel::setShowHourGrid,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
|
||||
onClick = { viewModel.setShowHourGrid(!state.showHourGrid) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -221,8 +277,6 @@ internal fun ViewsScreen(
|
||||
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// Turning a view off is blocked once only the minimum remain enabled.
|
||||
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
|
||||
ReorderableColumn(
|
||||
items = config.order,
|
||||
keyOf = { it },
|
||||
@@ -238,8 +292,6 @@ internal fun ViewsScreen(
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = checked,
|
||||
// Keep the last two on: with fewer, the pill can't switch.
|
||||
enabled = !checked || canDisable,
|
||||
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
||||
)
|
||||
},
|
||||
@@ -280,7 +332,7 @@ internal fun ViewsScreen(
|
||||
predictiveBack = true,
|
||||
options = IMPLEMENTED_VIEWS,
|
||||
selected = state.defaultView,
|
||||
label = { stringResource(it.labelRes) },
|
||||
label = { it.label() },
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = it.icon,
|
||||
@@ -342,6 +394,18 @@ internal fun ViewsScreen(
|
||||
onDismiss = { showPastEvents = false },
|
||||
)
|
||||
}
|
||||
if (showMultiDayCount) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_multi_day_count),
|
||||
header = { PickerDescription(stringResource(R.string.settings_multi_day_count_hint)) },
|
||||
predictiveBack = true,
|
||||
options = SettingsPrefs.MULTI_DAY_COUNTS.toList(),
|
||||
selected = state.multiDayCount,
|
||||
label = { multiDayLabel(it) },
|
||||
onSelect = viewModel::setMultiDayCount,
|
||||
onDismiss = { showMultiDayCount = false },
|
||||
)
|
||||
}
|
||||
if (showTimelineScale) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_timeline_scale),
|
||||
@@ -361,6 +425,30 @@ internal fun ViewsScreen(
|
||||
onDismiss = { showTimelineScale = false },
|
||||
)
|
||||
}
|
||||
if (showHoursStart) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_visible_hours_start),
|
||||
header = { PickerDescription(stringResource(R.string.settings_visible_hours_start_hint)) },
|
||||
predictiveBack = true,
|
||||
options = TimelineHours.startOptions(hours.endHour),
|
||||
selected = hours.startHour,
|
||||
label = { hourLabel(it) },
|
||||
onSelect = { viewModel.setTimelineHours(hours.copy(startHour = it)) },
|
||||
onDismiss = { showHoursStart = false },
|
||||
)
|
||||
}
|
||||
if (showHoursEnd) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_visible_hours_end),
|
||||
header = { PickerDescription(stringResource(R.string.settings_visible_hours_end_hint)) },
|
||||
predictiveBack = true,
|
||||
options = TimelineHours.endOptions(hours.startHour),
|
||||
selected = hours.endHour,
|
||||
label = { hourLabel(it) },
|
||||
onSelect = { viewModel.setTimelineHours(hours.copy(endHour = it)) },
|
||||
onDismiss = { showHoursEnd = false },
|
||||
)
|
||||
}
|
||||
if (showAgendaScreenRange) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
@@ -389,7 +477,7 @@ private fun ViewRow(
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = stringResource(view.labelRes),
|
||||
title = view.label(),
|
||||
position = position,
|
||||
dimmed = dimmed,
|
||||
minHeight = ReorderableRowHeight,
|
||||
@@ -454,3 +542,12 @@ internal fun pastEventDisplayLabelRes(mode: PastEventDisplay): Int = when (mode)
|
||||
PastEventDisplay.DIM -> R.string.settings_past_events_dim
|
||||
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun multiDayLabel(count: Int): String =
|
||||
pluralStringResource(R.plurals.view_multi_day, count, count)
|
||||
|
||||
/** A whole hour in the app's clock convention; 24 reads as the end of the day. */
|
||||
@Composable
|
||||
private fun hourLabel(hour: Int): String =
|
||||
formatMinuteOfDay(hour * 60, LocalUse24HourFormat.current, currentLocale())
|
||||
|
||||
@@ -42,6 +42,7 @@ internal fun WidgetsScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
visible: Boolean = true,
|
||||
) {
|
||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||
var showWidgetSize by remember { mutableStateOf(false) }
|
||||
@@ -50,6 +51,7 @@ internal fun WidgetsScreen(
|
||||
title = stringResource(R.string.settings_section_widgets),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
visible = visible,
|
||||
) {
|
||||
SettingsHint(stringResource(R.string.settings_widgets_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
package de.jeanlucmakiola.calendula.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Default Material 3 Expressive typography. Custom font + tuned scale will
|
||||
* land in a later UI-design iteration; the defaults are intentional for V1
|
||||
* scaffolding to keep the foundation lean.
|
||||
* Tracking the two label roles the calendar grids are set in. Material gives
|
||||
* both 0.5sp, tuned for isolated UI labels with room around them; a month chip
|
||||
* is a text box some 37dp wide, where 0.5sp on an 11sp glyph spends most of a
|
||||
* character on spacing alone. 0.1sp is what Material itself sets labelLarge to,
|
||||
* so the label family stays coherent (#190).
|
||||
*/
|
||||
val CalendulaTypography = Typography()
|
||||
private val LabelTracking = 0.1.sp
|
||||
|
||||
/**
|
||||
* Material 3 Expressive typography with the label roles' tracking tightened.
|
||||
* Everything else is the default scale.
|
||||
*/
|
||||
val CalendulaTypography: Typography = Typography().let { base ->
|
||||
base.copy(
|
||||
labelMedium = base.labelMedium.copy(letterSpacing = LabelTracking),
|
||||
labelSmall = base.labelSmall.copy(letterSpacing = LabelTracking),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.plus
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** A day column's place across the timeline, in pixels from its start edge. */
|
||||
@Immutable
|
||||
data class ColumnSlot(val left: Float, val width: Float) {
|
||||
val right: Float get() = left + width
|
||||
}
|
||||
|
||||
/**
|
||||
* [date]'s column on a page showing this span across [width] pixels, with [gap]
|
||||
* between columns. A date off the page carries on at the same pitch, off screen
|
||||
* on its side — where a view switch slides it in from, or out to.
|
||||
*/
|
||||
fun DaySpan.slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
|
||||
val pitch = (width + gap) / count
|
||||
return ColumnSlot(left = start.daysUntil(date) * pitch, width = pitch - gap)
|
||||
}
|
||||
|
||||
/** The last day of the span. */
|
||||
val DaySpan.end: LocalDate get() = start.plus(count - 1, DateTimeUnit.DAY)
|
||||
|
||||
/**
|
||||
* The timeline's columns: one page's, or — while switching between the day, the
|
||||
* multi-day and the week view (#184) — every day of both pages, each column
|
||||
* between where it sits in the one and where it sits in the other.
|
||||
*
|
||||
* @param progress how far from [from] to [to], read at layout time only.
|
||||
*/
|
||||
@Stable
|
||||
class ColumnGeometry(val from: DaySpan, val to: DaySpan, val progress: () -> Float) {
|
||||
|
||||
/** Every day either page shows, in order: the two overlap, so they run on unbroken. */
|
||||
val dates: List<LocalDate> = run {
|
||||
val first = minOf(from.start, to.start)
|
||||
val last = maxOf(from.end, to.end)
|
||||
(0..first.daysUntil(last)).map { first.plus(it, DateTimeUnit.DAY) }
|
||||
}
|
||||
|
||||
fun slot(date: LocalDate, width: Float, gap: Float): ColumnSlot {
|
||||
val a = from.slot(date, width, gap)
|
||||
if (from == to) return a
|
||||
val b = to.slot(date, width, gap)
|
||||
val p = progress()
|
||||
return ColumnSlot(lerp(a.left, b.left, p), lerp(a.width, b.width, p))
|
||||
}
|
||||
|
||||
/** The span of columns [range] covers, between its extent on each page. */
|
||||
fun extent(fromRange: ClosedRange<LocalDate>, toRange: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
|
||||
val a = from.extent(fromRange, width, gap)
|
||||
if (from == to && fromRange == toRange) return a
|
||||
val b = to.extent(toRange, width, gap)
|
||||
val p = progress()
|
||||
val left = lerp(a.left, b.left, p)
|
||||
return ColumnSlot(left, lerp(a.right, b.right, p) - left)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** One page's columns, standing still. */
|
||||
fun of(span: DaySpan): ColumnGeometry = ColumnGeometry(span, span) { 0f }
|
||||
}
|
||||
}
|
||||
|
||||
private fun DaySpan.extent(range: ClosedRange<LocalDate>, width: Float, gap: Float): ColumnSlot {
|
||||
val left = slot(range.start, width, gap).left
|
||||
return ColumnSlot(left, slot(range.endInclusive, width, gap).right - left)
|
||||
}
|
||||
|
||||
internal fun lerp(a: Float, b: Float, p: Float): Float = a + (b - a) * p
|
||||
|
||||
/**
|
||||
* Lays [content] out once per day of [geometry], each across its column. The
|
||||
* columns are placed in the layout pass, so a view switch moving them every
|
||||
* frame re-lays them out without recomposing them. Children fill the height
|
||||
* the layout is given, or wrap it when it has none.
|
||||
*/
|
||||
@Composable
|
||||
fun TimelineColumns(
|
||||
geometry: ColumnGeometry,
|
||||
gap: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (LocalDate) -> Unit,
|
||||
) {
|
||||
val dates = geometry.dates
|
||||
Layout(
|
||||
content = {
|
||||
dates.forEach { date ->
|
||||
key(date) { Box(propagateMinConstraints = true) { content(date) } }
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
) { measurables, constraints ->
|
||||
val width = constraints.maxWidth
|
||||
val gapPx = gap.toPx()
|
||||
val slots = dates.map { geometry.slot(it, width.toFloat(), gapPx) }
|
||||
val placeables = measurables.mapIndexed { i, measurable ->
|
||||
val left = slots[i].left.roundToInt()
|
||||
val columnWidth = (slots[i].right.roundToInt() - left).coerceAtLeast(0)
|
||||
measurable.measure(constraints.copy(minWidth = columnWidth, maxWidth = columnWidth))
|
||||
}
|
||||
val height = placeables.maxOfOrNull { it.height }?.coerceAtLeast(constraints.minHeight)
|
||||
?: constraints.minHeight
|
||||
layout(width, height) {
|
||||
placeables.forEachIndexed { i, placeable ->
|
||||
placeable.placeRelative(slots[i].left.roundToInt(), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An all-day bar as each page lays it out: the days it covers there, clipped to
|
||||
* the page, and its lane. A bar only one page has keeps its days on the other,
|
||||
* so it slides out with them rather than vanishing.
|
||||
*/
|
||||
@Immutable
|
||||
data class PlacedBar(
|
||||
val event: EventInstance,
|
||||
/** The day the bar is drawn from on the page it is tagged on, for the view morph. */
|
||||
val keyDate: LocalDate,
|
||||
val fromRange: ClosedRange<LocalDate>,
|
||||
val toRange: ClosedRange<LocalDate>,
|
||||
val fromLane: Int,
|
||||
val toLane: Int,
|
||||
)
|
||||
|
||||
/** [state]'s all-day bars, standing still. */
|
||||
internal fun staticBars(state: WeekUiState.Success): List<PlacedBar> =
|
||||
state.allDaySpans.map { span ->
|
||||
val range = state.days[span.startCol]..state.days[span.endCol]
|
||||
PlacedBar(span.event, range.start, range, range, span.lane, span.lane)
|
||||
}
|
||||
|
||||
/**
|
||||
* The all-day bars of both pages of a view switch, each once: matched by
|
||||
* occurrence, placed on each page as that page lays it out.
|
||||
*/
|
||||
internal fun morphBars(from: WeekUiState.Success?, to: WeekUiState.Success?): List<PlacedBar> {
|
||||
val fromBars = from?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
|
||||
val toBars = to?.let(::staticBars).orEmpty().associateBy { it.event.occurrence() }
|
||||
return (fromBars.keys + toBars.keys).map { occurrence ->
|
||||
val a = fromBars[occurrence]
|
||||
val b = toBars[occurrence]
|
||||
PlacedBar(
|
||||
event = (a ?: b)!!.event,
|
||||
keyDate = (a ?: b)!!.keyDate,
|
||||
fromRange = (a ?: b)!!.fromRange,
|
||||
toRange = (b ?: a)!!.toRange,
|
||||
fromLane = (a ?: b)!!.fromLane,
|
||||
toLane = (b ?: a)!!.toLane,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun EventInstance.occurrence(): Pair<Long, Long> = eventId to start.toEpochMilliseconds()
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.runtime.Stable
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* A switch between two timeline views in progress (#184): the page on screen
|
||||
* when it started, the page it ends on, and how far between the two it is.
|
||||
*/
|
||||
@Stable
|
||||
internal class TimelineMorph(
|
||||
val fromKind: TimelineKind,
|
||||
val toKind: TimelineKind,
|
||||
val from: DaySpan,
|
||||
val to: DaySpan,
|
||||
val fromState: StateFlow<WeekUiState>,
|
||||
val toState: StateFlow<WeekUiState>,
|
||||
) {
|
||||
val progress = Animatable(0f)
|
||||
|
||||
/** Whether the columns change size a lot, from a single day to a week or back, and take longer for it. */
|
||||
val isLong: Boolean get() = maxOf(from.count, to.count) > LONG_SWITCH_RATIO * minOf(from.count, to.count)
|
||||
|
||||
/** Where each day's events come from: the page it was on, else the page it goes to. */
|
||||
fun sourceOf(date: LocalDate): Side = if (date in from.start..from.end) Side.From else Side.To
|
||||
|
||||
enum class Side { From, To }
|
||||
}
|
||||
|
||||
private const val LONG_SWITCH_RATIO = 3
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/** Pages the week pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
|
||||
internal const val WEEK_PAGE_COUNT: Int = 20_000
|
||||
|
||||
private const val EPOCH_PAGE = WEEK_PAGE_COUNT / 2
|
||||
|
||||
/** The week at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||
private val PAGE_EPOCH = LocalDate(2000, 1, 3)
|
||||
|
||||
/** First day of the week shown on pager [page], with weeks starting on [firstDay]. */
|
||||
internal fun weekStartForPage(page: Int, firstDay: DayOfWeek): LocalDate =
|
||||
PAGE_EPOCH.startOfWeek(firstDay).plus((page - EPOCH_PAGE) * 7, DateTimeUnit.DAY)
|
||||
|
||||
/** The pager page whose week contains [date], with weeks starting on [firstDay]. */
|
||||
internal fun weekPageFor(date: LocalDate, firstDay: DayOfWeek): Int {
|
||||
val days = PAGE_EPOCH.startOfWeek(firstDay).daysUntil(date.startOfWeek(firstDay))
|
||||
return (EPOCH_PAGE + days / 7).coerceIn(0, WEEK_PAGE_COUNT - 1)
|
||||
}
|
||||
|
||||
/** How a timeline pager cuts the calendar into pages of [dayCount] days. */
|
||||
sealed interface PageGrid {
|
||||
val dayCount: Int
|
||||
val pageCount: Int
|
||||
|
||||
/** First day shown on pager [page]. */
|
||||
fun startForPage(page: Int): LocalDate
|
||||
|
||||
/** The pager page showing [date]. */
|
||||
fun pageFor(date: LocalDate): Int
|
||||
}
|
||||
|
||||
/** Whole weeks, starting on [firstDay]. */
|
||||
data class WeekGrid(val firstDay: DayOfWeek) : PageGrid {
|
||||
override val dayCount: Int get() = 7
|
||||
override val pageCount: Int get() = WEEK_PAGE_COUNT
|
||||
override fun startForPage(page: Int): LocalDate = weekStartForPage(page, firstDay)
|
||||
override fun pageFor(date: LocalDate): Int = weekPageFor(date, firstDay)
|
||||
}
|
||||
|
||||
/** Pages the multi-day pager spans — over three centuries at the widest page. */
|
||||
internal const val DAYS_PAGE_COUNT: Int = 40_000
|
||||
|
||||
private const val DAYS_EPOCH_PAGE = DAYS_PAGE_COUNT / 2
|
||||
|
||||
/** A fixed day the multi-day pages are counted from. */
|
||||
private val DAYS_EPOCH = LocalDate(2000, 1, 1)
|
||||
|
||||
/**
|
||||
* Runs of [dayCount] days (#193), offset [phase] days from a fixed epoch so any
|
||||
* date can start a page. The phase stays put while paging and only changes when
|
||||
* a jump moves another date to the first column.
|
||||
*/
|
||||
data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid {
|
||||
override val pageCount: Int get() = DAYS_PAGE_COUNT
|
||||
|
||||
override fun startForPage(page: Int): LocalDate =
|
||||
DAYS_EPOCH.plus(phase + (page - DAYS_EPOCH_PAGE) * dayCount, DateTimeUnit.DAY)
|
||||
|
||||
override fun pageFor(date: LocalDate): Int =
|
||||
(DAYS_EPOCH_PAGE + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount))
|
||||
.coerceIn(0, DAYS_PAGE_COUNT - 1)
|
||||
|
||||
companion object {
|
||||
/** The grid of [dayCount]-day pages on which [date] is a page's first day. */
|
||||
fun startingAt(date: LocalDate, dayCount: Int): DaysGrid =
|
||||
DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount))
|
||||
}
|
||||
}
|
||||
|
||||
/** Pages the day pager spans — a little under four centuries, centred on [DAY_EPOCH]. */
|
||||
internal const val DAY_PAGE_COUNT: Int = 140_000
|
||||
|
||||
private const val DAY_EPOCH_PAGE = DAY_PAGE_COUNT / 2
|
||||
|
||||
/** The day at [DAY_EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||
private val DAY_EPOCH = LocalDate(2000, 1, 1)
|
||||
|
||||
/** Single days: every date starts a page of its own. */
|
||||
data object DayGrid : PageGrid {
|
||||
override val dayCount: Int get() = 1
|
||||
override val pageCount: Int get() = DAY_PAGE_COUNT
|
||||
|
||||
override fun startForPage(page: Int): LocalDate =
|
||||
DAY_EPOCH.plus(page - DAY_EPOCH_PAGE, DateTimeUnit.DAY)
|
||||
|
||||
override fun pageFor(date: LocalDate): Int =
|
||||
(DAY_EPOCH_PAGE + DAY_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the focused date goes when the pager settles on [page] (#184): kept if
|
||||
* the page still shows it, otherwise moved by the pages travelled, so it keeps
|
||||
* its place in the page — the same weekday a week on.
|
||||
*/
|
||||
internal fun PageGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate {
|
||||
val start = startForPage(page)
|
||||
val end = start.plus(dayCount - 1, DateTimeUnit.DAY)
|
||||
if (focus in start..end) return focus
|
||||
return focus.plus((page - pageFor(focus)) * dayCount, DateTimeUnit.DAY).coerceIn(start, end)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user