Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deb43372ee | ||
|
|
c93aa998e3 |
@@ -37,12 +37,6 @@ jobs:
|
|||||||
- name: Reproducible-release invariant
|
- name: Reproducible-release invariant
|
||||||
run: bash scripts/check_reproducible_release.sh
|
run: bash scripts/check_reproducible_release.sh
|
||||||
|
|
||||||
# Play rejects a "What's New" over 500 characters, which would fail the
|
|
||||||
# upload after the release had already shipped everywhere else. Cheap, so
|
|
||||||
# it runs on every PR rather than only on the release merge.
|
|
||||||
- name: Changelog length invariant
|
|
||||||
run: bash scripts/check_changelog_lengths.sh
|
|
||||||
|
|
||||||
# Decide whether anything that affects the app build changed. Docs, store
|
# Decide whether anything that affects the app build changed. Docs, store
|
||||||
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
||||||
# the SDK + Gradle work below but still report a green `ci`.
|
# the SDK + Gradle work below but still report a green `ci`.
|
||||||
|
|||||||
@@ -5,4 +5,3 @@
|
|||||||
*.jpg binary
|
*.jpg binary
|
||||||
*.gif binary
|
*.gif binary
|
||||||
*.webp binary
|
*.webp binary
|
||||||
app/src/test/resources/ics/*.ics -text
|
|
||||||
|
|||||||
+1
-304
@@ -5,276 +5,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [2.21.0] — 2026-09-23
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Setup makes sure you have a calendar to write to.** A fresh install could
|
|
||||||
finish onboarding and land on an empty grid with nothing saying why — either
|
|
||||||
because the device had no calendar at all, or because every calendar it had
|
|
||||||
was switched off. Setup now covers both: it offers to create a calendar on
|
|
||||||
this device or to add an account that syncs one, and it lists the calendars
|
|
||||||
that exist but are switched off so you can turn them back on ([#287]).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **The month grid gives its days more room.** Week numbers are set smaller and
|
|
||||||
their gutter is measured from the widest number it has to seat instead of
|
|
||||||
holding a fixed 40dp; event chips spend less of their width on padding; and
|
|
||||||
the labels in both are tracked more tightly, which is worth roughly a
|
|
||||||
character per chip ([#189], [#190], [#212], [#213]).
|
|
||||||
- **A month day shows as many events as it has height for.** The limit was a
|
|
||||||
flat three at every size, so a tall week row threw away space it could have
|
|
||||||
filled, while a cramped one drew a third chip its band could not hold and put
|
|
||||||
the "more" marker below the clip, where nothing showed the day held more at
|
|
||||||
all. It is measured per week row now, and a tablet or a landscape phone seats
|
|
||||||
more of them.
|
|
||||||
- **Split view no longer pads a short month out to six rows.** A five-row month
|
|
||||||
was handing the day pane a blank row and its gap — a sixth of the grid. The
|
|
||||||
grid now draws only the rows its month spans and the pane grows into the rest
|
|
||||||
([#162]).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Short events show their title in week and day view.** A block needed 20dp
|
|
||||||
of height before it drew any title at all, which at fit-the-whole-day put the
|
|
||||||
cutoff around the half hour. The padding now tapers as the block shrinks, so
|
|
||||||
a short event keeps its name ([#289]).
|
|
||||||
- **A fully zoomed-out timeline sits still.** Rounding its height up left it as
|
|
||||||
much as a pixel per hour taller than the viewport those hours are meant to
|
|
||||||
fill — some 24px of leftover scroll, enough to bounce off Android's
|
|
||||||
overscroll stretch ([#290]).
|
|
||||||
- **Backup & restore says what is going on instead of coming up blank.** It
|
|
||||||
built its rows straight from the calendar list, so loading, a provider error,
|
|
||||||
and a device with nothing to export all rendered as an empty page with no
|
|
||||||
buttons on it ([#304]).
|
|
||||||
- **An event with no title reads in your language.** It was labelled "(Ohne
|
|
||||||
Titel)" on every screen but the event's own, whatever language the phone was
|
|
||||||
set to: the German text was chosen before the translated one could be
|
|
||||||
([#321]).
|
|
||||||
- **A calendar the system gives no name reads in your language.** The same
|
|
||||||
cause, in the calendar lists and pickers — and because the calendar editor
|
|
||||||
starts from that name, saving such a calendar wrote the German text into it
|
|
||||||
for real ([#329]).
|
|
||||||
|
|
||||||
## [2.20.4] — 2026-09-20
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **The app no longer reopens the event you last came in on.** Opening an event
|
|
||||||
from a reminder or a home-screen widget made the app return to that same
|
|
||||||
event on every later launch — and show an error screen once the event had
|
|
||||||
been deleted. Android hands an app the launch it was started with again each
|
|
||||||
time it is rebuilt, and Calendula was acting on it every time instead of
|
|
||||||
once ([#309]).
|
|
||||||
- **"Today" works in the agenda again.** Scrolling a few days ahead and tapping
|
|
||||||
today did nothing, because the agenda is browsed by scrolling rather than by
|
|
||||||
moving its date, and the button only moved the date. It now brings the list
|
|
||||||
back to today, and it stays offered while the list is scrolled away — before,
|
|
||||||
it was hidden in exactly the situation it is for ([#305]).
|
|
||||||
- **Calendula is filed under "Calendar & Agenda" in the self-hosted repo.**
|
|
||||||
Browsing by category in an F-Droid client set to prefer that repo listed the
|
|
||||||
app under "Time" instead ([#294]).
|
|
||||||
|
|
||||||
## [2.20.3] — 2026-09-12
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **A custom agenda range crashed the app in French.** Choosing a custom range
|
|
||||||
for the agenda — or simply typing a number into that field — closed the app on
|
|
||||||
any phone set to French. The French translation of the "%d days" label was
|
|
||||||
missing the wording French uses for counts of two and above, and Android has
|
|
||||||
nothing to fall back on when that particular form is absent, so every value
|
|
||||||
except one day was affected ([#297], [#298]).
|
|
||||||
|
|
||||||
## [2.20.2] — 2026-09-12
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Crash reports are filed in English.** Reporting a crash from a phone set to
|
|
||||||
another language prefilled the issue — title and body — in that language, so
|
|
||||||
a French crash arrived on Codeberg as a French issue ([#297], [#298]). The
|
|
||||||
text that is *posted* is now English for everyone; the dialog that asks you
|
|
||||||
to send it, and everything else you see in the app, is still translated.
|
|
||||||
|
|
||||||
## [2.20.1] — 2026-09-07
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **An empty calendar now explains itself.** "No calendars configured" offered
|
|
||||||
an "Open system calendar settings" button that opened nothing — every screen
|
|
||||||
wired it to the same reload that had just produced the empty state. It now
|
|
||||||
opens the system's account chooser, filtered to the providers that sync
|
|
||||||
calendars ([#239]).
|
|
||||||
- **Calendars that are all switched off no longer look like an empty month.**
|
|
||||||
When every calendar on the device is hidden at system level there is nothing
|
|
||||||
to draw, but the app said nothing at all: it only ever checked whether the
|
|
||||||
device had *no* calendars, so a device that had them and hid them fell
|
|
||||||
through to a blank grid with no hint. That case now says so, and points at
|
|
||||||
Settings → Calendars — the one screen that can switch a calendar back on. The
|
|
||||||
navigation drawer's calendar list drew the same conflation and is fixed with
|
|
||||||
it ([#239]).
|
|
||||||
|
|
||||||
## [2.20.0] — 2026-09-07
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Long-press to copy.** Holding down the title, the location or the
|
|
||||||
description on an event puts that whole field on the clipboard. Tapping still
|
|
||||||
does what it always did — the location opens maps, a link in the description
|
|
||||||
opens — and the times, calendar, repeat rule and guests stay uncopyable. Below
|
|
||||||
Android 13 a short confirmation appears; from Android 13 on the system shows
|
|
||||||
its own clipboard chip ([#195]).
|
|
||||||
- **The open-source projects Calendula ships** are now listed under Settings →
|
|
||||||
About, each with its copyright holder and licence, and each row tapping
|
|
||||||
through to that project's source ([#281]).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Event titles no longer end in an ellipsis. The "…" took the width of a couple
|
|
||||||
of characters and told you nothing you couldn't already see, so the title now
|
|
||||||
simply runs to the edge of its chip in the month, week and day views — a few
|
|
||||||
more letters per event, which is often the difference between two events you
|
|
||||||
can tell apart and two you can't. Right-to-left languages keep the ellipsis,
|
|
||||||
where clipping would cut the beginning of a title rather than its end
|
|
||||||
([#164]).
|
|
||||||
- Month-view events now show their start time before the title, where the chip
|
|
||||||
is wide enough to keep the title readable — in landscape, on a foldable and on
|
|
||||||
a tablet, and never at the cost of a narrow phone column. All-day events show
|
|
||||||
no time, and neither does a bar carried in from the previous week, whose start
|
|
||||||
is not in that row. Event times across the month, week and day views are also
|
|
||||||
set in a regular weight against the title's, so a time reads as a time rather
|
|
||||||
than as part of the name next to it ([#219]).
|
|
||||||
- **The hour lines in week and day view are now an hour grid.** The setting
|
|
||||||
that drew a faint separator line at each hour now seats every hour in its own
|
|
||||||
rounded cell, so an hour boundary reads as the seam between two surfaces
|
|
||||||
rather than a line drawn across one — the same negative space that separates
|
|
||||||
the month grid's days and the week's columns. An event that starts on the hour
|
|
||||||
fills its cell instead of overhanging the seam, and one that runs past
|
|
||||||
midnight still meets the edge of its column ([#113]).
|
|
||||||
- **An invitation you haven't answered now reads as an outline.** An event still
|
|
||||||
waiting on your reply is drawn with the calendar's colour on its border and
|
|
||||||
its title and nothing behind it, so it stands apart from the ones you have
|
|
||||||
accepted — on the month chips, the all-day bars and the week and day blocks
|
|
||||||
alike. Declined events keep their filled, struck-through chip and now sort
|
|
||||||
last in the month view as well, so a day with more events than fit drops the
|
|
||||||
ones you said no to first ([#230]).
|
|
||||||
- The calendar titles now shorten instead of being cut off. When the full month
|
|
||||||
name doesn't fit the top bar, the month and week views fall back to its
|
|
||||||
three-letter form and the day view drops the weekday, rather than trailing off
|
|
||||||
mid-word at large font sizes. The view switcher, the title and the agenda's
|
|
||||||
range bar also line up with the grid underneath them ([#165]).
|
|
||||||
- The jump-to-today button in the toolbar now shows today's date. It drew a
|
|
||||||
generic calendar icon, which told you nothing you didn't already know from
|
|
||||||
tapping it; it now carries the current day number in an outlined box, so the
|
|
||||||
bar says what day it is as well as taking you there. It rolls over at midnight
|
|
||||||
on its own ([#220]).
|
|
||||||
- The quick-switch pill disappears when there is nothing left to switch between,
|
|
||||||
and you can now turn calendar views off all the way down to a single one — the
|
|
||||||
setting used to hold two open purely to keep the pill fed ([#150]).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **A long location no longer runs off the edge of its field.** The location on
|
|
||||||
the edit screen sat on one line, so a full postal address or a long meeting
|
|
||||||
link scrolled sideways out of sight as you typed it. It now wraps and the card
|
|
||||||
grows to fit, with the pin and the contacts button staying level with the first
|
|
||||||
line at any font size. A multi-line address you paste in is joined with commas
|
|
||||||
— the way one picked from your contacts always has been — instead of running
|
|
||||||
together into a single word ([#273]).
|
|
||||||
- **The event form capitalizes sentences again.** The title, description and
|
|
||||||
guest fields had stopped starting a sentence with a capital letter; they
|
|
||||||
follow the keyboard's usual behaviour once more. The location is deliberately
|
|
||||||
left alone, since it is as often a URL as an address ([#146]).
|
|
||||||
- **A block you pick up no longer loses its second line.** Dragging an event
|
|
||||||
whose time or title wrapped to two lines clipped the floating copy to one line
|
|
||||||
for as long as you held it, and it snapped back on drop. The copy now measures
|
|
||||||
its text exactly as the block does ([#267]).
|
|
||||||
|
|
||||||
## [2.19.4] — 2026-09-01
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **A deleted occurrence no longer comes back when the series is re-timed.**
|
|
||||||
Delete a single occurrence of a repeating event, then change the time of the
|
|
||||||
whole series, and the occurrence you had removed reappeared. Doing it the
|
|
||||||
other way round — "This and all following events" — lost every removal in the
|
|
||||||
part of the series being changed. Removals now travel with the event: they
|
|
||||||
keep their place in the series across a time change, a move to a different
|
|
||||||
day, a timezone change and a switch to or from an all-day event, and they
|
|
||||||
carry over when a series is split. Repeating events on your device's own
|
|
||||||
calendars are affected, including the birthday and anniversary calendars
|
|
||||||
Calendula creates from your contacts ([#248]).
|
|
||||||
- **Home-screen widgets now turn the page at midnight.** Both the month and the
|
|
||||||
agenda widget kept highlighting yesterday as "today" — and the agenda kept
|
|
||||||
greying out the wrong events as already past — until you paged the month back
|
|
||||||
and forth or removed and re-added the widget. Calendula now wakes itself at the
|
|
||||||
day boundary and redraws, and re-arms after a reboot, a clock change or a
|
|
||||||
flight into another timezone. Paging the month widget forward and back also
|
|
||||||
stops quietly pinning it to that month, so it follows the date again instead of
|
|
||||||
being stranded on the month you happened to be looking at ([#228]).
|
|
||||||
- **"Only this event" now actually saves your edit.** On some calendars —
|
|
||||||
including Google ones that still show as on-device, and any local calendar —
|
|
||||||
editing a single occurrence of a repeating event did nothing at all: the scope
|
|
||||||
dialog closed, the edit screen stayed put, and saving again just repeated it.
|
|
||||||
Android can only attach a single-occurrence change to its series once the
|
|
||||||
calendar has been synced at least once, so on those calendars the change had
|
|
||||||
nowhere to go. Calendula now removes that one occurrence from the series and
|
|
||||||
saves the edit as its own event instead, which is what you see either way. A
|
|
||||||
save that does fail also says so for longer, rather than flashing past
|
|
||||||
([#234]).
|
|
||||||
- **A failed import no longer costs you the whole file.** One event the calendar
|
|
||||||
refuses used to abort the entire import with nothing on screen but "couldn't
|
|
||||||
read this file". Events are now added one at a time: anything rejected is
|
|
||||||
counted and shown, and the rest still arrive. A faulty repeat rule in the file
|
|
||||||
is repaired instead of being handed on ([#225]).
|
|
||||||
- **All-day reminders survive a backup and an import.** They fire at the hour you
|
|
||||||
chose, not at midnight UTC — the same rule the app already applied to all-day
|
|
||||||
events you create yourself — and an export no longer leaves them out of the
|
|
||||||
file entirely. An imported yearly birthday fires at the right hour too, even
|
|
||||||
though the file dates it back to the year of birth ([#225]).
|
|
||||||
- **Birthdays and anniversaries imported from Fossify Calendar now show up.**
|
|
||||||
Fossify writes the ones it mirrors from your contacts as events that start and
|
|
||||||
end on the same day, which the calendar read as lasting no time at all: they
|
|
||||||
imported without complaint and then appeared nowhere. Any all-day event that
|
|
||||||
ends where it starts, or carries no end at all, is now a one-day event
|
|
||||||
([#225]).
|
|
||||||
- **A backup no longer brings back occurrences you had deleted.** Remove a single
|
|
||||||
occurrence of a repeating event, back up, restore — and it was there again,
|
|
||||||
because the removals never made it into the file. They travel with the series
|
|
||||||
now, so a restored calendar looks the way you left it ([#225]).
|
|
||||||
- **A reminder left on your account's default no longer went off as the event
|
|
||||||
started.** An event whose reminder says "Default" is the account's to time, not
|
|
||||||
Calendula's — it was reading that setting as a lead time of its own and firing a
|
|
||||||
minute after the event began. It now leaves such a reminder alone, and says so
|
|
||||||
on the event.
|
|
||||||
- **"Only this event" on an event that doesn't repeat now edits or deletes it.**
|
|
||||||
Deleting used to write an exclusion onto a one-off event instead — which left
|
|
||||||
the event there and lost its end time — and editing filed the change away as an
|
|
||||||
exception to nothing, so it vanished.
|
|
||||||
- **An import that the calendar cuts short now tells you how far it got.** Losing
|
|
||||||
the calendar or the permission part way through a big file reported the whole
|
|
||||||
import as failed, so importing again added everything twice.
|
|
||||||
- **An event that runs past midnight can be dragged by either half.** Its second
|
|
||||||
half couldn't be picked up at all, and the first could only be moved later. Both
|
|
||||||
halves now move, and both are previewed while you hold either one, so the event
|
|
||||||
reads as moving rather than growing. The blocks are cut square at midnight
|
|
||||||
instead of rounded off, a month row draws an event crossing into the next week
|
|
||||||
as one bar, and a move across a daylight-saving change no longer lands an hour
|
|
||||||
out ([#253]).
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **More of an imported `.ics` survives the trip**: tasks come across as events
|
|
||||||
rather than being dropped in silence, deleted occurrences of a repeating event
|
|
||||||
stay deleted, a calendar named like the one the file came from is preselected,
|
|
||||||
and event colours are carried over — matched to the closest colour your
|
|
||||||
calendar's account offers, so a migrated calendar still reads at a glance
|
|
||||||
([#225]).
|
|
||||||
- **Belarusian, Hungarian and Slovak**, all three contributed as community
|
|
||||||
translations through
|
|
||||||
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
|
||||||
Belarusian covers about nine tenths of the app, Slovak about a third and
|
|
||||||
Hungarian is just under way, so untranslated parts still show in English until
|
|
||||||
they fill out. Pick any of them under Settings → Language or in Android's
|
|
||||||
per-app language settings. Thanks to Dmitri Gabinski (Belarusian), Ábel Nagy
|
|
||||||
(Hungarian) and Julie (Slovak); help finishing them is very welcome.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Arabic, Polish and Simplified Chinese are now complete**, all three carried
|
|
||||||
the rest of the way this cycle — Simplified Chinese from a fifth of the app to
|
|
||||||
all of it. Czech and Russian grew too — Russian to about half — and still have
|
|
||||||
room. Thanks to Sup99999 (Arabic), Bazyli Cyran (Polish), JeanWenzel
|
|
||||||
(Simplified Chinese), James Trivette (Czech), and SpiritOTHawk and Artur
|
|
||||||
(Russian).
|
|
||||||
|
|
||||||
## [2.19.3] — 2026-08-22
|
## [2.19.3] — 2026-08-22
|
||||||
|
|
||||||
@@ -1734,43 +1465,9 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||||
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||||
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
||||||
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164
|
|
||||||
[#165]: https://codeberg.org/jlmakiola/calendula/issues/165
|
|
||||||
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
||||||
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
||||||
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
||||||
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
|
||||||
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
|
||||||
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
|
||||||
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
|
|
||||||
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
|
||||||
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
|
||||||
[#219]: https://codeberg.org/jlmakiola/calendula/issues/219
|
|
||||||
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
|
|
||||||
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
|
|
||||||
[#273]: https://codeberg.org/jlmakiola/calendula/issues/273
|
|
||||||
[#113]: https://codeberg.org/jlmakiola/calendula/issues/113
|
|
||||||
[#220]: https://codeberg.org/jlmakiola/calendula/issues/220
|
|
||||||
[#195]: https://codeberg.org/jlmakiola/calendula/issues/195
|
|
||||||
[#281]: https://codeberg.org/jlmakiola/calendula/issues/281
|
|
||||||
[#230]: https://codeberg.org/jlmakiola/calendula/issues/230
|
|
||||||
[#150]: https://codeberg.org/jlmakiola/calendula/issues/150
|
|
||||||
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
|
|
||||||
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
|
|
||||||
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
|
|
||||||
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
|
|
||||||
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
|
|
||||||
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
|
|
||||||
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
|
|
||||||
[#294]: https://codeberg.org/jlmakiola/calendula/issues/294
|
|
||||||
[#287]: https://codeberg.org/jlmakiola/calendula/issues/287
|
|
||||||
[#189]: https://codeberg.org/jlmakiola/calendula/issues/189
|
|
||||||
[#190]: https://codeberg.org/jlmakiola/calendula/issues/190
|
|
||||||
[#212]: https://codeberg.org/jlmakiola/calendula/issues/212
|
|
||||||
[#213]: https://codeberg.org/jlmakiola/calendula/issues/213
|
|
||||||
[#162]: https://codeberg.org/jlmakiola/calendula/issues/162
|
|
||||||
[#289]: https://codeberg.org/jlmakiola/calendula/issues/289
|
|
||||||
[#290]: https://codeberg.org/jlmakiola/calendula/issues/290
|
|
||||||
[#304]: https://codeberg.org/jlmakiola/calendula/issues/304
|
|
||||||
[#321]: https://codeberg.org/jlmakiola/calendula/issues/321
|
|
||||||
[#329]: https://codeberg.org/jlmakiola/calendula/issues/329
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
|||||||
<p>
|
<p>
|
||||||
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
||||||
|
|
||||||
<a href="https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
|
<a href="https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="68"></a>
|
||||||
|
|
||||||
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
|
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
|
||||||
|
|
||||||
@@ -157,8 +157,6 @@ reinstalling. Google Play is the exception — see above.</sub>
|
|||||||
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
||||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
||||||
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
|
- **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
|
||||||
- **[Privacy policy](docs/PRIVACY.md)** — the source of the page published at
|
|
||||||
[jeanlucmakiola.de/calendula/privacy](https://jeanlucmakiola.de/calendula/privacy)
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
@@ -189,16 +187,6 @@ 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
|
and translate the strings in your browser. You can also reach this link in the
|
||||||
app from the top of **Settings → App language**.
|
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
|
## 📜 License
|
||||||
|
|
||||||
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
||||||
|
|||||||
+8
-12
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 22100
|
versionCode = 21903
|
||||||
versionName = "2.21.0"
|
versionName = "2.19.3"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -113,16 +113,12 @@ android {
|
|||||||
lint {
|
lint {
|
||||||
// Community translations are expected to be partial — a missing string
|
// Community translations are expected to be partial — a missing string
|
||||||
// falls back to the English base at runtime — so don't fail the build on
|
// falls back to the English base at runtime — so don't fail the build on
|
||||||
// it. A translated <plurals> may likewise skip a CLDR quantity form its
|
// it. Likewise a translated <plurals> may not fill every CLDR quantity
|
||||||
// locale defines (e.g. Arabic "zero"): Android falls back to "other" for
|
// form its locale defines (e.g. Arabic needs "zero"); the missing form
|
||||||
// any form it cannot find, so MissingQuantity is informational too.
|
// falls back to "other" at runtime, so MissingQuantity is informational
|
||||||
// What a translation must NOT skip is "other" itself — nothing falls back
|
// too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
|
||||||
// for that one, not even the base locale, so it throws
|
// check_translations.py guards the same invariants with clearer,
|
||||||
// Resources$NotFoundException at runtime (Codeberg #297/#298).
|
// translator-facing messages.
|
||||||
// MissingQuantity doesn't tell the two cases apart, so that invariant is
|
|
||||||
// enforced by scripts/check_translations.py instead, with a clearer
|
|
||||||
// translator-facing message. Stale/extra keys (ExtraTranslation) stay
|
|
||||||
// fatal.
|
|
||||||
informational += listOf("MissingTranslation", "MissingQuantity")
|
informational += listOf("MissingTranslation", "MissingQuantity")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
-6
@@ -50,9 +50,3 @@
|
|||||||
# the real names also survives app updates, which would otherwise renumber the
|
# the real names also survives app updates, which would otherwise renumber the
|
||||||
# obfuscated name and orphan the stored mapping.
|
# obfuscated name and orphan the stored mapping.
|
||||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
-keep class * extends androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
|
||||||
# Belt and braces one level up: the two receivers are nearly as alike, and the
|
|
||||||
# provider map is keyed off the receiver component too. Redundant today (AGP's
|
|
||||||
# manifest-derived rules cover them), but #89 cost a release to diagnose and the
|
|
||||||
# guarantee should not rest on a component staying in the manifest.
|
|
||||||
-keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver
|
|
||||||
|
|||||||
@@ -330,13 +330,9 @@
|
|||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||||
PROVIDER_CHANGED on any data change (our writes and external sync).
|
PROVIDER_CHANGED on any data change (our writes and external sync),
|
||||||
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
|
and the system broadcasts the date/time ones at midnight / clock
|
||||||
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
|
changes so "today" highlighting rolls over. -->
|
||||||
a free extra only, since Android 8+ withholds it from manifest
|
|
||||||
receivers. The four below re-arm that alarm: TIME_SET /
|
|
||||||
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
|
|
||||||
Exported: the system broadcasts arrive from outside the app. -->
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.WidgetUpdateReceiver"
|
android:name=".widget.WidgetUpdateReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -350,8 +346,6 @@
|
|||||||
<action android:name="android.intent.action.DATE_CHANGED" />
|
<action android:name="android.intent.action.DATE_CHANGED" />
|
||||||
<action android:name="android.intent.action.TIME_SET" />
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
|
||||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
|||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
|
||||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -32,34 +31,19 @@ class CalendulaApp : Application() {
|
|||||||
// respecting, on-device; the user submits the report by hand). The
|
// respecting, on-device; the user submits the report by hand). The
|
||||||
// capture/loop-detection/report machinery lives in floret-kit's
|
// capture/loop-detection/report machinery lives in floret-kit's
|
||||||
// core-crash; only the app label + issue-tracker URLs are app-specific.
|
// core-crash; only the app label + issue-tracker URLs are app-specific.
|
||||||
// The prefilled issue's own title and body are the kit's, in English:
|
|
||||||
// they are posted to the tracker, so they don't follow the device
|
|
||||||
// language the way the dialog around them does.
|
|
||||||
CrashReporter.install(
|
CrashReporter.install(
|
||||||
this,
|
this,
|
||||||
CrashConfig(
|
CrashConfig(
|
||||||
appLabel = getString(R.string.app_name),
|
appLabel = getString(R.string.app_name),
|
||||||
newIssueUrl = getString(R.string.report_issue_url),
|
newIssueUrl = getString(R.string.report_issue_url),
|
||||||
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||||
|
issueTitle = getString(R.string.crash_report_issue_title),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
reconcileAutoBackup()
|
reconcileAutoBackup()
|
||||||
reconcileSpecialDates()
|
reconcileSpecialDates()
|
||||||
reconcileCalendarVisibility()
|
reconcileCalendarVisibility()
|
||||||
startReminderDelivery()
|
startReminderDelivery()
|
||||||
reconcileWidgetRollover()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-arm the widgets' midnight rollover from whatever is actually placed
|
|
||||||
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
|
|
||||||
* dropped by a force-stop is armed again the next time the app is opened.
|
|
||||||
* Off the main thread: a handful of binder calls on every process start.
|
|
||||||
*/
|
|
||||||
private fun reconcileWidgetRollover() {
|
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
|
||||||
WidgetRolloverScheduler.sync(this@CalendulaApp)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
@@ -113,26 +113,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||||
Configuration.UI_MODE_NIGHT_YES
|
Configuration.UI_MODE_NIGHT_YES
|
||||||
applyEdgeToEdge()
|
applyEdgeToEdge()
|
||||||
// Android hands the intent that started the task back to onCreate every
|
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||||
// time the activity is recreated — on a rotation, or on a restart after
|
|
||||||
// the system reclaimed the process. Re-running a launch that opens one
|
|
||||||
// specific event is what made a single reminder or widget tap re-open
|
|
||||||
// that event on every later launch, an error screen once the event had
|
|
||||||
// been deleted (#309); the user left it long ago, and the activity
|
|
||||||
// restores where they actually were along with its saved state.
|
|
||||||
//
|
|
||||||
// Only those channels are held back. An .ics import or a prefilled
|
|
||||||
// create form keeps no saved state of its own, so re-reading the intent
|
|
||||||
// is what carries one through a recreation.
|
|
||||||
val replaying = savedInstanceState != null || intent.isRelaunch()
|
|
||||||
if (!replaying) {
|
|
||||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
|
||||||
requestedEditKey = intent.editEventKeyOrNull()
|
|
||||||
}
|
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
|
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
requestedInsert = intent.insertRequestOrNull()
|
requestedInsert = intent.insertRequestOrNull()
|
||||||
|
requestedEditKey = intent.editEventKeyOrNull()
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
@@ -186,7 +171,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalUse24HourFormat provides use24Hour,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
LocalShowHourGrid provides settings.showHourGrid,
|
LocalShowHourLines provides settings.showHourLines,
|
||||||
LocalTimelineZoom provides timelineZoom,
|
LocalTimelineZoom provides timelineZoom,
|
||||||
LocalSoftenColors provides settings.softenColors,
|
LocalSoftenColors provides settings.softenColors,
|
||||||
) {
|
) {
|
||||||
@@ -257,16 +242,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this launch is the task being resumed rather than a fresh
|
|
||||||
* delivery — Android sets the flag when an activity is started from the
|
|
||||||
* recents list, where the intent it carries is the one the task was started
|
|
||||||
* with however long ago. `onNewIntent` is the channel a real new request
|
|
||||||
* arrives on while the task lives, so nothing is lost by ignoring these.
|
|
||||||
*/
|
|
||||||
private fun Intent.isRelaunch(): Boolean =
|
|
||||||
flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
|
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
|
||||||
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
|
||||||
|
|||||||
+6
-48
@@ -31,13 +31,9 @@ private const val MILLIS_PER_MINUTE = 60_000L
|
|||||||
* event (a whole-day multiple; sub-day remainders are dropped), so it fires at
|
* event (a whole-day multiple; sub-day remainders are dropped), so it fires at
|
||||||
* [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be
|
* [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be
|
||||||
* **negative** — e.g. "at time of event" at 09:00 CEST encodes to −420, meaning
|
* **negative** — e.g. "at time of event" at 09:00 CEST encodes to −420, meaning
|
||||||
* the provider fires *after* DTSTART; this is valid and must not be clamped. The
|
* the provider fires *after* DTSTART; this is valid and must not be clamped.
|
||||||
* one negative it never is is [MINUTES_DEFAULT]: the sentinel and the encoding
|
* A negative [semanticMinutes] is the "provider default" sentinel and passes
|
||||||
* share a value space, so an encoding that lands on it is nudged one minute past
|
* through unchanged.
|
||||||
* it rather than being read back as "use the account default". That sentinel is
|
|
||||||
* also the one [semanticMinutes] that passes through unencoded — a lead time may
|
|
||||||
* itself be negative (a foreign row firing the day *after* the event), and
|
|
||||||
* waving those through would change when they fire.
|
|
||||||
*/
|
*/
|
||||||
internal fun toProviderAllDayMinutes(
|
internal fun toProviderAllDayMinutes(
|
||||||
semanticMinutes: Int,
|
semanticMinutes: Int,
|
||||||
@@ -45,13 +41,12 @@ internal fun toProviderAllDayMinutes(
|
|||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
timeOfDayMinutes: Int,
|
timeOfDayMinutes: Int,
|
||||||
): Int {
|
): Int {
|
||||||
if (semanticMinutes == MINUTES_DEFAULT) return semanticMinutes
|
if (semanticMinutes < 0) return semanticMinutes
|
||||||
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
|
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
|
||||||
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
|
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
|
||||||
.atZone(zone).toInstant().toEpochMilli()
|
.atZone(zone).toInstant().toEpochMilli()
|
||||||
val raw = ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||||
return if (raw == MINUTES_DEFAULT) MINUTES_DEFAULT - 1 else raw
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,52 +70,15 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
|
|||||||
return LocalDate.of(today.year, month, day)
|
return LocalDate.of(today.year, month, day)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The date an **imported** all-day event's reminder offset is sampled at.
|
|
||||||
*
|
|
||||||
* A one-off fires once, on its own date. A series' `DTSTART` is only an anchor
|
|
||||||
* and may be ancient — Fossify writes a year-less contact birthday at 1970, a
|
|
||||||
* year whose rules predate DST across most of Europe, and a real birth year is
|
|
||||||
* usually older still — so sampling there skews every modern occurrence by the
|
|
||||||
* offset delta (the same trap [nextYearlyOccurrence] exists for). A yearly
|
|
||||||
* series is therefore sampled at its next occurrence of the anchor's month/day,
|
|
||||||
* which shares the season; any other series at [today], which its upcoming
|
|
||||||
* occurrences are near.
|
|
||||||
*
|
|
||||||
* The decode side ([fromProviderAllDayMinutes]) only asks which local *day* the
|
|
||||||
* encoded instant falls on, so it still reads the lead time back correctly from
|
|
||||||
* the anchor's own date.
|
|
||||||
*/
|
|
||||||
internal fun importedAllDayReminderDate(
|
|
||||||
startDate: LocalDate,
|
|
||||||
recurrenceRule: String?,
|
|
||||||
today: LocalDate,
|
|
||||||
): LocalDate = when {
|
|
||||||
recurrenceRule.isNullOrBlank() -> startDate
|
|
||||||
recurrenceRule.contains("FREQ=YEARLY", ignoreCase = true) ->
|
|
||||||
nextYearlyOccurrence(startDate.monthValue, startDate.dayOfMonth, today)
|
|
||||||
startDate.isBefore(today) -> today
|
|
||||||
else -> startDate
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||||
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
|
||||||
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
|
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
|
||||||
* the reminder actually fires on.
|
* the reminder actually fires on.
|
||||||
*
|
|
||||||
* [MINUTES_DEFAULT] passes through unchanged, the mirror of the encode side. A
|
|
||||||
* real encoding is often negative, so only that exact value may be treated as the
|
|
||||||
* sentinel — decoding it would land it on the start date and turn "use the
|
|
||||||
* account default" into a concrete at-start alarm.
|
|
||||||
*/
|
*/
|
||||||
internal fun fromProviderAllDayMinutes(
|
internal fun fromProviderAllDayMinutes(
|
||||||
rawMinutes: Int,
|
rawMinutes: Int,
|
||||||
startDate: LocalDate,
|
startDate: LocalDate,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
timeOfDayMinutes: Int,
|
timeOfDayMinutes: Int,
|
||||||
): Int = if (rawMinutes == MINUTES_DEFAULT) {
|
): Int = allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
|
||||||
rawMinutes
|
|
||||||
} else {
|
|
||||||
allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
|
|
||||||
}
|
|
||||||
|
|||||||
+15
-28
@@ -1,35 +1,22 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The colours offered when creating or editing a calendar; ARGB ints for a raw
|
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
|
||||||
* `CALENDAR_COLOR` / `EVENT_COLOR`.
|
* `EVENT_COLOR`. The named entries exist for callers that need one specific
|
||||||
*
|
* hue (the managed special-dates calendars), so they can't drift from the
|
||||||
* Hues rather than final fills: every surface that draws a calendar colour runs
|
* swatches offered in the colour picker.
|
||||||
* it through the app's own tone pass first, so these are chosen to stay
|
|
||||||
* distinguishable after it. Shared with Agendula, whose list palette these are —
|
|
||||||
* the two apps are the same family and a calendar and a task list picked from
|
|
||||||
* different-looking sets read as different products.
|
|
||||||
*
|
|
||||||
* The named entries exist for callers that need one specific hue (the managed
|
|
||||||
* special-dates calendars), so they can't drift from the swatches on offer.
|
|
||||||
*/
|
*/
|
||||||
object CalendarColorPalette {
|
object CalendarColorPalette {
|
||||||
val Mauve = 0xFF7A5C6B.toInt()
|
val Red = 0xFFD50000.toInt()
|
||||||
val Red = 0xFFD7484A.toInt()
|
val Orange = 0xFFE67C00.toInt()
|
||||||
val Orange = 0xFFE8743B.toInt()
|
val Amber = 0xFFF6BF26.toInt()
|
||||||
val Amber = 0xFFE0A32E.toInt()
|
val Green = 0xFF33B679.toInt()
|
||||||
val Olive = 0xFF7CA83E.toInt()
|
val DarkGreen = 0xFF0B8043.toInt()
|
||||||
val Green = 0xFF35A06A.toInt()
|
val Blue = 0xFF039BE5.toInt()
|
||||||
val Teal = 0xFF19938C.toInt()
|
val Indigo = 0xFF3F51B5.toInt()
|
||||||
val Cyan = 0xFF2A9BC4.toInt()
|
val Purple = 0xFF8E24AA.toInt()
|
||||||
val Blue = 0xFF3C74C8.toInt()
|
val Graphite = 0xFF616161.toInt()
|
||||||
val Indigo = 0xFF6A5CC0.toInt()
|
|
||||||
val Purple = 0xFF9455B8.toInt()
|
|
||||||
val Pink = 0xFFC94F8E.toInt()
|
|
||||||
|
|
||||||
/** The full palette, in swatch-grid order. */
|
/** The full palette, in swatch-row order. */
|
||||||
val all: List<Int> = listOf(
|
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
||||||
Mauve, Red, Orange, Amber, Olive, Green,
|
|
||||||
Teal, Cyan, Blue, Indigo, Purple, Pink,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-283
@@ -35,7 +35,6 @@ import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
|||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
|
|
||||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -85,25 +84,14 @@ interface CalendarDataSource {
|
|||||||
*/
|
*/
|
||||||
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
/**
|
|
||||||
* The same palette **uncurated** — every key the account publishes, in
|
|
||||||
* provider order. Curation is a display concession (it folds look-alikes and
|
|
||||||
* drops the neutrals outright from an oversized palette), so matching a
|
|
||||||
* colour that came from outside the account has to run against the full set:
|
|
||||||
* those keys are all the calendar accepts. See [eventColorPalette].
|
|
||||||
*/
|
|
||||||
fun publishedEventColors(calendarId: Long): List<EventColorOption>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every master/one-off event of the writable local calendars, mapped for a
|
* Every master/one-off event of the writable local calendars, mapped for a
|
||||||
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||||
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
||||||
* given, only those calendars are exported (still intersected with the
|
* given, only those calendars are exported (still intersected with the
|
||||||
* eligible set); `null` exports every eligible calendar.
|
* eligible set); `null` exports every eligible calendar.
|
||||||
* [allDayReminderTimeMinutes]: needed to write all-day reminders as whole-day
|
|
||||||
* lead times rather than raw provider offsets (see [toIcsEvent]).
|
|
||||||
*/
|
*/
|
||||||
fun exportableEvents(calendarIds: Set<Long>?, allDayReminderTimeMinutes: Int): List<IcsEvent>
|
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||||
@@ -113,19 +101,10 @@ interface CalendarDataSource {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or
|
||||||
* minting one when absent); returns the new `Events._ID`.
|
* minting one when absent); returns the new `Events._ID`. Reminders are
|
||||||
*
|
* written as the file's raw lead minutes (METHOD_ALERT).
|
||||||
* [colorPalette] is the target account's published event colours
|
|
||||||
* ([publishedEventColors]), looked up once per import;
|
|
||||||
* [allDayReminderTimeMinutes] is the user's preferred all-day firing time,
|
|
||||||
* applied exactly as a hand-created event's is.
|
|
||||||
*/
|
*/
|
||||||
fun insertImportedEvent(
|
fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
|
||||||
event: ParsedIcsEvent,
|
|
||||||
calendarId: Long,
|
|
||||||
allDayReminderTimeMinutes: Int,
|
|
||||||
colorPalette: List<EventColorOption>,
|
|
||||||
): Long
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
||||||
@@ -253,21 +232,14 @@ interface CalendarDataSource {
|
|||||||
): Long
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event at [beginMillis] (the
|
* Change a single occurrence of a recurring event by inserting a
|
||||||
* occurrence's `Instances.BEGIN`) to [form]'s values; returns the
|
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||||
* `Events._ID` of the row now holding them.
|
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
||||||
*
|
* row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
* A series with a `_sync_id` gets a modified-occurrence exception. One
|
|
||||||
* without gets the occurrence excluded from the parent via EXDATE plus a
|
|
||||||
* standalone event carrying the edits — an exception cannot link to its
|
|
||||||
* parent there (Codeberg #234, the same constraint as [deleteOccurrence]).
|
|
||||||
* A row that turns out not to recur at all is edited whole, which is what
|
|
||||||
* [original] is for. [allDayReminderTimeMinutes]: see [insertEvent].
|
|
||||||
*/
|
*/
|
||||||
fun updateOccurrence(
|
fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long
|
): Long
|
||||||
@@ -376,7 +348,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||||
val name = displayName.trim()
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
@@ -400,7 +372,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
|
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
|
||||||
val name = displayName.trim()
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||||
put(CalendarContract.Calendars.NAME, name)
|
put(CalendarContract.Calendars.NAME, name)
|
||||||
@@ -464,7 +436,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
PackageManager.PERMISSION_GRANTED
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||||
val name = displayName.trim()
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
@@ -749,10 +721,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
override fun eventColorPalette(calendarId: Long): List<EventColorOption> {
|
||||||
publishedEventColors(calendarId).curatedForPicker()
|
|
||||||
|
|
||||||
override fun publishedEventColors(calendarId: Long): List<EventColorOption> {
|
|
||||||
val account = calendarAccount(calendarId) ?: return emptyList()
|
val account = calendarAccount(calendarId) ?: return emptyList()
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
CalendarContract.Colors.CONTENT_URI,
|
CalendarContract.Colors.CONTENT_URI,
|
||||||
@@ -770,13 +739,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||||
}
|
}
|
||||||
?.filter { it.key.isNotEmpty() }
|
?.filter { it.key.isNotEmpty() }
|
||||||
|
?.curatedForPicker()
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun exportableEvents(
|
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
||||||
calendarIds: Set<Long>?,
|
|
||||||
allDayReminderTimeMinutes: Int,
|
|
||||||
): List<IcsEvent> {
|
|
||||||
// Only the local calendars the app owns and can write — synced calendars
|
// Only the local calendars the app owns and can write — synced calendars
|
||||||
// already have a backup (their server). Exclude the managed special-dates
|
// already have a backup (their server). Exclude the managed special-dates
|
||||||
// mirror calendars: their events are derived from contacts, not authored
|
// mirror calendars: their events are derived from contacts, not authored
|
||||||
@@ -811,7 +778,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
reader.toIcsEvent(
|
reader.toIcsEvent(
|
||||||
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
||||||
calendarName = names[calendarId],
|
calendarName = names[calendarId],
|
||||||
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
@@ -820,83 +786,76 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
override fun existingUids(calendarId: Long): Set<String> = resolver.query(
|
||||||
CalendarContract.Events.CONTENT_URI,
|
CalendarContract.Events.CONTENT_URI,
|
||||||
arrayOf(CalendarContract.Events.UID_2445),
|
arrayOf(CalendarContract.Events.UID_2445),
|
||||||
// DELETED rows linger until a sync adapter purges them; counting those
|
|
||||||
// as present would make a re-import skip everything the user has since
|
|
||||||
// deleted, reporting "all duplicates" and importing nothing.
|
|
||||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
"${CalendarContract.Events.UID_2445} IS NOT NULL AND " +
|
"${CalendarContract.Events.UID_2445} IS NOT NULL",
|
||||||
"${CalendarContract.Events.DELETED} = 0",
|
|
||||||
arrayOf(calendarId.toString()),
|
arrayOf(calendarId.toString()),
|
||||||
null,
|
null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
|
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
|
||||||
} ?: emptySet()
|
} ?: emptySet()
|
||||||
|
|
||||||
override fun insertImportedEvent(
|
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
|
||||||
event: ParsedIcsEvent,
|
val startMillis = event.start.toEpochMillis()
|
||||||
calendarId: Long,
|
val endMillis = event.end.toEpochMillis()
|
||||||
allDayReminderTimeMinutes: Int,
|
val values = ContentValues().apply {
|
||||||
colorPalette: List<EventColorOption>,
|
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||||
): Long {
|
|
||||||
val values = buildImportedEventValues(
|
|
||||||
event = event,
|
|
||||||
calendarId = calendarId,
|
|
||||||
// Preserve the file's UID so a re-import dedups against it; mint one
|
// Preserve the file's UID so a re-import dedups against it; mint one
|
||||||
// only when the source event carried none.
|
// only when the source event carried none.
|
||||||
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
put(
|
||||||
palette = colorPalette,
|
CalendarContract.Events.UID_2445,
|
||||||
)
|
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
)
|
||||||
|
put(CalendarContract.Events.TITLE, event.summary.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, startMillis)
|
||||||
|
if (event.recurrenceRule == null) {
|
||||||
|
put(CalendarContract.Events.DTEND, endMillis)
|
||||||
|
} else {
|
||||||
|
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
importDuration(startMillis, endMillis, event.isAllDay),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// All-day rows live at UTC midnights (the file already encodes them so);
|
||||||
|
// timed rows keep the event's own zone.
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
||||||
|
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
||||||
|
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
|
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
?: throw WriteFailedException("import event into calendar id=$calendarId")
|
||||||
val eventId = ContentUris.parseId(uri)
|
val eventId = ContentUris.parseId(uri)
|
||||||
val zone = ZoneId.systemDefault()
|
// Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
|
||||||
// An all-day reminder is stored the same way a hand-created one is, so it
|
event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
|
||||||
// fires at the time the user picked rather than at UTC midnight — sampled
|
|
||||||
// where it will actually fire, not at an ancient recurrence anchor
|
|
||||||
// (see [importedAllDayReminderDate]).
|
|
||||||
val reminderDate = if (event.isAllDay) {
|
|
||||||
importedAllDayReminderDate(
|
|
||||||
startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds())
|
|
||||||
.atZone(ZoneOffset.UTC).toLocalDate(),
|
|
||||||
recurrenceRule = event.recurrenceRule,
|
|
||||||
today = LocalDate.now(zone),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
event.semanticReminderMinutes().forEach { minutes ->
|
|
||||||
val providerMinutes = if (reminderDate != null) {
|
|
||||||
toProviderAllDayMinutes(
|
|
||||||
semanticMinutes = minutes,
|
|
||||||
startDate = reminderDate,
|
|
||||||
zone = zone,
|
|
||||||
timeOfDayMinutes = allDayReminderTimeMinutes,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
minutes
|
|
||||||
}
|
|
||||||
val reminder = ContentValues().apply {
|
val reminder = ContentValues().apply {
|
||||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
put(CalendarContract.Reminders.MINUTES, providerMinutes)
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||||
}
|
}
|
||||||
// The event row is already in. A reminder that won't attach costs
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||||
// that one alarm, not the event: throwing here would have the import
|
|
||||||
// count an event it did create as failed, and the retry would then
|
|
||||||
// skip it as a duplicate.
|
|
||||||
val attached = try {
|
|
||||||
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Reminder insert threw for imported event $eventId", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (attached == null) {
|
|
||||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return eventId
|
return eventId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Provider DURATION for an imported recurring row: whole days / seconds. */
|
||||||
|
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||||
|
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||||
|
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||||
|
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
||||||
|
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
||||||
|
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
||||||
|
}
|
||||||
|
|
||||||
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||||
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||||
@@ -1017,12 +976,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
) {
|
) {
|
||||||
val row = querySeriesRow(eventId)
|
|
||||||
val values = buildEventUpdateValues(
|
val values = buildEventUpdateValues(
|
||||||
original = original,
|
original = original,
|
||||||
updated = updated,
|
updated = updated,
|
||||||
seriesDtStartMillis = row.dtStartMillis,
|
seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis,
|
||||||
seriesExdate = row.exdate,
|
|
||||||
zone = ZoneId.systemDefault(),
|
zone = ZoneId.systemDefault(),
|
||||||
)
|
)
|
||||||
if (values.isNotEmpty()) {
|
if (values.isNotEmpty()) {
|
||||||
@@ -1234,22 +1191,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
override fun updateOccurrence(
|
override fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
val row = querySeriesRow(eventId)
|
|
||||||
// Not a series, so there is no occurrence to single out: the row *is* the
|
|
||||||
// event, and an exception with nothing to be an exception to would leave
|
|
||||||
// an orphan holding the edit (the same test deleteOccurrence makes).
|
|
||||||
if (!row.recurs) {
|
|
||||||
updateEvent(eventId, original, form, allDayReminderTimeMinutes)
|
|
||||||
return eventId
|
|
||||||
}
|
|
||||||
// EXDATE needs no _sync_id; a modified exception does.
|
|
||||||
if (row.syncId == null) {
|
|
||||||
return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes)
|
|
||||||
}
|
|
||||||
// The provider clones the series row and applies these values on top.
|
// The provider clones the series row and applies these values on top.
|
||||||
val values = buildOccurrenceExceptionValues(
|
val values = buildOccurrenceExceptionValues(
|
||||||
form = form,
|
form = form,
|
||||||
@@ -1268,90 +1212,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
return exceptionId
|
return exceptionId
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* "Edit only this event" on a series with **no `_sync_id`**: drop the
|
|
||||||
* occurrence from the parent with EXDATE and insert the edited values as a
|
|
||||||
* standalone event on the same calendar.
|
|
||||||
*
|
|
||||||
* A modified exception attaches to its parent only through `ORIGINAL_SYNC_ID`,
|
|
||||||
* exactly like the cancelled one [deleteOccurrence] documents; with no
|
|
||||||
* `_sync_id` the link never forms and the edit is lost (Codeberg #234).
|
|
||||||
* EXDATE plus a standalone row needs no link — what a detached instance
|
|
||||||
* degrades to without a `RECURRENCE-ID` to carry it.
|
|
||||||
*
|
|
||||||
* The detached row keeps no stored link back to its series, so: it no longer
|
|
||||||
* travels with it ([moveEvent] copies the master and its `ORIGINAL_ID`
|
|
||||||
* children, and this is neither); its EXDATE hole is an absolute instant, so
|
|
||||||
* re-timing the whole series brings the occurrence back beside the copy (a
|
|
||||||
* #47 delete resurrects the same way); and it is built from the form, not
|
|
||||||
* cloned, so `ORGANIZER`, `STATUS` and the attendee rows [reconcileAttendees]
|
|
||||||
* preserves are dropped — the same limitation as [moveEvent].
|
|
||||||
*
|
|
||||||
* Insert first, so a failure leaves the series untouched
|
|
||||||
* ([updateEventFromOccurrence]'s discipline); roll the new row back if the
|
|
||||||
* EXDATE update then fails, since it would be a visible duplicate. The
|
|
||||||
* reverse order risks the worse outcome — an excluded occurrence with no
|
|
||||||
* replacement, i.e. an edit that quietly deletes.
|
|
||||||
*/
|
|
||||||
private fun detachOccurrence(
|
|
||||||
eventId: Long,
|
|
||||||
beginMillis: Long,
|
|
||||||
row: SeriesRow,
|
|
||||||
form: EventForm,
|
|
||||||
allDayReminderTimeMinutes: Int,
|
|
||||||
): Long {
|
|
||||||
// Already detached (or deleted) from a stale screen still pointing at the
|
|
||||||
// parent: the EXDATE merge would fold the repeat away and still report a
|
|
||||||
// changed row, quietly leaving a *second* standalone copy.
|
|
||||||
if (exdateContains(row.exdate, beginMillis, row.allDay != 0, row.timezone)) {
|
|
||||||
throw NoSuchEventException(eventId)
|
|
||||||
}
|
|
||||||
// Reminders, guests and colour come along like any new event, and so does
|
|
||||||
// a fresh UID — the detached row is a separate event now, and sharing the
|
|
||||||
// parent's would collide with it in .ics restore dedup.
|
|
||||||
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
|
|
||||||
val values = buildOccurrenceExdateValues(
|
|
||||||
existingExdate = row.exdate,
|
|
||||||
occurrenceMillis = beginMillis,
|
|
||||||
dtStartMillis = row.dtStartMillis,
|
|
||||||
rrule = row.rrule,
|
|
||||||
rdate = row.rdate,
|
|
||||||
duration = row.duration,
|
|
||||||
timezone = row.timezone,
|
|
||||||
allDay = row.allDay,
|
|
||||||
)
|
|
||||||
// Rows touched, not occurrences excluded — 1 whenever the series row still
|
|
||||||
// exists. It catches the row disappearing under us, not an EXDATE the
|
|
||||||
// provider's expansion fails to match.
|
|
||||||
val updatedRows = try {
|
|
||||||
resolver.update(
|
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
|
||||||
values.toContentValues(), null, null,
|
|
||||||
)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
rollBackDetached(detachedId)
|
|
||||||
throw t
|
|
||||||
}
|
|
||||||
if (updatedRows == 0) {
|
|
||||||
rollBackDetached(detachedId)
|
|
||||||
throw WriteFailedException(
|
|
||||||
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return detachedId
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Undo the standalone row [detachOccurrence] inserted before its EXDATE
|
|
||||||
* update failed. Best effort: the caller is already throwing, and the worst
|
|
||||||
* case is the duplicate we were avoiding — never a lost occurrence.
|
|
||||||
*/
|
|
||||||
private fun rollBackDetached(detachedId: Long) {
|
|
||||||
runCatching { deleteEvent(detachedId) }.onFailure {
|
|
||||||
Log.w(TAG, "Failed to roll back detached occurrence $detachedId", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateEventFromOccurrence(
|
override fun updateEventFromOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
@@ -1368,71 +1228,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
// Insert the new series first: if it fails, the original is untouched.
|
// Insert the new series first: if it fails, the original is untouched.
|
||||||
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
|
val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
|
||||||
carrySplitExdate(newEventId, row, beginMillis, original, updated)
|
|
||||||
truncateSeries(eventId, row, beginMillis)
|
truncateSeries(eventId, row, beginMillis)
|
||||||
return newEventId
|
return newEventId
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Carry the [parent]'s exclusions for occurrences past [beginMillis] onto the
|
|
||||||
* series [newEventId] that now owns them, re-timed by the shift the split
|
|
||||||
* applied ([shiftedExdate]/[exdateAfter]). [insertEvent] builds the new row
|
|
||||||
* from the form, which knows nothing about them, so without this every
|
|
||||||
* occurrence the user had deleted from the tail of the series comes back.
|
|
||||||
*
|
|
||||||
* The whole time/recurrence set rides along with EXDATE for the reason
|
|
||||||
* [buildOccurrenceExdateValues] documents: on its own the provider does not
|
|
||||||
* read an EXDATE write as a recurrence change, and leaves the instances it
|
|
||||||
* expanded on insert standing.
|
|
||||||
*
|
|
||||||
* A failure rolls the new series back and throws, so the split fails whole
|
|
||||||
* rather than landing with the exclusions quietly dropped — the parent is
|
|
||||||
* still untruncated at this point, so the event is left exactly as it was.
|
|
||||||
*/
|
|
||||||
private fun carrySplitExdate(
|
|
||||||
newEventId: Long,
|
|
||||||
parent: SeriesRow,
|
|
||||||
beginMillis: Long,
|
|
||||||
original: EventForm,
|
|
||||||
updated: EventForm,
|
|
||||||
) {
|
|
||||||
// Dropping the recurrence in the same save leaves a one-off tail, and an
|
|
||||||
// exclusion means nothing on a row that doesn't recur.
|
|
||||||
if (updated.rrule.isNullOrBlank()) return
|
|
||||||
val carried = shiftedExdate(
|
|
||||||
existingExdate = exdateAfter(
|
|
||||||
parent.exdate,
|
|
||||||
beginMillis,
|
|
||||||
original.isAllDay,
|
|
||||||
parent.timezone,
|
|
||||||
),
|
|
||||||
original = original,
|
|
||||||
updated = updated,
|
|
||||||
zone = ZoneId.systemDefault(),
|
|
||||||
) ?: return
|
|
||||||
val row = querySeriesRow(newEventId)
|
|
||||||
val values = ContentValues().apply {
|
|
||||||
put(CalendarContract.Events.EXDATE, carried)
|
|
||||||
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
|
|
||||||
put(CalendarContract.Events.RRULE, row.rrule)
|
|
||||||
put(CalendarContract.Events.DURATION, row.duration)
|
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
|
|
||||||
put(CalendarContract.Events.ALL_DAY, row.allDay)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val rows = resolver.update(
|
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId),
|
|
||||||
values, null, null,
|
|
||||||
)
|
|
||||||
if (rows == 0) {
|
|
||||||
throw WriteFailedException("carry exdate onto split series id=$newEventId")
|
|
||||||
}
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
runCatching { deleteEvent(newEventId) }
|
|
||||||
throw t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
|
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
val row = querySeriesRow(eventId)
|
val row = querySeriesRow(eventId)
|
||||||
// From the first occurrence on = the whole series; also the fallback
|
// From the first occurrence on = the whole series; also the fallback
|
||||||
@@ -1485,7 +1284,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
CalendarContract.Events._SYNC_ID,
|
CalendarContract.Events._SYNC_ID,
|
||||||
CalendarContract.Events.EXDATE,
|
CalendarContract.Events.EXDATE,
|
||||||
CalendarContract.Events.RDATE,
|
|
||||||
),
|
),
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
@@ -1498,7 +1296,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
allDay = c.getInt(4),
|
allDay = c.getInt(4),
|
||||||
syncId = c.getString(5),
|
syncId = c.getString(5),
|
||||||
exdate = c.getString(6),
|
exdate = c.getString(6),
|
||||||
rdate = c.getString(7),
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -1514,16 +1311,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
/** Null on a local calendar (and before a synced event's first push). */
|
/** Null on a local calendar (and before a synced event's first push). */
|
||||||
val syncId: String? = null,
|
val syncId: String? = null,
|
||||||
val exdate: String? = null,
|
val exdate: String? = null,
|
||||||
val rdate: String? = null,
|
|
||||||
) {
|
) {
|
||||||
/**
|
|
||||||
* Whether this row expands into more than itself. RDATE counts as much as
|
|
||||||
* RRULE: a sync adapter may list the occurrences instead of naming a rule,
|
|
||||||
* and the provider expands either — as does the read side, which marks
|
|
||||||
* such a row recurring too.
|
|
||||||
*/
|
|
||||||
val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
|
|
||||||
|
|
||||||
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||||
beginMillis = beginMillis,
|
beginMillis = beginMillis,
|
||||||
@@ -1714,12 +1502,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
val row = querySeriesRow(eventId)
|
val row = querySeriesRow(eventId)
|
||||||
// Not a series, so the row *is* the event: an exclusion would put a
|
|
||||||
// recurrence set on a one-off, an exception would leave an orphan.
|
|
||||||
if (!row.recurs) {
|
|
||||||
deleteEvent(eventId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (row.syncId == null) {
|
if (row.syncId == null) {
|
||||||
// No _sync_id — a local calendar, or a synced event not pushed yet.
|
// No _sync_id — a local calendar, or a synced event not pushed yet.
|
||||||
// A cancelled exception can only attach to its parent through
|
// A cancelled exception can only attach to its parent through
|
||||||
@@ -1733,7 +1515,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
occurrenceMillis = beginMillis,
|
occurrenceMillis = beginMillis,
|
||||||
dtStartMillis = row.dtStartMillis,
|
dtStartMillis = row.dtStartMillis,
|
||||||
rrule = row.rrule,
|
rrule = row.rrule,
|
||||||
rdate = row.rdate,
|
|
||||||
duration = row.duration,
|
duration = row.duration,
|
||||||
timezone = row.timezone,
|
timezone = row.timezone,
|
||||||
allDay = row.allDay,
|
allDay = row.allDay,
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
|||||||
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
|
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||||
return CalendarSource(
|
return CalendarSource(
|
||||||
id = getLong(CalendarProjection.IDX_ID),
|
id = getLong(CalendarProjection.IDX_ID),
|
||||||
// Blank when the provider row has no name: the placeholder is a display
|
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
|
||||||
// string and belongs to the UI, where it can be localised (#329).
|
?: Fallbacks.UNNAMED_CALENDAR,
|
||||||
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME).orEmpty(),
|
|
||||||
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
||||||
accountType = accountType,
|
accountType = accountType,
|
||||||
color = getInt(CalendarProjection.IDX_COLOR),
|
color = getInt(CalendarProjection.IDX_COLOR),
|
||||||
|
|||||||
@@ -88,15 +88,9 @@ interface CalendarRepository {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event (exception row with the
|
* Change a single occurrence of a recurring event (exception row with the
|
||||||
* form's values); returns the exception's `Events._ID`. [original] is what
|
* form's values); returns the exception's `Events._ID`.
|
||||||
* the row holds now, for the case where it turns out not to recur.
|
|
||||||
*/
|
*/
|
||||||
suspend fun updateOccurrence(
|
suspend fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
|
||||||
eventId: Long,
|
|
||||||
beginMillis: Long,
|
|
||||||
original: EventForm,
|
|
||||||
form: EventForm,
|
|
||||||
): Long
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a recurring event from [beginMillis] onwards (series split);
|
* Change a recurring event from [beginMillis] onwards (series split);
|
||||||
|
|||||||
+6
-49
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
@@ -27,7 +26,6 @@ import kotlinx.coroutines.flow.merge
|
|||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -212,60 +210,26 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||||
withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) }
|
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
||||||
|
|
||||||
override suspend fun importEvents(
|
override suspend fun importEvents(
|
||||||
targetCalendarId: Long,
|
targetCalendarId: Long,
|
||||||
events: List<ParsedIcsEvent>,
|
events: List<ParsedIcsEvent>,
|
||||||
): IcsImportSummary = withContext(io) {
|
): IcsImportSummary = withContext(io) {
|
||||||
val existing = dataSource.existingUids(targetCalendarId)
|
val existing = dataSource.existingUids(targetCalendarId)
|
||||||
// Both are per-calendar, not per-event: looking them up once keeps a
|
|
||||||
// thousand-event restore to two extra queries. The palette is the
|
|
||||||
// uncurated one — an imported colour is matched against every key the
|
|
||||||
// account accepts, not the subset the picker shows.
|
|
||||||
val palette = dataSource.publishedEventColors(targetCalendarId)
|
|
||||||
val allDayMinutes = allDayReminderTimeMinutes()
|
|
||||||
var imported = 0
|
var imported = 0
|
||||||
var skipped = 0
|
var skipped = 0
|
||||||
var failed = 0
|
for (event in events) {
|
||||||
var notAttempted = 0
|
|
||||||
for ((index, event) in events.withIndex()) {
|
|
||||||
// A known UID means the event is already in this calendar — skip,
|
// A known UID means the event is already in this calendar — skip,
|
||||||
// keeping a restore idempotent (no overwrite this pass).
|
// keeping a restore idempotent (no overwrite this pass).
|
||||||
if (event.uid != null && event.uid in existing) {
|
if (event.uid != null && event.uid in existing) {
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
} else {
|
||||||
}
|
dataSource.insertImportedEvent(event, targetCalendarId)
|
||||||
try {
|
|
||||||
dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette)
|
|
||||||
imported++
|
imported++
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
// Not this event's fault: the permission or the target calendar is
|
|
||||||
// gone, so every remaining insert would fail the same way.
|
|
||||||
Log.w(TAG, "Import stopped: the calendar is no longer writable", e)
|
|
||||||
// A known UID would have been skipped whatever happened, so it is
|
|
||||||
// no part of what a retry still has to import.
|
|
||||||
val remaining = events.drop(index)
|
|
||||||
val duplicates = remaining.count { it.uid?.let { uid -> uid in existing } == true }
|
|
||||||
skipped += duplicates
|
|
||||||
notAttempted = remaining.size - duplicates
|
|
||||||
break
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// One event the provider won't take must not cost the user the
|
|
||||||
// rest of the file — foreign exports do carry rows it rejects
|
|
||||||
// outright (a malformed RRULE throws straight out of insert).
|
|
||||||
failed++
|
|
||||||
Log.w(TAG, "Skipped an unimportable event", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IcsImportSummary(
|
IcsImportSummary(imported = imported, skippedDuplicate = skipped)
|
||||||
imported = imported,
|
|
||||||
skippedDuplicate = skipped,
|
|
||||||
failed = failed,
|
|
||||||
notAttempted = notAttempted,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||||
@@ -298,12 +262,9 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun updateOccurrence(
|
override suspend fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
original: EventForm,
|
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
): Long = withContext(io) {
|
): Long = withContext(io) {
|
||||||
dataSource.updateOccurrence(
|
dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
|
||||||
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateEventFromOccurrence(
|
override suspend fun updateEventFromOccurrence(
|
||||||
@@ -327,10 +288,6 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
) = withContext(io) {
|
) = withContext(io) {
|
||||||
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "CalendarRepository"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {
|
||||||
|
|||||||
@@ -3,23 +3,14 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
|
||||||
import de.jeanlucmakiola.calendula.domain.nearestTo
|
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toJavaLocalDateTime
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.time.ZonedDateTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.ResolverStyle
|
|
||||||
import java.time.LocalDate as JavaLocalDate
|
|
||||||
import java.time.LocalDateTime as JavaLocalDateTime
|
import java.time.LocalDateTime as JavaLocalDateTime
|
||||||
import java.time.LocalTime as JavaLocalTime
|
|
||||||
|
|
||||||
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||||
internal data class EventWriteTimes(
|
internal data class EventWriteTimes(
|
||||||
@@ -143,21 +134,18 @@ internal fun buildEventInsertValues(
|
|||||||
*
|
*
|
||||||
* Time fields travel together (the provider validates them as a unit):
|
* Time fields travel together (the provider validates them as a unit):
|
||||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||||
* - non-recurring result → DTSTART/DTEND, DURATION, RRULE and EXDATE cleared;
|
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||||
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
||||||
* shift the user applied to the displayed occurrence and is re-resolved in the
|
* shift the user applied to the displayed occurrence and is re-resolved in the
|
||||||
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
||||||
* replaces DTEND, RRULE is written, and the row's exclusions
|
* replaces DTEND, RRULE is written. This keeps past occurrences intact when
|
||||||
* ([seriesExdate], the current EXDATE) travel with the anchor. This keeps past
|
* someone edits a later occurrence's time, and keeps the anchor's time-of-day
|
||||||
* occurrences intact when someone edits a later occurrence's time, and keeps
|
* stable across a DST boundary or a zone change between the two.
|
||||||
* the anchor's time-of-day stable across a DST boundary or a zone change
|
|
||||||
* between the two.
|
|
||||||
*/
|
*/
|
||||||
internal fun buildEventUpdateValues(
|
internal fun buildEventUpdateValues(
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
updated: EventForm,
|
updated: EventForm,
|
||||||
seriesDtStartMillis: Long,
|
seriesDtStartMillis: Long,
|
||||||
seriesExdate: String?,
|
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
): Map<String, Any?> = buildMap {
|
): Map<String, Any?> = buildMap {
|
||||||
if (updated.title.trim() != original.title.trim()) {
|
if (updated.title.trim() != original.title.trim()) {
|
||||||
@@ -197,10 +185,6 @@ internal fun buildEventUpdateValues(
|
|||||||
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
|
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
|
||||||
put(CalendarContract.Events.RRULE, null)
|
put(CalendarContract.Events.RRULE, null)
|
||||||
put(CalendarContract.Events.DURATION, null)
|
put(CalendarContract.Events.DURATION, null)
|
||||||
// The exclusions named occurrences of a series that no longer exists.
|
|
||||||
// Left behind they are dormant rather than harmless: adding a recurrence
|
|
||||||
// back later would punch the old holes into the new one.
|
|
||||||
put(CalendarContract.Events.EXDATE, null)
|
|
||||||
} else {
|
} else {
|
||||||
// Move the series anchor by the *wall-clock* shift the user applied to the
|
// Move the series anchor by the *wall-clock* shift the user applied to the
|
||||||
// displayed occurrence, then re-resolve it in the event's (possibly new)
|
// displayed occurrence, then re-resolve it in the event's (possibly new)
|
||||||
@@ -222,14 +206,6 @@ internal fun buildEventUpdateValues(
|
|||||||
put(CalendarContract.Events.DTEND, null)
|
put(CalendarContract.Events.DTEND, null)
|
||||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||||
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
||||||
// An EXDATE stamp is an absolute instant, so it excludes an occurrence
|
|
||||||
// only while the series keeps generating one at exactly that instant. The
|
|
||||||
// anchor has just moved, so every occurrence regenerates elsewhere and a
|
|
||||||
// stamp left behind matches none of them — the occurrence the user deleted
|
|
||||||
// comes back. Move the stamps the same way the anchor moved.
|
|
||||||
shiftedExdate(seriesExdate, original, updated, zone)
|
|
||||||
?.takeIf { it != seriesExdate }
|
|
||||||
?.let { put(CalendarContract.Events.EXDATE, it) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,21 +243,6 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
putAll(eventColorColumns(form.colorKey, form.color))
|
putAll(eventColorColumns(form.colorKey, form.color))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The form as a **detached occurrence**: the same edited values, with the
|
|
||||||
* series rule dropped so [buildEventInsertValues] writes a standalone one-off
|
|
||||||
* row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times.
|
|
||||||
*
|
|
||||||
* The "edit only this event" shape for a series with **no `_sync_id`**, where an
|
|
||||||
* exception row can't attach to its parent at all (Codeberg #234).
|
|
||||||
*
|
|
||||||
* The exception path gets the rule dropped for free — the provider clears the
|
|
||||||
* RRULE it cloned when an exception carries DTSTART + DURATION
|
|
||||||
* ([buildOccurrenceExceptionValues]). Here nothing is cloned, so it is stripped
|
|
||||||
* by hand; leaving it on would insert a second *series* overlapping the first.
|
|
||||||
*/
|
|
||||||
internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
||||||
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
||||||
@@ -445,336 +406,52 @@ internal fun buildOccurrenceCancelValues(
|
|||||||
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
|
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
|
||||||
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
|
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
|
||||||
*
|
*
|
||||||
* [rdate] travels with [rrule]: a sync adapter may list a series' occurrences
|
|
||||||
* instead of naming a rule, and rewriting the recurrence set without it would
|
|
||||||
* leave the provider nothing but DTSTART to re-expand from — the collapse this
|
|
||||||
* values map exists to avoid.
|
|
||||||
*
|
|
||||||
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
|
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
|
||||||
* of the same occurrence is folded away — [mergedExdate]).
|
* of the same occurrence is folded away). All-day series take the `VALUE=DATE`
|
||||||
|
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`).
|
||||||
*/
|
*/
|
||||||
internal fun buildOccurrenceExdateValues(
|
internal fun buildOccurrenceExdateValues(
|
||||||
existingExdate: String?,
|
existingExdate: String?,
|
||||||
occurrenceMillis: Long,
|
occurrenceMillis: Long,
|
||||||
dtStartMillis: Long,
|
dtStartMillis: Long,
|
||||||
rrule: String?,
|
rrule: String?,
|
||||||
rdate: String?,
|
|
||||||
duration: String?,
|
duration: String?,
|
||||||
timezone: String?,
|
timezone: String?,
|
||||||
allDay: Int,
|
allDay: Int,
|
||||||
): Map<String, Any?> = mapOf(
|
): Map<String, Any?> {
|
||||||
CalendarContract.Events.EXDATE to
|
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
|
||||||
mergedExdate(existingExdate, occurrenceMillis, allDay != 0, timezone),
|
val existing = existingExdate?.split(',')
|
||||||
CalendarContract.Events.DTSTART to dtStartMillis,
|
?.map { it.trim() }
|
||||||
CalendarContract.Events.RRULE to rrule,
|
?.filter { it.isNotEmpty() }
|
||||||
CalendarContract.Events.RDATE to rdate,
|
.orEmpty()
|
||||||
CalendarContract.Events.DURATION to duration,
|
val merged = (existing + stamp).distinct().joinToString(",")
|
||||||
CalendarContract.Events.EVENT_TIMEZONE to timezone,
|
return mapOf(
|
||||||
CalendarContract.Events.ALL_DAY to allDay,
|
CalendarContract.Events.EXDATE to merged,
|
||||||
)
|
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||||
|
CalendarContract.Events.RRULE to rrule,
|
||||||
/**
|
CalendarContract.Events.DURATION to duration,
|
||||||
* [existingExdate] with the occurrence at [occurrenceMillis] excluded as well.
|
CalendarContract.Events.EVENT_TIMEZONE to timezone,
|
||||||
*
|
CalendarContract.Events.ALL_DAY to allDay,
|
||||||
* Existing stamps are canonicalised first — all-day series take the `VALUE=DATE`
|
)
|
||||||
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`) — so
|
|
||||||
* appending to a list a sync adapter wrote in the other accepted form folds the
|
|
||||||
* repeat away instead of leaving the same day excluded twice in two spellings.
|
|
||||||
*
|
|
||||||
* A stamp in no form Calendula reads makes the whole list untouchable: it is kept
|
|
||||||
* exactly as it is, behind the parameter prefix that tells a reader how to
|
|
||||||
* resolve it, and the new exclusion joins it in the list's own form.
|
|
||||||
* Canonicalising the readable stamps around it would leave absolute stamps under
|
|
||||||
* a prefix claiming a zone for all of them — which is how the unreadable one
|
|
||||||
* quietly changes instant.
|
|
||||||
*/
|
|
||||||
private fun mergedExdate(
|
|
||||||
existingExdate: String?,
|
|
||||||
occurrenceMillis: Long,
|
|
||||||
isAllDay: Boolean,
|
|
||||||
timezone: String?,
|
|
||||||
): String {
|
|
||||||
val listZone = exdateListZone(existingExdate, timezone)
|
|
||||||
val stamps = exdateStamps(existingExdate)
|
|
||||||
val canonical = stamps.map { canonicalExdateStamp(it, isAllDay, listZone) }
|
|
||||||
if (canonical.all { it != null }) {
|
|
||||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
|
|
||||||
return (canonical.filterNotNull() + stamp).distinct().joinToString(",")
|
|
||||||
}
|
|
||||||
val prefix = existingExdate?.exdateParameterPrefix().orEmpty()
|
|
||||||
val stamp = if (isAllDay || prefix.isEmpty()) {
|
|
||||||
formatExdateStamp(occurrenceMillis, isAllDay)
|
|
||||||
} else {
|
|
||||||
Instant.ofEpochMilli(occurrenceMillis).atZone(listZone).format(FLOATING_EXDATE)
|
|
||||||
}
|
|
||||||
return prefix + (stamps + stamp).distinct().joinToString(",")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
|
|
||||||
* — i.e. it has already been dropped from the series, deleted or detached.
|
|
||||||
*
|
|
||||||
* Guards the detach path against running twice from a stale screen: the EXDATE
|
|
||||||
* merge folds the repeat away silently and the update still reports one row
|
|
||||||
* changed, so a second save would leave a second standalone copy.
|
|
||||||
*/
|
|
||||||
internal fun exdateContains(
|
|
||||||
existingExdate: String?,
|
|
||||||
occurrenceMillis: Long,
|
|
||||||
isAllDay: Boolean,
|
|
||||||
timezone: String?,
|
|
||||||
): Boolean {
|
|
||||||
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
|
|
||||||
val listZone = exdateListZone(existingExdate, timezone)
|
|
||||||
// Canonicalised rather than compared verbatim: a sync adapter may spell the
|
|
||||||
// same exclusion differently to Calendula, and a guard that misses it lets
|
|
||||||
// the detach run a second time and leave a second standalone copy.
|
|
||||||
return exdateStamps(existingExdate)
|
|
||||||
.any { canonicalExdateStamp(it, isAllDay, listZone) == stamp }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [existingExdate] with every stamp re-timed by the same **wall-clock** shift the
|
|
||||||
* series anchor takes from [original] to [updated] — the exclusions' half of the
|
|
||||||
* anchor move [buildEventUpdateValues] performs, and what carries them onto the
|
|
||||||
* new series when "this and following" splits one.
|
|
||||||
*
|
|
||||||
* A stamp is an absolute instant, so it keeps naming its occurrence only if it
|
|
||||||
* moves exactly as the occurrence does: shifted in wall clock and re-resolved in
|
|
||||||
* the event's (possibly new) zone. A millisecond delta would instead bake in the
|
|
||||||
* offset that happened to apply at the edited occurrence — an hour off for any
|
|
||||||
* exclusion on the far side of a DST boundary. All-day-ness is read from
|
|
||||||
* [original] and written from [updated], so the `VALUE=DATE` and date-time forms
|
|
||||||
* convert into each other when the event switches.
|
|
||||||
*
|
|
||||||
* A zoned list is read in the zone its prefix names ([exdateListZone]). Null
|
|
||||||
* when there is nothing to carry: no stamps, or a stamp in a form Calendula
|
|
||||||
* cannot read at all. Those are left exactly as they are rather than guessed at —
|
|
||||||
* a stale stamp excludes nothing, but a mangled one could exclude the wrong
|
|
||||||
* occurrence.
|
|
||||||
*/
|
|
||||||
internal fun shiftedExdate(
|
|
||||||
existingExdate: String?,
|
|
||||||
original: EventForm,
|
|
||||||
updated: EventForm,
|
|
||||||
zone: ZoneId,
|
|
||||||
): String? {
|
|
||||||
val stamps = exdateStamps(existingExdate)
|
|
||||||
if (stamps.isEmpty()) return null
|
|
||||||
val fromZone = original.writeZone(zone)
|
|
||||||
val toZone = updated.writeZone(zone)
|
|
||||||
val listZone = exdateListZone(existingExdate, fromZone.id)
|
|
||||||
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
|
|
||||||
return stamps
|
|
||||||
.map { stamp ->
|
|
||||||
val local = parseExdateStamp(stamp, original.isAllDay, fromZone, listZone)
|
|
||||||
?: return null
|
|
||||||
formatExdateStamp(local.plus(wallClockShift), updated.isAllDay, toZone)
|
|
||||||
}
|
|
||||||
.distinct()
|
|
||||||
.joinToString(",")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The stamps of [existingExdate] naming occurrences after [beginMillis] — the ones
|
|
||||||
* that belong to the *new* series once "this and following" splits a recurring
|
|
||||||
* event there. The parent keeps the full list; its stamps past the split point are
|
|
||||||
* simply inert once it stops generating those occurrences.
|
|
||||||
*
|
|
||||||
* The occurrence at [beginMillis] itself is never carried. It is the one the user
|
|
||||||
* is editing, so it exists by definition, and honouring a stale exclusion for it
|
|
||||||
* would swallow the edit whole.
|
|
||||||
*
|
|
||||||
* The stamps that qualify come back canonicalised, since the zone prefix that
|
|
||||||
* told the reader how to resolve them does not survive the filter.
|
|
||||||
*
|
|
||||||
* Null when nothing qualifies, or when a stamp can't be read (see [shiftedExdate]).
|
|
||||||
*/
|
|
||||||
internal fun exdateAfter(
|
|
||||||
existingExdate: String?,
|
|
||||||
beginMillis: Long,
|
|
||||||
isAllDay: Boolean,
|
|
||||||
timezone: String?,
|
|
||||||
): String? {
|
|
||||||
val stamps = exdateStamps(existingExdate)
|
|
||||||
if (stamps.isEmpty()) return null
|
|
||||||
val listZone = exdateListZone(existingExdate, timezone)
|
|
||||||
return stamps
|
|
||||||
.mapNotNull { stamp ->
|
|
||||||
val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC, listZone) ?: return null
|
|
||||||
utc.takeIf { it.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis }
|
|
||||||
}
|
|
||||||
.map { formatExdateStamp(it, isAllDay, ZoneOffset.UTC) }
|
|
||||||
.takeIf { it.isNotEmpty() }
|
|
||||||
?.joinToString(",")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The individual stamps of an EXDATE column value; it is a comma-separated list,
|
|
||||||
* optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
|
|
||||||
* a zoned one ([exdateListZone] reads that half).
|
|
||||||
*/
|
|
||||||
private fun exdateStamps(exdate: String?): List<String> = exdate
|
|
||||||
?.withoutExdateParameters()
|
|
||||||
?.split(',')
|
|
||||||
?.map { it.trim() }
|
|
||||||
?.filter { it.isNotEmpty() }
|
|
||||||
.orEmpty()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An EXDATE column value with the parameter section its stamps sit behind taken
|
|
||||||
* off.
|
|
||||||
*/
|
|
||||||
internal fun String.withoutExdateParameters(): String = substring(exdateParameterPrefix().length)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The parameter section an EXDATE list's stamps sit behind, its `;` included, or
|
|
||||||
* empty when it has none. Only a `;` before the first stamp separator is one —
|
|
||||||
* anything later belongs to a stamp, and cutting there would drop every stamp in
|
|
||||||
* front of it.
|
|
||||||
*/
|
|
||||||
private fun String.exdateParameterPrefix(): String {
|
|
||||||
val semicolon = indexOf(';')
|
|
||||||
val comma = indexOf(',')
|
|
||||||
return if (semicolon >= 0 && (comma < 0 || semicolon < comma)) {
|
|
||||||
substring(0, semicolon + 1)
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The zone the floating stamps of [exdate] are wall clock in: the one its
|
|
||||||
* parameter prefix names, or [fallback] — the event's own `EVENT_TIMEZONE` —
|
|
||||||
* when there is no prefix, or the device's tz database doesn't know the name it
|
|
||||||
* uses (Exchange spells zones its own way). The series' own zone is the right
|
|
||||||
* guess, since a zoned exclusion list is written in it. UTC last, for a row that
|
|
||||||
* carries no zone either. Stamps already written as UTC instants ignore all this.
|
|
||||||
*
|
|
||||||
* Both spellings of the prefix are read. AOSP's `RecurrenceSet` stores the TZID
|
|
||||||
* parameter's *value* alone (`Europe/Berlin;<stamps>`), and so does DAVx5; the
|
|
||||||
* iCalendar `TZID=<zone>;` some writers keep is the other one. Only matching the
|
|
||||||
* second would miss every list the provider itself wrote.
|
|
||||||
*/
|
|
||||||
internal fun exdateListZone(exdate: String?, fallback: String?): ZoneId =
|
|
||||||
exdate?.exdateZoneName()?.toZoneIdOrNull()
|
|
||||||
?: fallback?.toZoneIdOrNull()
|
|
||||||
?: ZoneOffset.UTC
|
|
||||||
|
|
||||||
/** The zone id an EXDATE list's parameter prefix names, with or without `TZID=`. */
|
|
||||||
private fun String.exdateZoneName(): String {
|
|
||||||
val prefix = exdateParameterPrefix().dropLast(1)
|
|
||||||
return if (prefix.startsWith(TZID_PREFIX, ignoreCase = true)) {
|
|
||||||
prefix.drop(TZID_PREFIX.length)
|
|
||||||
} else {
|
|
||||||
prefix
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.toZoneIdOrNull(): ZoneId? = runCatching { ZoneId.of(this) }.getOrNull()
|
|
||||||
|
|
||||||
/** The optional `TZID=` a zoned EXDATE list's zone id may sit behind. */
|
|
||||||
private const val TZID_PREFIX = "TZID="
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
||||||
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
||||||
* reads off the UTC calendar day.
|
* reads off the UTC calendar day.
|
||||||
*/
|
*/
|
||||||
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String =
|
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
|
||||||
formatExdateStamp(
|
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
|
||||||
local = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC).toLocalDateTime(),
|
return if (isAllDay) {
|
||||||
isAllDay = isAllDay,
|
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
|
||||||
zone = ZoneOffset.UTC,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One EXDATE entry for the occurrence whose wall clock in [zone] is [local]. An
|
|
||||||
* all-day entry keeps only the date (its time-of-day is the anchor's, not the
|
|
||||||
* occurrence's); a timed one is resolved in [zone] and written as a UTC instant.
|
|
||||||
*/
|
|
||||||
private fun formatExdateStamp(local: JavaLocalDateTime, isAllDay: Boolean, zone: ZoneId): String =
|
|
||||||
if (isAllDay) {
|
|
||||||
local.toLocalDate().format(ALL_DAY_EXDATE)
|
|
||||||
} else {
|
} else {
|
||||||
local.atZone(zone).withZoneSameInstant(ZoneOffset.UTC).format(TIMED_EXDATE)
|
"%04d%02d%02dT%02d%02d%02dZ".format(
|
||||||
|
utc.year, utc.monthValue, utc.dayOfMonth,
|
||||||
|
utc.hour, utc.minute, utc.second,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* [stamp] as the wall clock it names in [zone] — the inverse of
|
|
||||||
* [formatExdateStamp]. Null for anything Calendula cannot read, so a caller can
|
|
||||||
* tell "not ours, leave it alone" from a value it may safely re-time.
|
|
||||||
*
|
|
||||||
* A timed exclusion is read both as the UTC instant Calendula writes and as a
|
|
||||||
* floating stamp, which [listZone] then resolves. An all-day one is read both
|
|
||||||
* bare (`yyyyMMdd`, what Calendula writes) and padded to a midnight stamp, in
|
|
||||||
* either spelling: sync adapters are inconsistent about which they store — the
|
|
||||||
* same forms [exportExDates] reshapes on the way out — and refusing one would
|
|
||||||
* drop every exclusion of a foreign all-day series the moment it is re-timed.
|
|
||||||
*/
|
|
||||||
private fun parseExdateStamp(
|
|
||||||
stamp: String,
|
|
||||||
isAllDay: Boolean,
|
|
||||||
zone: ZoneId,
|
|
||||||
listZone: ZoneId,
|
|
||||||
): JavaLocalDateTime? =
|
|
||||||
if (isAllDay) {
|
|
||||||
parseAllDayExdate(stamp)?.atStartOfDay()
|
|
||||||
} else {
|
|
||||||
parseTimedExdate(stamp, listZone)?.withZoneSameInstant(zone)?.toLocalDateTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A timed EXDATE stamp as the instant it names: the trailing `Z` form resolved in
|
|
||||||
* UTC, or the floating one resolved in [listZone].
|
|
||||||
*/
|
|
||||||
private fun parseTimedExdate(stamp: String, listZone: ZoneId): ZonedDateTime? =
|
|
||||||
runCatching { JavaLocalDateTime.parse(stamp, TIMED_EXDATE).atZone(ZoneOffset.UTC) }.getOrNull()
|
|
||||||
?: runCatching {
|
|
||||||
JavaLocalDateTime.parse(stamp, FLOATING_EXDATE).atZone(listZone)
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The calendar day an all-day EXDATE stamp names, bare or padded to a midnight
|
|
||||||
* stamp. A padded stamp naming any other time of day is refused rather than
|
|
||||||
* rounded: it is not a form an all-day series is written in, so it could be
|
|
||||||
* naming something else entirely. Midnight is midnight in any zone, so the
|
|
||||||
* padding's own spelling — trailing `Z` or floating — makes no difference.
|
|
||||||
*/
|
|
||||||
private fun parseAllDayExdate(stamp: String): JavaLocalDate? {
|
|
||||||
runCatching { JavaLocalDate.parse(stamp, ALL_DAY_EXDATE) }.getOrNull()?.let { return it }
|
|
||||||
val padded = parsePaddedExdate(stamp) ?: return null
|
|
||||||
return padded.toLocalDate().takeIf { padded.toLocalTime() == JavaLocalTime.MIDNIGHT }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An `uuuuMMddTHHmmss` stamp, with or without the trailing `Z`, as its own wall clock. */
|
|
||||||
private fun parsePaddedExdate(stamp: String): JavaLocalDateTime? =
|
|
||||||
runCatching { JavaLocalDateTime.parse(stamp, TIMED_EXDATE) }.getOrNull()
|
|
||||||
?: runCatching { JavaLocalDateTime.parse(stamp, FLOATING_EXDATE) }.getOrNull()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [stamp] in the form Calendula writes, so two spellings of the same exclusion
|
|
||||||
* compare equal. Null for a stamp in no form it reads.
|
|
||||||
*/
|
|
||||||
internal fun canonicalExdateStamp(
|
|
||||||
stamp: String,
|
|
||||||
isAllDay: Boolean,
|
|
||||||
listZone: ZoneId,
|
|
||||||
): String? = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC, listZone)
|
|
||||||
?.let { formatExdateStamp(it, isAllDay, ZoneOffset.UTC) }
|
|
||||||
|
|
||||||
/** `VALUE=DATE` EXDATE form, for an all-day series. */
|
|
||||||
private val ALL_DAY_EXDATE: DateTimeFormatter =
|
|
||||||
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
|
|
||||||
|
|
||||||
/** UTC date-time EXDATE form, for a timed series. */
|
|
||||||
private val TIMED_EXDATE: DateTimeFormatter =
|
|
||||||
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss'Z'").withResolverStyle(ResolverStyle.STRICT)
|
|
||||||
|
|
||||||
/** The same form without the `Z`, wall clock in the list's zone rather than UTC. */
|
|
||||||
private val FLOATING_EXDATE: DateTimeFormatter =
|
|
||||||
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss").withResolverStyle(ResolverStyle.STRICT)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||||
@@ -822,73 +499,3 @@ internal fun AccessLevel.toProviderValue(): Int = when (this) {
|
|||||||
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
|
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
|
||||||
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
|
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The `Events` row for a `.ics` event being imported into [calendarId].
|
|
||||||
*
|
|
||||||
* A recurring row carries RRULE + DURATION (and any EXDATE) with no DTEND; a
|
|
||||||
* one-off carries DTEND. All-day rows live at UTC midnights, exactly as the
|
|
||||||
* parser hands them over.
|
|
||||||
*
|
|
||||||
* [palette] is the target account's published event colours. A calendar that
|
|
||||||
* publishes one rejects a raw `EVENT_COLOR`, so an imported colour — which is
|
|
||||||
* an arbitrary ARGB from a foreign app — is snapped to the nearest key it does
|
|
||||||
* accept; accounts with no palette take the raw value (see [eventColorColumns]).
|
|
||||||
*/
|
|
||||||
internal fun buildImportedEventValues(
|
|
||||||
event: ParsedIcsEvent,
|
|
||||||
calendarId: Long,
|
|
||||||
uid: String,
|
|
||||||
palette: List<EventColorOption>,
|
|
||||||
): Map<String, Any?> = buildMap {
|
|
||||||
val startMillis = event.start.toEpochMilliseconds()
|
|
||||||
val endMillis = event.end.toEpochMilliseconds()
|
|
||||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
|
||||||
put(CalendarContract.Events.UID_2445, uid)
|
|
||||||
put(CalendarContract.Events.TITLE, event.summary.trim())
|
|
||||||
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
|
|
||||||
put(CalendarContract.Events.DTSTART, startMillis)
|
|
||||||
if (event.recurrenceRule == null) {
|
|
||||||
put(CalendarContract.Events.DTEND, endMillis)
|
|
||||||
} else {
|
|
||||||
put(CalendarContract.Events.RRULE, event.recurrenceRule)
|
|
||||||
put(
|
|
||||||
CalendarContract.Events.DURATION,
|
|
||||||
importDuration(startMillis, endMillis, event.isAllDay),
|
|
||||||
)
|
|
||||||
event.exDates.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put(CalendarContract.Events.EXDATE, it.joinToString(",")) }
|
|
||||||
}
|
|
||||||
// All-day rows live at UTC midnights (the file already encodes them so);
|
|
||||||
// timed rows keep the event's own zone.
|
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
|
|
||||||
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
|
|
||||||
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
|
|
||||||
event.location?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
|
||||||
event.description?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
|
||||||
event.color?.let { color ->
|
|
||||||
val key = palette.nearestTo(color)?.key
|
|
||||||
putAll(eventColorColumns(colorKey = key, color = if (key == null) color else null))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider `DURATION` for an imported recurring row: whole days for an all-day
|
|
||||||
* series, seconds otherwise. An all-day series is never shorter than a day —
|
|
||||||
* the provider expands a zero-length one into no instances at all, so the
|
|
||||||
* series would vanish (which is what a literal read of the Fossify family's
|
|
||||||
* inclusive DTEND used to produce; see "Importing foreign .ics" in
|
|
||||||
* docs/ARCHITECTURE.md).
|
|
||||||
*/
|
|
||||||
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
|
||||||
val span = (endMillis - startMillis).coerceAtLeast(0)
|
|
||||||
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
|
||||||
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
|
|
||||||
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
|
|
||||||
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
|
|||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.ZoneOffset
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
||||||
@@ -15,23 +12,14 @@ import java.time.ZoneOffset
|
|||||||
* [calendarName] the display name of its calendar (emitted as
|
* [calendarName] the display name of its calendar (emitted as
|
||||||
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
|
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
|
||||||
* MapColumnReader.
|
* MapColumnReader.
|
||||||
*
|
|
||||||
* An all-day row's raw offset has the firing time of day encoded into it and is
|
|
||||||
* normally *negative* (see [AllDayReminderEncoding]); written out literally it
|
|
||||||
* would be dropped as a trigger that fires after the event, losing the reminder.
|
|
||||||
* [allDayReminderTimeMinutes] and [zone] decode it back to the whole-day lead
|
|
||||||
* time the file should carry, which an import re-encodes for its own device.
|
|
||||||
*/
|
*/
|
||||||
internal fun ColumnReader.toIcsEvent(
|
internal fun ColumnReader.toIcsEvent(
|
||||||
reminderMinutes: List<Int>,
|
reminderMinutes: List<Int>,
|
||||||
calendarName: String?,
|
calendarName: String?,
|
||||||
allDayReminderTimeMinutes: Int,
|
|
||||||
zone: ZoneId = ZoneId.systemDefault(),
|
|
||||||
): IcsEvent {
|
): IcsEvent {
|
||||||
val eventId = getLong(EventExportProjection.IDX_ID)
|
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||||
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
|
val dtStart = getLong(EventExportProjection.IDX_DTSTART)
|
||||||
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
||||||
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
|
|
||||||
|
|
||||||
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
||||||
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
||||||
@@ -52,77 +40,16 @@ internal fun ColumnReader.toIcsEvent(
|
|||||||
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
|
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
|
||||||
start = dtStart.toKotlinInstantFromEpochMillis(),
|
start = dtStart.toKotlinInstantFromEpochMillis(),
|
||||||
end = end.toKotlinInstantFromEpochMillis(),
|
end = end.toKotlinInstantFromEpochMillis(),
|
||||||
isAllDay = isAllDay,
|
isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
|
||||||
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
|
||||||
?: "UTC",
|
?: "UTC",
|
||||||
recurrenceRule = rrule,
|
recurrenceRule = rrule,
|
||||||
exDates = if (rrule == null) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
exportExDates(
|
|
||||||
getString(EventExportProjection.IDX_EXDATE),
|
|
||||||
isAllDay,
|
|
||||||
getString(EventExportProjection.IDX_EVENT_TIMEZONE),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
location = getString(EventExportProjection.IDX_LOCATION),
|
location = getString(EventExportProjection.IDX_LOCATION),
|
||||||
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
||||||
reminderMinutes = if (isAllDay) {
|
reminderMinutes = reminderMinutes,
|
||||||
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
|
|
||||||
reminderMinutes.map {
|
|
||||||
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reminderMinutes
|
|
||||||
},
|
|
||||||
status = status,
|
status = status,
|
||||||
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
||||||
calendarName = calendarName,
|
calendarName = calendarName,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The row's `EXDATE` as the writer wants it: one stamp per entry, empties
|
|
||||||
* dropped.
|
|
||||||
*
|
|
||||||
* An all-day exclusion names a calendar day, and sync adapters are inconsistent
|
|
||||||
* about whether they write it as a bare `yyyyMMdd` or pad it to a midnight
|
|
||||||
* stamp; the time part is dropped so the exported value is the day either way.
|
|
||||||
* Calendula's own writes ([buildOccurrenceExdateValues]) are already bare.
|
|
||||||
*
|
|
||||||
* A zoned exclusion arrives in AOSP's `RecurrenceSet` form, `<zone>;<stamps>`,
|
|
||||||
* where the prefix names the zone the whole list is wall clock in. [IcsWriter]
|
|
||||||
* emits one bare `EXDATE:` line, and a parameter belongs before that colon, not
|
|
||||||
* inside the value — passed on as-is it would make the line unparseable. The
|
|
||||||
* prefix is therefore taken off and its stamps resolved to UTC instants
|
|
||||||
* ([canonicalExdateStamp], the same reading the write path does), the form every
|
|
||||||
* reader accepts.
|
|
||||||
*
|
|
||||||
* Which zone that resolution uses is [exdateListZone]'s call, the same one the
|
|
||||||
* write path makes: the `TZID=` when this device's tz database knows the name,
|
|
||||||
* else the row's own [eventTimezone]. A stamp is never emitted floating just
|
|
||||||
* because the prefix named a zone we couldn't place — against a `DTSTART;TZID=…`
|
|
||||||
* line every reader would resolve it in its own zone and the exclusion would stop
|
|
||||||
* matching its occurrence.
|
|
||||||
*/
|
|
||||||
internal fun exportExDates(
|
|
||||||
exdate: String?,
|
|
||||||
isAllDay: Boolean,
|
|
||||||
eventTimezone: String? = null,
|
|
||||||
): List<String> {
|
|
||||||
if (exdate.isNullOrBlank()) return emptyList()
|
|
||||||
val zone = exdateListZone(exdate, eventTimezone)
|
|
||||||
return exdate
|
|
||||||
.withoutExdateParameters()
|
|
||||||
.split(',')
|
|
||||||
.map { it.trim() }
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.map { stamp ->
|
|
||||||
if (isAllDay) {
|
|
||||||
stamp.substringBefore('T')
|
|
||||||
} else {
|
|
||||||
canonicalExdateStamp(stamp, isAllDay = false, listZone = zone) ?: stamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.distinct()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
|||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventResponse
|
|
||||||
|
|
||||||
private const val TAG = "InstanceMapper"
|
private const val TAG = "InstanceMapper"
|
||||||
|
|
||||||
@@ -21,9 +20,8 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left blank when the event has no title: the placeholder is a display
|
val rawTitle = getString(InstanceProjection.IDX_TITLE)
|
||||||
// string, so it belongs to the UI layer where it can be localised (#321).
|
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||||
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
|
|
||||||
|
|
||||||
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
|
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
|
||||||
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
||||||
@@ -41,17 +39,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
|||||||
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
|
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
|
||||||
color = color,
|
color = color,
|
||||||
location = getString(InstanceProjection.IDX_LOCATION),
|
location = getString(InstanceProjection.IDX_LOCATION),
|
||||||
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
|
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe"
|
|
||||||
* counts as going, and everything that is not an open or refused invitation —
|
|
||||||
* your own events included — falls through to [EventResponse.Going].
|
|
||||||
*/
|
|
||||||
internal fun mapEventResponse(raw: Int): EventResponse = when (raw) {
|
|
||||||
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> EventResponse.Declined
|
|
||||||
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> EventResponse.Invited
|
|
||||||
else -> EventResponse.Going
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -121,12 +121,10 @@ internal object EventDetailProjection {
|
|||||||
/**
|
/**
|
||||||
* Master/one-off Events rows for a whole-calendar backup. Unlike
|
* Master/one-off Events rows for a whole-calendar backup. Unlike
|
||||||
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
|
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
|
||||||
* backups) and `DURATION` (recurring rows carry it instead of DTEND), plus
|
* backups) and `DURATION` (recurring rows carry it instead of DTEND). Modified-
|
||||||
* `EXDATE` so a series exports the occurrences deleted from it. Modified-
|
|
||||||
* occurrence and cancelled-exception rows are filtered out by the query
|
* occurrence and cancelled-exception rows are filtered out by the query
|
||||||
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides aren't exported — a
|
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't
|
||||||
* documented v1 limit (import skips them too). EXDATE is unaffected by that
|
* exported yet — a documented v1 limit (import skips them too).
|
||||||
* filter: it lives on the master row, not on an exception of its own.
|
|
||||||
*/
|
*/
|
||||||
internal object EventExportProjection {
|
internal object EventExportProjection {
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
@@ -139,7 +137,6 @@ internal object EventExportProjection {
|
|||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.RRULE,
|
CalendarContract.Events.RRULE,
|
||||||
CalendarContract.Events.EXDATE,
|
|
||||||
CalendarContract.Events.EVENT_LOCATION,
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
CalendarContract.Events.DESCRIPTION,
|
CalendarContract.Events.DESCRIPTION,
|
||||||
CalendarContract.Events.STATUS,
|
CalendarContract.Events.STATUS,
|
||||||
@@ -156,12 +153,11 @@ internal object EventExportProjection {
|
|||||||
const val IDX_ALL_DAY = 6
|
const val IDX_ALL_DAY = 6
|
||||||
const val IDX_EVENT_TIMEZONE = 7
|
const val IDX_EVENT_TIMEZONE = 7
|
||||||
const val IDX_RRULE = 8
|
const val IDX_RRULE = 8
|
||||||
const val IDX_EXDATE = 9
|
const val IDX_LOCATION = 9
|
||||||
const val IDX_LOCATION = 10
|
const val IDX_DESCRIPTION = 10
|
||||||
const val IDX_DESCRIPTION = 11
|
const val IDX_STATUS = 11
|
||||||
const val IDX_STATUS = 12
|
const val IDX_AVAILABILITY = 12
|
||||||
const val IDX_AVAILABILITY = 13
|
const val IDX_CALENDAR_ID = 13
|
||||||
const val IDX_CALENDAR_ID = 14
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -312,3 +308,8 @@ internal object ReminderProjection {
|
|||||||
const val IDX_MINUTES = 0
|
const val IDX_MINUTES = 0
|
||||||
const val IDX_METHOD = 1
|
const val IDX_METHOD = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal object Fallbacks {
|
||||||
|
const val UNNAMED_CALENDAR = "(Unbenannter Kalender)"
|
||||||
|
const val UNTITLED_EVENT = "(Ohne Titel)"
|
||||||
|
}
|
||||||
|
|||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
|
||||||
|
|
||||||
import android.provider.CalendarContract
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Fire at whatever lead time this account defaults to" — a sentinel in the
|
|
||||||
* `Reminders.MINUTES` column, not an offset, so nothing may do arithmetic on it.
|
|
||||||
* `CalendarContract` never exposes what an account resolves it to, so Calendula
|
|
||||||
* neither fires such a row ([withoutProviderDefaults]) nor decodes it
|
|
||||||
* ([fromProviderAllDayMinutes]); the detail screen names it instead.
|
|
||||||
*
|
|
||||||
* It is the **only** negative that means this. A real all-day offset is often
|
|
||||||
* negative too — the firing time of day is encoded into it — so a `< 0` test
|
|
||||||
* would silence reminders that do fire.
|
|
||||||
*/
|
|
||||||
internal const val MINUTES_DEFAULT = CalendarContract.Reminders.MINUTES_DEFAULT
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw provider offsets with the account-default rows dropped, so the pure
|
|
||||||
* planner ([de.jeanlucmakiola.calendula.domain.reminders.planReminders]) only
|
|
||||||
* ever sees offsets it can resolve.
|
|
||||||
*/
|
|
||||||
internal fun Map<Long, List<Int>>.withoutProviderDefaults(): Map<Long, List<Int>> =
|
|
||||||
mapValues { (_, minutes) -> minutes.filter { it != MINUTES_DEFAULT } }
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
@@ -21,7 +22,8 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
|||||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||||
}.coerceAtLeast(dtStart)
|
}.coerceAtLeast(dtStart)
|
||||||
|
|
||||||
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
|
val rawTitle = getString(SearchProjection.IDX_TITLE)
|
||||||
|
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
|
||||||
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
||||||
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||||
} else {
|
} else {
|
||||||
@@ -41,6 +43,7 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
|||||||
location = getString(SearchProjection.IDX_LOCATION),
|
location = getString(SearchProjection.IDX_LOCATION),
|
||||||
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||||
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
|
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,15 +212,14 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the week/day timeline seats each hour in its own cell (v2.11, a
|
* Whether the week/day timeline draws a faint separator line at each hour
|
||||||
* separator line until v2.20). Defaults to OFF — the historical flat column;
|
* (v2.11). Defaults to OFF — the historical clean look; users opt in.
|
||||||
* users opt in. The stored key keeps its original name.
|
|
||||||
*/
|
*/
|
||||||
val showHourGrid: Flow<Boolean> = store.data.map { prefs ->
|
val showHourLines: Flow<Boolean> = store.data.map { prefs ->
|
||||||
prefs[SHOW_HOUR_LINES_KEY] ?: false
|
prefs[SHOW_HOUR_LINES_KEY] ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setShowHourGrid(enabled: Boolean) {
|
suspend fun setShowHourLines(enabled: Boolean) {
|
||||||
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,24 +585,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the wizard's calendar step has been answered (or skipped). */
|
|
||||||
val onboardingCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
|
|
||||||
prefs[ONBOARDING_CALENDARS_KEY] ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setOnboardingCalendarsDone(done: Boolean = true) {
|
|
||||||
store.edit { it[ONBOARDING_CALENDARS_KEY] = done }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the wizard's hidden-calendars step has been answered (or skipped). */
|
|
||||||
val onboardingHiddenCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
|
|
||||||
prefs[ONBOARDING_HIDDEN_KEY] ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setOnboardingHiddenCalendarsDone(done: Boolean = true) {
|
|
||||||
store.edit { it[ONBOARDING_HIDDEN_KEY] = done }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the wizard's backup step has been answered (or skipped). */
|
/** Whether the wizard's backup step has been answered (or skipped). */
|
||||||
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
||||||
@@ -995,10 +976,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
||||||
internal val ONBOARDING_CALENDARS_KEY =
|
|
||||||
booleanPreferencesKey("onboarding_calendars_done")
|
|
||||||
internal val ONBOARDING_HIDDEN_KEY =
|
|
||||||
booleanPreferencesKey("onboarding_hidden_calendars_done")
|
|
||||||
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
||||||
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
||||||
internal val ONBOARDING_MONTH_STYLE_KEY =
|
internal val ONBOARDING_MONTH_STYLE_KEY =
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import android.util.Log
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
@@ -91,8 +90,7 @@ class ReminderScanner @Inject constructor(
|
|||||||
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
|
||||||
val planned = planReminders(
|
val planned = planReminders(
|
||||||
instances = occurrences,
|
instances = occurrences,
|
||||||
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId })
|
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
|
||||||
.withoutProviderDefaults(),
|
|
||||||
zone = ZoneId.systemDefault(),
|
zone = ZoneId.systemDefault(),
|
||||||
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
|
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,19 +46,3 @@ fun hasSystemHiddenCalendars(
|
|||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
pendingDisabledIds: Set<Long>,
|
pendingDisabledIds: Set<Long>,
|
||||||
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
|
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
|
||||||
|
|
||||||
/**
|
|
||||||
* The failure a calendar list implies, or null when there is something to show.
|
|
||||||
* Distinguishes "the device has no calendars" from "every one of them is
|
|
||||||
* switched off" (#239) — both leave the grid empty, but only the second is
|
|
||||||
* fixed from Settings → Calendars.
|
|
||||||
*
|
|
||||||
* The filter sheet's own hidden set is deliberately not folded in: emptying it
|
|
||||||
* is a deliberate act taken moments earlier, in a sheet that is still one tap
|
|
||||||
* away.
|
|
||||||
*/
|
|
||||||
fun calendarListFailure(calendars: List<CalendarSource>): FailureReason? = when {
|
|
||||||
calendars.isEmpty() -> FailureReason.NoCalendarsConfigured
|
|
||||||
calendars.none { it.isVisibleInSystem } -> FailureReason.AllCalendarsHidden
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -110,17 +110,3 @@ private const val CURATION_TRIGGER_SIZE = 36
|
|||||||
* apart that two swatches never read as the same colour in the grid.
|
* apart that two swatches never read as the same colour in the grid.
|
||||||
*/
|
*/
|
||||||
private const val MIN_DISTANCE = 0.025f
|
private const val MIN_DISTANCE = 0.025f
|
||||||
|
|
||||||
/**
|
|
||||||
* The palette entry closest to [argb], or null when the account publishes no
|
|
||||||
* palette (its calendars then take a raw `EVENT_COLOR` instead).
|
|
||||||
*
|
|
||||||
* Used to land a colour that came from outside the account — an imported `.ics`
|
|
||||||
* carries an arbitrary ARGB, while a palette calendar only accepts one of its
|
|
||||||
* own keys. Distance is measured in Oklab, so the match is the one that looks
|
|
||||||
* closest rather than the one that is closest in RGB coordinates.
|
|
||||||
*/
|
|
||||||
fun List<EventColorOption>.nearestTo(argb: Int): EventColorOption? {
|
|
||||||
val target = oklchOf(argb)
|
|
||||||
return minByOrNull { oklchOf(it.argb).distanceTo(target) }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -68,35 +68,14 @@ data class EventInstance(
|
|||||||
*/
|
*/
|
||||||
val isRecurring: Boolean = false,
|
val isRecurring: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* This device user's own answer to the invitation, as far as the grids care
|
* This device user answered "no" to the invitation
|
||||||
* (#180, #230).
|
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is
|
||||||
|
* still an appointment someone expects an answer about — but every surface
|
||||||
|
* strikes it through, and it plans no reminders (#180).
|
||||||
*/
|
*/
|
||||||
val response: EventResponse = EventResponse.Going,
|
val isDeclined: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* How this device user stands towards an event's invitation
|
|
||||||
* (`Events.SELF_ATTENDEE_STATUS`), reduced to the three cases the calendar
|
|
||||||
* surfaces draw differently (#230).
|
|
||||||
*/
|
|
||||||
enum class EventResponse {
|
|
||||||
/** Your own event, or one you accepted — including a tentative "maybe". */
|
|
||||||
Going,
|
|
||||||
|
|
||||||
/** Invited, no answer given yet: drawn as an outline so it reads as still open. */
|
|
||||||
Invited,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* You answered "no". The event stays on the calendar — it is still an
|
|
||||||
* appointment someone expects an answer about — but every surface strikes it
|
|
||||||
* through, and it plans no reminders (#180).
|
|
||||||
*/
|
|
||||||
Declined,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shorthand for the declined case, which most surfaces test on its own (#180). */
|
|
||||||
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this event has finished relative to [now] — its end is at or before
|
* Whether this event has finished relative to [now] — its end is at or before
|
||||||
* the current instant. An in-progress event (already started but not yet ended)
|
* the current instant. An in-progress event (already started but not yet ended)
|
||||||
@@ -252,14 +231,6 @@ enum class RecurringWriteScope {
|
|||||||
enum class FailureReason {
|
enum class FailureReason {
|
||||||
PermissionRevoked,
|
PermissionRevoked,
|
||||||
NoCalendarsConfigured,
|
NoCalendarsConfigured,
|
||||||
AllCalendarsHidden,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calendars exist and are switched on, but none can receive an event: every
|
|
||||||
* one is read-only, app-managed, or not synced to this device. Distinct from
|
|
||||||
* [AllCalendarsHidden], which a visibility switch fixes.
|
|
||||||
*/
|
|
||||||
NoImportTarget,
|
|
||||||
ProviderUnavailable,
|
ProviderUnavailable,
|
||||||
EventNotFound,
|
EventNotFound,
|
||||||
Unknown,
|
Unknown,
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain.ics
|
|
||||||
|
|
||||||
/** Opaque alpha, forced onto any colour that arrives without one. */
|
|
||||||
private const val OPAQUE_ALPHA = 0xFF000000.toInt()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a colour value out of a foreign `.ics` to an opaque ARGB int.
|
|
||||||
*
|
|
||||||
* Three encodings are in the wild and all appear in the files Calendula is
|
|
||||||
* asked to import:
|
|
||||||
* - a CSS3 colour name, which is what RFC 7986 `COLOR` is defined as
|
|
||||||
* (`COLOR:tomato`);
|
|
||||||
* - a raw signed Android colour int, which the Simple Calendar / Fossify
|
|
||||||
* family writes in its `X-…-COLOR` extensions (`X-FOSSIFY-EVENT-COLOR:-2818048`);
|
|
||||||
* - `#rrggbb` / `#aarrggbb` hex, used by assorted other producers.
|
|
||||||
*
|
|
||||||
* Anything else — including the literal `null` Fossify emits when its calendar
|
|
||||||
* lookup misses — resolves to null.
|
|
||||||
*/
|
|
||||||
fun parseIcsColorValue(raw: String?): Int? {
|
|
||||||
val value = raw?.trim().orEmpty()
|
|
||||||
if (value.isEmpty()) return null
|
|
||||||
|
|
||||||
if (value.startsWith("#")) {
|
|
||||||
val hex = value.substring(1)
|
|
||||||
val parsed = hex.toLongOrNull(16)?.toInt() ?: return null
|
|
||||||
return when (hex.length) {
|
|
||||||
6 -> parsed or OPAQUE_ALPHA
|
|
||||||
8 -> parsed
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
value.toIntOrNull()?.let { return if (it ushr 24 == 0) it or OPAQUE_ALPHA else it }
|
|
||||||
return cssColors[value.lowercase()]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The CSS3 extended colour keywords, the value space of RFC 7986 `COLOR`. */
|
|
||||||
private val cssColors: Map<String, Int> by lazy {
|
|
||||||
CSS3_TABLE.split(' ').associate { entry ->
|
|
||||||
val name = entry.substringBefore('=')
|
|
||||||
name to (entry.substringAfter('=').toInt(16) or OPAQUE_ALPHA)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val CSS3_TABLE =
|
|
||||||
"aliceblue=f0f8ff antiquewhite=faebd7 aqua=00ffff aquamarine=7fffd4 " +
|
|
||||||
"azure=f0ffff beige=f5f5dc bisque=ffe4c4 black=000000 blanchedalmond=ffebcd " +
|
|
||||||
"blue=0000ff blueviolet=8a2be2 brown=a52a2a burlywood=deb887 cadetblue=5f9ea0 " +
|
|
||||||
"chartreuse=7fff00 chocolate=d2691e coral=ff7f50 cornflowerblue=6495ed " +
|
|
||||||
"cornsilk=fff8dc crimson=dc143c cyan=00ffff darkblue=00008b darkcyan=008b8b " +
|
|
||||||
"darkgoldenrod=b8860b darkgray=a9a9a9 darkgreen=006400 darkgrey=a9a9a9 " +
|
|
||||||
"darkkhaki=bdb76b darkmagenta=8b008b darkolivegreen=556b2f darkorange=ff8c00 " +
|
|
||||||
"darkorchid=9932cc darkred=8b0000 darksalmon=e9967a darkseagreen=8fbc8f " +
|
|
||||||
"darkslateblue=483d8b darkslategray=2f4f4f darkslategrey=2f4f4f darkturquoise=00ced1 " +
|
|
||||||
"darkviolet=9400d3 deeppink=ff1493 deepskyblue=00bfff dimgray=696969 " +
|
|
||||||
"dimgrey=696969 dodgerblue=1e90ff firebrick=b22222 floralwhite=fffaf0 " +
|
|
||||||
"forestgreen=228b22 fuchsia=ff00ff gainsboro=dcdcdc ghostwhite=f8f8ff " +
|
|
||||||
"gold=ffd700 goldenrod=daa520 gray=808080 green=008000 greenyellow=adff2f " +
|
|
||||||
"grey=808080 honeydew=f0fff0 hotpink=ff69b4 indianred=cd5c5c indigo=4b0082 " +
|
|
||||||
"ivory=fffff0 khaki=f0e68c lavender=e6e6fa lavenderblush=fff0f5 lawngreen=7cfc00 " +
|
|
||||||
"lemonchiffon=fffacd lightblue=add8e6 lightcoral=f08080 lightcyan=e0ffff " +
|
|
||||||
"lightgoldenrodyellow=fafad2 lightgray=d3d3d3 lightgreen=90ee90 lightgrey=d3d3d3 " +
|
|
||||||
"lightpink=ffb6c1 lightsalmon=ffa07a lightseagreen=20b2aa lightskyblue=87cefa " +
|
|
||||||
"lightslategray=778899 lightslategrey=778899 lightsteelblue=b0c4de " +
|
|
||||||
"lightyellow=ffffe0 lime=00ff00 limegreen=32cd32 linen=faf0e6 magenta=ff00ff " +
|
|
||||||
"maroon=800000 mediumaquamarine=66cdaa mediumblue=0000cd mediumorchid=ba55d3 " +
|
|
||||||
"mediumpurple=9370db mediumseagreen=3cb371 mediumslateblue=7b68ee " +
|
|
||||||
"mediumspringgreen=00fa9a mediumturquoise=48d1cc mediumvioletred=c71585 " +
|
|
||||||
"midnightblue=191970 mintcream=f5fffa mistyrose=ffe4e1 moccasin=ffe4b5 " +
|
|
||||||
"navajowhite=ffdead navy=000080 oldlace=fdf5e6 olive=808000 olivedrab=6b8e23 " +
|
|
||||||
"orange=ffa500 orangered=ff4500 orchid=da70d6 palegoldenrod=eee8aa " +
|
|
||||||
"palegreen=98fb98 paleturquoise=afeeee palevioletred=db7093 papayawhip=ffefd5 " +
|
|
||||||
"peachpuff=ffdab9 peru=cd853f pink=ffc0cb plum=dda0dd powderblue=b0e0e6 " +
|
|
||||||
"purple=800080 rebeccapurple=663399 red=ff0000 rosybrown=bc8f8f royalblue=4169e1 " +
|
|
||||||
"saddlebrown=8b4513 salmon=fa8072 sandybrown=f4a460 seagreen=2e8b57 " +
|
|
||||||
"seashell=fff5ee sienna=a0522d silver=c0c0c0 skyblue=87ceeb slateblue=6a5acd " +
|
|
||||||
"slategray=708090 slategrey=708090 snow=fffafa springgreen=00ff7f " +
|
|
||||||
"steelblue=4682b4 tan=d2b48c teal=008080 thistle=d8bfd8 tomato=ff6347 " +
|
|
||||||
"turquoise=40e0d0 violet=ee82ee wheat=f5deb3 white=ffffff whitesmoke=f5f5f5 " +
|
|
||||||
"yellow=ffff00 yellowgreen=9acd32"
|
|
||||||
@@ -22,12 +22,6 @@ data class IcsEvent(
|
|||||||
val zoneId: String,
|
val zoneId: String,
|
||||||
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
|
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
|
||||||
val recurrenceRule: String? = null,
|
val recurrenceRule: String? = null,
|
||||||
/**
|
|
||||||
* Occurrences deleted from the series, in the provider's `EXDATE` shape —
|
|
||||||
* `yyyyMMdd` for an all-day series, a UTC `yyyyMMddTHHmmssZ` stamp
|
|
||||||
* otherwise. Empty for a one-off event.
|
|
||||||
*/
|
|
||||||
val exDates: List<String> = emptyList(),
|
|
||||||
val location: String? = null,
|
val location: String? = null,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
/** Reminder lead times in minutes before start (raw provider offsets). */
|
/** Reminder lead times in minutes before start (raw provider offsets). */
|
||||||
|
|||||||
@@ -7,25 +7,14 @@ import kotlinx.datetime.LocalDateTime
|
|||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.number
|
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.math.ceil
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
|
|
||||||
private const val DAY_MILLIS = 86_400_000L
|
|
||||||
private const val MINUTES_PER_DAY = 1_440
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
|
||||||
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
* but [uid] is nullable (an incoming event may carry none; the insert layer
|
||||||
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
|
||||||
* how the writer encoded them.
|
* how the writer encoded them.
|
||||||
*
|
|
||||||
* [reminderMinutes] holds raw lead times as the file's `VALARM`s expressed
|
|
||||||
* them — minutes *before* start, so a negative entry means "after start".
|
|
||||||
* [semanticReminderMinutes] turns those into the lead times Calendula models.
|
|
||||||
*/
|
*/
|
||||||
data class ParsedIcsEvent(
|
data class ParsedIcsEvent(
|
||||||
val uid: String?,
|
val uid: String?,
|
||||||
@@ -41,45 +30,8 @@ data class ParsedIcsEvent(
|
|||||||
val status: EventStatus = EventStatus.Confirmed,
|
val status: EventStatus = EventStatus.Confirmed,
|
||||||
val availability: Availability = Availability.Busy,
|
val availability: Availability = Availability.Busy,
|
||||||
val calendarName: String? = null,
|
val calendarName: String? = null,
|
||||||
/** Opaque ARGB the source file gave this event, if any (see [parseIcsColorValue]). */
|
|
||||||
val color: Int? = null,
|
|
||||||
/** Excluded occurrences, already in the provider's `EXDATE` shape. */
|
|
||||||
val exDates: List<String> = emptyList(),
|
|
||||||
/** True when this came from a `VTODO` rather than a `VEVENT`. */
|
|
||||||
val isTask: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* [reminderMinutes] mapped onto Calendula's model: a lead time in minutes before
|
|
||||||
* the event, never negative.
|
|
||||||
*
|
|
||||||
* For a timed event the raw lead time already is that, and a trigger that fires
|
|
||||||
* *after* the start models no lead time at all — it is dropped rather than
|
|
||||||
* clamped, which would invent a reminder at the start the file never asked for
|
|
||||||
* (a legal `TRIGGER:PT30M` follow-up alarm is the case). All-day reminders are
|
|
||||||
* whole days before the event, fired at the user's configured time of day (see
|
|
||||||
* `AllDayReminderEncoding`), so a raw offset counts the whole days its trigger
|
|
||||||
* lands *earlier than* the event's UTC midnight — i.e. it rounds **up**.
|
|
||||||
*
|
|
||||||
* Rounding to the nearest day instead would lose a day for every producer whose
|
|
||||||
* all-day notifications fire after noon: a file's raw offset is
|
|
||||||
* `days × 1440 − timeOfDay`, so "1 day before at 18:00" arrives as
|
|
||||||
* `TRIGGER:-PT6H` (raw 360) and would read as "on the day". Rounding up also
|
|
||||||
* lands Fossify's encoding correctly — it writes "on the day at 09:00" as a
|
|
||||||
* *positive* `TRIGGER:P0DT9H0M0S`, nine hours after the UTC midnight, which
|
|
||||||
* ceils to zero days before.
|
|
||||||
*/
|
|
||||||
fun ParsedIcsEvent.semanticReminderMinutes(): List<Int> = reminderMinutes
|
|
||||||
.mapNotNull { raw ->
|
|
||||||
if (isAllDay) {
|
|
||||||
ceil(raw.toDouble() / MINUTES_PER_DAY).toInt().coerceAtLeast(0) * MINUTES_PER_DAY
|
|
||||||
} else {
|
|
||||||
raw.takeIf { it >= 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.distinct()
|
|
||||||
.sorted()
|
|
||||||
|
|
||||||
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
/** Things the parser dropped rather than failing — surfaced in the import report. */
|
||||||
enum class IcsParseWarning {
|
enum class IcsParseWarning {
|
||||||
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
|
||||||
@@ -93,12 +45,6 @@ enum class IcsParseWarning {
|
|||||||
|
|
||||||
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */
|
||||||
UnknownTimezone,
|
UnknownTimezone,
|
||||||
|
|
||||||
/** `VTODO` components were imported as events — Calendula has no task model. */
|
|
||||||
TasksImportedAsEvents,
|
|
||||||
|
|
||||||
/** A malformed `RRULE` was repaired or dropped (see [sanitizeRrule]). */
|
|
||||||
RecurrenceRuleRepaired,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class IcsParseResult(
|
data class IcsParseResult(
|
||||||
@@ -106,28 +52,8 @@ data class IcsParseResult(
|
|||||||
val warnings: Set<IcsParseWarning>,
|
val warnings: Set<IcsParseWarning>,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/** Outcome of a bulk `.ics` import into one calendar. */
|
||||||
* Outcome of a bulk `.ics` import into one calendar. [failed] counts events the
|
data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
|
||||||
* provider rejected — the import continues past them, so a single unusable
|
|
||||||
* event can't cost the user the rest of the file.
|
|
||||||
*
|
|
||||||
* [notAttempted] counts what a retry still has to import, because the permission
|
|
||||||
* or the target calendar went away mid-file and every remaining insert would have
|
|
||||||
* failed the same way. Events past that point whose UID is already in the
|
|
||||||
* calendar count under [skippedDuplicate] instead — they were never going to be
|
|
||||||
* added either way. It travels in the summary rather than as an exception so the
|
|
||||||
* counts of what *did* land survive: an import is only idempotent for events that
|
|
||||||
* carry a UID, so a retry told "nothing happened" would duplicate the rest.
|
|
||||||
*/
|
|
||||||
data class IcsImportSummary(
|
|
||||||
val imported: Int,
|
|
||||||
val skippedDuplicate: Int,
|
|
||||||
val failed: Int = 0,
|
|
||||||
val notAttempted: Int = 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The non-standard property most exporters name the whole calendar with. */
|
|
||||||
private const val CALENDAR_NAME_PROPERTY = "X-WR-CALNAME"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
|
||||||
@@ -136,9 +62,6 @@ private const val CALENDAR_NAME_PROPERTY = "X-WR-CALNAME"
|
|||||||
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
|
||||||
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
|
||||||
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
* resolved against the OS tz database instead ([deviceZone] is the fallback).
|
||||||
*
|
|
||||||
* `VTODO` components are read as all-day/timed events: Calendula models no
|
|
||||||
* tasks, and dropping them would silently lose half of some exports.
|
|
||||||
*/
|
*/
|
||||||
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
|
||||||
|
|
||||||
@@ -146,62 +69,42 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
val lines = unfoldLines(text)
|
val lines = unfoldLines(text)
|
||||||
val events = mutableListOf<ParsedIcsEvent>()
|
val events = mutableListOf<ParsedIcsEvent>()
|
||||||
val warnings = mutableSetOf<IcsParseWarning>()
|
val warnings = mutableSetOf<IcsParseWarning>()
|
||||||
// Scanned up front, not in document order: an X-WR-CALNAME after the
|
var calendarName: String? = null
|
||||||
// first VEVENT still names the calendar. The cheap prefix test first,
|
|
||||||
// so a file without one doesn't pay a full parse to find nothing.
|
|
||||||
val calendarName = lines.asSequence()
|
|
||||||
.filter { it.startsWith(CALENDAR_NAME_PROPERTY, ignoreCase = true) }
|
|
||||||
.mapNotNull(::parseContentLine)
|
|
||||||
.firstOrNull { it.name == CALENDAR_NAME_PROPERTY }
|
|
||||||
?.let { unescapeText(it.value).trim().ifEmpty { null } }
|
|
||||||
|
|
||||||
var i = 0
|
var i = 0
|
||||||
while (i < lines.size) {
|
while (i < lines.size) {
|
||||||
val line = parseContentLine(lines[i])
|
val line = parseContentLine(lines[i])
|
||||||
if (line == null) { i++; continue }
|
if (line == null) { i++; continue }
|
||||||
val component = when {
|
|
||||||
line.isBegin("VEVENT") -> "VEVENT"
|
|
||||||
line.isBegin("VTODO") -> "VTODO"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
when {
|
when {
|
||||||
component != null -> {
|
line.isBegin("VEVENT") -> {
|
||||||
// A component whose END line never arrives ends at the next
|
val end = indexOfEnd(lines, i + 1, "VEVENT")
|
||||||
// one's BEGIN, not at that one's END: reading to the far END
|
parseVevent(lines.subList(i + 1, end), calendarName, warnings)
|
||||||
// would fold two events into one body, where the later
|
?.let(events::add)
|
||||||
// properties overwrite the earlier and one event is lost.
|
i = end + 1
|
||||||
val end = indexOfEnd(lines, i + 1, component)
|
|
||||||
val body = indexOfNextComponent(lines, i + 1, end)
|
|
||||||
parseComponent(
|
|
||||||
body = lines.subList(i + 1, body),
|
|
||||||
fileCalendarName = calendarName,
|
|
||||||
warnings = warnings,
|
|
||||||
isTask = component == "VTODO",
|
|
||||||
)?.let(events::add)
|
|
||||||
i = if (body < end) body else end + 1
|
|
||||||
}
|
}
|
||||||
line.isBegin("VTIMEZONE") -> {
|
line.isBegin("VTIMEZONE") -> {
|
||||||
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
// Skipped wholesale; TZIDs resolve against the OS tz database.
|
||||||
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
|
||||||
}
|
}
|
||||||
|
line.name == "X-WR-CALNAME" -> {
|
||||||
|
calendarName = unescapeText(line.value).trim().ifEmpty { null }
|
||||||
|
i++
|
||||||
|
}
|
||||||
else -> i++
|
else -> i++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (events.any { it.isTask }) warnings.add(IcsParseWarning.TasksImportedAsEvents)
|
|
||||||
return IcsParseResult(events, warnings)
|
return IcsParseResult(events, warnings)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseComponent(
|
private fun parseVevent(
|
||||||
body: List<String>,
|
body: List<String>,
|
||||||
fileCalendarName: String?,
|
fileCalendarName: String?,
|
||||||
warnings: MutableSet<IcsParseWarning>,
|
warnings: MutableSet<IcsParseWarning>,
|
||||||
isTask: Boolean,
|
|
||||||
): ParsedIcsEvent? {
|
): ParsedIcsEvent? {
|
||||||
var uid: String? = null
|
var uid: String? = null
|
||||||
var summary = ""
|
var summary = ""
|
||||||
var dtStart: IcsDateTime? = null
|
var dtStart: IcsDateTime? = null
|
||||||
var dtEnd: IcsDateTime? = null
|
var dtEnd: IcsDateTime? = null
|
||||||
var due: IcsDateTime? = null
|
|
||||||
var duration: String? = null
|
var duration: String? = null
|
||||||
var rrule: String? = null
|
var rrule: String? = null
|
||||||
var location: String? = null
|
var location: String? = null
|
||||||
@@ -209,11 +112,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
var status = EventStatus.Confirmed
|
var status = EventStatus.Confirmed
|
||||||
var availability = Availability.Busy
|
var availability = Availability.Busy
|
||||||
var calendarName = fileCalendarName
|
var calendarName = fileCalendarName
|
||||||
var category: String? = null
|
|
||||||
var eventColor: Int? = null
|
|
||||||
var calendarColor: Int? = null
|
|
||||||
val reminders = mutableListOf<Int>()
|
val reminders = mutableListOf<Int>()
|
||||||
val exDateLines = mutableListOf<IcsContentLine>()
|
|
||||||
var skipAsOverride = false
|
var skipAsOverride = false
|
||||||
|
|
||||||
var i = 0
|
var i = 0
|
||||||
@@ -230,11 +129,9 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
"UID" -> uid = line.value.trim().ifEmpty { null }
|
"UID" -> uid = line.value.trim().ifEmpty { null }
|
||||||
"SUMMARY" -> summary = unescapeText(line.value)
|
"SUMMARY" -> summary = unescapeText(line.value)
|
||||||
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
|
||||||
"DUE" -> if (isTask) due = parseIcsDateTime(line, warnings)
|
|
||||||
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
|
||||||
"DURATION" -> duration = line.value.trim()
|
"DURATION" -> duration = line.value.trim()
|
||||||
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
"RRULE" -> rrule = line.value.trim().ifEmpty { null }
|
||||||
"EXDATE" -> exDateLines.add(line)
|
|
||||||
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
|
||||||
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
|
||||||
"STATUS" -> status = mapIcsStatus(line.value)
|
"STATUS" -> status = mapIcsStatus(line.value)
|
||||||
@@ -245,19 +142,6 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
|
||||||
"X-CALENDULA-CALENDAR" ->
|
"X-CALENDULA-CALENDAR" ->
|
||||||
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
|
||||||
// Fossify names the source calendar per event; it never writes
|
|
||||||
// an X-WR-CALNAME, so this is the only calendar label its files
|
|
||||||
// carry. Elsewhere CATEGORIES is a plain tag list, so it only
|
|
||||||
// stands in where nothing named the calendar outright.
|
|
||||||
"CATEGORIES" -> category = singleCategory(line.value) ?: category
|
|
||||||
// Per-event colour, most specific encoding first (see parseIcsColorValue).
|
|
||||||
"X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" ->
|
|
||||||
eventColor = parseIcsColorValue(line.value) ?: eventColor
|
|
||||||
"COLOR" -> eventColor = eventColor ?: parseIcsColorValue(line.value)
|
|
||||||
// The *calendar's* colour. Kept as a fallback so a migration that
|
|
||||||
// folds several source calendars into one keeps them apart visually.
|
|
||||||
"X-FOSSIFY-CATEGORY-COLOR", "X-SMT-CATEGORY-COLOR", "CATEGORY_COLOR" ->
|
|
||||||
calendarColor = parseIcsColorValue(line.value) ?: calendarColor
|
|
||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
@@ -266,125 +150,37 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
|
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// Most producers give a VTODO only a DUE, which is then the moment the
|
|
||||||
// task sits at. One that carries a DTSTART too means the span between
|
|
||||||
// them, so DUE stands in for the end it has no DTEND for. Resolved after
|
|
||||||
// the sweep rather than inside it: property order isn't guaranteed, and
|
|
||||||
// a DUE ahead of the DTSTART would otherwise be taken for the start.
|
|
||||||
if (isTask && due != null) {
|
|
||||||
if (dtStart == null) dtStart = due else if (dtEnd == null) dtEnd = due
|
|
||||||
}
|
|
||||||
val start = dtStart ?: run {
|
val start = dtStart ?: run {
|
||||||
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
warnings.add(IcsParseWarning.EventWithoutStartSkipped)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val recurrence = sanitizeRrule(rrule)
|
val end = dtEnd
|
||||||
if (recurrence.repaired) warnings.add(IcsParseWarning.RecurrenceRuleRepaired)
|
?: duration?.let {
|
||||||
val end = resolveEnd(start, dtEnd, duration)
|
start.copy(
|
||||||
|
instant = Instant.fromEpochMilliseconds(
|
||||||
|
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?: start
|
||||||
return ParsedIcsEvent(
|
return ParsedIcsEvent(
|
||||||
uid = uid,
|
uid = uid,
|
||||||
summary = summary,
|
summary = summary,
|
||||||
start = start.instant,
|
start = start.instant,
|
||||||
end = end,
|
end = end.instant,
|
||||||
isAllDay = start.isAllDay,
|
isAllDay = start.isAllDay,
|
||||||
zoneId = start.zoneId,
|
zoneId = start.zoneId,
|
||||||
recurrenceRule = recurrence.rule,
|
recurrenceRule = rrule,
|
||||||
location = location,
|
location = location,
|
||||||
description = description,
|
description = description,
|
||||||
reminderMinutes = reminders.distinct(),
|
reminderMinutes = reminders.distinct(),
|
||||||
status = status,
|
status = status,
|
||||||
availability = availability,
|
availability = availability,
|
||||||
calendarName = calendarName ?: category,
|
calendarName = calendarName,
|
||||||
color = eventColor ?: calendarColor,
|
|
||||||
exDates = if (recurrence.rule == null) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
normalizeExDates(exDateLines, start)
|
|
||||||
},
|
|
||||||
isTask = isTask,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
|
||||||
* The event's end instant.
|
|
||||||
*
|
|
||||||
* All-day events get two RFC 5545 §3.6.1 rules applied that a literal read
|
|
||||||
* would miss: a `DATE`-valued `DTSTART` with no `DTEND` lasts one day, and —
|
|
||||||
* since the provider expands a zero-length all-day series into no instances
|
|
||||||
* at all — an all-day event may never end at or before it starts.
|
|
||||||
*/
|
|
||||||
private fun resolveEnd(
|
|
||||||
start: IcsDateTime,
|
|
||||||
dtEnd: IcsDateTime?,
|
|
||||||
duration: String?,
|
|
||||||
): Instant {
|
|
||||||
val explicit = dtEnd?.instant ?: duration?.let {
|
|
||||||
Instant.fromEpochMilliseconds(
|
|
||||||
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!start.isAllDay) return explicit ?: start.instant
|
|
||||||
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `EXDATE` values in the shape `Events.EXDATE` wants: `yyyyMMdd` for an
|
|
||||||
* all-day series, a UTC `yyyyMMddTHHmmssZ` stamp otherwise.
|
|
||||||
*
|
|
||||||
* Fossify stores its excluded occurrences as bare day codes and writes them
|
|
||||||
* out that way even for timed series, where the property is then ambiguous.
|
|
||||||
* Such a value is resolved against the series' own time of day, which is
|
|
||||||
* what it meant — its repetitions never move within the day.
|
|
||||||
*
|
|
||||||
* An all-day exclusion names a calendar day, so a zone-qualified DATE-TIME
|
|
||||||
* still means the day it spells out; resolving it to an instant first and
|
|
||||||
* reading *that* back in UTC would roll a midnight east of UTC onto the day
|
|
||||||
* before and exclude an occurrence that doesn't exist.
|
|
||||||
*/
|
|
||||||
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
|
|
||||||
if (lines.isEmpty()) return emptyList()
|
|
||||||
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
|
|
||||||
val startTime = start.instant.toLocalDateTime(startZone).time
|
|
||||||
return lines
|
|
||||||
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
|
|
||||||
.mapNotNull { (line, token) ->
|
|
||||||
when {
|
|
||||||
token.isEmpty() -> null
|
|
||||||
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode)
|
|
||||||
token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp)
|
|
||||||
else -> parseBasicDate(token)
|
|
||||||
?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.distinct()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [startZone] is the series' own zone, which a floating value (no `Z`, no
|
|
||||||
* `TZID`) has to be read in — RFC 5545 ties `EXDATE` to `DTSTART`'s form,
|
|
||||||
* and reading it in the device's zone instead would put the exclusion on an
|
|
||||||
* instant no occurrence has.
|
|
||||||
*/
|
|
||||||
private fun parseExDateTime(
|
|
||||||
token: String,
|
|
||||||
line: IcsContentLine,
|
|
||||||
startZone: TimeZone,
|
|
||||||
): Instant? {
|
|
||||||
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
|
|
||||||
val zone = when {
|
|
||||||
token.endsWith("Z") -> TimeZone.UTC
|
|
||||||
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
|
|
||||||
?: startZone
|
|
||||||
}
|
|
||||||
return ldt.toInstant(zone)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A `VALARM`'s offset from the event start, in minutes *before* it — so a
|
|
||||||
* trigger that fires after the start (which is how the Fossify family
|
|
||||||
* encodes "on the day at 09:00") comes back negative. Null when the trigger
|
|
||||||
* isn't a usable relative offset.
|
|
||||||
*/
|
|
||||||
private fun parseAlarmMinutes(body: List<String>): Int? {
|
private fun parseAlarmMinutes(body: List<String>): Int? {
|
||||||
val trigger = body.asSequence()
|
val trigger = body.asSequence()
|
||||||
.mapNotNull { parseContentLine(it) }
|
.mapNotNull { parseContentLine(it) }
|
||||||
@@ -393,7 +189,8 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
// Absolute (DATE-TIME) triggers can't be expressed as a lead time.
|
||||||
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
|
||||||
val millis = parseRfc2445DurationMillis(trigger.value)
|
val millis = parseRfc2445DurationMillis(trigger.value)
|
||||||
return (-millis / 60_000L).toInt()
|
// Negative = before start (the normal case) → positive lead minutes.
|
||||||
|
return (-millis / 60_000L).toInt().coerceAtLeast(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
|
||||||
@@ -421,52 +218,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
|||||||
fun IcsContentLine.isBegin(component: String) =
|
fun IcsContentLine.isBegin(component: String) =
|
||||||
name == "BEGIN" && value.trim().equals(component, true)
|
name == "BEGIN" && value.trim().equals(component, true)
|
||||||
|
|
||||||
fun Instant.plusDays(days: Int): Instant =
|
|
||||||
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
|
|
||||||
|
|
||||||
fun dayCode(date: LocalDate): String =
|
|
||||||
"%04d%02d%02d".format(date.year, date.month.number, date.day)
|
|
||||||
|
|
||||||
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
|
|
||||||
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single-valued `CATEGORIES`, which is what the Fossify family writes
|
|
||||||
* for the source calendar's name. A multi-item list is a tag list and
|
|
||||||
* names no calendar, so it is ignored.
|
|
||||||
*/
|
|
||||||
fun singleCategory(raw: String): String? {
|
|
||||||
var escaped = false
|
|
||||||
for (c in raw) {
|
|
||||||
when {
|
|
||||||
escaped -> escaped = false
|
|
||||||
c == '\\' -> escaped = true
|
|
||||||
c == ',' -> return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return unescapeText(raw).trim()
|
|
||||||
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||||
/**
|
|
||||||
* Where an unterminated component's body has to stop: the next top-level
|
|
||||||
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
|
|
||||||
* nested `VALARM` is part of the body and is not a boundary.
|
|
||||||
*/
|
|
||||||
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int {
|
|
||||||
var i = from
|
|
||||||
while (i < end) {
|
|
||||||
val line = parseContentLine(lines[i])
|
|
||||||
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
return end
|
|
||||||
}
|
|
||||||
|
|
||||||
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||||
var i = from
|
var i = from
|
||||||
while (i < lines.size) {
|
while (i < lines.size) {
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain.ics
|
|
||||||
|
|
||||||
private val VALID_FREQ = setOf(
|
|
||||||
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
|
|
||||||
)
|
|
||||||
|
|
||||||
private val VALID_WEEKDAYS = setOf("SU", "MO", "TU", "WE", "TH", "FR", "SA")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What `EventRecurrence` accepts in each numeric list part: a value range, plus
|
|
||||||
* whether `0` is legal. Its `parseNumberList` throws on a non-number, on an item
|
|
||||||
* outside the range *and* on a zero in the by-position parts, so checking only
|
|
||||||
* that an item is a number would still hand the provider a rule it rejects.
|
|
||||||
*
|
|
||||||
* `BYSETPOS` is the one part it leaves unbounded; `0` is dropped from it anyway
|
|
||||||
* because RFC 5545 §3.3.10 gives it no meaning.
|
|
||||||
*/
|
|
||||||
private val NUMERIC_LIST_PARTS = mapOf(
|
|
||||||
"BYSECOND" to NumericPart(0..59, zeroAllowed = true),
|
|
||||||
"BYMINUTE" to NumericPart(0..59, zeroAllowed = true),
|
|
||||||
"BYHOUR" to NumericPart(0..23, zeroAllowed = true),
|
|
||||||
"BYMONTHDAY" to NumericPart(-31..31, zeroAllowed = false),
|
|
||||||
"BYYEARDAY" to NumericPart(-366..366, zeroAllowed = false),
|
|
||||||
"BYWEEKNO" to NumericPart(-53..53, zeroAllowed = false),
|
|
||||||
"BYMONTH" to NumericPart(1..12, zeroAllowed = false),
|
|
||||||
"BYSETPOS" to NumericPart(Int.MIN_VALUE..Int.MAX_VALUE, zeroAllowed = false),
|
|
||||||
)
|
|
||||||
|
|
||||||
private class NumericPart(private val range: IntRange, private val zeroAllowed: Boolean) {
|
|
||||||
fun accepts(item: String): Boolean {
|
|
||||||
val n = item.toIntOrNull() ?: return false
|
|
||||||
return n in range && (zeroAllowed || n != 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `UNTIL` in RFC 5545 basic format; anything else fails at expansion time. */
|
|
||||||
private val UNTIL_SHAPE = Regex("""\d{8}(T\d{6}Z?)?""")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A foreign RRULE made safe to hand `CalendarContract`.
|
|
||||||
*
|
|
||||||
* [rule] is what survived, or null when there is nothing left to salvage.
|
|
||||||
* [repaired] is true only when a part was actually dropped — pure normalisation
|
|
||||||
* (case, a stray separator) doesn't count, so a conformant file is never
|
|
||||||
* reported to the user as faulty.
|
|
||||||
*/
|
|
||||||
data class SanitizedRrule(val rule: String?, val repaired: Boolean)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Salvage a foreign RRULE.
|
|
||||||
*
|
|
||||||
* The provider validates every RRULE through `EventRecurrence.parse`, which
|
|
||||||
* **throws** on a malformed rule — and the throw surfaces from `insert`, not
|
|
||||||
* from a later read. One bad rule in an imported file would therefore abort the
|
|
||||||
* write it rides on. Producers do emit such rules: Fossify's exporter writes a
|
|
||||||
* bare `;BYDAY=` whenever a weekly event carries no weekday mask
|
|
||||||
* (`Parser.getByDay` interpolates an empty day string unconditionally), which is
|
|
||||||
* the state every weekly event it imported from elsewhere is left in.
|
|
||||||
*
|
|
||||||
* That parser has a fixed table of parts and throws on any name outside it, so
|
|
||||||
* this is a whitelist: an unrecognised part (RFC 7529 `RSCALE`, a vendor `X-`
|
|
||||||
* extension) and an unusable value are dropped rather than passed on to fail the
|
|
||||||
* whole event. Only a missing or unrecognised `FREQ` — without which there is no
|
|
||||||
* rule at all — gives up entirely.
|
|
||||||
*/
|
|
||||||
fun sanitizeRrule(raw: String?): SanitizedRrule {
|
|
||||||
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
|
|
||||||
if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
|
|
||||||
|
|
||||||
var freqSeen = false
|
|
||||||
var repaired = false
|
|
||||||
val parts = mutableListOf<String>()
|
|
||||||
|
|
||||||
/** The items [valid] accepts, uppercased and comma-joined; null if none survive. */
|
|
||||||
fun keepItems(value: String, valid: (String) -> Boolean): String? {
|
|
||||||
val items = value.split(',').map { it.trim().uppercase() }
|
|
||||||
val kept = items.filter(valid)
|
|
||||||
if (kept.size != items.size) repaired = true
|
|
||||||
return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
|
|
||||||
}
|
|
||||||
|
|
||||||
for (part in rule.split(';')) {
|
|
||||||
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
|
|
||||||
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
|
|
||||||
// being dropped, so it falls through and marks the rule repaired.
|
|
||||||
if (part.isBlank()) continue
|
|
||||||
val key = part.substringBefore('=', "").trim().uppercase()
|
|
||||||
val value = if ('=' in part) part.substringAfter('=').trim() else ""
|
|
||||||
val kept = when {
|
|
||||||
key.isEmpty() || value.isEmpty() -> null
|
|
||||||
key == "FREQ" ->
|
|
||||||
value.uppercase().takeIf { it in VALID_FREQ }
|
|
||||||
?.also { freqSeen = true }
|
|
||||||
?.let { "FREQ=$it" }
|
|
||||||
// A non-positive or non-numeric INTERVAL/COUNT is rejected by the
|
|
||||||
// provider, and a COUNT of zero would expand to no occurrences.
|
|
||||||
key == "INTERVAL" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "INTERVAL=$it" }
|
|
||||||
key == "COUNT" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "COUNT=$it" }
|
|
||||||
key == "UNTIL" ->
|
|
||||||
value.uppercase().takeIf { UNTIL_SHAPE.matches(it) }?.let { "UNTIL=$it" }
|
|
||||||
key == "WKST" ->
|
|
||||||
value.uppercase().takeIf { it in VALID_WEEKDAYS }?.let { "WKST=$it" }
|
|
||||||
key == "BYDAY" -> keepItems(value, ::isWeekdayItem)?.let { "BYDAY=$it" }
|
|
||||||
key in NUMERIC_LIST_PARTS ->
|
|
||||||
keepItems(value, NUMERIC_LIST_PARTS.getValue(key)::accepts)?.let { "$key=$it" }
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (kept == null) repaired = true else parts += kept
|
|
||||||
}
|
|
||||||
// `EventRecurrence.parse` ends with *two* global checks, not one: a missing
|
|
||||||
// FREQ throws, and so does an UNTIL that arrives together with a COUNT. A
|
|
||||||
// rule carrying both is malformed per RFC 5545 §3.3.10 but real producers
|
|
||||||
// emit it, and left alone it would throw straight out of `insert` — the very
|
|
||||||
// failure this function exists to prevent. UNTIL is kept: it bounds the
|
|
||||||
// series at an absolute date, so a stale COUNT can't over-generate past it.
|
|
||||||
if (parts.any { it.startsWith("UNTIL=") }) {
|
|
||||||
if (parts.removeAll { it.startsWith("COUNT=") }) repaired = true
|
|
||||||
}
|
|
||||||
return if (freqSeen) {
|
|
||||||
SanitizedRrule(rule = parts.joinToString(";"), repaired = repaired)
|
|
||||||
} else {
|
|
||||||
SanitizedRrule(rule = null, repaired = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A `BYDAY` item: a two-letter weekday, optionally preceded by an ordinal. */
|
|
||||||
private fun isWeekdayItem(item: String): Boolean {
|
|
||||||
if (item.length < 2) return false
|
|
||||||
val ordinal = item.dropLast(2)
|
|
||||||
return item.takeLast(2) in VALID_WEEKDAYS &&
|
|
||||||
(ordinal.isEmpty() || ordinal.toIntOrNull() != null)
|
|
||||||
}
|
|
||||||
@@ -46,7 +46,6 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
|||||||
appendTimes(event)
|
appendTimes(event)
|
||||||
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
||||||
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
|
?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
|
||||||
appendExDates(event)
|
|
||||||
event.location?.takeIf { it.isNotBlank() }
|
event.location?.takeIf { it.isNotBlank() }
|
||||||
?.let { add("LOCATION:${escapeText(it)}") }
|
?.let { add("LOCATION:${escapeText(it)}") }
|
||||||
event.description?.takeIf { it.isNotBlank() }
|
event.description?.takeIf { it.isNotBlank() }
|
||||||
@@ -84,20 +83,6 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The series' deleted occurrences. RFC 5545 ties `EXDATE`'s value type to
|
|
||||||
* `DTSTART`'s, so an all-day series needs the explicit `VALUE=DATE` — without
|
|
||||||
* it the bare day codes read as an invalid DATE-TIME. Only written for a
|
|
||||||
* recurring event: an exclusion names an occurrence, and a one-off has none.
|
|
||||||
*/
|
|
||||||
private fun MutableList<String>.appendExDates(event: IcsEvent) {
|
|
||||||
if (event.recurrenceRule.isNullOrBlank()) return
|
|
||||||
val stamps = event.exDates.filter { it.isNotBlank() }.distinct()
|
|
||||||
if (stamps.isEmpty()) return
|
|
||||||
val prefix = if (event.isAllDay) "EXDATE;VALUE=DATE:" else "EXDATE:"
|
|
||||||
add(prefix + stamps.joinToString(","))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
|
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
|
||||||
add("BEGIN:VALARM")
|
add("BEGIN:VALARM")
|
||||||
add("ACTION:DISPLAY")
|
add("ACTION:DISPLAY")
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
* calendar, exactly like a fresh create — the user confirms the target and
|
* calendar, exactly like a fresh create — the user confirms the target and
|
||||||
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
|
||||||
* handling (provider all-day times are UTC midnights with an exclusive end).
|
* handling (provider all-day times are UTC midnights with an exclusive end).
|
||||||
*
|
|
||||||
* [ParsedIcsEvent.color] is deliberately not carried over: picking the calendar
|
|
||||||
* is the first thing this form asks for, and that clears any colour (a raw ARGB
|
|
||||||
* is invalid on a calendar whose account publishes a palette). Bulk import,
|
|
||||||
* where the target is known up front, does keep it.
|
|
||||||
*/
|
*/
|
||||||
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||||
val (start, end) = if (isAllDay) {
|
val (start, end) = if (isAllDay) {
|
||||||
@@ -36,7 +31,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
|||||||
end = end,
|
end = end,
|
||||||
location = location.orEmpty(),
|
location = location.orEmpty(),
|
||||||
description = description.orEmpty(),
|
description = description.orEmpty(),
|
||||||
reminders = semanticReminderMinutes(),
|
reminders = reminderMinutes.distinct().sorted(),
|
||||||
availability = availability,
|
availability = availability,
|
||||||
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -63,12 +63,6 @@ private const val MINUTES_PER_DAY = 1_440
|
|||||||
* for *which day* it means ([allDayLeadDays]) and take the hour from
|
* for *which day* it means ([allDayLeadDays]) and take the hour from
|
||||||
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
|
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
|
||||||
* Duplicate offsets in [minutesByEvent] collapse.
|
* Duplicate offsets in [minutesByEvent] collapse.
|
||||||
*
|
|
||||||
* Every offset given is planned. The provider's "use the account default"
|
|
||||||
* sentinel names a lead time no reader can resolve, and is translated out on the
|
|
||||||
* way in
|
|
||||||
* ([de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults]) — taken
|
|
||||||
* as an offset it would arm an alarm a minute after the event began.
|
|
||||||
*/
|
*/
|
||||||
fun planReminders(
|
fun planReminders(
|
||||||
instances: List<ReminderEventInstance>,
|
instances: List<ReminderEventInstance>,
|
||||||
@@ -76,19 +70,17 @@ fun planReminders(
|
|||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
allDayTimeMinutes: Int,
|
allDayTimeMinutes: Int,
|
||||||
): List<PlannedReminder> = instances.flatMap { instance ->
|
): List<PlannedReminder> = instances.flatMap { instance ->
|
||||||
minutesByEvent[instance.eventId].orEmpty()
|
minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
|
||||||
.distinct()
|
PlannedReminder(
|
||||||
.map { minutes ->
|
instance = instance,
|
||||||
PlannedReminder(
|
minutes = minutes,
|
||||||
instance = instance,
|
alarmMillis = if (instance.isAllDay) {
|
||||||
minutes = minutes,
|
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
|
||||||
alarmMillis = if (instance.isAllDay) {
|
} else {
|
||||||
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
|
instance.beginMillis - minutes * MILLIS_PER_MINUTE
|
||||||
} else {
|
},
|
||||||
instance.beginMillis - minutes * MILLIS_PER_MINUTE
|
)
|
||||||
},
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
|
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
@@ -197,21 +196,10 @@ fun CalendarHost(
|
|||||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||||
// keeps routing a single event straight into the prefilled create form.
|
// keeps routing a single event straight into the prefilled create form.
|
||||||
var importForceMany by remember { mutableStateOf(false) }
|
var importForceMany by remember { mutableStateOf(false) }
|
||||||
// Where a restore came from, so closing the import puts it back: the import
|
|
||||||
// overlays are declared under Backup & restore and the manager, so starting
|
|
||||||
// one has to close them — without this, finishing a restore drops you on the
|
|
||||||
// calendar and the next file means walking in through Settings again.
|
|
||||||
var backupAfterImport by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var calendarsAfterImport by rememberSaveable { mutableStateOf(false) }
|
|
||||||
// One import run. The import VM lives in the Activity's store, so this is
|
|
||||||
// what tells it a *re-import of the same file* is new work and not the run
|
|
||||||
// it already finished; a rotation keeps the number and keeps the result.
|
|
||||||
var importSession by rememberSaveable { mutableStateOf(0) }
|
|
||||||
LaunchedEffect(requestedImportUri) {
|
LaunchedEffect(requestedImportUri) {
|
||||||
if (requestedImportUri != null) {
|
if (requestedImportUri != null) {
|
||||||
importUri = requestedImportUri
|
importUri = requestedImportUri
|
||||||
importForceMany = false
|
importForceMany = false
|
||||||
importSession++
|
|
||||||
onImportConsumed()
|
onImportConsumed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,10 +319,9 @@ fun CalendarHost(
|
|||||||
EventMoveScope(
|
EventMoveScope(
|
||||||
movableCalendarIds = movableCalendarIds,
|
movableCalendarIds = movableCalendarIds,
|
||||||
dragEnabled = dragToReschedule,
|
dragEnabled = dragToReschedule,
|
||||||
move = { reschedule.move(it) },
|
move = reschedule::move,
|
||||||
inFlight = reschedule.inFlight,
|
inFlight = reschedule.inFlight,
|
||||||
undoStarted = reschedule.undoStarted,
|
undoStarted = reschedule.undoStarted,
|
||||||
abandoned = reschedule.abandoned,
|
|
||||||
edit = onEditEvent,
|
edit = onEditEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -356,12 +343,7 @@ fun CalendarHost(
|
|||||||
// navigation, so it fades through rather than sliding — paging *within* a
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||||
val viewSwitch = fadeThrough()
|
val viewSwitch = fadeThrough()
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(LocalEventMove provides moveScope) {
|
||||||
LocalEventMove provides moveScope,
|
|
||||||
// The failure state's way out when every calendar is switched off
|
|
||||||
// (#239); nothing else in the calendar surfaces navigates here.
|
|
||||||
LocalManageCalendars provides { showCalendars = true },
|
|
||||||
) {
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = view,
|
targetState = view,
|
||||||
transitionSpec = { viewSwitch },
|
transitionSpec = { viewSwitch },
|
||||||
@@ -521,28 +503,11 @@ fun CalendarHost(
|
|||||||
importUri?.let { uri ->
|
importUri?.let { uri ->
|
||||||
ImportScreen(
|
ImportScreen(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
session = importSession,
|
|
||||||
forceMany = importForceMany,
|
forceMany = importForceMany,
|
||||||
onClose = {
|
onClose = { importUri = null },
|
||||||
importUri = null
|
onManageCalendars = { showCalendars = true },
|
||||||
// Back to the surface the restore started from, ready for
|
|
||||||
// the next file.
|
|
||||||
showCalendars = calendarsAfterImport
|
|
||||||
showBackup = backupAfterImport
|
|
||||||
calendarsAfterImport = false
|
|
||||||
backupAfterImport = false
|
|
||||||
},
|
|
||||||
onManageCalendars = {
|
|
||||||
// The manager is where this leads, so it must not be
|
|
||||||
// reopened underneath on close.
|
|
||||||
calendarsAfterImport = false
|
|
||||||
backupAfterImport = false
|
|
||||||
showCalendars = true
|
|
||||||
},
|
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
calendarsAfterImport = false
|
|
||||||
backupAfterImport = false
|
|
||||||
importFormSource = ImportSource.File
|
importFormSource = ImportSource.File
|
||||||
importForm = form
|
importForm = form
|
||||||
},
|
},
|
||||||
@@ -578,33 +543,18 @@ fun CalendarHost(
|
|||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
// Settings can open this screen without the manager underneath, so
|
BackupScreen(
|
||||||
// its failure states' "Manage calendars" way out has to open the
|
onBack = { showBackup = false },
|
||||||
// manager rather than just pop — popping alone lands on Settings
|
// Restore runs the normal .ics import, and both this screen and
|
||||||
// (#304). Coming from the manager the flag is already set, so this
|
// the manager that can have opened it are declared above the
|
||||||
// is the same pop-back there.
|
// import overlays — so both have to step aside.
|
||||||
CompositionLocalProvider(
|
onImport = {
|
||||||
LocalManageCalendars provides {
|
importUri = it
|
||||||
showCalendars = true
|
importForceMany = true
|
||||||
showBackup = false
|
showBackup = false
|
||||||
|
showCalendars = false
|
||||||
},
|
},
|
||||||
) {
|
)
|
||||||
BackupScreen(
|
|
||||||
onBack = { showBackup = false },
|
|
||||||
// Restore runs the normal .ics import, and both this screen
|
|
||||||
// and the manager that can have opened it are declared above
|
|
||||||
// the import overlays — so both have to step aside.
|
|
||||||
onImport = {
|
|
||||||
importUri = it
|
|
||||||
importForceMany = true
|
|
||||||
importSession++
|
|
||||||
backupAfterImport = true
|
|
||||||
calendarsAfterImport = showCalendars
|
|
||||||
showBackup = false
|
|
||||||
showCalendars = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Coffee
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
@@ -32,10 +30,9 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -43,9 +40,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -54,8 +51,6 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
@@ -100,6 +95,7 @@ fun AgendaScreen(
|
|||||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showRangePicker by remember { mutableStateOf(false) }
|
var showRangePicker by remember { mutableStateOf(false) }
|
||||||
@@ -110,27 +106,6 @@ fun AgendaScreen(
|
|||||||
}
|
}
|
||||||
val successState = state as? AgendaUiState.Success
|
val successState = state as? AgendaUiState.Success
|
||||||
|
|
||||||
// The agenda window always starts at the anchor, so moving through it is a
|
|
||||||
// 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 = {
|
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
@@ -155,24 +130,23 @@ fun AgendaScreen(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
AgendaTopBar(
|
AgendaTopBar(
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = viewModel::goToToday,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
CalendarFabColumn(
|
CalendarFabColumn(
|
||||||
todayVisible = (!isOnToday || (scrolledAway && successState != null)) &&
|
todayVisible = !isOnToday && !todayInToolbar,
|
||||||
!todayInToolbar,
|
|
||||||
todayText = stringResource(R.string.agenda_today_action),
|
todayText = stringResource(R.string.agenda_today_action),
|
||||||
onToday = jumpToToday,
|
onToday = viewModel::goToToday,
|
||||||
onCreate = { onCreateEvent(anchor, null) },
|
onCreate = { onCreateEvent(anchor, null) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -185,24 +159,13 @@ fun AgendaScreen(
|
|||||||
// One bar at the top: the "showing …" header on the left and the
|
// One bar at the top: the "showing …" header on the left and the
|
||||||
// session range switcher on the right (one settings toggle).
|
// session range switcher on the right (one settings toggle).
|
||||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||||
// end lines the selector up with whatever ends the top bar:
|
|
||||||
// the view switcher's background, or — once #150 hides it —
|
|
||||||
// the search icon's glyph.
|
|
||||||
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
|
|
||||||
AppBarSpacing.Inset
|
|
||||||
} else {
|
|
||||||
AppBarSpacing.IconTrailingInset
|
|
||||||
}
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
// end aligns the selector's right edge with the top-bar view
|
||||||
|
// switcher (its 8.dp margin + the app bar's 4.dp inset).
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(
|
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||||
start = RANGE_BAR_TEXT_INSET,
|
|
||||||
end = selectorEnd,
|
|
||||||
top = 8.dp,
|
|
||||||
bottom = 8.dp,
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
AgendaRangeBanner(
|
AgendaRangeBanner(
|
||||||
range = s.range,
|
range = s.range,
|
||||||
@@ -221,7 +184,6 @@ fun AgendaScreen(
|
|||||||
state = state,
|
state = state,
|
||||||
pastDisplay = pastDisplay,
|
pastDisplay = pastDisplay,
|
||||||
showToday = showToday,
|
showToday = showToday,
|
||||||
listState = listState,
|
|
||||||
onRetry = viewModel::goToToday,
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
@@ -283,9 +245,6 @@ private fun AgendaRangePill(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Optical start inset for the range bar's text, set against the title above it. */
|
|
||||||
private val RANGE_BAR_TEXT_INSET = 28.dp
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A header naming the concrete window currently shown under a "showing …" label,
|
* A header naming the concrete window currently shown under a "showing …" label,
|
||||||
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
||||||
@@ -325,7 +284,6 @@ internal fun AgendaContent(
|
|||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
listState: LazyListState = rememberLazyListState(),
|
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
AgendaUiState.Loading -> Box(modifier)
|
AgendaUiState.Loading -> Box(modifier)
|
||||||
@@ -354,16 +312,12 @@ internal fun AgendaContent(
|
|||||||
enabled = showToday && state.anchor == state.today,
|
enabled = showToday && state.anchor == state.today,
|
||||||
)
|
)
|
||||||
if (days.isEmpty()) {
|
if (days.isEmpty()) {
|
||||||
// Nothing to scroll: drop the position a previous list left, so
|
|
||||||
// the today FAB doesn't linger over an empty screen.
|
|
||||||
LaunchedEffect(Unit) { listState.scrollToItem(0) }
|
|
||||||
AgendaEmpty(modifier)
|
AgendaEmpty(modifier)
|
||||||
} else {
|
} else {
|
||||||
AgendaList(
|
AgendaList(
|
||||||
days = days,
|
days = days,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
zone = state.zone,
|
zone = state.zone,
|
||||||
listState = listState,
|
|
||||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||||
now = now,
|
now = now,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
@@ -381,7 +335,6 @@ private fun AgendaList(
|
|||||||
days: List<AgendaDay>,
|
days: List<AgendaDay>,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
listState: LazyListState,
|
|
||||||
dimPast: Boolean,
|
dimPast: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
@@ -390,7 +343,6 @@ private fun AgendaList(
|
|||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
state = listState,
|
|
||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
@@ -457,22 +409,17 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
|||||||
private fun AgendaTopBar(
|
private fun AgendaTopBar(
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
quickSwitchViews: List<CalendarView>,
|
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
// A plain label rather than a CalendarTitleButton, so it takes that
|
|
||||||
// one's inset and one-line clamp itself.
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.view_agenda),
|
text = stringResource(R.string.view_agenda),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -493,16 +440,14 @@ private fun AgendaTopBar(
|
|||||||
}
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
cycle = quickSwitchViews,
|
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
|
||||||
// it, but here the bar meets the header on the same surface and the
|
|
||||||
// tint is what makes that seam look like a separate block (#186).
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -157,7 +156,9 @@ class AgendaViewModel @Inject constructor(
|
|||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
): AgendaUiState {
|
): AgendaUiState {
|
||||||
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
|
if (calendars.isEmpty()) {
|
||||||
|
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
val anchor = params.anchor
|
val anchor = params.anchor
|
||||||
val rangeEnd = anchor.plus(
|
val rangeEnd = anchor.plus(
|
||||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import android.net.Uri
|
|||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -20,7 +18,6 @@ import androidx.compose.material.icons.filled.Schedule
|
|||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
@@ -50,10 +47,9 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
@@ -84,13 +80,18 @@ fun BackupScreen(
|
|||||||
onImport: (Uri) -> Unit,
|
onImport: (Uri) -> Unit,
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.backupState.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
// Export covers local calendars only; managed special-dates mirrors are
|
||||||
|
// rebuilt from contacts. Restore can target anything the import picker offers.
|
||||||
|
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||||
|
val canImport = calendars.any { it.isEventTarget }
|
||||||
|
|
||||||
// Exports everything eligible (null); the per-calendar selector owns its
|
// Exports everything eligible (null); the per-calendar selector owns its
|
||||||
// own launcher.
|
// own launcher.
|
||||||
val createBackup = rememberLauncherForActivityResult(
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
@@ -131,42 +132,78 @@ fun BackupScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_backup),
|
title = stringResource(R.string.settings_section_backup),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
// Loading and failure fill the screen and centre themselves, which they
|
|
||||||
// can only do in an unscrolled column — the scrolling one measures them
|
|
||||||
// against an unbounded height and leaves them hanging under the header.
|
|
||||||
scrollable = state is BackupUiState.Ready,
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
HintText(stringResource(R.string.calendars_backup_hint))
|
||||||
BackupUiState.Loading -> BackupLoading()
|
|
||||||
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
|
if (exportable.isNotEmpty()) {
|
||||||
is BackupUiState.Ready -> BackupContent(
|
GroupedRow(
|
||||||
exportable = s.exportable,
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
canImport = s.canImport,
|
position = Position.Top,
|
||||||
autoBackup = autoBackup,
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
viewModel = viewModel,
|
onClick = {
|
||||||
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
// A single exportable calendar skips the selector.
|
||||||
onPickFolder = { runCatching { pickFolder.launch(null) } },
|
if (exportable.size == 1) {
|
||||||
onExportAll = {
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
runCatching { createBackup.launch(defaultBackupName()) }
|
} else {
|
||||||
|
showExportPicker = true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onExportPick = { showExportPicker = true },
|
)
|
||||||
onEditInterval = { showInterval = true },
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
summary = stringResource(R.string.calendars_restore_hint),
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup),
|
||||||
|
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||||
|
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||||
|
trailing = {
|
||||||
|
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
||||||
|
)
|
||||||
|
if (autoBackup.enabled) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||||
|
summary = rememberFolderName(autoBackup.folderUri)
|
||||||
|
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { runCatching { pickFolder.launch(null) } },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||||
|
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showInterval = true },
|
||||||
|
)
|
||||||
|
HintText(backupStatusText(autoBackup.status))
|
||||||
|
}
|
||||||
|
} else if (canImport) {
|
||||||
|
// Nothing to back up, but restore is still possible — don't hide
|
||||||
|
// it behind export eligibility.
|
||||||
|
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||||
|
HintText(stringResource(R.string.calendars_restore_hint))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gated on Ready rather than resetting the flag when the state leaves it:
|
if (showExportPicker) {
|
||||||
// flipping it here would be a write during composition.
|
ExportCalendarPicker(
|
||||||
(state as? BackupUiState.Ready)?.let { ready ->
|
calendars = exportable,
|
||||||
if (showExportPicker) {
|
onExport = viewModel::exportBackup,
|
||||||
ExportCalendarPicker(
|
onDismiss = { showExportPicker = false },
|
||||||
calendars = ready.exportable,
|
)
|
||||||
onExport = viewModel::exportBackup,
|
|
||||||
onDismiss = { showExportPicker = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (showInterval) {
|
if (showInterval) {
|
||||||
BackupIntervalDialog(
|
BackupIntervalDialog(
|
||||||
@@ -177,103 +214,6 @@ fun BackupScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The screen's loading state, centred in the scaffold's content column. */
|
|
||||||
@Composable
|
|
||||||
private fun BackupLoading() {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default file name for a one-shot export. */
|
|
||||||
private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The working screen: export (when there are local calendars to export), restore,
|
|
||||||
* and the automatic-backup block. Restore is always on screen — when nothing can
|
|
||||||
* receive the events it says so and routes to the calendar manager rather than
|
|
||||||
* disappearing, which is what #304 reported as an invisible button.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun BackupContent(
|
|
||||||
exportable: List<CalendarSource>,
|
|
||||||
canImport: Boolean,
|
|
||||||
autoBackup: AutoBackupUiState,
|
|
||||||
viewModel: CalendarsViewModel,
|
|
||||||
onRestore: () -> Unit,
|
|
||||||
onPickFolder: () -> Unit,
|
|
||||||
onExportAll: () -> Unit,
|
|
||||||
onExportPick: () -> Unit,
|
|
||||||
onEditInterval: () -> Unit,
|
|
||||||
) {
|
|
||||||
val manageCalendars = LocalManageCalendars.current
|
|
||||||
// Restore never depends on export eligibility, and never disappears: when no
|
|
||||||
// calendar can receive events it explains that and offers the way to fix it.
|
|
||||||
val restoreSummary = if (canImport) {
|
|
||||||
stringResource(R.string.calendars_restore_hint)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.calendars_restore_unavailable)
|
|
||||||
}
|
|
||||||
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
|
|
||||||
|
|
||||||
if (exportable.isEmpty()) {
|
|
||||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
|
||||||
HintText(restoreSummary)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_restore_action),
|
|
||||||
position = Position.Alone,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
|
||||||
onClick = onRestoreClick,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
HintText(stringResource(R.string.calendars_backup_hint))
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_backup_action),
|
|
||||||
position = Position.Top,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
|
||||||
// A single exportable calendar skips the selector.
|
|
||||||
onClick = if (exportable.size == 1) onExportAll else onExportPick,
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_restore_action),
|
|
||||||
summary = restoreSummary,
|
|
||||||
position = Position.Middle,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
|
||||||
onClick = onRestoreClick,
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup),
|
|
||||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
|
||||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
|
||||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
|
||||||
trailing = {
|
|
||||||
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
|
|
||||||
},
|
|
||||||
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
|
|
||||||
)
|
|
||||||
if (autoBackup.enabled) {
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
|
||||||
summary = rememberFolderName(autoBackup.folderUri)
|
|
||||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = onPickFolder,
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
|
||||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
|
||||||
position = Position.Bottom,
|
|
||||||
onClick = onEditInterval,
|
|
||||||
)
|
|
||||||
HintText(backupStatusText(autoBackup.status))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||||
@@ -312,7 +252,7 @@ private fun ExportCalendarPicker(
|
|||||||
calendars.forEachIndexed { index, calendar ->
|
calendars.forEachIndexed { index, calendar ->
|
||||||
val isSelected = calendar.id in selected
|
val isSelected = calendar.id in selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName,
|
||||||
summary = calendar.description,
|
summary = calendar.description,
|
||||||
position = positionOf(index, calendars.size),
|
position = positionOf(index, calendars.size),
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.calendars
|
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
|
||||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
|
||||||
|
|
||||||
/**
|
|
||||||
* State of the Backup & restore screen (#69). Three states, because the calendar
|
|
||||||
* list it is derived from has all three: it arrives empty, it can throw, and
|
|
||||||
* "loaded but nothing to offer" is a real outcome the screen used to render as a
|
|
||||||
* blank page (#304).
|
|
||||||
*/
|
|
||||||
sealed interface BackupUiState {
|
|
||||||
data object Loading : BackupUiState
|
|
||||||
data class Failure(val reason: FailureReason) : BackupUiState
|
|
||||||
|
|
||||||
/**
|
|
||||||
* At least one half of the screen works. [exportable] may be empty (restore
|
|
||||||
* only) and [canImport] may be false (export only), but never both — that is
|
|
||||||
* a [Failure].
|
|
||||||
*/
|
|
||||||
data class Ready(
|
|
||||||
val exportable: List<CalendarSource>,
|
|
||||||
val canImport: Boolean,
|
|
||||||
) : BackupUiState
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the screen can offer for this calendar list.
|
|
||||||
*
|
|
||||||
* Export covers the app's own local calendars; managed special-dates mirrors are
|
|
||||||
* rebuilt from contacts, so they are excluded. Restore can target anything the
|
|
||||||
* import picker offers ([isEventTarget]).
|
|
||||||
*
|
|
||||||
* A failure is raised only when *neither* is possible — deliberately not
|
|
||||||
* [calendarListFailure] on its own, which would call an all-hidden device a
|
|
||||||
* failure while its local calendars are still perfectly exportable. When both
|
|
||||||
* halves are dead the list itself usually says why (no calendars, everything
|
|
||||||
* switched off); [FailureReason.NoImportTarget] covers the remaining case, where
|
|
||||||
* calendars exist and are visible but every one of them is read-only, managed or
|
|
||||||
* not synced to the device.
|
|
||||||
*/
|
|
||||||
fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
|
|
||||||
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
|
|
||||||
val canImport = calendars.any { it.isEventTarget }
|
|
||||||
if (exportable.isEmpty() && !canImport) {
|
|
||||||
return BackupUiState.Failure(
|
|
||||||
calendarListFailure(calendars) ?: FailureReason.NoImportTarget,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return BackupUiState.Ready(exportable = exportable, canImport = canImport)
|
|
||||||
}
|
|
||||||
@@ -90,7 +90,6 @@ import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||||
@@ -98,25 +97,11 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
|||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.selection.selectable
|
|
||||||
import androidx.compose.foundation.selection.selectableGroup
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
|
||||||
|
|
||||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||||
@@ -246,7 +231,7 @@ private fun CalendarsList(
|
|||||||
local.forEachIndexed { index, calendar ->
|
local.forEachIndexed { index, calendar ->
|
||||||
val disabled = !calendar.isVisibleInSystem
|
val disabled = !calendar.isVisibleInSystem
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName,
|
||||||
summary = calendarRowSummary(calendar),
|
summary = calendarRowSummary(calendar),
|
||||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
@@ -254,7 +239,7 @@ private fun CalendarsList(
|
|||||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
trailing = {
|
trailing = {
|
||||||
EnableSwitch(
|
EnableSwitch(
|
||||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
calendarName = calendar.displayName,
|
||||||
enabled = !disabled,
|
enabled = !disabled,
|
||||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||||
)
|
)
|
||||||
@@ -331,7 +316,7 @@ private fun CalendarsList(
|
|||||||
ordered.forEachIndexed { index, calendar ->
|
ordered.forEachIndexed { index, calendar ->
|
||||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName,
|
||||||
summary = calendarRowSummary(calendar),
|
summary = calendarRowSummary(calendar),
|
||||||
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
@@ -340,7 +325,7 @@ private fun CalendarsList(
|
|||||||
trailing = if (calendar.hasVisibilitySwitch) {
|
trailing = if (calendar.hasVisibilitySwitch) {
|
||||||
{
|
{
|
||||||
EnableSwitch(
|
EnableSwitch(
|
||||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
calendarName = calendar.displayName,
|
||||||
enabled = calendar.isVisibleInSystem,
|
enabled = calendar.isVisibleInSystem,
|
||||||
onToggle = { enabled ->
|
onToggle = { enabled ->
|
||||||
onSetVisible(calendar.id, enabled)
|
onSetVisible(calendar.id, enabled)
|
||||||
@@ -358,15 +343,9 @@ private fun CalendarsList(
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val SWATCHES_PER_ROW = 6
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
/**
|
|
||||||
* Create or edit a local calendar: a name field over the palette of calendar
|
|
||||||
* colours, on the family's full-screen sheet with the commit in its title bar.
|
|
||||||
* The same sheet Agendula edits a task list in, so the two read as one product.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CalendarEditor(
|
private fun CalendarEditor(
|
||||||
sessionKey: Int,
|
sessionKey: Int,
|
||||||
isNew: Boolean,
|
isNew: Boolean,
|
||||||
initialName: String,
|
initialName: String,
|
||||||
@@ -381,50 +360,121 @@ internal fun CalendarEditor(
|
|||||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||||
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
|
||||||
val valid = name.isNotBlank()
|
Scaffold(
|
||||||
val commit = { if (valid) onSave(name.trim(), color, description.trim().ifEmpty { null }) }
|
modifier = Modifier
|
||||||
|
.predictiveBack(onBack = onClose)
|
||||||
FullScreenPicker(
|
.fillMaxSize()
|
||||||
title = stringResource(
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
if (isNew) R.string.calendars_new_title else R.string.calendars_edit_title,
|
topBar = {
|
||||||
),
|
TopAppBar(
|
||||||
onDismiss = onClose,
|
title = {
|
||||||
predictiveBack = true,
|
Text(
|
||||||
actions = {
|
stringResource(
|
||||||
Button(
|
if (isNew) R.string.calendars_new_title
|
||||||
onClick = commit,
|
else R.string.calendars_edit_title,
|
||||||
enabled = valid,
|
),
|
||||||
modifier = Modifier.padding(end = 12.dp),
|
)
|
||||||
) { Text(stringResource(R.string.event_edit_save)) }
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.event_edit_close),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (!isNew) {
|
||||||
|
// Disabled rather than hidden while the special-dates
|
||||||
|
// sync owns this calendar; the card below says why.
|
||||||
|
IconButton(
|
||||||
|
onClick = { confirmDelete = true },
|
||||||
|
enabled = !deleteLocked,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_delete),
|
||||||
|
tint = if (deleteLocked) {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filled save button, matching the event editor's top bar.
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onSave(name.trim(), color, description.trim().ifEmpty { null })
|
||||||
|
},
|
||||||
|
enabled = name.isNotBlank(),
|
||||||
|
modifier = Modifier.padding(end = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.event_edit_save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
) {
|
) { innerPadding ->
|
||||||
if (deleteLocked) {
|
Column(
|
||||||
ManagedNote()
|
modifier = Modifier
|
||||||
Spacer(Modifier.height(20.dp))
|
.padding(innerPadding)
|
||||||
}
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
CalendarNameField(
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
name = name,
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
color = color,
|
) {
|
||||||
focusRequester = focusRequester,
|
if (deleteLocked) {
|
||||||
onNameChange = { name = it },
|
EditorCard(
|
||||||
onImeAction = commit,
|
icon = Icons.Default.Info,
|
||||||
)
|
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
// Inside the picker: it is a Dialog, so a request made from the caller's
|
iconAtTop = true,
|
||||||
// composition can run before this field's node exists.
|
) {
|
||||||
// A new calendar opens with the keyboard up: naming it is the whole task.
|
Text(
|
||||||
LaunchedEffect(isNew) { if (isNew) focusRequester.requestFocus() }
|
text = stringResource(R.string.calendars_managed_delete_locked),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
Spacer(Modifier.height(20.dp))
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
EditorSectionLabel(stringResource(R.string.calendars_color_label))
|
)
|
||||||
CalendarColorGrid(selected = color, onSelect = { color = it })
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(20.dp))
|
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
|
||||||
EditorSectionLabel(stringResource(R.string.calendars_description_label))
|
InlineTextField(
|
||||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
value = name,
|
||||||
Box(Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(16.dp)) {
|
onValueChange = { name = it },
|
||||||
|
placeholder = stringResource(R.string.calendars_name_label),
|
||||||
|
textStyle = MaterialTheme.typography.titleLarge,
|
||||||
|
capitalization = KeyboardCapitalization.Sentences,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
EditorCard(
|
||||||
|
icon = Icons.Default.Palette,
|
||||||
|
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
iconAtTop = true,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_color_label),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
ColorSwatchRow(
|
||||||
|
colors = CalendarColorPalette.all,
|
||||||
|
selected = color,
|
||||||
|
onSelect = { color = it },
|
||||||
|
dark = dark,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
EditorCard(
|
||||||
|
icon = Icons.AutoMirrored.Filled.Notes,
|
||||||
|
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
iconAtTop = true,
|
||||||
|
) {
|
||||||
InlineTextField(
|
InlineTextField(
|
||||||
value = description,
|
value = description,
|
||||||
onValueChange = { description = it },
|
onValueChange = { description = it },
|
||||||
@@ -435,21 +485,20 @@ internal fun CalendarEditor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isNew && !deleteLocked) {
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
DeleteCalendarRow(onClick = { confirmDelete = true })
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (confirmDelete) {
|
if (confirmDelete) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { confirmDelete = false },
|
onDismissRequest = { confirmDelete = false },
|
||||||
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
|
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
|
||||||
text = { Text(stringResource(R.string.calendars_delete_confirm_message, initialName)) },
|
text = {
|
||||||
|
Text(stringResource(R.string.calendars_delete_confirm_message, initialName))
|
||||||
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = { confirmDelete = false; onDelete() }) {
|
TextButton(onClick = {
|
||||||
|
confirmDelete = false
|
||||||
|
onDelete()
|
||||||
|
}) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.event_detail_delete),
|
stringResource(R.string.event_detail_delete),
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
@@ -465,205 +514,6 @@ internal fun CalendarEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The name, with the chosen colour beside it so the two read as one thing. */
|
|
||||||
@Composable
|
|
||||||
private fun CalendarNameField(
|
|
||||||
name: String,
|
|
||||||
color: Int,
|
|
||||||
focusRequester: FocusRequester,
|
|
||||||
onNameChange: (String) -> Unit,
|
|
||||||
onImeAction: () -> Unit,
|
|
||||||
) {
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
val soften = LocalSoftenColors.current
|
|
||||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.CalendarMonth,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = eventAccent(color, dark, soften),
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
InlineTextField(
|
|
||||||
value = name,
|
|
||||||
onValueChange = onNameChange,
|
|
||||||
placeholder = stringResource(R.string.calendars_name_label),
|
|
||||||
capitalization = KeyboardCapitalization.Sentences,
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
onImeAction = onImeAction,
|
|
||||||
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The palette as two rows of round swatches; the chosen one carries a check.
|
|
||||||
*
|
|
||||||
* Drawn through the app's own tone pass rather than raw, so a swatch is the
|
|
||||||
* colour the calendar's events will actually appear in — the user's "soften
|
|
||||||
* colours" preference included.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun CalendarColorGrid(selected: Int, onSelect: (Int) -> Unit) {
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
val soften = LocalSoftenColors.current
|
|
||||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp, vertical = 16.dp)
|
|
||||||
.selectableGroup(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
CalendarColorPalette.all.chunked(SWATCHES_PER_ROW).forEach { row ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
row.forEach { swatch ->
|
|
||||||
ColorSwatch(
|
|
||||||
fill = eventFill(swatch, dark, soften),
|
|
||||||
label = stringResource(swatchLabel(swatch)),
|
|
||||||
selected = swatch == selected,
|
|
||||||
onClick = { onSelect(swatch) },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Keeps a short final row's swatches the size a full row's
|
|
||||||
// are, rather than stretching them across the width.
|
|
||||||
repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ColorSwatch(
|
|
||||||
fill: Color,
|
|
||||||
label: String,
|
|
||||||
selected: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.aspectRatio(1f)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(fill)
|
|
||||||
.then(
|
|
||||||
if (selected) {
|
|
||||||
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
},
|
|
||||||
)
|
|
||||||
// selectable, not clickable: the swatch carries its chosen state in
|
|
||||||
// semantics too, so the check is not the only cue.
|
|
||||||
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
|
|
||||||
.semantics { contentDescription = label },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (selected) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = eventInk(fill, alpha = 0.7f),
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Names the swatch for a screen reader — twelve circles are otherwise one label. */
|
|
||||||
@StringRes
|
|
||||||
private fun swatchLabel(argb: Int): Int = when (argb) {
|
|
||||||
CalendarColorPalette.Mauve -> R.string.color_name_mauve
|
|
||||||
CalendarColorPalette.Red -> R.string.color_name_red
|
|
||||||
CalendarColorPalette.Orange -> R.string.color_name_orange
|
|
||||||
CalendarColorPalette.Amber -> R.string.color_name_amber
|
|
||||||
CalendarColorPalette.Olive -> R.string.color_name_olive
|
|
||||||
CalendarColorPalette.Green -> R.string.color_name_green
|
|
||||||
CalendarColorPalette.Teal -> R.string.color_name_teal
|
|
||||||
CalendarColorPalette.Cyan -> R.string.color_name_cyan
|
|
||||||
CalendarColorPalette.Blue -> R.string.color_name_blue
|
|
||||||
CalendarColorPalette.Indigo -> R.string.color_name_indigo
|
|
||||||
CalendarColorPalette.Purple -> R.string.color_name_purple
|
|
||||||
else -> R.string.color_name_pink
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Said in the sheet rather than on a disabled button: the sync owns this one. */
|
|
||||||
@Composable
|
|
||||||
private fun ManagedNote() {
|
|
||||||
GroupedSurface(
|
|
||||||
position = Position.Alone,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(20.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Info, contentDescription = null)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.calendars_managed_delete_locked),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DeleteCalendarRow(onClick: () -> Unit) {
|
|
||||||
GroupedSurface(
|
|
||||||
position = Position.Alone,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
onClick = onClick,
|
|
||||||
color = MaterialTheme.colorScheme.errorContainer,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Delete,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.event_detail_delete),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun EditorSectionLabel(text: String) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The row's supporting line: the states that make this calendar behave unlike a
|
* The row's supporting line: the states that make this calendar behave unlike a
|
||||||
* plain writable one (#76), then its own description.
|
* plain writable one (#76), then its own description.
|
||||||
@@ -706,6 +556,36 @@ private fun EnableSwitch(
|
|||||||
private fun dimIf(disabled: Boolean): Modifier =
|
private fun dimIf(disabled: Boolean): Modifier =
|
||||||
if (disabled) Modifier.alpha(0.38f) else Modifier
|
if (disabled) Modifier.alpha(0.38f) else Modifier
|
||||||
|
|
||||||
|
/** Tonal field card matching the event editor's design (icon + content). */
|
||||||
|
@Composable
|
||||||
|
private fun EditorCard(
|
||||||
|
icon: ImageVector,
|
||||||
|
iconTint: Color,
|
||||||
|
iconAtTop: Boolean = false,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = iconTint,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = if (iconAtTop) 2.dp else 0.dp)
|
||||||
|
.size(24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One collapsible calendar group rendered as a connected card. The header row is
|
* One collapsible calendar group rendered as a connected card. The header row is
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -25,7 +24,6 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -58,22 +56,6 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = emptyList(),
|
initialValue = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* The Backup & restore screen's own view of that list, with the loading and
|
|
||||||
* failure states [calendars] flattens away — it starts empty and catches to
|
|
||||||
* empty, which that screen used to render as a blank page (#304).
|
|
||||||
*/
|
|
||||||
val backupState: StateFlow<BackupUiState> =
|
|
||||||
repository.calendars()
|
|
||||||
.map { backupUiState(it) }
|
|
||||||
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
|
|
||||||
.flowOn(io)
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = BackupUiState.Loading,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||||
settingsPrefs.autoBackupEnabled,
|
settingsPrefs.autoBackupEnabled,
|
||||||
|
|||||||
@@ -56,14 +56,12 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
|||||||
* account comes from when another account shares the name (#77).
|
* account comes from when another account shares the name (#77).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun accountGroupTitle(group: CalendarAccountGroup): String {
|
fun accountGroupTitle(group: CalendarAccountGroup): String =
|
||||||
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) }
|
if (!group.ambiguous) {
|
||||||
return if (!group.ambiguous) {
|
group.label
|
||||||
label
|
|
||||||
} else {
|
} else {
|
||||||
stringResource(R.string.calendars_account_from_source, label, sourceAppName(group.accountType))
|
stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The human name of the app backing [accountType], falling back to the raw
|
* The human name of the app backing [accountType], falling back to the raw
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trailing-edge and title spacing shared by the four calendar app bars (#165).
|
|
||||||
*
|
|
||||||
* Only the trailing edge and the title's start are shared; the leading edge and
|
|
||||||
* the gaps between the action icons stay on M3's defaults, which derive both
|
|
||||||
* from the icon buttons' width.
|
|
||||||
*/
|
|
||||||
object AppBarSpacing {
|
|
||||||
|
|
||||||
/** Side inset shared by the bars' trailing edge and the month grid. */
|
|
||||||
val Inset = 8.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start inset on the title, taking it clear of the navigation icon M3 places
|
|
||||||
* it flush against. Real padding rather than a negative offset, which would
|
|
||||||
* swallow taps meant for the menu button.
|
|
||||||
*/
|
|
||||||
val TitleInset = 8.dp
|
|
||||||
|
|
||||||
/** M3's own padding around the actions row. */
|
|
||||||
internal val BarPadding = 4.dp
|
|
||||||
|
|
||||||
private val IconButtonSize = 48.dp
|
|
||||||
|
|
||||||
/** M3's icon slot inside an icon button; the today glyph fills the same box. */
|
|
||||||
internal val IconSize = 24.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End padding for a container-backed control that ends the bar, measured to
|
|
||||||
* its background. Coerced because [androidx.compose.foundation.layout.padding]
|
|
||||||
* throws on a negative value.
|
|
||||||
*/
|
|
||||||
val ContainerTrailingInset = (Inset - BarPadding).coerceAtLeast(0.dp)
|
|
||||||
|
|
||||||
/** Screen edge to the glyph of an icon button that ends the bar. */
|
|
||||||
val IconTrailingInset = BarPadding + (IconButtonSize - IconSize) / 2
|
|
||||||
}
|
|
||||||
@@ -10,203 +10,24 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
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.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
|
||||||
/** Gap a timed block leaves to its neighbours in the column. */
|
|
||||||
val BLOCK_OUTER_INSET = 1.dp
|
|
||||||
|
|
||||||
/** Padding between a timed block's edge and its text. */
|
|
||||||
val BLOCK_TEXT_PADDING = 4.dp
|
|
||||||
|
|
||||||
/** 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
|
* A timed block's own time label, crossfaded rather than replaced — the block
|
||||||
* slides to its new slot, so the label shouldn't change in a single frame.
|
* slides to its new slot, so the label shouldn't change in a single frame.
|
||||||
*
|
|
||||||
* Overflows like a title does (#164): the "…" costs two characters of a string
|
|
||||||
* that is nothing but characters, so the label clips at the block's edge
|
|
||||||
* instead. [maxLines] lets a narrow column spend spare height on the range
|
|
||||||
* rather than losing its end.
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun BlockTimeLabel(
|
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
||||||
label: String,
|
|
||||||
color: Color,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
maxLines: Int = 1,
|
|
||||||
) {
|
|
||||||
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
||||||
snap()
|
snap()
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||||
}
|
}
|
||||||
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
|
||||||
// Regular weight against the title's medium above it (#219).
|
|
||||||
val style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime())
|
|
||||||
Crossfade(
|
Crossfade(
|
||||||
targetState = label,
|
targetState = label,
|
||||||
animationSpec = spec,
|
animationSpec = spec,
|
||||||
@@ -215,10 +36,9 @@ fun BlockTimeLabel(
|
|||||||
) { text ->
|
) { text ->
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = style,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = maxLines,
|
maxLines = 1,
|
||||||
overflow = overflow.overflow,
|
overflow = TextOverflow.Ellipsis,
|
||||||
softWrap = overflow.softWrap,
|
|
||||||
color = color,
|
color = color,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,17 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.provider.CalendarContract
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.CalendarMonth
|
|
||||||
import androidx.compose.material.icons.outlined.EditOff
|
|
||||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
|
||||||
import androidx.compose.material.icons.outlined.EventBusy
|
|
||||||
import androidx.compose.material.icons.outlined.Lock
|
|
||||||
import androidx.compose.material.icons.outlined.SyncProblem
|
|
||||||
import androidx.compose.material.icons.outlined.VisibilityOff
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -43,168 +20,37 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen failure state shared by every calendar screen (spec §7).
|
* Full-screen failure state shared by every calendar screen (spec §7).
|
||||||
* A tonal icon, one headline, one supporting line and one recovery action —
|
* One explanation line + one recovery action, never a toast.
|
||||||
* never a toast.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarFailure(
|
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
|
||||||
reason: FailureReason,
|
val titleRes = when (reason) {
|
||||||
onRetry: () -> Unit,
|
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||||
modifier: Modifier = Modifier,
|
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||||
) {
|
FailureReason.ProviderUnavailable -> R.string.state_failure_provider
|
||||||
val context = LocalContext.current
|
FailureReason.Unknown,
|
||||||
val manageCalendars = LocalManageCalendars.current
|
FailureReason.EventNotFound -> R.string.state_failure_unknown
|
||||||
val copy = failureCopy(reason)
|
}
|
||||||
val onAction: () -> Unit = when (reason) {
|
val actionRes = when (reason) {
|
||||||
FailureReason.NoCalendarsConfigured -> {
|
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
|
||||||
{ context.startCalendarSetup() }
|
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
|
||||||
}
|
else -> R.string.state_retry
|
||||||
FailureReason.AllCalendarsHidden,
|
|
||||||
FailureReason.NoImportTarget,
|
|
||||||
-> manageCalendars ?: onRetry
|
|
||||||
else -> onRetry
|
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 32.dp, vertical = 24.dp),
|
.padding(32.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
FailureIcon(icon = copy.icon, isError = copy.isError)
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(copy.title),
|
text = stringResource(titleRes),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
|
|
||||||
)
|
)
|
||||||
copy.body?.let { body ->
|
Spacer(Modifier.height(24.dp))
|
||||||
Spacer(Modifier.height(12.dp))
|
FilledTonalButton(onClick = onRetry) {
|
||||||
Text(
|
Text(stringResource(actionRes))
|
||||||
text = stringResource(body),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
FilledTonalButton(onClick = onAction) {
|
|
||||||
Text(stringResource(copy.action))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The state's mark: the reason's icon in a tonal circle above the headline. */
|
|
||||||
@Composable
|
|
||||||
private fun FailureIcon(icon: ImageVector, isError: Boolean) {
|
|
||||||
val container: Color
|
|
||||||
val content: Color
|
|
||||||
if (isError) {
|
|
||||||
container = MaterialTheme.colorScheme.errorContainer
|
|
||||||
content = MaterialTheme.colorScheme.onErrorContainer
|
|
||||||
} else {
|
|
||||||
container = MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
content = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(72.dp)
|
|
||||||
.background(container, CircleShape),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = content,
|
|
||||||
modifier = Modifier.size(36.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a reason says and offers. Headline and supporting line are separate so
|
|
||||||
* the explanation reads as body text instead of a headline that runs over three
|
|
||||||
* lines; [isError] tints the mark for the two reasons that are an actual fault
|
|
||||||
* rather than a calendar the user can switch back on.
|
|
||||||
*/
|
|
||||||
private data class FailureCopy(
|
|
||||||
val icon: ImageVector,
|
|
||||||
@StringRes val title: Int,
|
|
||||||
@StringRes val body: Int?,
|
|
||||||
@StringRes val action: Int,
|
|
||||||
val isError: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun failureCopy(reason: FailureReason): FailureCopy = when (reason) {
|
|
||||||
FailureReason.PermissionRevoked -> FailureCopy(
|
|
||||||
icon = Icons.Outlined.Lock,
|
|
||||||
title = R.string.state_failure_permission,
|
|
||||||
body = R.string.state_failure_permission_body,
|
|
||||||
action = R.string.state_failure_permission_action,
|
|
||||||
)
|
|
||||||
FailureReason.NoCalendarsConfigured -> FailureCopy(
|
|
||||||
icon = Icons.Outlined.CalendarMonth,
|
|
||||||
title = R.string.state_failure_no_calendars,
|
|
||||||
body = R.string.state_failure_no_calendars_body,
|
|
||||||
action = R.string.state_failure_no_calendars_action,
|
|
||||||
)
|
|
||||||
FailureReason.AllCalendarsHidden -> FailureCopy(
|
|
||||||
icon = Icons.Outlined.VisibilityOff,
|
|
||||||
title = R.string.state_failure_all_hidden,
|
|
||||||
body = R.string.state_failure_all_hidden_body,
|
|
||||||
action = R.string.state_failure_all_hidden_action,
|
|
||||||
)
|
|
||||||
FailureReason.NoImportTarget -> FailureCopy(
|
|
||||||
icon = Icons.Outlined.EditOff,
|
|
||||||
title = R.string.state_failure_no_import_target,
|
|
||||||
body = R.string.state_failure_no_import_target_body,
|
|
||||||
action = R.string.state_failure_all_hidden_action,
|
|
||||||
)
|
|
||||||
FailureReason.EventNotFound -> FailureCopy(
|
|
||||||
icon = Icons.Outlined.EventBusy,
|
|
||||||
title = R.string.state_failure_event_not_found,
|
|
||||||
body = R.string.state_failure_event_not_found_body,
|
|
||||||
action = R.string.state_retry,
|
|
||||||
)
|
|
||||||
FailureReason.ProviderUnavailable -> FailureCopy(
|
|
||||||
icon = Icons.Outlined.SyncProblem,
|
|
||||||
title = R.string.state_failure_provider,
|
|
||||||
body = R.string.state_failure_provider_body,
|
|
||||||
action = R.string.state_retry,
|
|
||||||
isError = true,
|
|
||||||
)
|
|
||||||
FailureReason.Unknown -> FailureCopy(
|
|
||||||
icon = Icons.Outlined.ErrorOutline,
|
|
||||||
title = R.string.state_failure_unknown,
|
|
||||||
body = null,
|
|
||||||
action = R.string.state_retry,
|
|
||||||
isError = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keeps the headline and its supporting line at a readable measure. */
|
|
||||||
private val TEXT_MAX_WIDTH = 320.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens Settings → Calendars, the only screen that can switch a calendar back
|
|
||||||
* on. Null outside the calendar host — screens without it never raise the
|
|
||||||
* reason that needs it, and fall back to their own retry.
|
|
||||||
*/
|
|
||||||
val LocalManageCalendars = compositionLocalOf<(() -> Unit)?> { null }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open the system screen for putting a calendar on the device: the account
|
|
||||||
* chooser filtered to calendar providers, falling back to account settings
|
|
||||||
* where no chooser resolves. Both are system components, so nothing here needs
|
|
||||||
* a `<queries>` entry.
|
|
||||||
*/
|
|
||||||
private fun Context.startCalendarSetup() {
|
|
||||||
val intents = listOf(
|
|
||||||
Intent(Settings.ACTION_ADD_ACCOUNT)
|
|
||||||
.putExtra(Settings.EXTRA_AUTHORITIES, arrayOf(CalendarContract.AUTHORITY)),
|
|
||||||
Intent(Settings.ACTION_SYNC_SETTINGS),
|
|
||||||
)
|
|
||||||
intents.firstOrNull { runCatching { startActivity(it) }.isSuccess }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ private fun CalendarPickerGroup(
|
|||||||
calendars.forEachIndexed { index, calendar ->
|
calendars.forEachIndexed { index, calendar ->
|
||||||
val isSelected = calendar.id == selectedId
|
val isSelected = calendar.id == selectedId
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName,
|
||||||
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
|
||||||
import androidx.compose.ui.text.style.LineHeightStyle
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
|
|
||||||
* a 16sp line box — room a label wants when it stands on its own, and close to
|
|
||||||
* a fifth of an event row when it doesn't. The outer edges only, so a wrapped
|
|
||||||
* title keeps its interior line spacing (#190).
|
|
||||||
*
|
|
||||||
* Trimmed rather than set to a smaller line height: the font picker can load a
|
|
||||||
* serif, a monospace or a file of the user's own, and a line height under a
|
|
||||||
* face's own ascent and descent overlaps its lines. There is nothing to trim
|
|
||||||
* below that, so this is safe whatever font is chosen.
|
|
||||||
*/
|
|
||||||
private val TrimmedLines = LineHeightStyle(
|
|
||||||
alignment = LineHeightStyle.Alignment.Center,
|
|
||||||
trim = LineHeightStyle.Trim.Both,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Ascenders and descenders both, so a line is measured at its full extent. */
|
|
||||||
private const val LINE_SAMPLE = "Ag"
|
|
||||||
|
|
||||||
/** [this] with Material's outer leading trimmed — see [TrimmedLines]. */
|
|
||||||
fun TextStyle.trimmedLines(): TextStyle = copy(lineHeightStyle = TrimmedLines)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [style] with its wrapped lines packed onto the face's own extent instead of
|
|
||||||
* Material's line box.
|
|
||||||
*
|
|
||||||
* [trimmedLines] takes the leading off the outer edges of a run of text; this
|
|
||||||
* takes it from between the lines as well, which is the half a wrapped event
|
|
||||||
* title pays for twice over. The line height is *measured* from the font rather
|
|
||||||
* than picked, so it lands exactly on the face's ascent-plus-descent and can
|
|
||||||
* never be short enough to overlap — whatever the font picker has loaded (#190).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberPackedLines(style: TextStyle): TextStyle {
|
|
||||||
val line = rememberTrimmedLineHeight(style)
|
|
||||||
return with(LocalDensity.current) { style.trimmedLines().copy(lineHeight = line.toSp()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What one trimmed line of [style] actually draws in. */
|
|
||||||
@Composable
|
|
||||||
fun rememberTrimmedLineHeight(style: TextStyle): Dp {
|
|
||||||
val measurer = rememberTextMeasurer()
|
|
||||||
val density = LocalDensity.current
|
|
||||||
return remember(style, density, measurer) {
|
|
||||||
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -13,7 +12,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -21,8 +19,6 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -35,9 +31,10 @@ import kotlinx.datetime.LocalDate
|
|||||||
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
||||||
* visible day, week start or month anchor).
|
* visible day, week start or month anchor).
|
||||||
*
|
*
|
||||||
* [shortTitle] replaces [title] when the full one does not fit the width the app
|
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
|
||||||
* bar hands the title slot (#165). Either way the line clamps to one and
|
* start alignment: M3 places the title flush against the navigation icon's
|
||||||
* ellipsises, so pass a [shortTitle] wherever there is something left to drop.
|
* trailing edge, and the title is hit-tested on top of it, so a negative offset
|
||||||
|
* would swallow taps meant for the menu button.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarTitleButton(
|
fun CalendarTitleButton(
|
||||||
@@ -45,7 +42,6 @@ fun CalendarTitleButton(
|
|||||||
currentDate: LocalDate,
|
currentDate: LocalDate,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
shortTitle: String = title,
|
|
||||||
) {
|
) {
|
||||||
var showDatePicker by rememberSaveable { mutableStateOf(false) }
|
var showDatePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -57,27 +53,14 @@ fun CalendarTitleButton(
|
|||||||
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
||||||
role = Role.Button,
|
role = Role.Button,
|
||||||
) { showDatePicker = true }
|
) { showDatePicker = true }
|
||||||
.padding(horizontal = AppBarSpacing.TitleInset),
|
.padding(horizontal = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
val style = MaterialTheme.typography.titleLarge
|
Text(
|
||||||
BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) {
|
text = title,
|
||||||
val measurer = rememberTextMeasurer()
|
style = MaterialTheme.typography.titleLarge,
|
||||||
val shown = remember(title, shortTitle, style, constraints.maxWidth, measurer) {
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
titleFor(
|
)
|
||||||
title = title,
|
|
||||||
shortTitle = shortTitle,
|
|
||||||
titleWidth = measurer.measure(title, style).size.width,
|
|
||||||
availableWidth = constraints.maxWidth,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = shown,
|
|
||||||
style = style,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.ArrowDropDown,
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -96,11 +79,3 @@ fun CalendarTitleButton(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Falls back to [shortTitle] when [title] is wider than [availableWidth] (#165). */
|
|
||||||
internal fun titleFor(
|
|
||||||
title: String,
|
|
||||||
shortTitle: String,
|
|
||||||
titleWidth: Int,
|
|
||||||
availableWidth: Int,
|
|
||||||
): String = if (titleWidth <= availableWidth) title else shortTitle
|
|
||||||
|
|||||||
@@ -83,10 +83,8 @@ fun calendarSlideTransition(
|
|||||||
initialContentExit =
|
initialContentExit =
|
||||||
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
|
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
|
||||||
// AnimatedContent clips to the animating container by default, which
|
// AnimatedContent clips to the animating container by default, which
|
||||||
// shears the pages against the viewport edge as they pass. Left off even
|
// shears the pages against the viewport edge as they pass. There is no
|
||||||
// where the two pages differ in height — the split grid stands as many
|
// size change here to contain — both pages are the same grid.
|
||||||
// rows as its month spans (#162) — since a page sliding out over the row
|
|
||||||
// below it reads as travel, and the shear reads as a fault.
|
|
||||||
sizeTransform = SizeTransform(clip = false),
|
sizeTransform = SizeTransform(clip = false),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,7 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
|
|||||||
* implemented view — the settings screen reorders the whole set — while [cycle]
|
* implemented view — the settings screen reorders the whole set — while [cycle]
|
||||||
* is the subset the pill actually steps through, in [order]. The navigation
|
* is the subset the pill actually steps through, in [order]. The navigation
|
||||||
* drawer keeps its own separate order and always lists every view, so a view
|
* drawer keeps its own separate order and always lists every view, so a view
|
||||||
* disabled here stays reachable there — including when [cycle] is emptied and
|
* disabled here stays reachable there.
|
||||||
* the pill disappears altogether.
|
|
||||||
*/
|
*/
|
||||||
data class QuickSwitchConfig(
|
data class QuickSwitchConfig(
|
||||||
val order: List<CalendarView>,
|
val order: List<CalendarView>,
|
||||||
@@ -68,15 +67,14 @@ data class QuickSwitchConfig(
|
|||||||
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
|
||||||
* Fewest views that keep the switch meaningful. A single target is not a
|
|
||||||
* switch, so below this the pill is hidden rather than special-cased (#150);
|
|
||||||
* the drawer still reaches every view.
|
|
||||||
*/
|
|
||||||
const val MIN_CYCLE = 2
|
|
||||||
|
|
||||||
/** All views, in default order, all enabled. */
|
/** All views, in default order, all enabled. */
|
||||||
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fewest views that keep the switch meaningful — a "switch" needs at
|
||||||
|
* least two targets, so the settings screen blocks disabling below this.
|
||||||
|
*/
|
||||||
|
const val MIN_ENABLED = 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.onLongClick
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/** Puts one labelled field on the clipboard. */
|
|
||||||
fun interface FieldCopier {
|
|
||||||
operator fun invoke(label: String, text: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A [FieldCopier] that confirms the copy only where the system doesn't (#195).
|
|
||||||
*
|
|
||||||
* Android 13 raises its own clipboard chip for every copy, so a snackbar on top
|
|
||||||
* of it reads as the app having done the job twice. A failure is always worth a
|
|
||||||
* word, though: the clipboard can refuse a very long description outright.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
|
|
||||||
val clipboard = LocalClipboard.current
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val confirmation = stringResource(R.string.field_copied)
|
|
||||||
val failure = stringResource(R.string.field_copy_failed)
|
|
||||||
return remember(clipboard, scope, snackbarHostState, confirmation, failure) {
|
|
||||||
FieldCopier { label, text ->
|
|
||||||
scope.launch {
|
|
||||||
val message = runCatching {
|
|
||||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, text)))
|
|
||||||
}.fold(
|
|
||||||
onSuccess = {
|
|
||||||
confirmation.takeIf {
|
|
||||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onFailure = { failure },
|
|
||||||
)
|
|
||||||
if (message != null) {
|
|
||||||
// Copying twice in a row shouldn't queue two four-second
|
|
||||||
// confirmations — the newest one wins.
|
|
||||||
snackbarHostState.currentSnackbarData?.dismiss()
|
|
||||||
snackbarHostState.showSnackbar(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Long-press to copy [text] whole, filed on the clipboard under [label].
|
|
||||||
*
|
|
||||||
* [onTap] carries a field's existing tap action through; a field without one
|
|
||||||
* stays unclickable rather than growing a ripple that leads nowhere, and gets
|
|
||||||
* its long press announced through semantics instead. That branch merges the
|
|
||||||
* node it sits on, so a screen reader lands on the field itself and finds the
|
|
||||||
* action there — a bare container is never focused.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun Modifier.copyOnLongPress(
|
|
||||||
label: String,
|
|
||||||
text: String,
|
|
||||||
copy: FieldCopier,
|
|
||||||
onTap: (() -> Unit)? = null,
|
|
||||||
): Modifier {
|
|
||||||
val actionLabel = stringResource(R.string.field_copy_action)
|
|
||||||
return if (onTap != null) {
|
|
||||||
combinedClickable(
|
|
||||||
onClick = onTap,
|
|
||||||
onLongClickLabel = actionLabel,
|
|
||||||
onLongClick = { copy(label, text) },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
val haptics = LocalHapticFeedback.current
|
|
||||||
pointerInput(label, text, copy) {
|
|
||||||
detectTapGestures(
|
|
||||||
onLongPress = {
|
|
||||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
||||||
copy(label, text)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}.semantics(mergeDescendants = true) {
|
|
||||||
onLongClick(actionLabel) {
|
|
||||||
copy(label, text)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/** The corner radius every event chip keeps on each edge the event doesn't cross. */
|
|
||||||
val EVENT_CHIP_CORNER = 4.dp
|
|
||||||
|
|
||||||
/** An event chip that is cut on no edge — an all-day bar, a floating copy. */
|
|
||||||
val EventChipShape = RoundedCornerShape(EVENT_CHIP_CORNER)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A timed block's corners: square on whichever edge the event runs past, so a
|
|
||||||
* cut edge reads as "this carries on" rather than as the event's own end. The
|
|
||||||
* vertical counterpart of [monthBarShape].
|
|
||||||
*/
|
|
||||||
fun timedBlockShape(continuesBefore: Boolean, continuesAfter: Boolean): RoundedCornerShape =
|
|
||||||
RoundedCornerShape(
|
|
||||||
topStart = if (continuesBefore) 0.dp else EVENT_CHIP_CORNER,
|
|
||||||
topEnd = if (continuesBefore) 0.dp else EVENT_CHIP_CORNER,
|
|
||||||
bottomStart = if (continuesAfter) 0.dp else EVENT_CHIP_CORNER,
|
|
||||||
bottomEnd = if (continuesAfter) 0.dp else EVENT_CHIP_CORNER,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A month/all-day bar's corners: square on whichever side the event runs past
|
|
||||||
* the row it is drawn in.
|
|
||||||
*/
|
|
||||||
fun monthBarShape(continuesLeft: Boolean, continuesRight: Boolean): RoundedCornerShape =
|
|
||||||
RoundedCornerShape(
|
|
||||||
topStart = if (continuesLeft) 0.dp else EVENT_CHIP_CORNER,
|
|
||||||
bottomStart = if (continuesLeft) 0.dp else EVENT_CHIP_CORNER,
|
|
||||||
topEnd = if (continuesRight) 0.dp else EVENT_CHIP_CORNER,
|
|
||||||
bottomEnd = if (continuesRight) 0.dp else EVENT_CHIP_CORNER,
|
|
||||||
)
|
|
||||||
@@ -35,12 +35,8 @@ class EventMoveScope(
|
|||||||
* accidental drags the setting exists to stop.
|
* accidental drags the setting exists to stop.
|
||||||
*/
|
*/
|
||||||
val dragEnabled: Boolean,
|
val dragEnabled: Boolean,
|
||||||
/**
|
/** False when the drop was refused outright, so nothing will be written. */
|
||||||
* Hand a drop to the writer. Returns nothing on purpose: a drop is answered
|
val move: (MoveRequest) -> Boolean,
|
||||||
* before its write resolves, so [abandoned] — not a return value — is what
|
|
||||||
* tells a held copy that nothing landed.
|
|
||||||
*/
|
|
||||||
val move: (MoveRequest) -> Unit,
|
|
||||||
/**
|
/**
|
||||||
* True while a dropped event is being written, including the time its scope
|
* True while a dropped event is being written, including the time its scope
|
||||||
* dialog is up. A flow rather than a value so this scope stays the same
|
* dialog is up. A flow rather than a value so this scope stays the same
|
||||||
@@ -49,12 +45,6 @@ class EventMoveScope(
|
|||||||
val inFlight: StateFlow<Boolean>,
|
val inFlight: StateFlow<Boolean>,
|
||||||
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
|
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
|
||||||
val undoStarted: StateFlow<Int>,
|
val undoStarted: StateFlow<Int>,
|
||||||
/**
|
|
||||||
* Ticks when a drop ends with nothing landing — see
|
|
||||||
* `RescheduleViewModel.abandoned`. [move] answers before the write is
|
|
||||||
* resolved, so this is what tells a held copy to stop waiting.
|
|
||||||
*/
|
|
||||||
val abandoned: StateFlow<Int>,
|
|
||||||
/** Open an event in the edit form — the pointer-free route to the same change. */
|
/** Open an event in the edit form — the pointer-free route to the same change. */
|
||||||
val edit: (EventInstance) -> Unit,
|
val edit: (EventInstance) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -67,8 +57,6 @@ private val NEVER_IN_FLIGHT = MutableStateFlow(false)
|
|||||||
|
|
||||||
private val NEVER_UNDONE = MutableStateFlow(0)
|
private val NEVER_UNDONE = MutableStateFlow(0)
|
||||||
|
|
||||||
private val NEVER_ABANDONED = MutableStateFlow(0)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a dropped event is still being written — false wherever moving is off.
|
* Whether a dropped event is still being written — false wherever moving is off.
|
||||||
* The drag overlays hold a landed block on its target for this window.
|
* The drag overlays hold a landed block on its target for this window.
|
||||||
@@ -79,17 +67,6 @@ fun moveInFlight(): Boolean {
|
|||||||
return flow.collectAsStateWithLifecycle().value
|
return flow.collectAsStateWithLifecycle().value
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* How many drops have ended with nothing landing. Read as a plain count so a
|
|
||||||
* drag overlay can capture it at the moment of the drop and tell, later, whether
|
|
||||||
* *its own* drop was one of them.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun abandonedMoves(): Int {
|
|
||||||
val flow = LocalEventMove.current?.abandoned ?: NEVER_ABANDONED
|
|
||||||
return flow.collectAsStateWithLifecycle().value
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs [onUndo] when an undo write begins, and never for one that began before
|
* Runs [onUndo] when an undo write begins, and never for one that began before
|
||||||
* this composable came on screen.
|
* this composable came on screen.
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.drawBehind
|
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventResponse
|
|
||||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
|
||||||
|
|
||||||
/** Stroke an invitation you have not answered is outlined with. */
|
|
||||||
val EVENT_OUTLINE_WIDTH = 1.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The surface an event chip is drawn on, which an outlined one fills itself with
|
|
||||||
* so it reads as the background showing through rather than as a pale block laid
|
|
||||||
* over it. Defaults to the `surfaceContainer` the month cells and the timeline
|
|
||||||
* columns carry; the all-day strips sit on plain `surface` and a floating drag
|
|
||||||
* copy wants a lifted one, so both provide their own.
|
|
||||||
*/
|
|
||||||
val LocalChipGround = compositionLocalOf { Color.Unspecified }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which edges of a chip the event runs past. Those edges are squared by
|
|
||||||
* [monthBarShape] / [timedBlockShape] so the cut reads as "this carries on", and
|
|
||||||
* an outline has to leave them open for the same reason — a stroke all the way
|
|
||||||
* round would close a bar carried across a week boundary into two boxes.
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
data class ChipCuts(
|
|
||||||
val start: Boolean = false,
|
|
||||||
val end: Boolean = false,
|
|
||||||
val top: Boolean = false,
|
|
||||||
val bottom: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** [ChipCuts] for a month or all-day bar, which is only ever cut left and right. */
|
|
||||||
fun monthBarCuts(continuesLeft: Boolean, continuesRight: Boolean): ChipCuts =
|
|
||||||
ChipCuts(start = continuesLeft, end = continuesRight)
|
|
||||||
|
|
||||||
/** [ChipCuts] for a timed block, which is only ever cut top and bottom. */
|
|
||||||
fun timedBlockCuts(continuesBefore: Boolean, continuesAfter: Boolean): ChipCuts =
|
|
||||||
ChipCuts(top = continuesBefore, bottom = continuesAfter)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How one event's chip or block is painted, which turns on your answer to its
|
|
||||||
* invitation (#180, #230).
|
|
||||||
*
|
|
||||||
* An invitation you have not answered is drawn as an outline: its calendar's
|
|
||||||
* colour on the border and on the title, over the surface the chip sits on. So
|
|
||||||
* it holds a chip's shape and a chip's weight without claiming the filled
|
|
||||||
* container an event you are going to gets. A declined one keeps the fill and
|
|
||||||
* its strike-through.
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
data class EventPaint(
|
|
||||||
val fill: Color,
|
|
||||||
/** Null for a filled chip; the border colour when the chip is outlined. */
|
|
||||||
val outline: Color?,
|
|
||||||
val titleInk: Color,
|
|
||||||
val secondaryInk: Color,
|
|
||||||
val decoration: TextDecoration?,
|
|
||||||
/**
|
|
||||||
* Weight to set the title at, or null to keep whatever the surface's own
|
|
||||||
* text style carries.
|
|
||||||
*/
|
|
||||||
val titleWeight: FontWeight?,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** [EventPaint] for [event] on a [dark] scheme. */
|
|
||||||
@Composable
|
|
||||||
fun eventPaint(event: EventInstance, dark: Boolean): EventPaint {
|
|
||||||
val soften = LocalSoftenColors.current
|
|
||||||
if (event.response == EventResponse.Invited) {
|
|
||||||
// Harmonised even when the setting is off. Raw mode exists so a filled
|
|
||||||
// container matches what the sync source paints, and an outlined chip
|
|
||||||
// has no container — what it has is coloured *text*, which needs the
|
|
||||||
// lightness pinned against the surface or a pale calendar goes
|
|
||||||
// unreadable (eventTone returns the raw colour verbatim otherwise).
|
|
||||||
val accent = eventAccent(event.color, dark, soften = true)
|
|
||||||
val ground = LocalChipGround.current.takeIf { it != Color.Unspecified }
|
|
||||||
?: MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
return EventPaint(
|
|
||||||
fill = ground,
|
|
||||||
outline = accent,
|
|
||||||
titleInk = accent,
|
|
||||||
// A neutral token rather than the accent faded: the time is the
|
|
||||||
// smallest text on the chip, and an alpha step off an accent that is
|
|
||||||
// itself only just clear of the surface is where legibility goes.
|
|
||||||
secondaryInk = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
decoration = null,
|
|
||||||
// The label styles' medium weight is set to carry ink on a filled
|
|
||||||
// container. In a calendar colour on a plain one it thickens into
|
|
||||||
// something harder to read, so step it back to regular.
|
|
||||||
titleWeight = FontWeight.Normal,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val fill = eventFill(event.color, dark, soften)
|
|
||||||
return EventPaint(
|
|
||||||
fill = fill,
|
|
||||||
outline = null,
|
|
||||||
titleInk = eventInk(fill, alpha = TITLE_INK_ALPHA),
|
|
||||||
secondaryInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
|
||||||
decoration = declinedDecoration(event.isDeclined),
|
|
||||||
titleWeight = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** [this] set at the weight [paint] asks a title for, for measuring and for drawing alike. */
|
|
||||||
fun TextStyle.withTitleWeight(paint: EventPaint): TextStyle =
|
|
||||||
if (paint.titleWeight == null) this else copy(fontWeight = paint.titleWeight)
|
|
||||||
|
|
||||||
/** Seats a chip or block on [shape]: its fill, plus the border when it has one. */
|
|
||||||
fun Modifier.eventSurface(
|
|
||||||
paint: EventPaint,
|
|
||||||
shape: Shape,
|
|
||||||
cuts: ChipCuts = ChipCuts(),
|
|
||||||
): Modifier {
|
|
||||||
val filled = background(paint.fill, shape)
|
|
||||||
val outline = paint.outline ?: return filled
|
|
||||||
return filled.clip(shape).drawBehind { drawChipOutline(outline, cuts) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The border, stroked as one round-rect whose edges run past the chip on every
|
|
||||||
* side the event continues over. Clipped to the chip's own shape, so those
|
|
||||||
* strokes — and the corners that would have turned back in — fall outside and
|
|
||||||
* the edge stays open.
|
|
||||||
*/
|
|
||||||
private fun DrawScope.drawChipOutline(color: Color, cuts: ChipCuts) {
|
|
||||||
val stroke = EVENT_OUTLINE_WIDTH.toPx()
|
|
||||||
val radius = EVENT_CHIP_CORNER.toPx()
|
|
||||||
// Far enough out that the corner arc clears the clip too, not just the edge.
|
|
||||||
val bleed = radius + stroke
|
|
||||||
val rtl = layoutDirection == LayoutDirection.Rtl
|
|
||||||
val leftCut = if (rtl) cuts.end else cuts.start
|
|
||||||
val rightCut = if (rtl) cuts.start else cuts.end
|
|
||||||
val left = if (leftCut) -bleed else stroke / 2f
|
|
||||||
val top = if (cuts.top) -bleed else stroke / 2f
|
|
||||||
val right = if (rightCut) size.width + bleed else size.width - stroke / 2f
|
|
||||||
val bottom = if (cuts.bottom) size.height + bleed else size.height - stroke / 2f
|
|
||||||
drawRoundRect(
|
|
||||||
color = color,
|
|
||||||
topLeft = Offset(left, top),
|
|
||||||
size = Size(right - left, bottom - top),
|
|
||||||
cornerRadius = CornerRadius(radius),
|
|
||||||
style = Stroke(width = stroke),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
|
||||||
import androidx.compose.ui.text.withStyle
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
|
||||||
|
|
||||||
private val TIME_WEIGHT = FontWeight.Normal
|
|
||||||
private val TIME_TRACKING = 0.sp
|
|
||||||
private val TIME_SPAN = SpanStyle(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ink for an event's title, against [SECONDARY_INK_ALPHA] for the time beside or
|
|
||||||
* beneath it. Full, so the pair separates on a typeface with no medium weight to
|
|
||||||
* step down from — Atkinson Hyperlegible and JetBrains Mono ship regular and bold
|
|
||||||
* only, and a user's imported font a single face, so [asEventTime] is a no-op
|
|
||||||
* there and the ink is the whole difference (#219).
|
|
||||||
*/
|
|
||||||
const val TITLE_INK_ALPHA = 1f
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [this] set as an event's time rather than as its title: the label styles' medium
|
|
||||||
* weight and 0.5sp of tracking left a time reading as part of the title next to it.
|
|
||||||
*/
|
|
||||||
fun TextStyle.asEventTime(): TextStyle =
|
|
||||||
copy(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
|
|
||||||
|
|
||||||
/** Text reading [time], set apart in [timeInk], before [title]. */
|
|
||||||
fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedString =
|
|
||||||
buildAnnotatedString {
|
|
||||||
if (time != null) {
|
|
||||||
withStyle(TIME_SPAN.copy(color = timeInk)) { append(time) }
|
|
||||||
append(" ")
|
|
||||||
}
|
|
||||||
append(title)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The characters of title that have to survive a time prefix for it to be worth
|
|
||||||
* its place — lowercase Latin of average advance, priced at the surface's own
|
|
||||||
* text style rather than guessed in dp.
|
|
||||||
*/
|
|
||||||
private const val TITLE_SAMPLE = "notepad"
|
|
||||||
|
|
||||||
/** The widest wall-clock time in either convention: two-digit hour, and a meridiem in 12-hour. */
|
|
||||||
private const val SAMPLE_HOUR = 12
|
|
||||||
private const val SAMPLE_MINUTE = 45
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The narrowest run of [style] text that may carry a time in front of a title:
|
|
||||||
* wide enough for the widest time in the current convention plus [TITLE_SAMPLE].
|
|
||||||
*
|
|
||||||
* Measured rather than a device breakpoint, so it follows the font scale, the
|
|
||||||
* 12/24-hour setting, the locale's own time format and whatever else has
|
|
||||||
* already been taken off the width.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberInlineTimeWidth(style: TextStyle): Dp {
|
|
||||||
val measurer = rememberTextMeasurer()
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val locale = currentLocale()
|
|
||||||
val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale)
|
|
||||||
return remember(sample, style, density, measurer) {
|
|
||||||
val label = inlineTimeLabel(sample, TITLE_SAMPLE, Color.Unspecified)
|
|
||||||
with(density) { measurer.measure(label, style).size.width.toDp() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
|
||||||
|
|
||||||
/** How an event chip's title should overflow: what to pass to `Text`. */
|
|
||||||
data class EventTitleOverflow(val overflow: TextOverflow, val softWrap: Boolean)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overflow for an event chip's title (#164). Chips are narrow enough that the
|
|
||||||
* "…" costs a couple of readable characters, so the title runs to the chip's
|
|
||||||
* edge and clips instead — mid-glyph on one line, at the last whole word it
|
|
||||||
* could fit on several.
|
|
||||||
*
|
|
||||||
* One case keeps the ellipsis: a single **[rtl]** line. With `softWrap` off
|
|
||||||
* Compose lays the line out at its full intrinsic width and clips to the node's
|
|
||||||
* left edge, which in RTL is the *end* of the string — an Arabic title would
|
|
||||||
* lose its beginning. The ellipsis truncates at the logical end in both
|
|
||||||
* directions. Wrapping needs `softWrap` on, which clips the right end either
|
|
||||||
* way, so more than one line needs no such exception.
|
|
||||||
*/
|
|
||||||
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
|
|
||||||
when {
|
|
||||||
!singleLine -> EventTitleOverflow(TextOverflow.Clip, softWrap = true)
|
|
||||||
rtl -> EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true)
|
|
||||||
else -> EventTitleOverflow(TextOverflow.Clip, softWrap = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** [eventTitleOverflowFor] against the current layout direction. */
|
|
||||||
@Composable
|
|
||||||
fun eventTitleOverflow(singleLine: Boolean = true): EventTitleOverflow =
|
|
||||||
eventTitleOverflowFor(
|
|
||||||
rtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
|
|
||||||
singleLine = singleLine,
|
|
||||||
)
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.drawBehind
|
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the week/day timeline seats each hour in its own cell, from the
|
|
||||||
* `show_hour_lines` preference. Provided once at the app root (like
|
|
||||||
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
|
|
||||||
* Defaults to off — the historical flat column.
|
|
||||||
*/
|
|
||||||
val LocalShowHourGrid = staticCompositionLocalOf { false }
|
|
||||||
|
|
||||||
/** Gap between two hour cells, matching the month grid's gap between day cells. */
|
|
||||||
val HOUR_CELL_GAP = 2.dp
|
|
||||||
|
|
||||||
/** Half a gap: what a cell — and a block seated in it — gives up at each edge. */
|
|
||||||
val HOUR_CELL_INSET = HOUR_CELL_GAP / 2
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The half-gap a block seated in the grid gives up at one of its ends, so an
|
|
||||||
* on-the-hour event fills its cell instead of overhanging the seam into its
|
|
||||||
* neighbours'. Zero with the grid off, and zero at an end [cut] at midnight:
|
|
||||||
* that edge is squared off against the column's own, and stopping a half-gap
|
|
||||||
* short of it would leave the block floating there rather than running off.
|
|
||||||
*/
|
|
||||||
fun hourCellBlockInset(show: Boolean, cut: Boolean): Dp =
|
|
||||||
if (show && !cut) HOUR_CELL_INSET else 0.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Corner radius of an hour cell: the event chip's own, so the grid never rounds
|
|
||||||
* harder than the blocks it seats. The month grid's 12dp belongs to a cell many
|
|
||||||
* times the size — on an hour cell it out-rounds its own content.
|
|
||||||
*/
|
|
||||||
private val HOUR_CELL_CORNER = EVENT_CHIP_CORNER
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Radius an hour cell of [cellHeight] by [cellWidth] pixels may round to. The
|
|
||||||
* hour pitch runs from a fit-the-day sliver up to [MAX_PINCH_HOUR_HEIGHT] and a
|
|
||||||
* week column is a seventh of the screen, so the radius is held to half the
|
|
||||||
* shorter side — the point past which the corners would meet and the cell turn
|
|
||||||
* into a lozenge.
|
|
||||||
*/
|
|
||||||
internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Float): Float =
|
|
||||||
minOf(maxRadius, cellHeight / 2f, cellWidth / 2f).coerceAtLeast(0f)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seat each of the day's 24 hours in its own rounded cell when [show] is true,
|
|
||||||
* so the hour boundary reads as a seam between two surfaces rather than a line
|
|
||||||
* drawn across one. Applied to a day column's content, so the cells sit over the
|
|
||||||
* column background but beneath the event blocks — blocks stay in a continuous
|
|
||||||
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
|
|
||||||
* height; [color] is resolved by the caller from the theme.
|
|
||||||
*/
|
|
||||||
fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
|
|
||||||
if (!show) {
|
|
||||||
this
|
|
||||||
} else {
|
|
||||||
drawBehind {
|
|
||||||
val inset = HOUR_CELL_INSET.toPx()
|
|
||||||
val cellHeight = hourHeightPx - inset * 2f
|
|
||||||
if (cellHeight <= 0f) return@drawBehind
|
|
||||||
val radius = CornerRadius(
|
|
||||||
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
|
|
||||||
)
|
|
||||||
val cellSize = Size(size.width, cellHeight)
|
|
||||||
for (hour in 0 until 24) {
|
|
||||||
drawRoundRect(
|
|
||||||
color = color,
|
|
||||||
topLeft = Offset(0f, hour * hourHeightPx + inset),
|
|
||||||
size = cellSize,
|
|
||||||
cornerRadius = radius,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -73,11 +72,6 @@ fun HourGutter(
|
|||||||
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
||||||
label = "hourLabelAlpha",
|
label = "hourLabelAlpha",
|
||||||
)
|
)
|
||||||
// Each label straddles the boundary it names, so it lines up with the seam
|
|
||||||
// the hour grid leaves there instead of hanging below it. Derived from the
|
|
||||||
// label's own line height, so it holds at any font scale.
|
|
||||||
val labelStyle = MaterialTheme.typography.labelSmall
|
|
||||||
val labelLift = with(LocalDensity.current) { labelStyle.lineHeight.toDp() } / 2
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -96,12 +90,12 @@ fun HourGutter(
|
|||||||
if (h > 0) {
|
if (h > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = formatHourLabel(h, use24Hour, locale),
|
text = formatHourLabel(h, use24Hour, locale),
|
||||||
style = labelStyle,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
.copy(alpha = hourAlpha),
|
.copy(alpha = hourAlpha),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.offset(y = -labelLift),
|
.offset(y = (-6).dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the week/day timeline draws an hour separator line, from the
|
||||||
|
* `showHourLines` preference. Provided once at the app root (like
|
||||||
|
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
|
||||||
|
* Defaults to off — the historical clean look.
|
||||||
|
*/
|
||||||
|
val LocalShowHourLines = staticCompositionLocalOf { false }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a faint separator line at the top of each hour (1..23) when [show] is
|
||||||
|
* true. Applied to a day column's content so each line sits over the column's
|
||||||
|
* background but beneath the event blocks. [hourHeightPx] is one hour's pixel
|
||||||
|
* height; [color] is resolved by the caller from the theme.
|
||||||
|
*/
|
||||||
|
fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
|
||||||
|
if (!show) {
|
||||||
|
this
|
||||||
|
} else {
|
||||||
|
drawBehind {
|
||||||
|
for (hour in 1 until 24) {
|
||||||
|
val y = hour * hourHeightPx
|
||||||
|
drawLine(
|
||||||
|
color = color,
|
||||||
|
start = Offset(0f, y),
|
||||||
|
end = Offset(size.width, y),
|
||||||
|
strokeWidth = 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import kotlin.time.Instant
|
|||||||
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
|
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
|
||||||
* the "dim completed events" setting is on, or `null` when it is off (nothing
|
* the "dim completed events" setting is on, or `null` when it is off (nothing
|
||||||
* dims). Provided per grid screen so only the event chips that read it recompose
|
* dims). Provided per grid screen so only the event chips that read it recompose
|
||||||
* as the minute ticks, mirroring [LocalShowHourGrid] / [LocalUse24HourFormat].
|
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
|
||||||
*/
|
*/
|
||||||
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.MINUTES_DEFAULT
|
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
|
||||||
/** Common reminder lead times offered as quick picks in the form and settings. */
|
/** Common reminder lead times offered as quick picks in the form and settings. */
|
||||||
@@ -21,20 +20,15 @@ fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Humanise a reminder lead time (minutes before the event start) into one
|
* Humanise a reminder lead time (minutes before the event start) into one
|
||||||
* line: "Default reminder" ([MINUTES_DEFAULT]), "At time of event" (0),
|
* line: "Default reminder" (negative = the provider default), "At time of
|
||||||
* "10 minutes before", "1 hour before", … Shared by the detail screen, the event
|
* event" (0), "10 minutes before", "1 hour before", … Shared by the detail
|
||||||
* form and the default-reminder settings so the wording never drifts.
|
* screen, the event form and the default-reminder settings so the wording
|
||||||
*
|
* never drifts.
|
||||||
* Only the sentinel itself is the account default. Any other negative is a real
|
|
||||||
* lead time firing *after* the start — rare, but a foreign all-day row decodes to
|
|
||||||
* one — and naming it "Default reminder" would tell the user an alarm that does
|
|
||||||
* go off is not ours.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun reminderLeadTimeLabel(minutes: Int): String = when {
|
fun reminderLeadTimeLabel(minutes: Int): String = when {
|
||||||
minutes == MINUTES_DEFAULT -> stringResource(R.string.reminder_default)
|
minutes < 0 -> stringResource(R.string.reminder_default)
|
||||||
minutes == 0 -> stringResource(R.string.reminder_at_time)
|
minutes == 0 -> stringResource(R.string.reminder_at_time)
|
||||||
minutes < 0 -> stringResource(R.string.reminder_after, durationLabel(-minutes))
|
|
||||||
minutes % 10_080 == 0 ->
|
minutes % 10_080 == 0 ->
|
||||||
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
|
||||||
minutes % 1_440 == 0 ->
|
minutes % 1_440 == 0 ->
|
||||||
|
|||||||
@@ -140,17 +140,6 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
|
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
|
||||||
|
|
||||||
private val _abandoned = MutableStateFlow(0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ticks when a drop ends with nothing on the target day: refused outright,
|
|
||||||
* refused before the write, written and failed, or its scope dialog
|
|
||||||
* dismissed. [move] answers before any of that is known, so without this
|
|
||||||
* signal the view that drew the drop held its copy out for the full settle
|
|
||||||
* timeout waiting for a grid that was never going to draw it (#253).
|
|
||||||
*/
|
|
||||||
val abandoned: StateFlow<Int> = _abandoned.asStateFlow()
|
|
||||||
|
|
||||||
private val _undoStarted = MutableStateFlow(0)
|
private val _undoStarted = MutableStateFlow(0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -200,23 +189,15 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Take a drop, unless one is already being written. False means nothing will
|
* Take a drop, unless one is already being written. False means nothing will
|
||||||
* be written; [abandoned] ticks for that refusal too, so a held copy is let
|
* be written and the caller must let its held copy go now — waiting on
|
||||||
* go by the one signal rather than by every caller remembering to.
|
* [inFlight] would strand it until the settle timeout instead.
|
||||||
*
|
|
||||||
* The Boolean is diagnostic — the tests read it, [EventMoveScope] does not
|
|
||||||
* hand it on. A caller that gated on it would be back to releasing its copy
|
|
||||||
* from two places, and it answers before the write is resolved anyway.
|
|
||||||
*/
|
*/
|
||||||
fun move(request: MoveRequest): Boolean {
|
fun move(request: MoveRequest): Boolean {
|
||||||
if (busy || _scopePrompt.value != null) {
|
if (busy || _scopePrompt.value != null) return false
|
||||||
_abandoned.value += 1
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
busy = true
|
busy = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val prepared = prepare(request)
|
val prepared = prepare(request)
|
||||||
if (prepared == null) {
|
if (prepared == null) {
|
||||||
_abandoned.value += 1
|
|
||||||
busy = false
|
busy = false
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -250,7 +231,6 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
pending = null
|
pending = null
|
||||||
busy = false
|
busy = false
|
||||||
_scopePrompt.value = null
|
_scopePrompt.value = null
|
||||||
_abandoned.value += 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -258,12 +238,6 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
* inverse write reports back, so the one chip changes what it says rather
|
* inverse write reports back, so the one chip changes what it says rather
|
||||||
* than closing and reopening. False when another write is already running —
|
* than closing and reopening. False when another write is already running —
|
||||||
* the chip hides its action for that window, so this is the backstop.
|
* the chip hides its action for that window, so this is the backstop.
|
||||||
*
|
|
||||||
* [abandoned] ticks once an inverse that *started* fails to land: [undoStarted]
|
|
||||||
* has sent the chip back on its journey by then and nothing else will seat it.
|
|
||||||
* A refusal ticks nothing — it launched no journey of its own, and the counter
|
|
||||||
* is global, so a tick here would release some other drop's held copy before
|
|
||||||
* the grid has drawn it.
|
|
||||||
*/
|
*/
|
||||||
fun undo(undo: MoveUndo): Boolean {
|
fun undo(undo: MoveUndo): Boolean {
|
||||||
if (busy) return false
|
if (busy) return false
|
||||||
@@ -282,9 +256,6 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
MoveOutcome.Failed
|
MoveOutcome.Failed
|
||||||
}
|
}
|
||||||
// Read off the outcome, the way [write] does — no second copy of the
|
|
||||||
// same fact to drift from it.
|
|
||||||
if (_outcome.value !is MoveOutcome.Undone) _abandoned.value += 1
|
|
||||||
busy = false
|
busy = false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -369,7 +340,6 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
val request = prepared.request
|
val request = prepared.request
|
||||||
if (endsBeforeItStarts(prepared, scope)) {
|
if (endsBeforeItStarts(prepared, scope)) {
|
||||||
_outcome.value = MoveOutcome.BlockedSeriesEnd
|
_outcome.value = MoveOutcome.BlockedSeriesEnd
|
||||||
_abandoned.value += 1
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_outcome.value = try {
|
_outcome.value = try {
|
||||||
@@ -381,7 +351,6 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
repository.updateOccurrence(
|
repository.updateOccurrence(
|
||||||
request.eventId,
|
request.eventId,
|
||||||
request.beginMillis,
|
request.beginMillis,
|
||||||
prepared.original,
|
|
||||||
prepared.updated,
|
prepared.updated,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -406,9 +375,6 @@ class RescheduleViewModel @Inject constructor(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
MoveOutcome.Failed
|
MoveOutcome.Failed
|
||||||
}
|
}
|
||||||
// Nothing landed, so the copy the view is holding has nothing to fade
|
|
||||||
// into — let it go now rather than at the end of the settle timeout.
|
|
||||||
if (_outcome.value !is MoveOutcome.Moved) _abandoned.value += 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.absoluteOffset
|
import androidx.compose.foundation.layout.absoluteOffset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.requiredSize
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -25,9 +24,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.TransformOrigin
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.LayoutCoordinates
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
import androidx.compose.ui.layout.boundsInRoot
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
@@ -36,6 +33,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
|||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -46,11 +44,7 @@ import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
|||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
@@ -72,181 +66,17 @@ private val AUTO_SCROLL_STEP = 16.dp
|
|||||||
data class TimelineDrag(
|
data class TimelineDrag(
|
||||||
val event: EventInstance,
|
val event: EventInstance,
|
||||||
val date: LocalDate,
|
val date: LocalDate,
|
||||||
/**
|
|
||||||
* Where the dragged *block's* top edge now sits, in minutes from [date]'s
|
|
||||||
* midnight. The gesture's own quantity — what snaps, what the haptics tick
|
|
||||||
* on, and what the drop is expressed in. Not the event's start: for the tail
|
|
||||||
* of an event that began the day before, the two are a clip offset apart.
|
|
||||||
*/
|
|
||||||
val startMin: Int,
|
val startMin: Int,
|
||||||
/** The event's new start, from [date]'s midnight — negative if it began earlier. */
|
val endMin: Int,
|
||||||
val eventStartMin: Int,
|
val topLeftInRoot: Offset,
|
||||||
val eventSpanMin: Int,
|
val sizePx: IntSize,
|
||||||
/** The event as the grid would draw it, one piece per day column it covers. */
|
|
||||||
val pieces: List<TimelineDragPiece>,
|
|
||||||
/**
|
|
||||||
* Lines the block the finger picked up drew its title over, zero for one too
|
|
||||||
* short to have drawn it at all. The floating copy is that block's size, so
|
|
||||||
* it has to spend its height the same way or the text re-wraps under the
|
|
||||||
* finger and snaps back on drop (#267).
|
|
||||||
*/
|
|
||||||
val titleLines: Int,
|
|
||||||
/**
|
|
||||||
* True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
|
|
||||||
* every column this timeline shows. A hint under the finger, not where the
|
|
||||||
* drop lands, so it is dropped once the gesture ends ([finish]).
|
|
||||||
*/
|
|
||||||
val edgeOnly: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
||||||
val slot: Pair<LocalDate, Int> get() = date to startMin
|
val slot: Pair<LocalDate, Int> get() = date to startMin
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Where a finished drag asks its event to go. */
|
||||||
* One day column's share of a dragged event, ready to draw. Both halves of an
|
data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int)
|
||||||
* event crossing midnight are previewed, whichever half the finger picked up:
|
|
||||||
* showing only the held one made the event read as growing rather than moving,
|
|
||||||
* since the day beside it still stood at the block's old extent (#253).
|
|
||||||
*/
|
|
||||||
data class TimelineDragPiece(
|
|
||||||
val topLeftInRoot: Offset,
|
|
||||||
val sizePx: IntSize,
|
|
||||||
val continuesBefore: Boolean,
|
|
||||||
val continuesAfter: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How a run of [spanMin] minutes beginning [eventStartMin] minutes into a day —
|
|
||||||
* negative when it began on an earlier one — falls across day columns, as
|
|
||||||
* offsets from that day. Every slice but the first and last is a whole day, and
|
|
||||||
* the flags say which edges are cuts rather than the event's own ends.
|
|
||||||
*
|
|
||||||
* [within] bounds the day offsets that have a column to draw in, so a very long
|
|
||||||
* event doesn't slice days no one can see.
|
|
||||||
*/
|
|
||||||
internal fun dragSlices(
|
|
||||||
eventStartMin: Int,
|
|
||||||
spanMin: Int,
|
|
||||||
within: IntRange? = null,
|
|
||||||
): List<DragSlice> {
|
|
||||||
val span = spanMin.coerceAtLeast(0)
|
|
||||||
val end = eventStartMin + span
|
|
||||||
val days = dragDayRange(eventStartMin, spanMin)
|
|
||||||
val first = days.first
|
|
||||||
val last = days.last
|
|
||||||
val from = maxOf(first, within?.first ?: first)
|
|
||||||
val to = minOf(last, within?.last ?: last)
|
|
||||||
if (from > to) return emptyList()
|
|
||||||
return (from..to).map { day ->
|
|
||||||
val dayStart = day * MINUTES_PER_DAY
|
|
||||||
val sliceStart = maxOf(eventStartMin - dayStart, 0)
|
|
||||||
val sliceEnd = minOf(end - dayStart, MINUTES_PER_DAY)
|
|
||||||
DragSlice(
|
|
||||||
dayOffset = day,
|
|
||||||
startMin = sliceStart,
|
|
||||||
spanMin = sliceEnd - sliceStart,
|
|
||||||
continuesBefore = day > first,
|
|
||||||
continuesAfter = day < last,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one piece to draw when a drag has carried the whole event clear of the
|
|
||||||
* columns this timeline shows, so [dragSlices] has nothing to hand back — where a
|
|
||||||
* short clipped tail lands at its [dragFloorMin]: the event then belongs to the
|
|
||||||
* day before, which Day view has no column for, and the copy would blink out from
|
|
||||||
* under the finger mid-gesture. Pinned to the top of the nearest visible column
|
|
||||||
* at minimum height instead, cut at that edge, so it reads as having slid off the
|
|
||||||
* top. Null whenever a real slice exists. The drop is unaffected either way — it
|
|
||||||
* reads [TimelineDrag.startMin], not the pieces.
|
|
||||||
*/
|
|
||||||
internal fun edgeDragSlice(eventStartMin: Int, spanMin: Int, within: IntRange): DragSlice? {
|
|
||||||
if (dragDayRange(eventStartMin, spanMin).last >= within.first) return null
|
|
||||||
return DragSlice(
|
|
||||||
dayOffset = within.first,
|
|
||||||
startMin = 0,
|
|
||||||
spanMin = 0,
|
|
||||||
continuesBefore = true,
|
|
||||||
continuesAfter = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The day offsets a run of [spanMin] minutes beginning [eventStartMin] covers,
|
|
||||||
* before any clipping to what a timeline shows. One rule, so [dragSlices] and
|
|
||||||
* [edgeDragSlice] can't drift apart on where an event's days begin and end.
|
|
||||||
*/
|
|
||||||
private fun dragDayRange(eventStartMin: Int, spanMin: Int): IntRange {
|
|
||||||
val end = eventStartMin + spanMin.coerceAtLeast(0)
|
|
||||||
val first = eventStartMin.floorDiv(MINUTES_PER_DAY)
|
|
||||||
// A zero-length event still has a block, on the one day it sits in.
|
|
||||||
return first..maxOf((end - 1).floorDiv(MINUTES_PER_DAY), first)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How far above its day's midnight a block's top edge may be dragged. Zero for
|
|
||||||
* an ordinary block, whose top edge is the event's own start. A tail clipped at
|
|
||||||
* midnight has to go lower: its top edge *is* midnight and stays there however
|
|
||||||
* much earlier the event moves, so pinning it at zero made dragging the second
|
|
||||||
* half earlier a no-op (#253). It may rise until the event's end reaches one
|
|
||||||
* snap step into this day — or by a single step, for a tail too short to afford
|
|
||||||
* even that, which then leaves the day for the one before it.
|
|
||||||
*/
|
|
||||||
internal fun dragFloorMin(clipOffsetMin: Int, eventSpanMin: Int): Int =
|
|
||||||
if (clipOffsetMin > 0) {
|
|
||||||
minOf(-DRAG_SNAP_MINUTES, DRAG_SNAP_MINUTES + clipOffsetMin - eventSpanMin)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where one dragged piece's top edge sits below its column's top, and how tall
|
|
||||||
* it is drawn, at [hourPx] to the hour. [insetPx] is the half-gap a block seated
|
|
||||||
* in the hour grid gives up at each end, zero with the grid off — taken off the
|
|
||||||
* copy exactly as the grid takes it off the block, or the copy is a gap taller
|
|
||||||
* than the block it lifted off and spends the difference on a time label that
|
|
||||||
* block had no room for (#267). An end cut at midnight keeps none of it, as the
|
|
||||||
* block's own squared-off edge does.
|
|
||||||
*/
|
|
||||||
internal fun dragPieceBounds(
|
|
||||||
slice: DragSlice,
|
|
||||||
hourPx: Float,
|
|
||||||
insetPx: Float,
|
|
||||||
): DragPieceBounds {
|
|
||||||
val top = if (slice.continuesBefore) 0f else insetPx
|
|
||||||
val bottom = if (slice.continuesAfter) 0f else insetPx
|
|
||||||
val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
|
||||||
return DragPieceBounds(
|
|
||||||
top = slice.startMin / 60f * hourPx + top,
|
|
||||||
height = (full - top - bottom).coerceAtLeast(0f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A piece's placement within its column — see [dragPieceBounds]. */
|
|
||||||
internal data class DragPieceBounds(val top: Float, val height: Float)
|
|
||||||
|
|
||||||
/** One day's share of a dragged event, before it is placed on screen. */
|
|
||||||
internal data class DragSlice(
|
|
||||||
val dayOffset: Int,
|
|
||||||
val startMin: Int,
|
|
||||||
val spanMin: Int,
|
|
||||||
val continuesBefore: Boolean,
|
|
||||||
val continuesAfter: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where a finished drag asks its event to go. [date]/[startMin] are where the
|
|
||||||
* dragged *block's* top edge landed, which is the event's own start only for a
|
|
||||||
* block that isn't clipped — see [clipOffsetMin] and [startInstant].
|
|
||||||
*/
|
|
||||||
data class TimelineDrop(
|
|
||||||
val event: EventInstance,
|
|
||||||
val date: LocalDate,
|
|
||||||
val startMin: Int,
|
|
||||||
/** Minutes from the event's start to the dragged block's top edge. */
|
|
||||||
val clipOffsetMin: Int = 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The timeline's live geometry, republished on every layout. Plain fields rather
|
* The timeline's live geometry, republished on every layout. Plain fields rather
|
||||||
@@ -269,13 +99,6 @@ class TimelineGeometry {
|
|||||||
var stepPx: Float = 0f
|
var stepPx: Float = 0f
|
||||||
var days: List<LocalDate> = emptyList()
|
var days: List<LocalDate> = emptyList()
|
||||||
|
|
||||||
/**
|
|
||||||
* The half-gap a block gives up at each uncut end when the hour grid seats
|
|
||||||
* it in a cell, zero with the grid off. The floating copy gives up the same,
|
|
||||||
* so it is the size of the block it lifted off (#267).
|
|
||||||
*/
|
|
||||||
var blockInsetPx: Float = 0f
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
||||||
* never mirrored but the grid is, so the mapping has to flip with it.
|
* never mirrored but the grid is, so the mapping has to flip with it.
|
||||||
@@ -311,14 +134,12 @@ class TimelineDragController {
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event row and start instant the settled drop came from. The instance id
|
* Where the settled drop came from, and which event row it belongs to. The
|
||||||
* alone can't identify the source for the length of the write: the provider
|
* instance id alone can't identify the source block for the length of the
|
||||||
* regenerates `Instances` rows, so a re-read carrying the *old* time can
|
* write: the provider regenerates `Instances` rows, so a re-read carrying the
|
||||||
* arrive under a new instance id. Matching the start instant rather than a
|
* *old* time can arrive under a new instance id.
|
||||||
* single day and slot keeps *both* halves of an event that crosses midnight
|
|
||||||
* ghosted until the write lands (#253).
|
|
||||||
*/
|
*/
|
||||||
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
|
private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null)
|
||||||
|
|
||||||
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
||||||
var isDragging: Boolean by mutableStateOf(false)
|
var isDragging: Boolean by mutableStateOf(false)
|
||||||
@@ -332,47 +153,18 @@ class TimelineDragController {
|
|||||||
var settledOnGrid: Boolean by mutableStateOf(false)
|
var settledOnGrid: Boolean by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
/**
|
|
||||||
* The abandoned-move counter, read at the drop so the mark can't be a
|
|
||||||
* composition behind the tick it is compared against.
|
|
||||||
*/
|
|
||||||
var abandonedTicks: StateFlow<Int>? = null
|
|
||||||
|
|
||||||
/** What [abandonedTicks] stood at when the settling drop was let go. */
|
|
||||||
var settledAbandonedMark: Int = 0
|
|
||||||
private set
|
|
||||||
|
|
||||||
private var source: TimedBlock? = null
|
private var source: TimedBlock? = null
|
||||||
private var grab = Offset.Zero
|
private var grab = Offset.Zero
|
||||||
private var pointer = Offset.Zero
|
private var pointer = Offset.Zero
|
||||||
|
|
||||||
/**
|
|
||||||
* How far the picked-up block's top edge sits past the event's own start —
|
|
||||||
* nonzero only for a block clipped at midnight, which is the tail of an
|
|
||||||
* event that began the day before (#253). The gesture tracks the block the
|
|
||||||
* finger is on; this is what turns where it lands back into an event start.
|
|
||||||
*/
|
|
||||||
private var clipOffsetMin = 0
|
|
||||||
|
|
||||||
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
|
|
||||||
private var titleLines = 0
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The slot the block already occupied when it was picked up — not the same
|
* The slot the block already occupied when it was picked up — not the same
|
||||||
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
||||||
*/
|
*/
|
||||||
private var originSlot: Pair<LocalDate, Int>? = null
|
private var originSlot: Pair<LocalDate, Int>? = null
|
||||||
|
|
||||||
fun begin(
|
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
|
||||||
block: TimedBlock,
|
|
||||||
clipOffsetMin: Int,
|
|
||||||
titleLines: Int,
|
|
||||||
pointerInRoot: Offset,
|
|
||||||
blockInRoot: Offset,
|
|
||||||
) {
|
|
||||||
source = block
|
source = block
|
||||||
this.clipOffsetMin = clipOffsetMin
|
|
||||||
this.titleLines = titleLines
|
|
||||||
settling = null
|
settling = null
|
||||||
isDragging = true
|
isDragging = true
|
||||||
liftedInstanceId = block.event.instanceId
|
liftedInstanceId = block.event.instanceId
|
||||||
@@ -390,8 +182,6 @@ class TimelineDragController {
|
|||||||
|
|
||||||
fun cancel() {
|
fun cancel() {
|
||||||
source = null
|
source = null
|
||||||
clipOffsetMin = 0
|
|
||||||
titleLines = 0
|
|
||||||
isDragging = false
|
isDragging = false
|
||||||
liftedInstanceId = null
|
liftedInstanceId = null
|
||||||
originSlot = null
|
originSlot = null
|
||||||
@@ -402,41 +192,32 @@ class TimelineDragController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether [block] is part of the event whose copy is in flight, and so must
|
* Whether [block] is the one whose copy is in flight, and so must stay a
|
||||||
* stay a ghost: the instance the finger picked up, or once dropped whatever
|
* ghost: the instance the finger picked up, or once dropped whatever now
|
||||||
* the re-read now carries at the time it came from — both halves of it, for
|
* sits in the slot it left.
|
||||||
* an event that crosses midnight (#253).
|
|
||||||
*/
|
*/
|
||||||
fun ghosts(block: TimedBlock): Boolean {
|
fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
|
||||||
if (liftedInstanceId == null) return false
|
if (liftedInstanceId == null) return false
|
||||||
if (block.event.instanceId == liftedInstanceId) return true
|
if (block.event.instanceId == liftedInstanceId) return true
|
||||||
val (eventId, start) = settledOrigin ?: return false
|
val (eventId, originDate, originMin) = settledOrigin ?: return false
|
||||||
return block.event.eventId == eventId && block.event.start == start
|
return block.event.eventId == eventId &&
|
||||||
|
date == originDate &&
|
||||||
|
block.startMin == originMin
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a day column now holds, so a settled drop can tell when the grid has
|
* What a day column now holds, so a settled drop can tell when the grid has
|
||||||
* caught up with it. Matched on the landing slot plus either the event row
|
* caught up with it. Matched on the landing slot plus either the event row
|
||||||
* or its title, since a single-occurrence move writes a new `eventId`. A
|
* or its title, since a single-occurrence move writes a new `eventId`.
|
||||||
* blank title matches nothing: every untitled event has one (#253).
|
|
||||||
*/
|
*/
|
||||||
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
|
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
|
||||||
val landed = settling ?: return
|
val landed = settling ?: return
|
||||||
if (settledOnGrid) return
|
if (settledOnGrid || landed.date != date) return
|
||||||
// Where this day would draw it, which for a drop clipped at midnight is
|
|
||||||
// the day's own start rather than the edge the finger held (#253) — and
|
|
||||||
// which for a tail dragged clear of its day is the day before it.
|
|
||||||
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
|
|
||||||
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
|
|
||||||
.firstOrNull() ?: return
|
|
||||||
settledOnGrid = blocks.any { block ->
|
settledOnGrid = blocks.any { block ->
|
||||||
block.startMin == slice.startMin &&
|
block.startMin == landed.startMin &&
|
||||||
(
|
(
|
||||||
block.event.eventId == landed.event.eventId ||
|
block.event.eventId == landed.event.eventId ||
|
||||||
(
|
block.event.title == landed.event.title
|
||||||
landed.event.title.isNotBlank() &&
|
|
||||||
block.event.title == landed.event.title
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,18 +230,12 @@ class TimelineDragController {
|
|||||||
fun finish(): TimelineDrop? {
|
fun finish(): TimelineDrop? {
|
||||||
val landed = drag
|
val landed = drag
|
||||||
val origin = originSlot
|
val origin = originSlot
|
||||||
val clipOffset = clipOffsetMin
|
|
||||||
cancel()
|
cancel()
|
||||||
if (landed == null || landed.slot == origin) return null
|
if (landed == null || landed.slot == origin) return null
|
||||||
// An edge piece is a hint for the finger, not a preview of the landing
|
settling = landed
|
||||||
// slot: the event belongs to a day this timeline has no column for, so
|
|
||||||
// [noteGrid] can never seat it and it would sit at the top of today for
|
|
||||||
// the whole settle timeout, long after the event moved off-screen.
|
|
||||||
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
|
|
||||||
liftedInstanceId = landed.event.instanceId
|
liftedInstanceId = landed.event.instanceId
|
||||||
settledOrigin = landed.event.eventId to landed.event.start
|
settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) }
|
||||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
return TimelineDrop(landed.event, landed.date, landed.startMin)
|
||||||
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -496,46 +271,28 @@ class TimelineDragController {
|
|||||||
val origin = grid.positionInRoot()
|
val origin = grid.positionInRoot()
|
||||||
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
|
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
|
||||||
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||||
val eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt()
|
// Clamp the start into the target day; a tail past midnight is fine.
|
||||||
.coerceAtLeast(0)
|
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
||||||
val floor = dragFloorMin(clipOffsetMin, eventSpan)
|
// The event's own length, not the block's: TimedBlock.endMin is clipped
|
||||||
val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
// at midnight, which would draw a 22:00–02:00 event as a two-hour copy.
|
||||||
val eventStartMin = startMin - clipOffsetMin
|
val span = (block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0)
|
||||||
|
// The column the finger is over on screen, and the day that column shows.
|
||||||
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
||||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||||
val visible = -dayIndex..days.lastIndex - dayIndex
|
val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
||||||
val onGrid = dragSlices(eventStartMin, eventSpan, within = visible)
|
|
||||||
val slices = onGrid.ifEmpty {
|
|
||||||
listOfNotNull(edgeDragSlice(eventStartMin, eventSpan, visible))
|
|
||||||
}
|
|
||||||
drag = TimelineDrag(
|
drag = TimelineDrag(
|
||||||
event = block.event,
|
event = block.event,
|
||||||
date = days[dayIndex],
|
date = days[dayIndex],
|
||||||
startMin = startMin,
|
startMin = startMin,
|
||||||
eventStartMin = eventStartMin,
|
endMin = startMin + span,
|
||||||
eventSpanMin = eventSpan,
|
topLeftInRoot = Offset(
|
||||||
titleLines = titleLines,
|
x = origin.x + column * columnPx,
|
||||||
// Bounded to the columns this timeline actually shows: a day it
|
y = origin.y + startMin / 60f * hourPx,
|
||||||
// doesn't has no piece to draw. The write is unaffected.
|
),
|
||||||
pieces = slices
|
sizePx = IntSize(
|
||||||
.map { slice ->
|
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||||
val day = dayIndex + slice.dayOffset
|
height.roundToInt(),
|
||||||
val col = if (geometry.isRtl) days.lastIndex - day else day
|
),
|
||||||
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
|
|
||||||
TimelineDragPiece(
|
|
||||||
topLeftInRoot = Offset(
|
|
||||||
x = origin.x + col * columnPx,
|
|
||||||
y = origin.y + bounds.top,
|
|
||||||
),
|
|
||||||
sizePx = IntSize(
|
|
||||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
|
||||||
bounds.height.roundToInt(),
|
|
||||||
),
|
|
||||||
continuesBefore = slice.continuesBefore,
|
|
||||||
continuesAfter = slice.continuesAfter,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
edgeOnly = onGrid.isEmpty(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,52 +334,24 @@ fun rememberTimelineDragController(): TimelineDragController {
|
|||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
||||||
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
||||||
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far this block's top edge sits past its event's start, in minutes — zero
|
* True when [block] actually begins on [day] rather than being the tail of an
|
||||||
* for a block that begins on [day], and the part already run for the tail of an
|
* event that started earlier. A clipped block's top edge is midnight, not the
|
||||||
* event that started earlier, whose top edge is midnight rather than the event's
|
* event's start, so dragging it would move the event to a time it never had.
|
||||||
* start. Dragging such a tail moves the event by where *its own* top edge lands,
|
|
||||||
* so the offset has to come back off at the drop (#253).
|
|
||||||
*/
|
*/
|
||||||
fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
|
fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean =
|
||||||
val start = event.start.toLocalDateTime(zone)
|
event.start.toLocalDateTime(zone).date == day
|
||||||
val days = (day.toEpochDays() - start.date.toEpochDays()).toInt()
|
|
||||||
val minutes = days * MINUTES_PER_DAY + startMin - start.time.toSecondOfDay() / 60
|
|
||||||
return minutes.coerceAtLeast(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when the event began before [day] — this block is its tail, cut at
|
* The instant a drop asks for, read in the zone the timeline is drawn in. A drop
|
||||||
* midnight rather than starting where the event does.
|
* into a spring-forward gap has no instant of its own; `toInstant` resolves it
|
||||||
|
* forward by the missing hour, and the block visibly settles there.
|
||||||
*/
|
*/
|
||||||
fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean =
|
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
||||||
event.start < day.atStartOfDayIn(zone)
|
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
|
||||||
|
|
||||||
/** True when the event runs past [day]'s midnight, so this block is cut there. */
|
|
||||||
fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
|
|
||||||
event.end > day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The instant a drop asks for — the *event's* new start, so the tail of an
|
|
||||||
* overnight event drops back onto the day its own start belongs to. Read in the
|
|
||||||
* zone the timeline is drawn in. A drop into a spring-forward gap has no instant
|
|
||||||
* of its own; `toInstant` resolves it forward by the missing hour, and the block
|
|
||||||
* visibly settles there.
|
|
||||||
*/
|
|
||||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant {
|
|
||||||
// Wall clock throughout, and past this day at either end: a block's top edge
|
|
||||||
// can be dragged above its midnight and a tail's below the next one, so the
|
|
||||||
// start is resolved as a day plus a minute of it rather than as an offset
|
|
||||||
// from an instant — which would shift by an hour across a DST boundary.
|
|
||||||
val fromMidnight = startMin - clipOffsetMin
|
|
||||||
val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
|
|
||||||
val minute = fromMidnight.mod(MINUTES_PER_DAY)
|
|
||||||
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A beat after the grid draws the drop, so its own block is under way before the
|
* A beat after the grid draws the drop, so its own block is under way before the
|
||||||
@@ -650,11 +379,12 @@ const val SETTLE_FADE_MILLIS: Int = 250
|
|||||||
@Composable
|
@Composable
|
||||||
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
||||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||||
val density = LocalDensity.current
|
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val density = LocalDensity.current
|
||||||
val moveInFlight = moveInFlight()
|
val moveInFlight = moveInFlight()
|
||||||
|
|
||||||
// Both live here rather than beside the controller: they read [drag], which
|
// Both live here rather than beside the controller: they read [drag], which
|
||||||
@@ -667,14 +397,9 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
|||||||
// recurring drop's scope dialog is up — and then until the grid draws the
|
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||||
// drop itself.
|
// drop itself.
|
||||||
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||||
val abandoned = abandonedMoves()
|
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
|
||||||
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) {
|
|
||||||
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
||||||
// Past the mark this drop was let go on, it was abandoned: nothing will
|
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||||
// land and there is nothing to wait for.
|
|
||||||
if (abandoned == controller.settledAbandonedMark) {
|
|
||||||
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
|
||||||
}
|
|
||||||
handingOver = true
|
handingOver = true
|
||||||
controller.handOver()
|
controller.handOver()
|
||||||
delay(SETTLE_FADE_MILLIS.toLong())
|
delay(SETTLE_FADE_MILLIS.toLong())
|
||||||
@@ -701,179 +426,57 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
|||||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||||
label = "drag-handover",
|
label = "drag-handover",
|
||||||
)
|
)
|
||||||
val paint = eventPaint(drag.event, dark)
|
val fill = eventFill(drag.event.color, dark, soften)
|
||||||
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
// Read off the event rather than the held block, so grabbing either half
|
// An end past midnight wraps rather than saturating, so a four-hour
|
||||||
// of an event that crosses midnight names the same hours. An end past
|
// event dragged to 22:00 reads "22:00–02:00" and not "22:00–24:00".
|
||||||
// midnight wraps rather than saturating, so a four-hour event dragged to
|
val endMin = if (drag.endMin > MINUTES_PER_DAY) {
|
||||||
// 22:00 reads "22:00–02:00" and not "22:00–24:00".
|
drag.endMin % MINUTES_PER_DAY
|
||||||
val startMin = drag.eventStartMin.mod(MINUTES_PER_DAY)
|
} else {
|
||||||
val rawEnd = startMin + drag.eventSpanMin
|
drag.endMin
|
||||||
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
|
}
|
||||||
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}–" +
|
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" +
|
||||||
formatMinuteOfDay(endMin, use24Hour, locale)
|
formatMinuteOfDay(endMin, use24Hour, locale)
|
||||||
// The timeline's own bounds. The copy is drawn over the whole calendar
|
|
||||||
// so no column clip or rounded corner cuts it, but it still belongs to
|
|
||||||
// the timeline: a piece a whole day tall reaches far past both ends of
|
|
||||||
// the viewport, and unclipped it paints over the headers above (#267).
|
|
||||||
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
|
|
||||||
// How much of each piece the viewport actually shows. A multi-day event
|
|
||||||
// has a piece per day, and the widest one is a full 24 hours — taller
|
|
||||||
// than the screen, its top at a midnight scrolled out of sight. Picking
|
|
||||||
// the range's piece by raw height put it there, off the top of the
|
|
||||||
// timeline; picking by *visible* height puts it where it can be read.
|
|
||||||
val shown = drag.pieces.map { piece ->
|
|
||||||
if (port == null) {
|
|
||||||
piece.sizePx.height.toFloat()
|
|
||||||
} else {
|
|
||||||
val top = maxOf(piece.topLeftInRoot.y, port.top)
|
|
||||||
val bottom = minOf(piece.topLeftInRoot.y + piece.sizePx.height, port.bottom)
|
|
||||||
bottom - top
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val labelled = shown.indices.maxByOrNull { shown[it] }
|
|
||||||
Box(
|
Box(
|
||||||
modifier = if (port == null) {
|
modifier = Modifier
|
||||||
Modifier
|
// Absolute: these are root coordinates, and the direction-aware
|
||||||
} else {
|
// offset would mirror them across the screen in an RTL layout.
|
||||||
Modifier
|
.absoluteOffset {
|
||||||
.absoluteOffset {
|
IntOffset(
|
||||||
IntOffset(
|
(drag.topLeftInRoot.x - origin.x).roundToInt(),
|
||||||
(port.left - origin.x).roundToInt(),
|
(drag.topLeftInRoot.y - origin.y).roundToInt(),
|
||||||
(port.top - origin.y).roundToInt(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.size(
|
|
||||||
width = with(density) { port.width.toDp() },
|
|
||||||
height = with(density) { port.height.toDp() },
|
|
||||||
)
|
)
|
||||||
.clipToBounds()
|
}
|
||||||
},
|
.size(
|
||||||
|
width = with(density) { drag.sizePx.width.toDp() },
|
||||||
|
height = with(density) { drag.sizePx.height.toDp() },
|
||||||
|
)
|
||||||
|
.padding(horizontal = 1.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = 1f + 0.02f * lift
|
||||||
|
scaleY = 1f + 0.02f * lift
|
||||||
|
shadowElevation = 8.dp.toPx() * lift
|
||||||
|
alpha = copyAlpha
|
||||||
|
shape = RoundedCornerShape(4.dp)
|
||||||
|
clip = false
|
||||||
|
}
|
||||||
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
) {
|
) {
|
||||||
val pieceOrigin = port?.topLeft ?: origin
|
Column {
|
||||||
drag.pieces.forEachIndexed { index, piece ->
|
Text(
|
||||||
DragCopy(
|
text = title,
|
||||||
topLeftInRoot = piece.topLeftInRoot,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
overlayOrigin = pieceOrigin,
|
maxLines = 1,
|
||||||
sizePx = piece.sizePx,
|
overflow = TextOverflow.Ellipsis,
|
||||||
paint = paint,
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
|
|
||||||
cuts = timedBlockCuts(piece.continuesBefore, piece.continuesAfter),
|
|
||||||
lift = lift,
|
|
||||||
alpha = copyAlpha,
|
|
||||||
title = title,
|
|
||||||
titleLines = drag.titleLines,
|
|
||||||
// Once for the whole event: repeated, it would name it per day.
|
|
||||||
label = label.takeIf { index == labelled },
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One piece of a dragged block, floating over the calendar. */
|
|
||||||
@Composable
|
|
||||||
private fun DragCopy(
|
|
||||||
topLeftInRoot: Offset,
|
|
||||||
overlayOrigin: Offset,
|
|
||||||
sizePx: IntSize,
|
|
||||||
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
|
|
||||||
// offset would mirror them across the screen in an RTL layout.
|
|
||||||
.absoluteOffset {
|
|
||||||
IntOffset(
|
|
||||||
(topLeftInRoot.x - overlayOrigin.x).roundToInt(),
|
|
||||||
(topLeftInRoot.y - overlayOrigin.y).roundToInt(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Required: the parent is the viewport's size, and a plain `size`
|
|
||||||
// would let it cap a piece a whole day tall at one screen — drawn
|
|
||||||
// from a midnight scrolled off the top, that ended the column a
|
|
||||||
// scroll offset short of its own bottom (#267). The parent clips it.
|
|
||||||
.requiredSize(width = width, height = height)
|
|
||||||
.padding(horizontal = BLOCK_OUTER_INSET)
|
|
||||||
.graphicsLayer {
|
|
||||||
// Anchored at the top edge, not the middle: scaled about the
|
|
||||||
// centre, the 2% lift raises the top by 1% of the height — a few
|
|
||||||
// pixels on an hour, but a steady upward creep of the text as a
|
|
||||||
// dragged multi-day piece grows, and a shear against the
|
|
||||||
// timeline's clip. It snapped back when the lift animated out
|
|
||||||
// on drop (#267).
|
|
||||||
transformOrigin = TransformOrigin(0.5f, 0f)
|
|
||||||
scaleX = 1f + 0.02f * lift
|
|
||||||
scaleY = 1f + 0.02f * lift
|
|
||||||
shadowElevation = 8.dp.toPx() * lift
|
|
||||||
this.alpha = alpha
|
|
||||||
this.shape = shape
|
|
||||||
clip = false
|
|
||||||
}
|
|
||||||
.eventSurface(paint, shape, cuts)
|
|
||||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset),
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
if (lines > 0) {
|
|
||||||
BlockTitle(
|
|
||||||
title = title,
|
|
||||||
maxLines = lines,
|
|
||||||
color = paint.titleInk,
|
|
||||||
textDecoration = paint.decoration,
|
|
||||||
fontWeight = paint.titleWeight,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (showTime) {
|
|
||||||
val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1)
|
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
// As the block it lifted off sets its own time (#219).
|
style = MaterialTheme.typography.labelSmall,
|
||||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
maxLines = 1,
|
||||||
maxLines = timeMaxLines,
|
overflow = TextOverflow.Ellipsis,
|
||||||
overflow = overflow.overflow,
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
softWrap = overflow.softWrap,
|
|
||||||
color = paint.secondaryInk,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,17 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
|
|||||||
*/
|
*/
|
||||||
const val MIN_EVENT_FRACTION = 26f / 60f
|
const val MIN_EVENT_FRACTION = 26f / 60f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrowest an event block may be and still wrap its title over several lines.
|
||||||
|
*
|
||||||
|
* Wrapping is driven by the block's height, so a tall block on a lane-split
|
||||||
|
* column would otherwise stack two or three characters per line — "Da/ily",
|
||||||
|
* "Fa/rmer/s…" — which reads worse than one ellipsised line. A full week column
|
||||||
|
* clears this on any phone; a split one never does, while the day view's much
|
||||||
|
* wider columns keep wrapping even several lanes deep.
|
||||||
|
*/
|
||||||
|
val MIN_TITLE_WRAP_WIDTH = 36.dp
|
||||||
|
|
||||||
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
|
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */
|
||||||
val FIT_DAY_MIN = 24.dp
|
val FIT_DAY_MIN = 24.dp
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.compose.ui.input.pointer.PointerEventPass
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.ceil
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -196,26 +197,18 @@ fun rememberTimelinePinchZoom(
|
|||||||
* names, jumping the whole column as a pinch drifts across each half pixel.
|
* names, jumping the whole column as a pinch drifts across each half pixel.
|
||||||
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
|
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
|
||||||
*
|
*
|
||||||
* The ceiling is pulled onto that grid too, downwards, so it stays a height the
|
* The bounds themselves are pulled onto that grid too, each in the direction
|
||||||
* pinch can actually land on.
|
* that keeps its own promise — up for the fill floor, so no dead space opens
|
||||||
*
|
* under midnight, down for the ceiling. A fractional bound would be a height the
|
||||||
* [fillPx] is deliberately *not* rounded (#290). It is the one height the whole
|
* pinch can be held against but never actually land on, and the difference feeds
|
||||||
* day exactly fills the viewport at, and it is the same value
|
* the focal anchor a scroll correction on every frame the fingers sit still.
|
||||||
* [TimelineScale.FitDay] resolves to — rounding it up by the fraction of a pixel
|
|
||||||
* that 24 hours don't divide the viewport into leaves the timeline a pixel per
|
|
||||||
* hour taller than its own viewport, so a pinched-all-the-way-out day still
|
|
||||||
* scrolls a hair and bounces off Android's overscroll stretch, while the
|
|
||||||
* identical FitDay preset sits still. Being the clamp result rather than a bound
|
|
||||||
* the gesture is merely held against, it is a height the pinch does land on: the
|
|
||||||
* next frame reads it back unchanged and the focal anchor is handed nothing to
|
|
||||||
* correct.
|
|
||||||
*/
|
*/
|
||||||
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
|
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
|
||||||
// Filling the viewport wins over the ceiling: on a screen tall enough for
|
// Filling the viewport wins over the ceiling: on a screen tall enough for
|
||||||
// the two to disagree, dead space is the worse of the two failures.
|
// the two to disagree, dead space is the worse of the two failures.
|
||||||
target.roundToInt().toFloat()
|
target.roundToInt().toFloat()
|
||||||
.coerceAtMost(floor(maxPx))
|
.coerceAtMost(floor(maxPx))
|
||||||
.coerceAtLeast(fillPx)
|
.coerceAtLeast(ceil(fillPx))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The scroll offset that keeps the moment under [centroidY] under it after the
|
* The scroll offset that keeps the moment under [centroidY] under it after the
|
||||||
|
|||||||
@@ -1,81 +1,26 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.material.icons.filled.Today
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LocalContentColor
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
|
|
||||||
private val GlyphShape = RoundedCornerShape(7.dp)
|
|
||||||
private val GlyphBorder = 1.5.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Height of the day number. Expressed in dp and converted to sp at draw time so
|
|
||||||
* it does not follow the font scale: this is an icon glyph sharing the bar with
|
|
||||||
* stroke icons, and a two-digit day that grew with the text would push the bar
|
|
||||||
* past the width #165 measured it to. The button's content description carries
|
|
||||||
* the meaning for screen readers, so nothing is lost by holding it still.
|
|
||||||
*/
|
|
||||||
private val GlyphTextHeight = 12.dp
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The top-bar "jump to today" icon button, shared by every calendar view's app
|
* The top-bar "jump to today" icon button, shared by every calendar view's app
|
||||||
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
|
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
|
||||||
* today control into the toolbar; renders nothing when [show] is false, so it
|
* today control into the toolbar; renders nothing when [show] is false, so it
|
||||||
* drops straight into an app bar's `actions` slot without a wrapping condition.
|
* drops straight into an app bar's `actions` slot without a wrapping condition.
|
||||||
*
|
|
||||||
* The glyph is the current day number in an outlined box rather than a generic
|
|
||||||
* calendar icon (#220), so the bar also says what day it is. Outlined rather
|
|
||||||
* than the month grid's filled circle, which reads as "this cell is today" and
|
|
||||||
* would sit heavier here than the stroke icons beside it.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TodayAction(show: Boolean, onToday: () -> Unit) {
|
fun TodayAction(show: Boolean, onToday: () -> Unit) {
|
||||||
if (!show) return
|
if (!show) return
|
||||||
val now = rememberCurrentMinute()
|
IconButton(onClick = onToday) {
|
||||||
// Only the day number is read, so the bar recomposes at midnight rather
|
Icon(
|
||||||
// than on every minute tick.
|
imageVector = Icons.Default.Today,
|
||||||
val day by remember {
|
contentDescription = stringResource(R.string.today_jump_action),
|
||||||
derivedStateOf { now.value.toLocalDateTime(TimeZone.currentSystemDefault()).date.day }
|
)
|
||||||
}
|
|
||||||
val description = stringResource(R.string.today_jump_action)
|
|
||||||
IconButton(
|
|
||||||
onClick = onToday,
|
|
||||||
modifier = Modifier.semantics { contentDescription = description },
|
|
||||||
) {
|
|
||||||
val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() }
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(AppBarSpacing.IconSize)
|
|
||||||
.border(GlyphBorder, LocalContentColor.current, GlyphShape),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = day.toString(),
|
|
||||||
fontSize = textSize,
|
|
||||||
lineHeight = textSize,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = LocalContentColor.current,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,26 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-bar pill that shows the current view and cycles to the next one on tap
|
* Top-bar pill that shows the current view and cycles to the next one on tap
|
||||||
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
|
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
|
||||||
*
|
|
||||||
* Renders nothing when [cycle] holds fewer than [QuickSwitchConfig.MIN_CYCLE]
|
|
||||||
* views (#150), so it drops straight into an app bar's `actions` slot without a
|
|
||||||
* wrapping condition — the same way [TodayAction] handles being turned off.
|
|
||||||
*
|
|
||||||
* [trailingInset] lands the pill's background on the bar's own rhythm; pass
|
|
||||||
* `0.dp` outside a top app bar, where M3's actions padding is not there to
|
|
||||||
* cancel (#165).
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ViewSwitcherPill(
|
fun ViewSwitcherPill(
|
||||||
current: CalendarView,
|
current: CalendarView,
|
||||||
cycle: List<CalendarView>,
|
|
||||||
onCycle: () -> Unit,
|
onCycle: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
|
|
||||||
) {
|
) {
|
||||||
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
|
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = onCycle,
|
onClick = onCycle,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
modifier = modifier.padding(end = trailingInset),
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Text(stringResource(current.labelRes))
|
Text(stringResource(current.labelRes))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -39,7 +40,6 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
@@ -53,8 +53,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -63,6 +65,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.customActions
|
import androidx.compose.ui.semantics.customActions
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -70,9 +73,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventSurface
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||||
@@ -81,12 +81,7 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
@@ -96,15 +91,9 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||||
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
|
import de.jeanlucmakiola.calendula.ui.common.beginsOn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ChipCuts
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||||
@@ -114,22 +103,24 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
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.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
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.hourHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -141,8 +132,7 @@ import kotlin.time.Clock
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
|
|
||||||
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
||||||
@@ -176,9 +166,21 @@ fun DayScreen(
|
|||||||
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
|
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// The all-day strip shares the app bar's scrolled colour so the whole top
|
||||||
|
// region elevates together once the timeline scrolls under it.
|
||||||
|
val topSectionColor by animateColorAsState(
|
||||||
|
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
label = "day-top-section-color",
|
||||||
|
)
|
||||||
|
|
||||||
val isOnToday = when (val s = state) {
|
val isOnToday = when (val s = state) {
|
||||||
is DayUiState.Success -> s.date == s.today
|
is DayUiState.Success -> s.date == s.today
|
||||||
else -> true
|
else -> true
|
||||||
@@ -236,19 +238,19 @@ fun DayScreen(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
DayTopBar(
|
DayTopBar(
|
||||||
date = date,
|
date = date,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onJumpToDate = jumpToDate,
|
onJumpToDate = jumpToDate,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@@ -263,6 +265,7 @@ fun DayScreen(
|
|||||||
DayContent(
|
DayContent(
|
||||||
state = state,
|
state = state,
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
|
topSectionColor = topSectionColor,
|
||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
@@ -280,6 +283,7 @@ fun DayScreen(
|
|||||||
private fun DayContent(
|
private fun DayContent(
|
||||||
state: DayUiState,
|
state: DayUiState,
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
|
topSectionColor: Color,
|
||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
@@ -340,13 +344,14 @@ private fun DayContent(
|
|||||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
is DayUiState.Success -> DaySuccess(
|
is DayUiState.Success -> DaySuccess(
|
||||||
state = s,
|
state = s,
|
||||||
|
topSectionColor = topSectionColor,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = { drop ->
|
onDrop = { drop ->
|
||||||
move?.move(
|
val took = move?.move(
|
||||||
MoveRequest(
|
MoveRequest(
|
||||||
eventId = drop.event.eventId,
|
eventId = drop.event.eventId,
|
||||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
@@ -354,6 +359,9 @@ private fun DayContent(
|
|||||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// Refused, so nothing will land: let the copy go now
|
||||||
|
// rather than hold it out for a settle that never comes.
|
||||||
|
if (took != true) dragController.release()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -365,6 +373,7 @@ private fun DayContent(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun DaySuccess(
|
internal fun DaySuccess(
|
||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
|
topSectionColor: Color,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
@@ -375,22 +384,16 @@ internal fun DaySuccess(
|
|||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// All-day strip collapses to nothing when the day has no all-day events,
|
// All-day strip collapses to nothing when the day has no all-day events,
|
||||||
// so the timeline sits directly under the app bar.
|
// so the timeline sits directly under the app bar.
|
||||||
// This strip is painted on `surface`, so an outlined chip has to fill
|
AllDayStrip(
|
||||||
// with that and not with the column's container (#230).
|
state = state,
|
||||||
CompositionLocalProvider(
|
height = allDayHeight,
|
||||||
LocalChipGround provides MaterialTheme.colorScheme.surface,
|
onEventClick = onEventClick,
|
||||||
) {
|
modifier = Modifier
|
||||||
AllDayStrip(
|
.fillMaxWidth()
|
||||||
state = state,
|
.background(topSectionColor),
|
||||||
height = allDayHeight,
|
)
|
||||||
onEventClick = onEventClick,
|
// Breathing room between the (colour-shifting) top section and the
|
||||||
modifier = Modifier
|
// scrolling timeline below.
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.surface),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Breathing room between the top section and the scrolling timeline
|
|
||||||
// below.
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
@@ -410,25 +413,20 @@ private fun DayTopBar(
|
|||||||
currentYear: Int,
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
quickSwitchViews: List<CalendarView>,
|
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val (title, shortTitle) = remember(date, locale, currentYear) {
|
|
||||||
formatDayTitle(date, locale, currentYear) to
|
|
||||||
formatDayTitle(date, locale, currentYear, abbreviated = true)
|
|
||||||
}
|
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
CalendarTitleButton(
|
CalendarTitleButton(
|
||||||
title = title,
|
title = formatDayTitle(date, locale, currentYear),
|
||||||
currentDate = date,
|
currentDate = date,
|
||||||
onJumpToDate = onJumpToDate,
|
onJumpToDate = onJumpToDate,
|
||||||
shortTitle = shortTitle,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -449,17 +447,15 @@ private fun DayTopBar(
|
|||||||
}
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
cycle = quickSwitchViews,
|
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
|
||||||
// it, but here the bar meets the header on the same surface and the
|
|
||||||
// tint is what makes that seam look like a separate block (#186).
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,25 +515,23 @@ private fun AllDayBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val paint = eventPaint(event, dark)
|
val soften = LocalSoftenColors.current
|
||||||
|
val fill = eventFill(event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.eventSurface(paint, EventChipShape)
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
val titleOverflow = eventTitleOverflow()
|
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall.trimmedLines(),
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = titleOverflow.overflow,
|
overflow = TextOverflow.Ellipsis,
|
||||||
softWrap = titleOverflow.softWrap,
|
color = eventInk(fill),
|
||||||
color = paint.titleInk,
|
textDecoration = declinedDecoration(event.isDeclined),
|
||||||
fontWeight = paint.titleWeight,
|
|
||||||
textDecoration = paint.decoration,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -555,12 +549,6 @@ private fun Timeline(
|
|||||||
val zoom = LocalTimelineZoom.current
|
val zoom = LocalTimelineZoom.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||||
// What the hour grid takes off a block's ends, for the floating copy to
|
|
||||||
// take off its own. Per-end, a cut edge keeps it — that much the drag
|
|
||||||
// geometry decides itself, from the slice it is drawing.
|
|
||||||
val blockInsetPx = with(density) {
|
|
||||||
hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx()
|
|
||||||
}
|
|
||||||
|
|
||||||
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
// 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
|
// timeline's own viewport height, which is only known here — below the top
|
||||||
@@ -614,7 +602,6 @@ private fun Timeline(
|
|||||||
it.grid = coords
|
it.grid = coords
|
||||||
it.scroll = scrollState
|
it.scroll = scrollState
|
||||||
it.hourPx = with(density) { hourHeight.toPx() }
|
it.hourPx = with(density) { hourHeight.toPx() }
|
||||||
it.blockInsetPx = blockInsetPx
|
|
||||||
it.columnGapPx = 0f
|
it.columnGapPx = 0f
|
||||||
it.columnWidthPx = coords.size.width.toFloat()
|
it.columnWidthPx = coords.size.width.toFloat()
|
||||||
it.days = listOf(state.date)
|
it.days = listOf(state.date)
|
||||||
@@ -641,8 +628,8 @@ private fun DayColumnCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||||
val showHourGrid = LocalShowHourGrid.current
|
val showHourLines = LocalShowHourLines.current
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
// Tells a settled drop when this column has caught up with it.
|
// Tells a settled drop when this column has caught up with it.
|
||||||
LaunchedEffect(blocks, dragController.settling) {
|
LaunchedEffect(blocks, dragController.settling) {
|
||||||
dragController.noteGrid(date, blocks)
|
dragController.noteGrid(date, blocks)
|
||||||
@@ -658,23 +645,16 @@ private fun DayColumnCard(
|
|||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
shape = RectangleShape,
|
shape = RectangleShape,
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
// With the grid on, the container colour moves onto the hour cells
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
// and the column behind them recedes to surface, so an hour boundary
|
|
||||||
// reads as negative space between two surfaces.
|
|
||||||
containerColor = if (showHourGrid) {
|
|
||||||
MaterialTheme.colorScheme.surface
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
// The hour cells sit over the column background but under the
|
// Faint hour separators sit over the column background but under
|
||||||
// event blocks (drawBehind paints before the children).
|
// the event blocks (drawBehind paints before the children).
|
||||||
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer)
|
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
|
||||||
// Tap an empty slot to create an event there. Taps on event
|
// Tap an empty slot to create an event there. Taps on event
|
||||||
// blocks are consumed by their own click handler first, so this
|
// blocks are consumed by their own click handler first, so this
|
||||||
// only fires on the column background. Snaps to the tapped hour.
|
// only fires on the column background. Snaps to the tapped hour.
|
||||||
@@ -706,34 +686,19 @@ private fun DayColumnCard(
|
|||||||
width = laneWidth,
|
width = laneWidth,
|
||||||
height = height,
|
height = height,
|
||||||
)
|
)
|
||||||
// Where this block is cut at midnight, which decides both
|
|
||||||
// its shape and the half-gap it gives up to the hour cells.
|
|
||||||
val cuts = remember(block, date, zone) {
|
|
||||||
timedBlockCuts(
|
|
||||||
block.continuesBefore(date, zone),
|
|
||||||
block.continuesAfter(date, zone),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
|
|
||||||
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
|
|
||||||
val blockHeight = (place.height - topInset - bottomInset)
|
|
||||||
.coerceAtLeast(0.dp)
|
|
||||||
EventBlock(
|
EventBlock(
|
||||||
block = block,
|
block = block,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
height = blockHeight,
|
height = place.height,
|
||||||
width = place.width,
|
|
||||||
date = date,
|
date = date,
|
||||||
cuts = cuts,
|
|
||||||
topInset = topInset,
|
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
onClick = { onEventClick(block.event) },
|
onClick = { onEventClick(block.event) },
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = place.x, y = place.y + topInset)
|
.offset(x = place.x, y = place.y)
|
||||||
.width(place.width)
|
.width(place.width)
|
||||||
.height(blockHeight)
|
.height(place.height)
|
||||||
.padding(horizontal = BLOCK_OUTER_INSET),
|
.padding(horizontal = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -750,10 +715,7 @@ private fun EventBlock(
|
|||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
height: Dp,
|
height: Dp,
|
||||||
width: Dp,
|
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
cuts: ChipCuts,
|
|
||||||
topInset: Dp,
|
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
@@ -765,65 +727,46 @@ private fun EventBlock(
|
|||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val metrics = rememberBlockTextMetrics(height)
|
val titleLineHeight = with(density) {
|
||||||
// A block that cannot afford both lines spends its space on the title, and
|
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||||
// one too short even for that drops the title rather than serving a sliced one.
|
}
|
||||||
// Height alone decides: a duration threshold would keep hiding the time on a
|
val timeLineHeight = with(density) {
|
||||||
// half-hour block the user has pinched open to three times the room it needs.
|
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||||
val showTime = metrics.available >= metrics.titleLine + metrics.timeLine
|
}
|
||||||
val showTitle = metrics.fitsTitle
|
// What's left for text once the 2.dp top/bottom padding is paid for. A block
|
||||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
// that cannot afford both lines spends its space on the title, and one too
|
||||||
// Only lines the block can actually draw: a block too short for the time is
|
// short even for that drops the title rather than serving a sliced one.
|
||||||
// too short for a second title line too, and asking for one served a sliced
|
val available = height - 4.dp
|
||||||
// one — as well as handing the drag copy a count it couldn't honour, so the
|
val showTime = block.endMin - block.startMin >= 45 &&
|
||||||
// title re-wrapped the moment the block was lifted (#267).
|
available >= titleLineHeight + timeLineHeight
|
||||||
val titleBudget = metrics.titleBudget(metrics.available).coerceAtLeast(1)
|
val showTitle = available >= titleLineHeight
|
||||||
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
|
val soften = LocalSoftenColors.current
|
||||||
// On a day column — wide enough for "09:30–11:00" several times over — the
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
// range never needs the second line, until lanes cut the column down.
|
|
||||||
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
|
|
||||||
if (showTime) metrics.timeLine else 0.dp
|
|
||||||
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
|
||||||
val paint = eventPaint(block.event, dark)
|
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
val moveAction = eventMoveAction(block.event)
|
val moveAction = eventMoveAction(block.event)
|
||||||
val draggable = eventDragAllowed(block.event)
|
// A block clipped at the top continues from the previous day: its top edge
|
||||||
// The drop takes this offset back off, so a tail clipped at midnight lands
|
// is midnight, not the event's start, so dragging it would invent a time.
|
||||||
// where the event's own start belongs (#253).
|
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
|
||||||
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
|
||||||
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
|
|
||||||
val topInsetPx = with(density) { topInset.toPx() }
|
|
||||||
val dragModifier = rememberEventDragSource(
|
val dragModifier = rememberEventDragSource(
|
||||||
enabled = draggable,
|
enabled = draggable,
|
||||||
key = block.event.instanceId,
|
key = block.event.instanceId,
|
||||||
onPickUp = { pointer, blockRoot, _ ->
|
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
|
||||||
dragController.begin(
|
|
||||||
block = block,
|
|
||||||
clipOffsetMin = clipOffset,
|
|
||||||
titleLines = if (showTitle) titleMaxLines else 0,
|
|
||||||
pointerInRoot = pointer,
|
|
||||||
// Measured off the block's placement rather than off where the
|
|
||||||
// hour grid seats it: the copy is drawn from that placement and
|
|
||||||
// re-seated the same half-gap down (#267).
|
|
||||||
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onMove = dragController::move,
|
onMove = dragController::move,
|
||||||
onDrop = { dragController.finish()?.let(onDrop) },
|
onDrop = { dragController.finish()?.let(onDrop) },
|
||||||
onCancel = dragController::cancel,
|
onCancel = dragController::cancel,
|
||||||
)
|
)
|
||||||
val lifted = draggable && dragController.ghosts(block)
|
val lifted = draggable && dragController.ghosts(block, date)
|
||||||
val ghost = ghostAlpha(lifted)
|
val ghost = ghostAlpha(lifted)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.eventSurface(paint, shape, cuts)
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
// After clickable, so it is the inner node and wins the main pass;
|
// After clickable, so it is the inner node and wins the main pass;
|
||||||
// the tap still works, since a drag consumes the up.
|
// the tap still works, since a drag consumes the up.
|
||||||
.then(dragModifier)
|
.then(dragModifier)
|
||||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = "$title, $timeLabel"
|
contentDescription = "$title, $timeLabel"
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
@@ -831,19 +774,19 @@ private fun EventBlock(
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (showTitle) {
|
if (showTitle) {
|
||||||
BlockTitle(
|
Text(
|
||||||
title = title,
|
text = title,
|
||||||
maxLines = titleMaxLines,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = paint.titleInk,
|
maxLines = if (showTime) 1 else 2,
|
||||||
textDecoration = paint.decoration,
|
overflow = TextOverflow.Ellipsis,
|
||||||
fontWeight = paint.titleWeight,
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
|
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
BlockTimeLabel(
|
BlockTimeLabel(
|
||||||
label = timeLabel,
|
label = timeLabel,
|
||||||
color = paint.secondaryInk,
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
maxLines = timeMaxLines,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -857,12 +800,7 @@ private fun DayLoading() {
|
|||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
// Same scale resolution as the loaded timeline, so the skeleton's column
|
// Same scale resolution as the loaded timeline, so the skeleton's column
|
||||||
// doesn't resize the moment the real day arrives.
|
// doesn't resize the moment the real day arrives.
|
||||||
val hourHeight = scale.hourHeight(maxHeight)
|
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||||
val totalHeight = hourHeight * 24
|
|
||||||
// The skeleton wears the loaded column's own ground, so the arrival of
|
|
||||||
// the real day doesn't flash a solid block into a gapped grid.
|
|
||||||
val showHourGrid = LocalShowHourGrid.current
|
|
||||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -875,11 +813,7 @@ private fun DayLoading() {
|
|||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(totalHeight)
|
.height(totalHeight)
|
||||||
.padding(horizontal = 2.dp)
|
.padding(horizontal = 2.dp)
|
||||||
.background(
|
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||||
if (showHourGrid) MaterialTheme.colorScheme.surface
|
|
||||||
else MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
)
|
|
||||||
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -888,16 +822,10 @@ private fun DayLoading() {
|
|||||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||||
formatMinuteOfDay(min, is24Hour, locale)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
/** [abbreviated] drops the weekday, leaving the date itself (#165). */
|
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
|
||||||
private fun formatDayTitle(
|
|
||||||
date: LocalDate,
|
|
||||||
locale: Locale,
|
|
||||||
currentYear: Int,
|
|
||||||
abbreviated: Boolean = false,
|
|
||||||
): String =
|
|
||||||
formatCalendarTitle(
|
formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
|
skeleton = "EEEdMMM",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
import de.jeanlucmakiola.calendula.ui.week.layoutDay
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -89,7 +88,9 @@ class DayViewModel @Inject constructor(
|
|||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
): DayUiState {
|
): DayUiState {
|
||||||
calendarListFailure(calendars)?.let { return DayUiState.Failure(it) }
|
if (calendars.isEmpty()) {
|
||||||
|
return DayUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
val days = listOf(day)
|
val days = listOf(day)
|
||||||
val allDay = instances.filter { it.isAllDay }
|
val allDay = instances.filter { it.isAllDay }
|
||||||
val timed = instances.filterNot { it.isAllDay }
|
val timed = instances.filterNot { it.isAllDay }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -40,6 +41,7 @@ internal fun DayViewPreview(
|
|||||||
ScaledViewPreview(height = height, modifier = modifier) {
|
ScaledViewPreview(height = height, modifier = modifier) {
|
||||||
DaySuccess(
|
DaySuccess(
|
||||||
state = state,
|
state = state,
|
||||||
|
topSectionColor = MaterialTheme.colorScheme.surface,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = state.allDayStripHeight(),
|
allDayHeight = state.allDayStripHeight(),
|
||||||
dragController = rememberTimelineDragController(),
|
dragController = rememberTimelineDragController(),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.net.Uri
|
|||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -97,9 +98,6 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
|||||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
@@ -152,7 +150,6 @@ fun EventDetailScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
val copyField = rememberFieldCopier(snackbarHostState)
|
|
||||||
|
|
||||||
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
|
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
|
||||||
// an .ics in the cache and hands back a content Uri for the chooser.
|
// an .ics in the cache and hands back a content Uri for the chooser.
|
||||||
@@ -302,9 +299,8 @@ fun EventDetailScreen(
|
|||||||
is EventDetailUiState.Failure -> CalendarFailure(
|
is EventDetailUiState.Failure -> CalendarFailure(
|
||||||
reason = s.reason,
|
reason = s.reason,
|
||||||
onRetry = viewModel::retry,
|
onRetry = viewModel::retry,
|
||||||
modifier = contentModifier,
|
|
||||||
)
|
)
|
||||||
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
|
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,11 +382,7 @@ private fun DeleteEventDialog(
|
|||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventDetailContent(
|
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
|
||||||
state: EventDetailUiState.Success,
|
|
||||||
copyField: FieldCopier,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val detail = state.detail
|
val detail = state.detail
|
||||||
val instance = detail.instance
|
val instance = detail.instance
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -407,7 +399,6 @@ private fun EventDetailContent(
|
|||||||
// event, so it's left implicit — only Free is worth surfacing. A
|
// event, so it's left implicit — only Free is worth surfacing. A
|
||||||
// cancelled event strikes through its title.
|
// cancelled event strikes through its title.
|
||||||
Row(verticalAlignment = Alignment.Top) {
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
val titleLabel = stringResource(R.string.event_detail_title)
|
|
||||||
Text(
|
Text(
|
||||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
@@ -417,17 +408,7 @@ private fun EventDetailContent(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier.weight(1f),
|
||||||
.weight(1f)
|
|
||||||
.then(
|
|
||||||
// Nothing to copy off an untitled event — the placeholder
|
|
||||||
// isn't the event's own text.
|
|
||||||
if (instance.title.isNotBlank()) {
|
|
||||||
Modifier.copyOnLongPress(titleLabel, instance.title, copyField)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if (detail.availability == Availability.Free) {
|
if (detail.availability == Availability.Free) {
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
@@ -527,8 +508,7 @@ private fun EventDetailContent(
|
|||||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = state.calendarName?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
text = state.calendarName ?: stringResource(R.string.event_detail_calendar_unknown),
|
||||||
?: stringResource(R.string.event_detail_calendar_unknown),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -536,11 +516,10 @@ private fun EventDetailContent(
|
|||||||
// Location (conditional, tap → maps).
|
// Location (conditional, tap → maps).
|
||||||
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val locationLabel = stringResource(R.string.event_detail_location)
|
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.Default.Place,
|
icon = Icons.Default.Place,
|
||||||
iconContentDescription = locationLabel,
|
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = location,
|
text = location,
|
||||||
@@ -548,12 +527,7 @@ private fun EventDetailContent(
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.copyOnLongPress(
|
.clickable { openInMaps(context, location) }
|
||||||
label = locationLabel,
|
|
||||||
text = location,
|
|
||||||
copy = copyField,
|
|
||||||
onTap = { openInMaps(context, location) },
|
|
||||||
)
|
|
||||||
.padding(vertical = 2.dp),
|
.padding(vertical = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -561,15 +535,10 @@ private fun EventDetailContent(
|
|||||||
|
|
||||||
// Description (conditional). URLs are auto-linked.
|
// Description (conditional). URLs are auto-linked.
|
||||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||||
val descriptionLabel = stringResource(R.string.event_detail_description)
|
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.AutoMirrored.Filled.Notes,
|
icon = Icons.AutoMirrored.Filled.Notes,
|
||||||
iconContentDescription = descriptionLabel,
|
iconContentDescription = stringResource(R.string.event_detail_description),
|
||||||
// The gesture sits on the card so the icon and padding answer
|
|
||||||
// it too: every linkified URL owns the pointer over its own
|
|
||||||
// glyphs, which leaves the text itself a patchy target.
|
|
||||||
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||||
@@ -642,14 +611,13 @@ private fun EventDetailContent(
|
|||||||
private fun DetailCard(
|
private fun DetailCard(
|
||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
iconContentDescription: String?,
|
iconContentDescription: String?,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.requiredSize
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@@ -65,7 +64,6 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.SnackbarDuration
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -91,7 +89,6 @@ import androidx.compose.ui.graphics.isSpecified
|
|||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
@@ -103,7 +100,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -268,16 +264,13 @@ fun EventEditScreen(
|
|||||||
viewModel.reset()
|
viewModel.reset()
|
||||||
onSaved()
|
onSaved()
|
||||||
}
|
}
|
||||||
// A failed save leaves the form looking unchanged, so the snackbar is
|
|
||||||
// the only sign anything happened — long rather than the default
|
|
||||||
// flash (Codeberg #234: it read as "nothing happens at all").
|
|
||||||
SaveUiState.Failed -> {
|
SaveUiState.Failed -> {
|
||||||
viewModel.consumeSaveResult()
|
viewModel.consumeSaveResult()
|
||||||
snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long)
|
snackbarHostState.showSnackbar(saveFailedMessage)
|
||||||
}
|
}
|
||||||
SaveUiState.NeedsPermission -> {
|
SaveUiState.NeedsPermission -> {
|
||||||
viewModel.consumeSaveResult()
|
viewModel.consumeSaveResult()
|
||||||
snackbarHostState.showSnackbar(writeDeniedMessage, duration = SnackbarDuration.Long)
|
snackbarHostState.showSnackbar(writeDeniedMessage)
|
||||||
}
|
}
|
||||||
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
||||||
else -> Unit
|
else -> Unit
|
||||||
@@ -620,9 +613,7 @@ private fun EventEditContent(
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(
|
text = stringResource(
|
||||||
R.string.event_edit_managed_hint,
|
R.string.event_edit_managed_hint,
|
||||||
selectedCalendar?.displayName
|
selectedCalendar?.displayName.orEmpty(),
|
||||||
?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
|
||||||
.orEmpty(),
|
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -721,7 +712,6 @@ private fun EventEditContent(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = selectedCalendar?.displayName
|
text = selectedCalendar?.displayName
|
||||||
?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
|
||||||
?: stringResource(R.string.event_edit_error_no_calendar),
|
?: stringResource(R.string.event_edit_error_no_calendar),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = if (selectedCalendar == null) {
|
color = if (selectedCalendar == null) {
|
||||||
@@ -794,59 +784,38 @@ private fun EventEditContent(
|
|||||||
EditCard(
|
EditCard(
|
||||||
icon = Icons.Default.Place,
|
icon = Icons.Default.Place,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||||
iconAtTop = true,
|
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.Top) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
InlineField(
|
InlineField(
|
||||||
value = form.location,
|
value = form.location,
|
||||||
onValueChange = viewModel::setLocation,
|
onValueChange = viewModel::setLocation,
|
||||||
placeholder = stringResource(R.string.event_detail_location),
|
placeholder = stringResource(R.string.event_detail_location),
|
||||||
// A location is as often a meeting URL as an address,
|
|
||||||
// and "Zoom.us/j/123" reads wrong (#146).
|
|
||||||
capitalization = KeyboardCapitalization.None,
|
|
||||||
// Multi-line so a long address or meeting URL wraps and
|
|
||||||
// the card grows instead of scrolling off one line
|
|
||||||
// (#273). The field holds no newlines of its own, so
|
|
||||||
// the IME's action key closes the keyboard rather than
|
|
||||||
// inserting a break setLocation would only fold away.
|
|
||||||
singleLine = false,
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.animateContentSizeMotion()
|
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
)
|
)
|
||||||
// The box is exactly one text line tall, so the button
|
IconButton(
|
||||||
// centres on the first line without making a single-line
|
onClick = {
|
||||||
// card taller than its text; requiredSize keeps the
|
runCatching {
|
||||||
// button's own 40dp ripple.
|
pickContactAddress.launch(
|
||||||
Box(
|
Intent(
|
||||||
modifier = Modifier.height(firstLineHeight()),
|
Intent.ACTION_PICK,
|
||||||
contentAlignment = Alignment.Center,
|
ContactsContract.CommonDataKinds.StructuredPostal
|
||||||
|
.CONTENT_URI,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
) {
|
) {
|
||||||
IconButton(
|
Icon(
|
||||||
onClick = {
|
imageVector = Icons.Default.Contacts,
|
||||||
runCatching {
|
contentDescription = stringResource(
|
||||||
pickContactAddress.launch(
|
R.string.event_edit_location_from_contacts,
|
||||||
Intent(
|
),
|
||||||
Intent.ACTION_PICK,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
ContactsContract.CommonDataKinds.StructuredPostal
|
modifier = Modifier.size(22.dp),
|
||||||
.CONTENT_URI,
|
)
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.requiredSize(40.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Contacts,
|
|
||||||
contentDescription = stringResource(
|
|
||||||
R.string.event_edit_location_from_contacts,
|
|
||||||
),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2016,8 +1985,8 @@ private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>?
|
|||||||
/**
|
/**
|
||||||
* Read the formatted postal address from a contact-pick result URI. No
|
* Read the formatted postal address from a contact-pick result URI. No
|
||||||
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
|
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
|
||||||
* The provider's formatted address is multi-line; collapse it to one comma-
|
* The provider's formatted address is multi-line; collapse it to one line so
|
||||||
* separated line, which is what the location field holds.
|
* it sits cleanly in the single-line location field.
|
||||||
*/
|
*/
|
||||||
private fun readContactAddress(context: Context, uri: Uri): String? =
|
private fun readContactAddress(context: Context, uri: Uri): String? =
|
||||||
context.contentResolver.query(
|
context.contentResolver.query(
|
||||||
@@ -2201,19 +2170,14 @@ private fun EditCard(
|
|||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
|
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// A top-aligned icon centres on the first text line, whatever
|
|
||||||
// that line measures at the user's font scale.
|
|
||||||
val iconOffset = if (iconAtTop) {
|
|
||||||
((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp)
|
|
||||||
} else {
|
|
||||||
0.dp
|
|
||||||
}
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = iconContentDescription,
|
contentDescription = iconContentDescription,
|
||||||
tint = iconTint,
|
tint = iconTint,
|
||||||
|
// 4dp mirrors InlineField's vertical padding, so a
|
||||||
|
// top-aligned icon (24dp) centres on the ~24sp first line.
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = iconOffset)
|
.padding(top = if (iconAtTop) 4.dp else 0.dp)
|
||||||
.size(24.dp),
|
.size(24.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
@@ -2255,9 +2219,7 @@ private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Lo
|
|||||||
/**
|
/**
|
||||||
* Borderless text input used inside the cards (and as the headline title).
|
* Borderless text input used inside the cards (and as the headline title).
|
||||||
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
||||||
* the app share one input style. Sentence-case by default, as floret-kit is;
|
* the app share one input style.
|
||||||
* a field holding an identifier rather than prose passes
|
|
||||||
* [KeyboardCapitalization.None].
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun InlineField(
|
private fun InlineField(
|
||||||
@@ -2269,8 +2231,6 @@ private fun InlineField(
|
|||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
|
|
||||||
imeAction: ImeAction = ImeAction.Default,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
@@ -2285,25 +2245,10 @@ private fun InlineField(
|
|||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
keyboardType = keyboardType,
|
keyboardType = keyboardType,
|
||||||
capitalization = capitalization,
|
capitalization = KeyboardCapitalization.None,
|
||||||
imeAction = imeAction,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Height of one [InlineField] line: the resolved [TextStyle.lineHeight] plus the
|
|
||||||
* field's 4dp vertical padding. In dp, so it tracks the user's font scale
|
|
||||||
* instead of assuming the 24sp line of a 1.0 scale (#165).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun firstLineHeight(
|
|
||||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
|
||||||
): Dp {
|
|
||||||
val lineHeight = textStyle.lineHeight
|
|
||||||
if (!lineHeight.isSp) return 32.dp
|
|
||||||
return with(LocalDensity.current) { lineHeight.toDp() } + 8.dp
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One schedule row: label, then tappable date and (unless all-day) time. */
|
/** One schedule row: label, then tappable date and (unless all-day) time. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScheduleRow(
|
private fun ScheduleRow(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.edit
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -57,11 +56,6 @@ import kotlin.time.Duration.Companion.minutes
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val TAG = "EventEdit"
|
|
||||||
|
|
||||||
/** Any line break, with the whitespace around it, as one match. */
|
|
||||||
private val LINE_BREAK = Regex("""[ \t]*\R[ \t]*""")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
||||||
* want different reminder handling (#49), and differ in whether they own the
|
* want different reminder handling (#49), and differ in whether they own the
|
||||||
@@ -535,11 +529,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// paste would introduce, so it never reaches the provider's TITLE column.
|
// paste would introduce, so it never reaches the provider's TITLE column.
|
||||||
fun setTitle(value: String) =
|
fun setTitle(value: String) =
|
||||||
update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
|
update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
|
||||||
// The location wraps too (#273) but is likewise one logical line. A pasted
|
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||||
// multi-line address joins the way the contact picker's does (#146), so it
|
|
||||||
// never runs together into "12 Main St10115 Berlin".
|
|
||||||
fun setLocation(value: String) =
|
|
||||||
update { it.copy(location = value.replace(LINE_BREAK, ", ")) }
|
|
||||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) {
|
fun setAllDay(value: Boolean) {
|
||||||
// Going all-day drops any pinned zone: the times become bare dates that
|
// Going all-day drops any pinned zone: the times become bare dates that
|
||||||
@@ -744,12 +734,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
when (scope) {
|
when (scope) {
|
||||||
RecurringWriteScope.ThisEvent ->
|
RecurringWriteScope.ThisEvent ->
|
||||||
repository.updateOccurrence(
|
repository.updateOccurrence(target.eventId, target.beginMillis, form)
|
||||||
target.eventId,
|
|
||||||
target.beginMillis,
|
|
||||||
target.original,
|
|
||||||
form,
|
|
||||||
)
|
|
||||||
RecurringWriteScope.ThisAndFollowing ->
|
RecurringWriteScope.ThisAndFollowing ->
|
||||||
repository.updateEventFromOccurrence(
|
repository.updateEventFromOccurrence(
|
||||||
eventId = target.eventId,
|
eventId = target.eventId,
|
||||||
@@ -766,15 +751,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
throw e
|
throw e
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
SaveUiState.NeedsPermission
|
SaveUiState.NeedsPermission
|
||||||
} catch (e: NoSuchEventException) {
|
|
||||||
// The event or occurrence is already gone: the same answer the
|
|
||||||
// pre-check gives, and better than a bare "couldn't save".
|
|
||||||
SaveUiState.Gone
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// The user only gets a generic snackbar, so without this a failed
|
|
||||||
// write leaves nothing to report (Codeberg #234). Scope and event
|
|
||||||
// id only — never the form's content.
|
|
||||||
Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e)
|
|
||||||
SaveUiState.Failed
|
SaveUiState.Failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,16 +62,15 @@ private fun FilterList(
|
|||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
groups.forEach { group ->
|
groups.forEach { group ->
|
||||||
val account = group.account.ifBlank { stringResource(R.string.calendar_unnamed) }
|
|
||||||
Text(
|
Text(
|
||||||
text = if (group.ambiguous) {
|
text = if (group.ambiguous) {
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.calendars_account_from_source,
|
R.string.calendars_account_from_source,
|
||||||
account,
|
group.account,
|
||||||
sourceAppName(group.accountType),
|
sourceAppName(group.accountType),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
account
|
group.account
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -84,7 +83,7 @@ private fun FilterList(
|
|||||||
)
|
)
|
||||||
group.calendars.forEachIndexed { index, cal ->
|
group.calendars.forEachIndexed { index, cal ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = cal.displayName,
|
||||||
position = positionOf(index, group.calendars.size),
|
position = positionOf(index, group.calendars.size),
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { CalendarColorChip(cal.color) },
|
leading = { CalendarColorChip(cal.color) },
|
||||||
@@ -126,7 +125,6 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
|
|||||||
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
|
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
|
||||||
val msg = when (reason) {
|
val msg = when (reason) {
|
||||||
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
|
||||||
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
|
|
||||||
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
FailureReason.PermissionRevoked -> R.string.state_failure_permission
|
||||||
else -> R.string.state_failure_provider
|
else -> R.string.state_failure_provider
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -36,9 +35,8 @@ class FilterViewModel @Inject constructor(
|
|||||||
// Calendars switched off device-wide don't belong in the drawer's
|
// Calendars switched off device-wide don't belong in the drawer's
|
||||||
// hide/show list; they live only in Settings → Calendars.
|
// hide/show list; they live only in Settings → Calendars.
|
||||||
val enabled = calendars.filter { it.isVisibleInSystem }
|
val enabled = calendars.filter { it.isVisibleInSystem }
|
||||||
val failure = calendarListFailure(calendars)
|
if (enabled.isEmpty()) {
|
||||||
if (failure != null) {
|
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
FilterUiState.Failure(failure)
|
|
||||||
} else {
|
} else {
|
||||||
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
|
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.PriorityHigh
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -73,19 +72,19 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
|||||||
@Composable
|
@Composable
|
||||||
fun ImportScreen(
|
fun ImportScreen(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
session: Int,
|
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onOpenSingle: (EventForm) -> Unit,
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
forceMany: Boolean = false,
|
forceMany: Boolean = false,
|
||||||
onManageCalendars: (() -> Unit)? = null,
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
viewModel: ImportViewModel = hiltViewModel(),
|
// 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()),
|
||||||
) {
|
) {
|
||||||
// hiltViewModel() resolves to the Activity's store and is retained across
|
LaunchedEffect(uri) { viewModel.load(uri, forceMany) }
|
||||||
// imports, so the reload is driven by [session] rather than by a fresh VM:
|
|
||||||
// keying per uri looked right but handed a *re-import of the same file* the
|
|
||||||
// previous run's finished state, importing nothing (#304). A rotation keeps
|
|
||||||
// the session and so keeps the result.
|
|
||||||
LaunchedEffect(session) { viewModel.load(uri, forceMany, session) }
|
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// A single event isn't shown here — it opens the create form for review.
|
// A single event isn't shown here — it opens the create form for review.
|
||||||
@@ -94,11 +93,15 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hoisted target calendar so the always-visible top-bar Import action can
|
// Hoisted target calendar so the always-visible top-bar Import action can
|
||||||
// read it without the user scrolling to a bottom button (see
|
// read it without the user scrolling to a bottom button. Defaults to the
|
||||||
// [defaultImportTarget] for the choice). Re-defaults when the "many" list
|
// first *local* calendar — the first row the picker shows ("Your calendars"
|
||||||
// first arrives (keyed on it), then holds the pick.
|
// group leads) — so the pre-selection lines up with the top of the list;
|
||||||
|
// falls back to the first calendar if there are no local ones. Re-defaults
|
||||||
|
// when the "many" list first arrives (keyed on it), then holds the pick.
|
||||||
val many = state as? ImportUiState.Many
|
val many = state as? ImportUiState.Many
|
||||||
val defaultTarget = many?.let { defaultImportTarget(it.calendars, it.fileCalendarName) }
|
val defaultTarget = many?.calendars?.let { cals ->
|
||||||
|
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
|
||||||
|
}
|
||||||
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
|
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -220,10 +223,6 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Part of the file never reached the calendar, so the screen doesn't get to
|
|
||||||
// read as a plain success.
|
|
||||||
val stopped = state.summary.notAttempted > 0
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().padding(24.dp),
|
Modifier.fillMaxSize().padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -237,43 +236,22 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
|||||||
scaleY = badgeScale.value
|
scaleY = badgeScale.value
|
||||||
}
|
}
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
if (stopped) {
|
|
||||||
MaterialTheme.colorScheme.errorContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
|
||||||
},
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
if (stopped) Icons.Default.PriorityHigh else Icons.Default.Check,
|
Icons.Default.Check,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = if (stopped) {
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
MaterialTheme.colorScheme.onErrorContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(56.dp),
|
modifier = Modifier.size(56.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
stringResource(
|
stringResource(R.string.import_done_title),
|
||||||
if (stopped) R.string.import_done_stopped_title else R.string.import_done_title,
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
if (state.summary.failed > 0) {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.import_done_failed_note),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (state.summary.skippedDuplicate > 0) {
|
if (state.summary.skippedDuplicate > 0) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -283,21 +261,6 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// The one note that isn't about individual events: the counts below cover
|
|
||||||
// the part of the file that was tried, so say how much never was.
|
|
||||||
if (stopped) {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
pluralStringResource(
|
|
||||||
R.plurals.import_done_stopped,
|
|
||||||
state.summary.notAttempted,
|
|
||||||
state.summary.notAttempted,
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
@@ -327,19 +290,6 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
|||||||
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
|
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (state.summary.failed > 0) {
|
|
||||||
ImportStatCard(
|
|
||||||
count = state.summary.failed,
|
|
||||||
label = stringResource(R.string.import_done_failed_label),
|
|
||||||
contentDescription = pluralStringResource(
|
|
||||||
R.plurals.import_done_failed,
|
|
||||||
state.summary.failed,
|
|
||||||
state.summary.failed,
|
|
||||||
),
|
|
||||||
container = MaterialTheme.colorScheme.errorContainer,
|
|
||||||
onContainer = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
Button(
|
Button(
|
||||||
@@ -351,7 +301,7 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A big-number tonal tile summarising one import outcome (added / skipped / failed). */
|
/** A big-number tonal tile summarising one import outcome (added / skipped). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun RowScope.ImportStatCard(
|
private fun RowScope.ImportStatCard(
|
||||||
count: Int,
|
count: Int,
|
||||||
@@ -393,9 +343,6 @@ private fun WarningText(warning: IcsParseWarning) {
|
|||||||
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
|
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
|
||||||
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
|
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
|
||||||
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
|
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
|
||||||
IcsParseWarning.TasksImportedAsEvents -> stringResource(R.string.import_warning_tasks)
|
|
||||||
IcsParseWarning.RecurrenceRuleRepaired ->
|
|
||||||
stringResource(R.string.import_warning_recurrence_repaired)
|
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
|||||||
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -44,12 +43,6 @@ sealed interface ImportUiState {
|
|||||||
val events: List<ParsedIcsEvent>,
|
val events: List<ParsedIcsEvent>,
|
||||||
val warnings: Set<IcsParseWarning>,
|
val warnings: Set<IcsParseWarning>,
|
||||||
val calendars: List<CalendarSource>,
|
val calendars: List<CalendarSource>,
|
||||||
/**
|
|
||||||
* The calendar the file says its events came from, when every one of
|
|
||||||
* them names the same — used to preselect a matching target (see
|
|
||||||
* [fileCalendarName]).
|
|
||||||
*/
|
|
||||||
val fileCalendarName: String? = null,
|
|
||||||
) : ImportUiState
|
) : ImportUiState
|
||||||
|
|
||||||
data class Done(val summary: IcsImportSummary) : ImportUiState
|
data class Done(val summary: IcsImportSummary) : ImportUiState
|
||||||
@@ -70,26 +63,18 @@ class ImportViewModel @Inject constructor(
|
|||||||
private val parser = IcsParser()
|
private val parser = IcsParser()
|
||||||
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
|
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
|
||||||
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
||||||
private var loadedSession: Int? = null
|
private var started = false
|
||||||
private var loadJob: Job? = null
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read + parse [uri] once per [session]; recomposition (and a rotation,
|
* Read + parse [uri] once; subsequent calls (recomposition) are ignored.
|
||||||
* which keeps the session) re-calls this and keeps the result. A new import
|
|
||||||
* of the *same* file is a new session, and has to parse and run again —
|
|
||||||
* keying on the uri alone showed the previous run's summary and imported
|
|
||||||
* nothing (#304).
|
|
||||||
*
|
|
||||||
* When [forceMany] is set (an in-app restore), a single-event file still goes
|
* When [forceMany] is set (an in-app restore), a single-event file still goes
|
||||||
* through the bulk picker + summary rather than the prefilled create form —
|
* through the bulk picker + summary rather than the prefilled create form —
|
||||||
* a restore is "bring back a backup", not "add this one event".
|
* a restore is "bring back a backup", not "add this one event".
|
||||||
*/
|
*/
|
||||||
fun load(uri: Uri, forceMany: Boolean = false, session: Int = 0) {
|
fun load(uri: Uri, forceMany: Boolean = false) {
|
||||||
if (loadedSession == session) return
|
if (started) return
|
||||||
loadedSession = session
|
started = true
|
||||||
loadJob?.cancel()
|
viewModelScope.launch {
|
||||||
_state.value = ImportUiState.Loading
|
|
||||||
loadJob = viewModelScope.launch {
|
|
||||||
val parsed = withContext(io) {
|
val parsed = withContext(io) {
|
||||||
importer.readText(uri)?.let(parser::parse)
|
importer.readText(uri)?.let(parser::parse)
|
||||||
}
|
}
|
||||||
@@ -107,7 +92,6 @@ class ImportViewModel @Inject constructor(
|
|||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
calendars = repository.calendars().first()
|
calendars = repository.calendars().first()
|
||||||
.filter { it.isEventTarget },
|
.filter { it.isEventTarget },
|
||||||
fileCalendarName = fileCalendarName(parsed.events),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,33 +113,3 @@ class ImportViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The one calendar [events] all came from, or null.
|
|
||||||
*
|
|
||||||
* Every event has to agree, unnamed ones included: a foreign file where a single
|
|
||||||
* `VEVENT` carries a `CATEGORIES` tag and the rest carry nothing names no
|
|
||||||
* calendar — it just has one tagged event, and letting it pick the target would
|
|
||||||
* land the whole file wherever that tag happens to match.
|
|
||||||
*/
|
|
||||||
internal fun fileCalendarName(events: List<ParsedIcsEvent>): String? =
|
|
||||||
events.map { it.calendarName }.distinct().singleOrNull()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The target calendar to preselect for a bulk import.
|
|
||||||
*
|
|
||||||
* A calendar named like the one the file came from wins — restoring "Birthdays"
|
|
||||||
* onto the Birthdays calendar is what the user means. Otherwise the first
|
|
||||||
* *local* calendar, which is the first row the picker shows ("Your calendars"
|
|
||||||
* leads), so the pre-selection lines up with the top of the list; failing that
|
|
||||||
* the first calendar of any kind.
|
|
||||||
*/
|
|
||||||
internal fun defaultImportTarget(
|
|
||||||
calendars: List<CalendarSource>,
|
|
||||||
fileCalendarName: String?,
|
|
||||||
): Long? {
|
|
||||||
val named = fileCalendarName?.let { name ->
|
|
||||||
calendars.firstOrNull { it.displayName.equals(name, ignoreCase = true) }
|
|
||||||
}
|
|
||||||
return (named ?: calendars.firstOrNull { it.isLocal } ?: calendars.firstOrNull())?.id
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
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) {
|
|
||||||
val uriHandler = LocalUriHandler.current
|
|
||||||
|
|
||||||
CollapsingScaffold(
|
|
||||||
title = stringResource(R.string.licences_title),
|
|
||||||
onBack = onBack,
|
|
||||||
predictiveBack = true,
|
|
||||||
) {
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -13,8 +13,6 @@ import androidx.compose.ui.layout.boundsInRoot
|
|||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
@@ -35,8 +33,6 @@ class MonthRowGeometry(
|
|||||||
val isRtl: Boolean,
|
val isRtl: Boolean,
|
||||||
/** What this row currently draws in [lane] of column `col`, or null. */
|
/** What this row currently draws in [lane] of column `col`, or null. */
|
||||||
val chipAt: (col: Int, lane: Int) -> EventInstance?,
|
val chipAt: (col: Int, lane: Int) -> EventInstance?,
|
||||||
/** The column the chip in [lane] of column `col` is drawn from. */
|
|
||||||
val chipStart: (col: Int, lane: Int) -> Int,
|
|
||||||
) {
|
) {
|
||||||
/** Root top-left of the chip seated in [lane] of column [col]. */
|
/** Root top-left of the chip seated in [lane] of column [col]. */
|
||||||
fun seat(col: Int, lane: Int): Offset? {
|
fun seat(col: Int, lane: Int): Offset? {
|
||||||
@@ -51,43 +47,21 @@ class MonthRowGeometry(
|
|||||||
* the day isn't in this week, or the chip went into the day's "+N" overflow.
|
* the day isn't in this week, or the chip went into the day's "+N" overflow.
|
||||||
*/
|
*/
|
||||||
fun seatOf(event: EventInstance, date: LocalDate): Offset? {
|
fun seatOf(event: EventInstance, date: LocalDate): Offset? {
|
||||||
val (col, lane) = chipSeat(days, laneCount, chipAt, chipStart, event, date) ?: return null
|
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
|
||||||
|
val lane = (0 until laneCount).firstOrNull { lane ->
|
||||||
|
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
|
||||||
|
} ?: return null
|
||||||
return seat(col, lane)
|
return seat(col, lane)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The column and lane a row draws [event]'s chip in, given it covers [date] —
|
|
||||||
* null when the row seats no such chip. The column is the *left end* of the bar
|
|
||||||
* within this row, not [date]'s own: a bar spanning several days is drawn once,
|
|
||||||
* from its first column, so an event dropped onto the second day it covers is
|
|
||||||
* seated a column (or more) to the left of where it landed (#253).
|
|
||||||
*/
|
|
||||||
internal fun chipSeat(
|
|
||||||
days: List<LocalDate>,
|
|
||||||
laneCount: Int,
|
|
||||||
chipAt: (col: Int, lane: Int) -> EventInstance?,
|
|
||||||
chipStart: (col: Int, lane: Int) -> Int,
|
|
||||||
event: EventInstance,
|
|
||||||
date: LocalDate,
|
|
||||||
): Pair<Int, Int>? {
|
|
||||||
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
|
|
||||||
val lane = (0 until laneCount).firstOrNull { lane ->
|
|
||||||
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
|
|
||||||
} ?: return null
|
|
||||||
return chipStart(col, lane) to lane
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither id
|
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither id
|
||||||
* alone will do: re-read instances get new instance ids, and a single-occurrence
|
* alone will do: re-read instances get new instance ids, and a single-occurrence
|
||||||
* move writes a new event id — the title survives both. A *blank* title is no
|
* move writes a new event id — the title survives both.
|
||||||
* evidence at all, though: every untitled event carries one, so matching on it
|
|
||||||
* made any two of them the same event (#253).
|
|
||||||
*/
|
*/
|
||||||
internal fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
|
private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
|
||||||
chip.eventId == moved.eventId ||
|
chip.eventId == moved.eventId || chip.title == moved.title
|
||||||
(moved.title.isNotBlank() && chip.title == moved.title)
|
|
||||||
|
|
||||||
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
|
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
|
||||||
data class MonthChipDrag(
|
data class MonthChipDrag(
|
||||||
@@ -97,13 +71,6 @@ data class MonthChipDrag(
|
|||||||
val targetDate: LocalDate?,
|
val targetDate: LocalDate?,
|
||||||
val topLeftInRoot: Offset,
|
val topLeftInRoot: Offset,
|
||||||
val sizePx: IntSize,
|
val sizePx: IntSize,
|
||||||
/**
|
|
||||||
* The start time of the chip this lifted off, already formatted (#219).
|
|
||||||
* Carried rather than re-derived: the overlay has no row to ask whether the
|
|
||||||
* bar was carried in from the previous week, and no zone the grid laid out
|
|
||||||
* in. Null where the source chip had no time to show.
|
|
||||||
*/
|
|
||||||
val time: String? = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
|
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
|
||||||
@@ -167,19 +134,8 @@ class MonthDragController {
|
|||||||
*/
|
*/
|
||||||
private var undoable: MonthChipDrag? = null
|
private var undoable: MonthChipDrag? = null
|
||||||
|
|
||||||
/**
|
|
||||||
* The abandoned-move counter, read at the drop so the mark can't be a
|
|
||||||
* composition behind the tick it is compared against.
|
|
||||||
*/
|
|
||||||
var abandonedTicks: StateFlow<Int>? = null
|
|
||||||
|
|
||||||
/** What [abandonedTicks] stood at when the settling chip was let go. */
|
|
||||||
var settledAbandonedMark: Int = 0
|
|
||||||
private set
|
|
||||||
|
|
||||||
private var event: EventInstance? = null
|
private var event: EventInstance? = null
|
||||||
private var grabDate: LocalDate? = null
|
private var grabDate: LocalDate? = null
|
||||||
private var time: String? = null
|
|
||||||
private var grab = Offset.Zero
|
private var grab = Offset.Zero
|
||||||
private var pointer = Offset.Zero
|
private var pointer = Offset.Zero
|
||||||
private var sizePx = IntSize.Zero
|
private var sizePx = IntSize.Zero
|
||||||
@@ -198,11 +154,9 @@ class MonthDragController {
|
|||||||
pointerInRoot: Offset,
|
pointerInRoot: Offset,
|
||||||
chipInRoot: Offset,
|
chipInRoot: Offset,
|
||||||
size: IntSize,
|
size: IntSize,
|
||||||
time: String? = null,
|
|
||||||
) {
|
) {
|
||||||
this.event = event
|
this.event = event
|
||||||
this.grabDate = grabDate
|
this.grabDate = grabDate
|
||||||
this.time = time
|
|
||||||
settling = null
|
settling = null
|
||||||
isDragging = true
|
isDragging = true
|
||||||
liftedInstanceId = event.instanceId
|
liftedInstanceId = event.instanceId
|
||||||
@@ -220,7 +174,6 @@ class MonthDragController {
|
|||||||
fun cancel() {
|
fun cancel() {
|
||||||
event = null
|
event = null
|
||||||
grabDate = null
|
grabDate = null
|
||||||
time = null
|
|
||||||
isDragging = false
|
isDragging = false
|
||||||
liftedInstanceId = null
|
liftedInstanceId = null
|
||||||
drag = null
|
drag = null
|
||||||
@@ -269,7 +222,6 @@ class MonthDragController {
|
|||||||
liftedInstanceId = last.event.instanceId
|
liftedInstanceId = last.event.instanceId
|
||||||
settledGhost = true
|
settledGhost = true
|
||||||
settledInRoot = null
|
settledInRoot = null
|
||||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +242,6 @@ class MonthDragController {
|
|||||||
liftedInstanceId = landed.event.instanceId
|
liftedInstanceId = landed.event.instanceId
|
||||||
settledGhost = true
|
settledGhost = true
|
||||||
undoable = settling
|
undoable = settling
|
||||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
|
||||||
return MonthChipDrop(landed.event, landed.grabDate, target)
|
return MonthChipDrop(landed.event, landed.grabDate, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,16 +293,11 @@ class MonthDragController {
|
|||||||
targetDate = resolved ?: drag?.targetDate,
|
targetDate = resolved ?: drag?.targetDate,
|
||||||
topLeftInRoot = pointer - grab,
|
topLeftInRoot = pointer - grab,
|
||||||
sizePx = sizePx,
|
sizePx = sizePx,
|
||||||
time = time,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberMonthDragController(): MonthDragController {
|
fun rememberMonthDragController(): MonthDragController = remember { MonthDragController() }
|
||||||
val controller = remember { MonthDragController() }
|
|
||||||
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
|
||||||
return controller
|
|
||||||
}
|
|
||||||
|
|
||||||
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
|
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
|
||||||
|
|||||||
@@ -88,18 +88,13 @@ import androidx.compose.ui.platform.LocalLayoutDirection
|
|||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventSurface
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.monthBarCuts
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
|
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.abandonedMoves
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
|
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
||||||
@@ -117,17 +112,16 @@ import androidx.compose.ui.graphics.graphicsLayer
|
|||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -139,7 +133,6 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||||
@@ -148,14 +141,12 @@ import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.monthBarShape
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
@@ -218,6 +209,7 @@ fun MonthScreen(
|
|||||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -303,14 +295,10 @@ fun MonthScreen(
|
|||||||
// carries the year instead of repeating it two lines further down. Dense has
|
// carries the year instead of repeating it two lines further down. Dense has
|
||||||
// no header to defer to, so it keeps the full title.
|
// no header to defer to, so it keeps the full title.
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val (topBarTitle, topBarShortTitle) = remember(viewStyle, titleMonth, locale, today.year) {
|
val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) {
|
||||||
if (viewStyle == MonthViewStyle.Continuous) {
|
titleMonth.year.toString()
|
||||||
val year = titleMonth.year.toString()
|
} else {
|
||||||
year to year
|
formatMonthTitle(titleMonth, locale, currentYear = today.year)
|
||||||
} else {
|
|
||||||
formatMonthTitle(titleMonth, locale, currentYear = today.year) to
|
|
||||||
formatMonthTitle(titleMonth, locale, currentYear = today.year, abbreviated = true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
||||||
@@ -395,20 +383,19 @@ fun MonthScreen(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
MonthTopBar(
|
MonthTopBar(
|
||||||
title = topBarTitle,
|
title = topBarTitle,
|
||||||
shortTitle = topBarShortTitle,
|
|
||||||
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onJumpToDate = jumpToDate,
|
onJumpToDate = jumpToDate,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@@ -504,7 +491,6 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
|||||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
val moveInFlight = moveInFlight()
|
val moveInFlight = moveInFlight()
|
||||||
// Here rather than beside the controller: this reads [drag], which changes
|
// Here rather than beside the controller: this reads [drag], which changes
|
||||||
@@ -519,14 +505,11 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
|||||||
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||||
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
||||||
val seat = controller.settledInRoot
|
val seat = controller.settledInRoot
|
||||||
val abandoned = abandonedMoves()
|
LaunchedEffect(controller.settling, moveInFlight, seat) {
|
||||||
LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) {
|
|
||||||
val landed = controller.settling
|
val landed = controller.settling
|
||||||
if (landed == null || moveInFlight) return@LaunchedEffect
|
if (landed == null || moveInFlight) return@LaunchedEffect
|
||||||
if (seat == null) {
|
if (seat == null) {
|
||||||
// Past the mark this drop was let go on, it was abandoned and no
|
delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
||||||
// seat is ever coming.
|
|
||||||
if (abandoned == controller.settledAbandonedMark) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
|
||||||
} else {
|
} else {
|
||||||
if (!gliding) glide.snapTo(landed.topLeftInRoot)
|
if (!gliding) glide.snapTo(landed.topLeftInRoot)
|
||||||
gliding = true
|
gliding = true
|
||||||
@@ -562,11 +545,6 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
continuesLeft = false,
|
continuesLeft = false,
|
||||||
continuesRight = false,
|
continuesRight = false,
|
||||||
// The time the source chip carried, drawn only if this one-column
|
|
||||||
// cut-out of it has the room — which a slice of a multi-day bar has
|
|
||||||
// not, however wide the bar was.
|
|
||||||
time = drag.time,
|
|
||||||
showTime = with(density) { drag.sizePx.width.toDp() } >= timeChipWidth,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
// Absolute: these are root coordinates, and the direction-aware
|
// Absolute: these are root coordinates, and the direction-aware
|
||||||
// offset would mirror them across the screen in an RTL layout.
|
// offset would mirror them across the screen in an RTL layout.
|
||||||
@@ -579,13 +557,13 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
|||||||
}
|
}
|
||||||
.width(with(density) { drag.sizePx.width.toDp() })
|
.width(with(density) { drag.sizePx.width.toDp() })
|
||||||
.height(with(density) { drag.sizePx.height.toDp() })
|
.height(with(density) { drag.sizePx.height.toDp() })
|
||||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
scaleX = 1f + 0.04f * lift
|
scaleX = 1f + 0.04f * lift
|
||||||
scaleY = 1f + 0.04f * lift
|
scaleY = 1f + 0.04f * lift
|
||||||
shadowElevation = 8.dp.toPx() * lift
|
shadowElevation = 8.dp.toPx() * lift
|
||||||
alpha = copyAlpha
|
alpha = copyAlpha
|
||||||
shape = EventChipShape
|
shape = RoundedCornerShape(4.dp)
|
||||||
clip = false
|
clip = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -680,16 +658,15 @@ private fun ContinuousMonthContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MonthTopBar(
|
private fun MonthTopBar(
|
||||||
title: String,
|
title: String,
|
||||||
shortTitle: String,
|
|
||||||
titleDate: LocalDate,
|
titleDate: LocalDate,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
quickSwitchViews: List<CalendarView>,
|
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
@@ -697,7 +674,6 @@ private fun MonthTopBar(
|
|||||||
title = title,
|
title = title,
|
||||||
currentDate = titleDate,
|
currentDate = titleDate,
|
||||||
onJumpToDate = onJumpToDate,
|
onJumpToDate = onJumpToDate,
|
||||||
shortTitle = shortTitle,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -718,17 +694,11 @@ private fun MonthTopBar(
|
|||||||
}
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
cycle = quickSwitchViews,
|
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
scrollBehavior = scrollBehavior,
|
||||||
// it, but here the bar meets the header on the same surface and the
|
|
||||||
// tint is what makes that seam look like a separate block (#186).
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,10 +713,10 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
// Reserve the gutter so the weekday labels stay over their day columns.
|
// Reserve the gutter so the weekday labels stay over their day columns.
|
||||||
if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter()))
|
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
|
||||||
days.forEach { dow ->
|
days.forEach { dow ->
|
||||||
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
||||||
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
||||||
@@ -764,36 +734,12 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
|||||||
|
|
||||||
private val EVENT_ROW_HEIGHT = 20.dp
|
private val EVENT_ROW_HEIGHT = 20.dp
|
||||||
private val DAY_NUMBER_HEIGHT = 22.dp
|
private val DAY_NUMBER_HEIGHT = 22.dp
|
||||||
/** Padding between the week-number pill's edge and the number inside it. */
|
/** Width of the optional left calendar-week gutter (#25); narrow, since it only
|
||||||
private val WEEK_NUMBER_PADDING = 6.dp
|
* seats a one- or two-digit week number in a full-height tonal pill. */
|
||||||
|
private val WEEK_NUMBER_GUTTER = 40.dp
|
||||||
/** The widest week number an ISO year reaches; digits are tabular, so one
|
|
||||||
* measurement of it prices every week in the grid. */
|
|
||||||
private const val WEEK_NUMBER_SAMPLE = "53"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Width of the optional left calendar-week gutter (#25), measured rather than
|
|
||||||
* fixed: it is sized to the number it seats at the style the pill draws it in,
|
|
||||||
* so it follows the font scale instead of reserving slack for it, and spends
|
|
||||||
* nothing more on a column the grid would rather hand to the seven days (#213).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun rememberWeekNumberGutter(): Dp {
|
|
||||||
val measurer = rememberTextMeasurer()
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val style = weekNumberStyle()
|
|
||||||
return remember(style, density, measurer) {
|
|
||||||
val text = with(density) { measurer.measure(WEEK_NUMBER_SAMPLE, style).size.width.toDp() }
|
|
||||||
text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The week number's own style — a step down from the day numbers beside it. */
|
|
||||||
@Composable
|
|
||||||
private fun weekNumberStyle() =
|
|
||||||
MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold)
|
|
||||||
private val DAY_NUMBER_GAP = 4.dp
|
private val DAY_NUMBER_GAP = 4.dp
|
||||||
private val CELL_TOP_PADDING = 6.dp
|
private val CELL_TOP_PADDING = 6.dp
|
||||||
|
private val CELL_GAP = 2.dp
|
||||||
/** Named separately because the split style's selection outline draws its own
|
/** Named separately because the split style's selection outline draws its own
|
||||||
* rounded rect and has to match this radius exactly. */
|
* rounded rect and has to match this radius exactly. */
|
||||||
private val CELL_CORNER = 12.dp
|
private val CELL_CORNER = 12.dp
|
||||||
@@ -801,79 +747,14 @@ private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER)
|
|||||||
|
|
||||||
/** Width of the split style's selected-day outline. */
|
/** Width of the split style's selected-day outline. */
|
||||||
private val SPLIT_SELECTION_STROKE = 1.5.dp
|
private val SPLIT_SELECTION_STROKE = 1.5.dp
|
||||||
/**
|
/** Lanes of bars/pills a day cell draws before the rest become overflow dots. */
|
||||||
* Dots a split-style cell draws. Fixed, unlike the paged grid's measured cap:
|
internal const val MAX_EVENT_ROWS = 3
|
||||||
* the split rows are a fixed height whatever the screen is.
|
|
||||||
*
|
|
||||||
* Dot *i* is lane *i*, so a dot morphs into the bar the expanded grid draws
|
|
||||||
* there (#53) — [MonthWeek.laneEvents] seats the same events in the same order
|
|
||||||
* at any cap, so a larger one only appends. Where the expanded grid seats fewer
|
|
||||||
* lanes than this — a six-row month in a short landscape viewport — the dots
|
|
||||||
* past its cap have no bar to become and simply fade instead of travelling.
|
|
||||||
*/
|
|
||||||
internal const val SPLIT_DOT_LANES = 3
|
|
||||||
|
|
||||||
/** Diameter of a single overflow dot. */
|
|
||||||
private val OVERFLOW_DOT_SIZE = 6.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Height of the overflow row: the "+N" beside the dots, measured at the style it
|
|
||||||
* is drawn in.
|
|
||||||
*
|
|
||||||
* It was taking a whole event lane, which is more than a dot and a label line
|
|
||||||
* need and one lane fewer for the chips. Measured rather than picked, so it
|
|
||||||
* still holds the label at a large font scale — a fixed height clipped the "+N"
|
|
||||||
* at anything above the default.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun rememberOverflowRowHeight(): Dp {
|
|
||||||
val measurer = rememberTextMeasurer()
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val style = MaterialTheme.typography.labelSmall
|
|
||||||
return remember(style, density, measurer) {
|
|
||||||
with(density) { measurer.measure(OVERFLOW_SAMPLE, style).size.height.toDp() }
|
|
||||||
.coerceAtLeast(OVERFLOW_DOT_SIZE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The tallest the counter gets; digits are tabular, so one is as wide as any. */
|
|
||||||
private const val OVERFLOW_SAMPLE = "+9"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lanes of chips this week's cells draw in a row [rowHeight] tall, before the
|
|
||||||
* rest become dots.
|
|
||||||
*
|
|
||||||
* The cap used to be a flat three at every size, so a tall five-row month threw
|
|
||||||
* away lanes it had the room for while a cramped six-row one drew a third chip
|
|
||||||
* its band could not hold and put the dots below the clip, where nothing showed
|
|
||||||
* that the day held more at all.
|
|
||||||
*
|
|
||||||
* Measured instead, with no ceiling: a cell spends whatever height it was given.
|
|
||||||
* The overflow row is only charged for when some day in the week actually
|
|
||||||
* overflows — a week that fits gets that space as another lane rather than
|
|
||||||
* reserving room for a marker it will not draw.
|
|
||||||
*/
|
|
||||||
internal fun MonthWeek.laneCapFor(rowHeight: Dp, overflowRow: Dp): Int {
|
|
||||||
val band = monthBandHeight(rowHeight)
|
|
||||||
val full = (band / EVENT_ROW_HEIGHT).toInt().coerceAtLeast(1)
|
|
||||||
if (!overflowsAt(full)) return full
|
|
||||||
return ((band - overflowRow) / EVENT_ROW_HEIGHT).toInt().coerceAtLeast(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What a row [rowHeight] tall leaves for chips once the day number is drawn. */
|
|
||||||
internal fun monthBandHeight(rowHeight: Dp): Dp =
|
|
||||||
rowHeight - CELL_TOP_PADDING - DAY_NUMBER_HEIGHT - DAY_NUMBER_GAP
|
|
||||||
|
|
||||||
/** Whether any day in this week holds more than [lanes] lanes can seat. */
|
|
||||||
private fun MonthWeek.overflowsAt(lanes: Int): Boolean =
|
|
||||||
days.withIndex().any { (col, day) -> overflowEvents(col, day, lanes).isNotEmpty() }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Row height in the continuous grid. The paged grid divides the viewport between
|
* Row height in the continuous grid. The paged grid divides the viewport between
|
||||||
* however many rows the month has; a scrolling stream has no such bound, so it
|
* however many rows the month has; a scrolling stream has no such bound, so it
|
||||||
* fixes one — close to what a five-row month gets on a typical phone. How many
|
* fixes a height that seats the day number plus [MAX_EVENT_ROWS] event rows —
|
||||||
* chips that seats is [MonthWeek.laneCapFor]'s answer like anywhere else, so a
|
* close to what a five-row month gets on a typical phone.
|
||||||
* week that does not overflow fills the band rather than holding a lane back.
|
|
||||||
*/
|
*/
|
||||||
private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
||||||
|
|
||||||
@@ -885,11 +766,6 @@ private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
|||||||
*/
|
*/
|
||||||
private val CONTINUOUS_MONTH_GAP = 20.dp
|
private val CONTINUOUS_MONTH_GAP = 20.dp
|
||||||
|
|
||||||
/** The paged grid's own vertical padding, and the gap between its week rows —
|
|
||||||
* named because [monthLaneCap] has to take them off the viewport first. */
|
|
||||||
private val GRID_VERTICAL_PADDING = 4.dp
|
|
||||||
private val GRID_ROW_GAP = 2.dp
|
|
||||||
|
|
||||||
/** Gap between the weekday header and the seamless stream's first week row. */
|
/** Gap between the weekday header and the seamless stream's first week row. */
|
||||||
private val DENSE_HEADER_GAP = 4.dp
|
private val DENSE_HEADER_GAP = 4.dp
|
||||||
|
|
||||||
@@ -902,43 +778,29 @@ internal fun MonthGrid(
|
|||||||
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
|
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
|
||||||
selected: LocalDate? = null,
|
selected: LocalDate? = null,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
Column(
|
||||||
// The rows divide whatever the viewport leaves once the Column's own
|
modifier = Modifier
|
||||||
// padding and the gaps between them are paid, so how many chips a cell
|
.fillMaxSize()
|
||||||
// can seat is only knowable here.
|
// Match the weekday header's 8dp inset so day cells sit under their
|
||||||
val rows = state.weeks.size.coerceAtLeast(1)
|
// labels, and so the week-number gutter's centre lines up with the
|
||||||
val rowHeight =
|
// top bar's hamburger (4dp bar inset + 24dp half icon button).
|
||||||
(maxHeight - GRID_VERTICAL_PADDING * 2 - GRID_ROW_GAP * (rows - 1)) / rows
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
Column(
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
modifier = Modifier
|
) {
|
||||||
.fillMaxSize()
|
val month = state.month
|
||||||
// Match the weekday header's inset so day cells sit under their
|
state.weeks.forEach { week ->
|
||||||
// labels, and so the week-number gutter's centre lines up with the
|
MonthWeekRow(
|
||||||
// top bar's hamburger (4dp bar inset + 24dp half icon button).
|
week = week,
|
||||||
.padding(horizontal = AppBarSpacing.Inset, vertical = GRID_VERTICAL_PADDING),
|
today = state.today,
|
||||||
verticalArrangement = Arrangement.spacedBy(GRID_ROW_GAP),
|
inMonth = { it.month == month.month && it.year == month.year },
|
||||||
) {
|
showWeekNumbers = showWeekNumbers,
|
||||||
val month = state.month
|
onOpenDay = onOpenDay,
|
||||||
// Once per grid: the value depends on the typography, the density, the
|
onEventClick = onEventClick,
|
||||||
// locale and the 24-hour setting, none of which vary by row (#219).
|
selected = selected,
|
||||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
modifier = Modifier
|
||||||
state.weeks.forEach { week ->
|
.fillMaxWidth()
|
||||||
MonthWeekRow(
|
.weight(1f),
|
||||||
week = week,
|
)
|
||||||
today = state.today,
|
|
||||||
zone = state.zone,
|
|
||||||
timeChipWidth = timeChipWidth,
|
|
||||||
rowHeight = rowHeight,
|
|
||||||
inMonth = { it.month == month.month && it.year == month.year },
|
|
||||||
showWeekNumbers = showWeekNumbers,
|
|
||||||
onOpenDay = onOpenDay,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
selected = selected,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -969,7 +831,6 @@ internal fun ContinuousMonthGrid(
|
|||||||
) {
|
) {
|
||||||
val monthCount = remember { continuousMonthCount() }
|
val monthCount = remember { continuousMonthCount() }
|
||||||
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) }
|
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) }
|
||||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
@@ -991,8 +852,6 @@ internal fun ContinuousMonthGrid(
|
|||||||
weeks = state.monthsByIndex[index],
|
weeks = state.monthsByIndex[index],
|
||||||
weekStart = state.weekStart,
|
weekStart = state.weekStart,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
zone = state.zone,
|
|
||||||
timeChipWidth = timeChipWidth,
|
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
@@ -1013,8 +872,6 @@ private fun ContinuousMonthBlock(
|
|||||||
weeks: List<MonthWeek>?,
|
weeks: List<MonthWeek>?,
|
||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
zone: TimeZone,
|
|
||||||
timeChipWidth: Dp,
|
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
@@ -1023,7 +880,7 @@ private fun ContinuousMonthBlock(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = AppBarSpacing.Inset)
|
.padding(horizontal = 8.dp)
|
||||||
.padding(bottom = CONTINUOUS_MONTH_GAP),
|
.padding(bottom = CONTINUOUS_MONTH_GAP),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
@@ -1034,9 +891,6 @@ private fun ContinuousMonthBlock(
|
|||||||
MonthWeekRow(
|
MonthWeekRow(
|
||||||
week = week,
|
week = week,
|
||||||
today = today,
|
today = today,
|
||||||
zone = zone,
|
|
||||||
timeChipWidth = timeChipWidth,
|
|
||||||
rowHeight = CONTINUOUS_ROW_HEIGHT,
|
|
||||||
inMonth = { it.month == month.month && it.year == month.year },
|
inMonth = { it.month == month.month && it.year == month.year },
|
||||||
// The block owns its month alone: a day from either
|
// The block owns its month alone: a day from either
|
||||||
// neighbour is left out entirely rather than dimmed.
|
// neighbour is left out entirely rather than dimmed.
|
||||||
@@ -1106,7 +960,6 @@ internal fun DenseMonthGrid(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
|
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
|
||||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -1115,7 +968,7 @@ internal fun DenseMonthGrid(
|
|||||||
// padding: a lazy list scrolls its rows *through* the before-content
|
// padding: a lazy list scrolls its rows *through* the before-content
|
||||||
// padding, so scrolling to a week landed its row that far down with
|
// padding, so scrolling to a week landed its row that far down with
|
||||||
// the tail of the previous one showing above it (#191).
|
// the tail of the previous one showing above it (#191).
|
||||||
.padding(horizontal = AppBarSpacing.Inset)
|
.padding(horizontal = 8.dp)
|
||||||
.padding(top = DENSE_HEADER_GAP),
|
.padding(top = DENSE_HEADER_GAP),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
@@ -1129,9 +982,6 @@ internal fun DenseMonthGrid(
|
|||||||
MonthWeekRow(
|
MonthWeekRow(
|
||||||
week = week,
|
week = week,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
zone = state.zone,
|
|
||||||
timeChipWidth = timeChipWidth,
|
|
||||||
rowHeight = CONTINUOUS_ROW_HEIGHT,
|
|
||||||
// Every day in the stream belongs to a month equally — there
|
// Every day in the stream belongs to a month equally — there
|
||||||
// is no "other month" to recede here.
|
// is no "other month" to recede here.
|
||||||
inMonth = { true },
|
inMonth = { true },
|
||||||
@@ -1249,10 +1099,10 @@ private val MONTH_EXPAND_THRESHOLD = 48.dp
|
|||||||
* lists whatever day is selected — and a downward drag trades the pane away for
|
* lists whatever day is selected — and a downward drag trades the pane away for
|
||||||
* the full paged grid, an upward one brings it back (#53).
|
* the full paged grid, an upward one brings it back (#53).
|
||||||
*
|
*
|
||||||
* The grid slides between months like the paged style, and stands only as many
|
* The grid slides between months like the paged style, which it can only do
|
||||||
* rows tall as its own month spans (#162). A swipe between a five-row month and
|
* because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it
|
||||||
* a six-row one therefore moves the pane by a row as well as swapping the grid;
|
* stood 4–6 rows tall, so every swipe shunted the pane up or down by a row on
|
||||||
* the row it hands back is worth more to the pane than a still edge is.
|
* top of swapping the grid — the pane now holds still and only the grid moves.
|
||||||
*
|
*
|
||||||
* Expansion is deliberately **not** a stored preference. It is a way to look at
|
* Expansion is deliberately **not** a stored preference. It is a way to look at
|
||||||
* the month you are on, not a fourth style; persisted, someone would expand it
|
* the month you are on, not a fourth style; persisted, someone would expand it
|
||||||
@@ -1545,10 +1395,17 @@ private fun SplitExpandHandle(
|
|||||||
*/
|
*/
|
||||||
private val SPLIT_ROW_HEIGHT = 46.dp
|
private val SPLIT_ROW_HEIGHT = 46.dp
|
||||||
private val SPLIT_DOT_SIZE = 5.dp
|
private val SPLIT_DOT_SIZE = 5.dp
|
||||||
// Dots are capped by SPLIT_DOT_LANES, not a constant of their own: they stand for
|
// Dots are capped by MAX_EVENT_ROWS, not a constant of their own: they stand for
|
||||||
// the paged grid's lanes, so the two caps have to be the same number or a dot
|
// the paged grid's lanes, so the two caps have to be the same number or a dot
|
||||||
// would have no bar to become (#53).
|
// would have no bar to become (#53).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows the split grid always reserves — the most any month needs. A month that
|
||||||
|
* fits in fewer pads the remainder with blank rows rather than shrinking, which
|
||||||
|
* is what lets the pane below hold still from month to month.
|
||||||
|
*/
|
||||||
|
private const val SPLIT_GRID_ROWS = 6
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The expand handle: M3's drag-handle pill (32×4dp), in a row tall enough to be a
|
* The expand handle: M3's drag-handle pill (32×4dp), in a row tall enough to be a
|
||||||
* comfortable tap target on its own.
|
* comfortable tap target on its own.
|
||||||
@@ -1561,11 +1418,6 @@ private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
|
|||||||
* The split style's grid (#53): the month compressed to day numbers and event
|
* The split style's grid (#53): the month compressed to day numbers and event
|
||||||
* dots, with the selected day listed underneath by [SplitDayPane].
|
* dots, with the selected day listed underneath by [SplitDayPane].
|
||||||
*
|
*
|
||||||
* Only the rows the month actually spans. It used to pad every month out to six
|
|
||||||
* so the pane below held still from page to page, but a row is a sixth of the
|
|
||||||
* grid and a third of what the pane gets to show — too much to leave blank on
|
|
||||||
* the months that don't need it (#162).
|
|
||||||
*
|
|
||||||
* Tapping selects rather than drilling into the Day view — the pane is the
|
* Tapping selects rather than drilling into the Day view — the pane is the
|
||||||
* answer to "what's on this day", so opening a whole screen for it would defeat
|
* answer to "what's on this day", so opening a whole screen for it would defeat
|
||||||
* the layout. The full Day view stays one tap away on the pane's date header.
|
* the layout. The full Day view stays one tap away on the pane's date header.
|
||||||
@@ -1583,7 +1435,7 @@ internal fun SplitMonthGrid(
|
|||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
state.weeks.forEach { week ->
|
state.weeks.forEach { week ->
|
||||||
@@ -1592,7 +1444,7 @@ internal fun SplitMonthGrid(
|
|||||||
WeekNumberGutter(
|
WeekNumberGutter(
|
||||||
weekStart = week.days.first(),
|
weekStart = week.days.first(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(rememberWeekNumberGutter())
|
.width(WEEK_NUMBER_GUTTER)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1600,11 +1452,11 @@ internal fun SplitMonthGrid(
|
|||||||
val inMonth = day.month == month.month && day.year == month.year
|
val inMonth = day.month == month.month && day.year == month.year
|
||||||
// Seated by lane rather than gathered by colour, so each dot
|
// Seated by lane rather than gathered by colour, so each dot
|
||||||
// is the event the expanded grid draws in that same lane.
|
// is the event the expanded grid draws in that same lane.
|
||||||
val seated = week.laneEvents(col, day, SPLIT_DOT_LANES)
|
val seated = week.laneEvents(col, day, MAX_EVENT_ROWS)
|
||||||
SplitDayCell(
|
SplitDayCell(
|
||||||
date = day,
|
date = day,
|
||||||
events = seated,
|
events = seated,
|
||||||
hidden = week.overflowEvents(col, day, SPLIT_DOT_LANES),
|
hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS),
|
||||||
isToday = day == state.today,
|
isToday = day == state.today,
|
||||||
// A page marks only the days its own month owns. Paging
|
// A page marks only the days its own month owns. Paging
|
||||||
// moves the selection before this month's replacement
|
// moves the selection before this month's replacement
|
||||||
@@ -1624,11 +1476,17 @@ internal fun SplitMonthGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Hold the grid at a constant height whatever shape the month is, so the
|
||||||
|
// pane beneath it doesn't move as you page and one month can slide over
|
||||||
|
// another without a height change under it.
|
||||||
|
repeat(SPLIT_GRID_ROWS - state.weeks.size) {
|
||||||
|
Spacer(Modifier.fillMaxWidth().height(SPLIT_ROW_HEIGHT))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One compact day: its number over up to [SPLIT_DOT_LANES] lane-seated event dots.
|
* One compact day: its number over up to [MAX_EVENT_ROWS] lane-seated event dots.
|
||||||
*
|
*
|
||||||
* Selection and today are deliberately different signals — a tinted, outlined
|
* Selection and today are deliberately different signals — a tinted, outlined
|
||||||
* cell versus the filled circle the other views already use for today — so the
|
* cell versus the filled circle the other views already use for today — so the
|
||||||
@@ -1942,7 +1800,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = AppBarSpacing.Inset)
|
.padding(horizontal = 8.dp)
|
||||||
.clipToBounds(),
|
.clipToBounds(),
|
||||||
) {
|
) {
|
||||||
if (!dense) {
|
if (!dense) {
|
||||||
@@ -2002,19 +1860,13 @@ private fun rememberSkeletonPulse(): Float {
|
|||||||
* One week of the grid. Bars (all-day / multi-day) are positioned absolutely so
|
* One week of the grid. Bars (all-day / multi-day) are positioned absolutely so
|
||||||
* a multi-day event is one connected bar across the columns; single-day timed
|
* a multi-day event is one connected bar across the columns; single-day timed
|
||||||
* events sit beneath them as filled pills in their own cell. The cap is
|
* events sit beneath them as filled pills in their own cell. The cap is
|
||||||
* [SPLIT_DOT_LANES] rows of bars+pills, then a "+N" dot indicator per day.
|
* [MAX_EVENT_ROWS] rows of bars+pills, then a "+N" dot indicator per day.
|
||||||
* A transparent per-day layer on top turns a tap into "open that day".
|
* A transparent per-day layer on top turns a tap into "open that day".
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthWeekRow(
|
private fun MonthWeekRow(
|
||||||
week: MonthWeek,
|
week: MonthWeek,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
/** The zone the grid was laid out in — never re-read here (see [MonthUiState.Success.zone]). */
|
|
||||||
zone: TimeZone,
|
|
||||||
/** The narrowest chip that may carry a start time, measured once per grid (#219). */
|
|
||||||
timeChipWidth: Dp,
|
|
||||||
/** The height this row was given, which decides its lanes — see [laneCapFor]. */
|
|
||||||
rowHeight: Dp,
|
|
||||||
inMonth: (LocalDate) -> Boolean,
|
inMonth: (LocalDate) -> Boolean,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
@@ -2031,32 +1883,9 @@ private fun MonthWeekRow(
|
|||||||
selected: LocalDate? = null,
|
selected: LocalDate? = null,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val overflowRow = rememberOverflowRowHeight()
|
|
||||||
val laneCap = week.laneCapFor(rowHeight, overflowRow)
|
|
||||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||||
val shownLanes = laneCount.coerceAtMost(laneCap)
|
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||||
val morphing = morphInFlight()
|
val morphing = morphInFlight()
|
||||||
// Every chip's start time for this row at once, and only when the row's own
|
|
||||||
// inputs change: formatting is a parsed pattern per call, and the dim cutoff
|
|
||||||
// ticks every minute while a drag recomposes every frame (#219).
|
|
||||||
val is24Hour = LocalUse24HourFormat.current
|
|
||||||
val locale = currentLocale()
|
|
||||||
val chipTimes = remember(week, zone, is24Hour, locale) {
|
|
||||||
buildMap {
|
|
||||||
week.spans.forEach { span ->
|
|
||||||
put(
|
|
||||||
span.event.instanceId,
|
|
||||||
monthChipTime(span.event, span.continuesLeft, zone, is24Hour, locale),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
week.timedByDay.values.flatten().forEach { event ->
|
|
||||||
put(
|
|
||||||
event.instanceId,
|
|
||||||
monthChipTime(event, continuesLeft = false, zone, is24Hour, locale),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drag to reschedule (#68). The chips can take no pointer input of their own
|
// Drag to reschedule (#68). The chips can take no pointer input of their own
|
||||||
// — the full-bleed tap layer sits on top of them — so one detector on the
|
// — the full-bleed tap layer sits on top of them — so one detector on the
|
||||||
@@ -2087,10 +1916,9 @@ private fun MonthWeekRow(
|
|||||||
band = bandCoordinates[0],
|
band = bandCoordinates[0],
|
||||||
columnWidthPx = cell.size.width / 7f,
|
columnWidthPx = cell.size.width / 7f,
|
||||||
laneHeightPx = rowHeightPx,
|
laneHeightPx = rowHeightPx,
|
||||||
laneCount = laneCap,
|
laneCount = MAX_EVENT_ROWS,
|
||||||
isRtl = isRtl,
|
isRtl = isRtl,
|
||||||
chipAt = { col, lane -> week.chipAt(col, lane, laneCap) },
|
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
|
||||||
chipStart = { col, lane -> week.chipStartCol(col, lane) },
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2111,7 +1939,7 @@ private fun MonthWeekRow(
|
|||||||
WeekNumberGutter(
|
WeekNumberGutter(
|
||||||
weekStart = week.days.first(),
|
weekStart = week.days.first(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(rememberWeekNumberGutter())
|
.width(WEEK_NUMBER_GUTTER)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2130,20 +1958,11 @@ private fun MonthWeekRow(
|
|||||||
controller = dragController,
|
controller = dragController,
|
||||||
band = bandCoordinates,
|
band = bandCoordinates,
|
||||||
rowHeightPx = rowHeightPx,
|
rowHeightPx = rowHeightPx,
|
||||||
laneCap = laneCap,
|
|
||||||
isRtl = isRtl,
|
isRtl = isRtl,
|
||||||
chipTimes = chipTimes,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
// What a chip has to spend, against the [timeChipWidth] a start time
|
|
||||||
// costs it (#219).
|
|
||||||
val colW = maxWidth / 7
|
val colW = maxWidth / 7
|
||||||
// Held here rather than read at the offset: the dots are placed
|
|
||||||
// inside a plain lambda, which is no longer in this scope. The box
|
|
||||||
// is the whole row, so the day number's share comes off it — the
|
|
||||||
// dots are positioned inside the band, not inside this.
|
|
||||||
val bandHeight = monthBandHeight(maxHeight)
|
|
||||||
|
|
||||||
// Per-day background pills — same surfaceContainer rounded surface the
|
// Per-day background pills — same surfaceContainer rounded surface the
|
||||||
// week/day views use, so the three views share one visual language.
|
// week/day views use, so the three views share one visual language.
|
||||||
@@ -2223,8 +2042,6 @@ private fun MonthWeekRow(
|
|||||||
continuesLeft = span.continuesLeft,
|
continuesLeft = span.continuesLeft,
|
||||||
continuesRight = span.continuesRight,
|
continuesRight = span.continuesRight,
|
||||||
days = week.days.subList(span.startCol, span.endCol + 1),
|
days = week.days.subList(span.startCol, span.endCol + 1),
|
||||||
time = chipTimes[span.event.instanceId],
|
|
||||||
showTime = colW * cols >= timeChipWidth,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * span.startCol,
|
x = colW * span.startCol,
|
||||||
@@ -2248,7 +2065,7 @@ private fun MonthWeekRow(
|
|||||||
)
|
)
|
||||||
.width(colW * cols)
|
.width(colW * cols)
|
||||||
.height(EVENT_ROW_HEIGHT)
|
.height(EVENT_ROW_HEIGHT)
|
||||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||||
)
|
)
|
||||||
// One invisible slice of the bar per further column it
|
// One invisible slice of the bar per further column it
|
||||||
// covers. A multi-day event has a dot on every day but
|
// covers. A multi-day event has a dot on every day but
|
||||||
@@ -2272,7 +2089,7 @@ private fun MonthWeekRow(
|
|||||||
)
|
)
|
||||||
.width(colW)
|
.width(colW)
|
||||||
.height(EVENT_ROW_HEIGHT)
|
.height(EVENT_ROW_HEIGHT)
|
||||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2286,7 +2103,7 @@ private fun MonthWeekRow(
|
|||||||
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
||||||
.map { it.lane }
|
.map { it.lane }
|
||||||
.toSet()
|
.toSet()
|
||||||
val freeSlots = (0 until laneCap).filter { it !in occupied }
|
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
|
||||||
val pillsShown = timed.take(freeSlots.size)
|
val pillsShown = timed.take(freeSlots.size)
|
||||||
pillsShown.forEachIndexed { i, ev ->
|
pillsShown.forEachIndexed { i, ev ->
|
||||||
MonthBar(
|
MonthBar(
|
||||||
@@ -2295,8 +2112,6 @@ private fun MonthWeekRow(
|
|||||||
continuesLeft = false,
|
continuesLeft = false,
|
||||||
continuesRight = false,
|
continuesRight = false,
|
||||||
days = listOf(d),
|
days = listOf(d),
|
||||||
time = chipTimes[ev.instanceId],
|
|
||||||
showTime = colW >= timeChipWidth,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * col,
|
x = colW * col,
|
||||||
@@ -2305,7 +2120,7 @@ private fun MonthWeekRow(
|
|||||||
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
||||||
.width(colW)
|
.width(colW)
|
||||||
.height(EVENT_ROW_HEIGHT)
|
.height(EVENT_ROW_HEIGHT)
|
||||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||||
@@ -2320,20 +2135,8 @@ private fun MonthWeekRow(
|
|||||||
events = hiddenEvents,
|
events = hiddenEvents,
|
||||||
total = hidden,
|
total = hidden,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
rowHeight = overflowRow,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
// After the chip lanes, but never past the
|
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
||||||
// band: on a row too short for the lanes it
|
|
||||||
// is holding, dots placed below it are
|
|
||||||
// clipped away entirely and the day looks
|
|
||||||
// like it has nothing more to show.
|
|
||||||
.offset(
|
|
||||||
x = colW * col,
|
|
||||||
y = minOf(
|
|
||||||
EVENT_ROW_HEIGHT * laneCap,
|
|
||||||
bandHeight - overflowRow,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.morphBounds(MonthMorphKey.Overflow(d))
|
.morphBounds(MonthMorphKey.Overflow(d))
|
||||||
.width(colW)
|
.width(colW)
|
||||||
.padding(horizontal = 3.dp),
|
.padding(horizontal = 3.dp),
|
||||||
@@ -2408,7 +2211,6 @@ private fun MonthWeekRow(
|
|||||||
bandCoordinates,
|
bandCoordinates,
|
||||||
),
|
),
|
||||||
rowHeightPx = rowHeightPx,
|
rowHeightPx = rowHeightPx,
|
||||||
laneCap = laneCap,
|
|
||||||
)
|
)
|
||||||
if (chip != null) onEventClick(chip) else onOpenDay(d)
|
if (chip != null) onEventClick(chip) else onOpenDay(d)
|
||||||
},
|
},
|
||||||
@@ -2450,12 +2252,11 @@ internal fun MonthWeek.chipAtCellY(
|
|||||||
cellY: Float,
|
cellY: Float,
|
||||||
bandTopInCell: Float?,
|
bandTopInCell: Float?,
|
||||||
rowHeightPx: Float,
|
rowHeightPx: Float,
|
||||||
laneCap: Int,
|
|
||||||
): EventInstance? {
|
): EventInstance? {
|
||||||
if (bandTopInCell == null || rowHeightPx <= 0f) return null
|
if (bandTopInCell == null || rowHeightPx <= 0f) return null
|
||||||
val bandY = cellY - bandTopInCell
|
val bandY = cellY - bandTopInCell
|
||||||
if (bandY < 0f) return null
|
if (bandY < 0f) return null
|
||||||
return chipAt(col, (bandY / rowHeightPx).toInt(), laneCap)
|
return chipAt(col, (bandY / rowHeightPx).toInt(), MAX_EVENT_ROWS)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2470,11 +2271,7 @@ private fun monthChipDragModifier(
|
|||||||
controller: MonthDragController?,
|
controller: MonthDragController?,
|
||||||
band: Array<LayoutCoordinates?>,
|
band: Array<LayoutCoordinates?>,
|
||||||
rowHeightPx: Float,
|
rowHeightPx: Float,
|
||||||
laneCap: Int,
|
|
||||||
isRtl: Boolean,
|
isRtl: Boolean,
|
||||||
/** The row's formatted chip times by instance id, so the copy carries the
|
|
||||||
* one its source chip had rather than deriving another (#219). */
|
|
||||||
chipTimes: Map<Long, String?>,
|
|
||||||
): Modifier = rememberDragSurface(
|
): Modifier = rememberDragSurface(
|
||||||
enabled = moveScope?.dragEnabled == true && controller != null,
|
enabled = moveScope?.dragEnabled == true && controller != null,
|
||||||
key = week.days.first(),
|
key = week.days.first(),
|
||||||
@@ -2489,7 +2286,7 @@ private fun monthChipDragModifier(
|
|||||||
val event = if (bandY < 0f || columnPx <= 0f) {
|
val event = if (bandY < 0f || columnPx <= 0f) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
week.chipAt(dayIndex, lane, laneCap)
|
week.chipAt(dayIndex, lane, MAX_EVENT_ROWS)
|
||||||
}
|
}
|
||||||
if (event == null || moveScope?.allows(event) != true) {
|
if (event == null || moveScope?.allows(event) != true) {
|
||||||
false
|
false
|
||||||
@@ -2503,7 +2300,6 @@ private fun monthChipDragModifier(
|
|||||||
y = requireNotNull(bandTop) + lane * rowHeightPx,
|
y = requireNotNull(bandTop) + lane * rowHeightPx,
|
||||||
),
|
),
|
||||||
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
|
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
|
||||||
time = chipTimes[event.instanceId],
|
|
||||||
)
|
)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -2514,7 +2310,7 @@ private fun monthChipDragModifier(
|
|||||||
// How many columns the finger crossed — grabbing the middle of a
|
// How many columns the finger crossed — grabbing the middle of a
|
||||||
// multi-day bar shifts by what it travelled, not to where it landed.
|
// multi-day bar shifts by what it travelled, not to where it landed.
|
||||||
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
|
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
|
||||||
moveScope?.move(
|
val took = moveScope?.move(
|
||||||
MoveRequest(
|
MoveRequest(
|
||||||
eventId = drop.event.eventId,
|
eventId = drop.event.eventId,
|
||||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
@@ -2522,6 +2318,9 @@ private fun monthChipDragModifier(
|
|||||||
target = MoveTarget.ByDays(delta),
|
target = MoveTarget.ByDays(delta),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// Refused, so nothing will land: let the copy go now rather than
|
||||||
|
// hold the chip out for a settle that can never arrive.
|
||||||
|
if (took != true) controller?.release()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCancel = { controller?.cancel() },
|
onCancel = { controller?.cancel() },
|
||||||
@@ -2547,7 +2346,8 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = weekNumber.toString(),
|
text = weekNumber.toString(),
|
||||||
style = weekNumberStyle(),
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2610,10 +2410,7 @@ private fun shortMonthName(date: LocalDate): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */
|
||||||
* A filled event pill/bar — softened (or raw) fill, title clipped to one line,
|
|
||||||
* with the start time before it where the chip is wide enough ([showTime], #219).
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthBar(
|
private fun MonthBar(
|
||||||
event: de.jeanlucmakiola.calendula.domain.EventInstance,
|
event: de.jeanlucmakiola.calendula.domain.EventInstance,
|
||||||
@@ -2627,54 +2424,42 @@ private fun MonthBar(
|
|||||||
* the provider hands the re-read instance a new one.
|
* the provider hands the re-read instance a new one.
|
||||||
*/
|
*/
|
||||||
days: List<LocalDate>? = null,
|
days: List<LocalDate>? = null,
|
||||||
/** The chip's start time, or null where it has none — see `monthChipTime` (#219). */
|
|
||||||
time: String? = null,
|
|
||||||
/** Whether this chip has the width to draw [time]; a screen reader gets it either way. */
|
|
||||||
showTime: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||||
val paint = eventPaint(event, dark)
|
val soften = LocalSoftenColors.current
|
||||||
// The same title/secondary ink pairing the week and day blocks use, with
|
val fill = eventFill(event.color, dark, soften)
|
||||||
// the time on the quieter half.
|
|
||||||
val label = inlineTimeLabel(
|
|
||||||
time = time.takeIf { showTime },
|
|
||||||
title = title,
|
|
||||||
timeInk = paint.secondaryInk,
|
|
||||||
)
|
|
||||||
// Announced whether or not it is drawn, and comma-separated as the week and
|
|
||||||
// day blocks do it: what a screen reader hears shouldn't turn on how wide
|
|
||||||
// the chip happens to be (#219).
|
|
||||||
val description = if (time != null) "$title, $time" else title
|
|
||||||
val moveAction = eventMoveAction(event)
|
val moveAction = eventMoveAction(event)
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
val monthDrag = LocalMonthDrag.current
|
val monthDrag = LocalMonthDrag.current
|
||||||
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
|
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
|
||||||
monthDrag?.isSettledChip(event, days) == true
|
monthDrag?.isSettledChip(event, days) == true
|
||||||
val ghost = ghostAlpha(lifted)
|
val ghost = ghostAlpha(lifted)
|
||||||
val shape = monthBarShape(continuesLeft, continuesRight)
|
val shape = RoundedCornerShape(
|
||||||
|
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
|
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
|
topEnd = if (continuesRight) 0.dp else 4.dp,
|
||||||
|
bottomEnd = if (continuesRight) 0.dp else 4.dp,
|
||||||
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.eventSurface(paint, shape, monthBarCuts(continuesLeft, continuesRight))
|
.background(fill, shape)
|
||||||
.padding(horizontal = MONTH_CHIP_TEXT_PADDING)
|
.padding(horizontal = 4.dp)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = description
|
contentDescription = title
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
val titleOverflow = eventTitleOverflow()
|
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall.trimmedLines(),
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = titleOverflow.overflow,
|
overflow = TextOverflow.Ellipsis,
|
||||||
softWrap = titleOverflow.softWrap,
|
color = eventInk(fill),
|
||||||
color = paint.titleInk,
|
textDecoration = declinedDecoration(event.isDeclined),
|
||||||
fontWeight = paint.titleWeight,
|
|
||||||
textDecoration = paint.decoration,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2689,7 +2474,6 @@ private fun OverflowDots(
|
|||||||
events: List<EventInstance>,
|
events: List<EventInstance>,
|
||||||
total: Int,
|
total: Int,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
rowHeight: Dp,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
@@ -2697,14 +2481,14 @@ private fun OverflowDots(
|
|||||||
val byColor = events.groupBy { it.color }
|
val byColor = events.groupBy { it.color }
|
||||||
val dots = byColor.keys.take(3)
|
val dots = byColor.keys.take(3)
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.height(rowHeight),
|
modifier = modifier.height(EVENT_ROW_HEIGHT),
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
dots.forEach { argb ->
|
dots.forEach { argb ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(OVERFLOW_DOT_SIZE)
|
.size(6.dp)
|
||||||
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventAccent(argb, dark, soften), CircleShape),
|
.background(eventAccent(argb, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
@@ -2731,7 +2515,7 @@ private fun MonthGridLoading() {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
repeat(6) {
|
repeat(6) {
|
||||||
@@ -2757,16 +2541,10 @@ private fun MonthGridLoading() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** [abbreviated] swaps the full month name for its three-letter form (#165). */
|
private fun formatMonthTitle(ym: YearMonth, locale: Locale, currentYear: Int): String =
|
||||||
private fun formatMonthTitle(
|
|
||||||
ym: YearMonth,
|
|
||||||
locale: Locale,
|
|
||||||
currentYear: Int,
|
|
||||||
abbreviated: Boolean = false,
|
|
||||||
): String =
|
|
||||||
formatCalendarTitle(
|
formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
|
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
skeleton = if (abbreviated) "LLL" else "LLLL",
|
skeleton = "LLLL",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
|||||||
*/
|
*/
|
||||||
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
||||||
if (col !in days.indices || lane !in 0 until laneCap) return null
|
if (col !in days.indices || lane !in 0 until laneCap) return null
|
||||||
spanAt(col, lane)?.let { return it.event }
|
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event }
|
||||||
val occupied = spans
|
val occupied = spans
|
||||||
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||||
.map { it.lane }
|
.map { it.lane }
|
||||||
@@ -84,19 +84,6 @@ fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
|||||||
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
|
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The column the chip drawn in lane [lane] of column [col] begins in. A bar is
|
|
||||||
* drawn once, from its own first column, so a chip seated by [chipAt] on the
|
|
||||||
* second day it covers belongs a column (or more) to the left (#253). Answers
|
|
||||||
* [col] for a single-day chip, and for an empty slot no one should be asking
|
|
||||||
* about.
|
|
||||||
*/
|
|
||||||
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int = 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
|
* The events on [day] that [laneEvents] had no lane left for — its exact
|
||||||
* complement, in the same bars-then-pills order. Returned as events rather than
|
* complement, in the same bars-then-pills order. Returned as events rather than
|
||||||
@@ -139,8 +126,6 @@ sealed interface ContinuousMonthUiState {
|
|||||||
val monthsByIndex: Map<Int, List<MonthWeek>>,
|
val monthsByIndex: Map<Int, List<MonthWeek>>,
|
||||||
val weeksByIndex: Map<Int, MonthWeek>,
|
val weeksByIndex: Map<Int, MonthWeek>,
|
||||||
val weekStart: DayOfWeek,
|
val weekStart: DayOfWeek,
|
||||||
/** As [MonthUiState.Success.zone], and for the same reason. */
|
|
||||||
val zone: TimeZone = TimeZone.currentSystemDefault(),
|
|
||||||
) : ContinuousMonthUiState
|
) : ContinuousMonthUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
|
||||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
|
||||||
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -209,7 +206,9 @@ class MonthViewModel @Inject constructor(
|
|||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
): ContinuousMonthUiState {
|
): ContinuousMonthUiState {
|
||||||
calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) }
|
if (calendars.isEmpty()) {
|
||||||
|
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
// Bucketed once for the whole window: a row then takes the handful of
|
// Bucketed once for the whole window: a row then takes the handful of
|
||||||
// events on its seven days instead of re-scanning a year of them.
|
// events on its seven days instead of re-scanning a year of them.
|
||||||
val byDay = DayIndex(instances, zone)
|
val byDay = DayIndex(instances, zone)
|
||||||
@@ -238,7 +237,6 @@ class MonthViewModel @Inject constructor(
|
|||||||
monthsByIndex = months,
|
monthsByIndex = months,
|
||||||
weeksByIndex = weeks,
|
weeksByIndex = weeks,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
zone = zone,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +311,9 @@ class MonthViewModel @Inject constructor(
|
|||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
): MonthUiState {
|
): MonthUiState {
|
||||||
calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) }
|
if (calendars.isEmpty()) {
|
||||||
|
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
||||||
return MonthUiState.Success(
|
return MonthUiState.Success(
|
||||||
month = ym,
|
month = ym,
|
||||||
@@ -417,11 +417,8 @@ internal fun layoutCalendarWeek(
|
|||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
): MonthWeek {
|
): MonthWeek {
|
||||||
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
||||||
// A bar is decided by the *event*, not by how much of it this row holds: one
|
|
||||||
// crossing midnight into the next week row covers a single day in each, and
|
|
||||||
// counting per row drew it as two unrelated pills (#253).
|
|
||||||
val (bars, singles) = weekEvents.partition { ev ->
|
val (bars, singles) = weekEvents.partition { ev ->
|
||||||
ev.isAllDay || ev.spansMultipleDays(zone)
|
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
||||||
}
|
}
|
||||||
val spans = layoutAllDay(bars, days, zone).map { s ->
|
val spans = layoutAllDay(bars, days, zone).map { s ->
|
||||||
MonthSpan(
|
MonthSpan(
|
||||||
@@ -437,18 +434,14 @@ internal fun layoutCalendarWeek(
|
|||||||
days = days,
|
days = days,
|
||||||
spans = spans,
|
spans = spans,
|
||||||
timedByDay = days.associateWith { d ->
|
timedByDay = days.associateWith { d ->
|
||||||
// Declined last, so a day that overflows drops the events you said
|
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
|
||||||
// no to before the ones you are actually going to (#230).
|
|
||||||
singles.filter { it.coversDay(d, zone) }
|
|
||||||
.sortedWith(compareBy<EventInstance> { it.isDeclined }.thenBy { it.start })
|
|
||||||
},
|
},
|
||||||
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every event touching each of [days], declined ones last (#230), then all-day
|
* Every event touching each of [days], all-day first then by start time. Unlike
|
||||||
* first and by start time within each group. Unlike
|
|
||||||
* [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date
|
* [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date
|
||||||
* they cover and applies no display cap, so the split style's day pane can list a
|
* they cover and applies no display cap, so the split style's day pane can list a
|
||||||
* date in full without querying the provider again.
|
* date in full without querying the provider again.
|
||||||
@@ -461,11 +454,7 @@ internal fun instancesByDay(
|
|||||||
days.associateWith { day ->
|
days.associateWith { day ->
|
||||||
instances
|
instances
|
||||||
.filter { it.coversDay(day, zone) }
|
.filter { it.coversDay(day, zone) }
|
||||||
.sortedWith(
|
.sortedWith(compareByDescending<EventInstance> { it.isAllDay }.thenBy { it.start })
|
||||||
compareBy<EventInstance> { it.isDeclined }
|
|
||||||
.thenByDescending { it.isAllDay }
|
|
||||||
.thenBy { it.start },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
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,19 +5,6 @@ enum class OnboardingStep {
|
|||||||
/** Required: nothing works without the calendar grant. */
|
/** Required: nothing works without the calendar grant. */
|
||||||
Permission,
|
Permission,
|
||||||
|
|
||||||
/**
|
|
||||||
* Only when every calendar on the device is switched off, so the app would
|
|
||||||
* open on a blank grid: lists them and offers to switch them back on (#287).
|
|
||||||
*/
|
|
||||||
HiddenCalendars,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Only when the device has nothing to put an event in — no calendar at all,
|
|
||||||
* or none left showing after the step above. The app can make a local one,
|
|
||||||
* or send you to whatever would do the syncing (#287).
|
|
||||||
*/
|
|
||||||
Calendars,
|
|
||||||
|
|
||||||
/** Whether Calendula delivers reminder notifications itself. */
|
/** Whether Calendula delivers reminder notifications itself. */
|
||||||
Reminders,
|
Reminders,
|
||||||
|
|
||||||
@@ -80,18 +67,6 @@ data class OnboardingPlan(
|
|||||||
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
||||||
* counter had not accounted for.
|
* counter had not accounted for.
|
||||||
*
|
*
|
||||||
* [calendarsApplies] carries the same contract for the calendar step, with one
|
|
||||||
* wrinkle: for the backup step an empty list means "could not read", but here
|
|
||||||
* an empty list *is* the condition. The two are indistinguishable at this
|
|
||||||
* layer and both want the step, so it turns false only once a calendar the app
|
|
||||||
* could actually show has arrived.
|
|
||||||
*
|
|
||||||
* [hiddenApplies] is the step in front of it (#287): the device has calendars
|
|
||||||
* but every one of them is switched off. It comes first because switching one
|
|
||||||
* back on is the lighter remedy — and where it is declined, the calendar step
|
|
||||||
* behind it still offers to make one, the same offer a device with no calendars
|
|
||||||
* at all gets.
|
|
||||||
*
|
|
||||||
* The month-style step is *not* conditional on Month being the chosen view:
|
* The month-style step is *not* conditional on Month being the chosen view:
|
||||||
* Month is reachable from the drawer whatever opens first, and a step that came
|
* Month is reachable from the drawer whatever opens first, and a step that came
|
||||||
* and went as the view is picked would move the counter under the user on the
|
* and went as the view is picked would move the counter under the user on the
|
||||||
@@ -100,9 +75,7 @@ data class OnboardingPlan(
|
|||||||
* [visibilityArmed] carries the one-time notice that the device is hiding some
|
* [visibilityArmed] carries the one-time notice that the device is hiding some
|
||||||
* calendars (#75) — the only optional step an *existing* install can be given,
|
* calendars (#75) — the only optional step an *existing* install can be given,
|
||||||
* which is why it is not gated on `fresh`, and on its own it earns no closing
|
* which is why it is not gated on `fresh`, and on its own it earns no closing
|
||||||
* screen. It stays a pure announcement: the all-hidden *state* is
|
* screen.
|
||||||
* [hiddenApplies]'s business, up at the front of the flow where something can
|
|
||||||
* still be done about it.
|
|
||||||
*/
|
*/
|
||||||
fun onboardingPlan(
|
fun onboardingPlan(
|
||||||
hasPermission: Boolean,
|
hasPermission: Boolean,
|
||||||
@@ -112,10 +85,6 @@ fun onboardingPlan(
|
|||||||
viewDone: Boolean,
|
viewDone: Boolean,
|
||||||
monthStyleDone: Boolean,
|
monthStyleDone: Boolean,
|
||||||
backupApplies: Boolean?,
|
backupApplies: Boolean?,
|
||||||
hiddenDone: Boolean = false,
|
|
||||||
hiddenApplies: Boolean? = false,
|
|
||||||
calendarsDone: Boolean = false,
|
|
||||||
calendarsApplies: Boolean? = null,
|
|
||||||
visibilityArmed: Boolean = false,
|
visibilityArmed: Boolean = false,
|
||||||
visibilityDone: Boolean = false,
|
visibilityDone: Boolean = false,
|
||||||
doneShown: Boolean = false,
|
doneShown: Boolean = false,
|
||||||
@@ -123,8 +92,6 @@ fun onboardingPlan(
|
|||||||
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
||||||
val steps = buildList {
|
val steps = buildList {
|
||||||
if (!hasPermission || fresh) add(OnboardingStep.Permission)
|
if (!hasPermission || fresh) add(OnboardingStep.Permission)
|
||||||
if (fresh && hiddenApplies != false) add(OnboardingStep.HiddenCalendars)
|
|
||||||
if (fresh && calendarsApplies != false) add(OnboardingStep.Calendars)
|
|
||||||
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
|
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
|
||||||
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
||||||
if (fresh) {
|
if (fresh) {
|
||||||
@@ -137,8 +104,6 @@ fun onboardingPlan(
|
|||||||
val current = steps.firstOrNull { step ->
|
val current = steps.firstOrNull { step ->
|
||||||
when (step) {
|
when (step) {
|
||||||
OnboardingStep.Permission -> !hasPermission
|
OnboardingStep.Permission -> !hasPermission
|
||||||
OnboardingStep.HiddenCalendars -> !hiddenDone
|
|
||||||
OnboardingStep.Calendars -> !calendarsDone
|
|
||||||
OnboardingStep.Reminders -> !remindersDone
|
OnboardingStep.Reminders -> !remindersDone
|
||||||
OnboardingStep.Backup -> !backupDone
|
OnboardingStep.Backup -> !backupDone
|
||||||
OnboardingStep.View -> !viewDone
|
OnboardingStep.View -> !viewDone
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ fun OnboardingSteps(
|
|||||||
// Collected for every step, not just the two that draw a preview: starting
|
// Collected for every step, not just the two that draw a preview: starting
|
||||||
// it when the picker composes would leave that step blank as it slides in.
|
// it when the picker composes would leave that step blank as it slides in.
|
||||||
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
|
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
|
||||||
val creation by viewModel.creation.collectAsStateWithLifecycle()
|
|
||||||
val hiddenCalendars by viewModel.hiddenCalendars.collectAsStateWithLifecycle()
|
|
||||||
val hiddenWriteFailed by viewModel.hiddenWriteFailed.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
// Coerced because the outgoing half of a transition may be a step the live
|
// Coerced because the outgoing half of a transition may be a step the live
|
||||||
// plan has since dropped — the backup step goes once the calendars say it
|
// plan has since dropped — the backup step goes once the calendars say it
|
||||||
@@ -70,26 +67,6 @@ fun OnboardingSteps(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
progress = progress,
|
progress = progress,
|
||||||
)
|
)
|
||||||
OnboardingStep.HiddenCalendars -> HiddenCalendarsStep(
|
|
||||||
calendars = hiddenCalendars,
|
|
||||||
onSetVisible = viewModel::setHiddenCalendarVisible,
|
|
||||||
onSwitchAllOn = viewModel::switchAllCalendarsOn,
|
|
||||||
onFinished = viewModel::finishHiddenCalendars,
|
|
||||||
writeFailed = hiddenWriteFailed,
|
|
||||||
modifier = modifier,
|
|
||||||
progress = progress,
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
)
|
|
||||||
OnboardingStep.Calendars -> CalendarsStep(
|
|
||||||
onCreateLocal = viewModel::createLocalCalendar,
|
|
||||||
onSkip = viewModel::skipCalendars,
|
|
||||||
creation = creation,
|
|
||||||
onFinished = viewModel::finishCalendars,
|
|
||||||
onClearCreation = viewModel::clearCalendarCreation,
|
|
||||||
modifier = modifier,
|
|
||||||
progress = progress,
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
)
|
|
||||||
OnboardingStep.Reminders -> ReminderStep(
|
OnboardingStep.Reminders -> ReminderStep(
|
||||||
onFinished = viewModel::finishReminders,
|
onFinished = viewModel::finishReminders,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|||||||
+10
-235
@@ -16,8 +16,6 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
|
||||||
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -26,7 +24,6 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -77,27 +74,6 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
initialValue = null,
|
initialValue = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* The calendar list every derived step reads, collected once: one per step
|
|
||||||
* would put a provider query each on every visibility tick, and let the
|
|
||||||
* steps judge different snapshots of the same moment. Null is "no list to
|
|
||||||
* judge" — before the grant, and on a read that failed.
|
|
||||||
*/
|
|
||||||
private val calendars: StateFlow<List<CalendarSource>?> = hasPermission
|
|
||||||
.flatMapLatest { granted ->
|
|
||||||
if (granted != true) {
|
|
||||||
flowOf(null)
|
|
||||||
} else {
|
|
||||||
val list: Flow<List<CalendarSource>?> = repository.calendars()
|
|
||||||
list.catch { emit(null) }.flowOn(io)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the backup step applies: something of yours is worth exporting and
|
* Whether the backup step applies: something of yours is worth exporting and
|
||||||
* nothing you write to is synced anywhere. Null while the calendar list
|
* nothing you write to is synced anywhere. Null while the calendar list
|
||||||
@@ -105,90 +81,17 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
* an empty list is also how a null cursor arrives, and guessing false there
|
* an empty list is also how a null cursor arrives, and guessing false there
|
||||||
* would drop the step for the whole session.
|
* would drop the step for the whole session.
|
||||||
*/
|
*/
|
||||||
private val backupApplies: Flow<Boolean?> = calendars.map { list ->
|
private val backupApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
|
||||||
if (list.isNullOrEmpty()) null else list.backupApplies()
|
if (granted != true) {
|
||||||
|
flowOf(null)
|
||||||
|
} else {
|
||||||
|
repository.calendars()
|
||||||
|
.map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() }
|
||||||
|
.catch { emit(null) }
|
||||||
|
.flowOn(io)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How the step's own create attempt ended, while it is still saying so (#287). */
|
|
||||||
private val _creation = MutableStateFlow<CalendarCreation?>(null)
|
|
||||||
val creation: StateFlow<CalendarCreation?> = _creation.asStateFlow()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the step made a calendar here. Latched for the session and never
|
|
||||||
* cleared: the new calendar answers [calendarsApplies] with false, which
|
|
||||||
* would drop the step out of the plan the moment it is used — first from
|
|
||||||
* under the message that it worked, and then from the step count, which
|
|
||||||
* would fall by one just as the user pressed Continue.
|
|
||||||
*/
|
|
||||||
private val calendarCreatedHere = MutableStateFlow(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the calendar step applies: the device has nothing to show and so
|
|
||||||
* nothing to put events in. Unlike [backupApplies] an empty list is the
|
|
||||||
* condition itself, not a failed read — the two look identical here and both
|
|
||||||
* want the step, so this only resolves once a calendar the app can actually
|
|
||||||
* show has arrived. Calendars that exist but are all switched off leave it
|
|
||||||
* unresolved too: the step in front (#287) offers to switch them on, and
|
|
||||||
* declining that lands here, on the same offer a device with none gets.
|
|
||||||
*/
|
|
||||||
private val calendarsApplies: Flow<Boolean?> = combine(
|
|
||||||
calendars.map { list -> if (list?.any { it.isVisibleInSystem } == true) false else null },
|
|
||||||
calendarCreatedHere,
|
|
||||||
) { applies, latched -> if (latched) null else applies }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set when the step starts a write of its own, and never cleared: from that
|
|
||||||
* moment the step holds its place in the plan until it is answered. Set
|
|
||||||
* *before* the write rather than after it, because a successful one answers
|
|
||||||
* [hiddenApplies] with false — the step would drop out between the provider
|
|
||||||
* taking the change and the latch seeing it.
|
|
||||||
*/
|
|
||||||
private val hiddenTouched = MutableStateFlow(false)
|
|
||||||
|
|
||||||
/** A visibility write the provider did not take, while the step says so (#287). */
|
|
||||||
private val _hiddenWriteFailed = MutableStateFlow(false)
|
|
||||||
val hiddenWriteFailed: StateFlow<Boolean> = _hiddenWriteFailed.asStateFlow()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the device is showing nothing at all right now: calendars exist,
|
|
||||||
* none is visible, and at least one of them has a switch the step can flip
|
|
||||||
* (#287). Null only while there is no list to judge — before the grant, and
|
|
||||||
* on a read that failed — which keeps the step in the plan until the answer
|
|
||||||
* arrives, the same shrink-only contract [backupApplies] keeps.
|
|
||||||
*
|
|
||||||
* An empty list answers false rather than null, unlike [calendarsApplies]:
|
|
||||||
* a phone with no calendars at all is the next step's business, and this one
|
|
||||||
* would be listing calendars it cannot see. [hiddenTouched] holds it at null
|
|
||||||
* from the step's first write onwards.
|
|
||||||
*/
|
|
||||||
private val hiddenApplies: Flow<Boolean?> = combine(
|
|
||||||
calendars.map { list -> list?.allHidden() },
|
|
||||||
hiddenTouched,
|
|
||||||
) { hidden, touched -> if (touched) null else hidden }
|
|
||||||
|
|
||||||
/** Both calendar steps' inputs: what each applies to, and whether it is answered. */
|
|
||||||
private val calendarsFlags: Flow<CalendarsFlags> = combine(
|
|
||||||
calendarsApplies,
|
|
||||||
prefs.onboardingCalendarsDone,
|
|
||||||
hiddenApplies,
|
|
||||||
prefs.onboardingHiddenCalendarsDone,
|
|
||||||
::CalendarsFlags,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The calendars the hidden-calendars step lists, so the user can judge which
|
|
||||||
* to switch on rather than take the app's word that there are some. Live: a
|
|
||||||
* row follows its own switch, and the ones without one stay listed anyway —
|
|
||||||
* being told a calendar exists is half the point.
|
|
||||||
*/
|
|
||||||
val hiddenCalendars: StateFlow<List<CalendarSource>> = calendars
|
|
||||||
.map { it.orEmpty().orderedForManager() }
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
private val flags: Flow<OnboardingFlags> = combine(
|
private val flags: Flow<OnboardingFlags> = combine(
|
||||||
prefs.reminderOnboardingDone,
|
prefs.reminderOnboardingDone,
|
||||||
prefs.onboardingWizardArmed,
|
prefs.onboardingWizardArmed,
|
||||||
@@ -207,13 +110,7 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val plan: StateFlow<OnboardingPlan?> =
|
val plan: StateFlow<OnboardingPlan?> =
|
||||||
combine(
|
combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup ->
|
||||||
hasPermission,
|
|
||||||
flags,
|
|
||||||
closingFlags,
|
|
||||||
backupApplies,
|
|
||||||
calendarsFlags,
|
|
||||||
) { granted, stored, closing, backup, calendars ->
|
|
||||||
granted?.let {
|
granted?.let {
|
||||||
onboardingPlan(
|
onboardingPlan(
|
||||||
hasPermission = it,
|
hasPermission = it,
|
||||||
@@ -223,10 +120,6 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
viewDone = stored.viewDone,
|
viewDone = stored.viewDone,
|
||||||
monthStyleDone = stored.monthStyleDone,
|
monthStyleDone = stored.monthStyleDone,
|
||||||
backupApplies = backup,
|
backupApplies = backup,
|
||||||
hiddenDone = calendars.hiddenDone,
|
|
||||||
hiddenApplies = calendars.hiddenApplies,
|
|
||||||
calendarsDone = calendars.done,
|
|
||||||
calendarsApplies = calendars.applies,
|
|
||||||
visibilityArmed = closing.visibilityArmed,
|
visibilityArmed = closing.visibilityArmed,
|
||||||
visibilityDone = closing.visibilityDone,
|
visibilityDone = closing.visibilityDone,
|
||||||
doneShown = closing.doneShown,
|
doneShown = closing.doneShown,
|
||||||
@@ -293,8 +186,6 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
fun goBack() {
|
fun goBack() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (plan.value?.previous) {
|
when (plan.value?.previous) {
|
||||||
OnboardingStep.HiddenCalendars -> prefs.setOnboardingHiddenCalendarsDone(false)
|
|
||||||
OnboardingStep.Calendars -> prefs.setOnboardingCalendarsDone(false)
|
|
||||||
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
||||||
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
||||||
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
||||||
@@ -311,53 +202,6 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Make the device-only calendar the step's editor just described. The step
|
|
||||||
* stays up on [creation] either way — to confirm the calendar and say where
|
|
||||||
* to make more, or to say the provider refused it. Answering the step for
|
|
||||||
* them would leave them where the step exists to stop them being: past it,
|
|
||||||
* with nowhere to put an event.
|
|
||||||
*/
|
|
||||||
fun createLocalCalendar(displayName: String, color: Int, description: String?) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val name = displayName.trim()
|
|
||||||
_creation.value = runCatching {
|
|
||||||
repository.createLocalCalendar(
|
|
||||||
displayName = name,
|
|
||||||
color = color,
|
|
||||||
description = description,
|
|
||||||
)
|
|
||||||
}.fold(
|
|
||||||
onSuccess = {
|
|
||||||
calendarCreatedHere.value = true
|
|
||||||
CalendarCreation.Created(name)
|
|
||||||
},
|
|
||||||
onFailure = { CalendarCreation.Failed },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Close the calendar step once its confirmation has been read. */
|
|
||||||
fun finishCalendars() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
prefs.setOnboardingCalendarsDone()
|
|
||||||
_creation.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop the failed attempt so the step offers itself again. */
|
|
||||||
fun clearCalendarCreation() {
|
|
||||||
_creation.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the calendar step without making one. An empty app is a legitimate
|
|
||||||
* choice, and so is going off to set up an account and coming back.
|
|
||||||
*/
|
|
||||||
fun skipCalendars() {
|
|
||||||
viewModelScope.launch { prefs.setOnboardingCalendarsDone() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turn automatic backup on, writing to the folder the user just picked
|
* Turn automatic backup on, writing to the folder the user just picked
|
||||||
* (taking a durable write grant so background runs can keep writing), and
|
* (taking a durable write grant so background runs can keep writing), and
|
||||||
@@ -402,52 +246,6 @@ class OnboardingViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
|
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Switch one listed calendar on or off, from the hidden-calendars step (#287). */
|
|
||||||
fun setHiddenCalendarVisible(id: Long, visible: Boolean) = visibilityWrite {
|
|
||||||
repository.setCalendarsVisible(listOf(id), visible)
|
|
||||||
calendarList().any { it.id == id && it.isVisibleInSystem == visible }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Switch every hidden calendar back on — the step's one-tap way out (#287). */
|
|
||||||
fun switchAllCalendarsOn() = visibilityWrite {
|
|
||||||
val hidden = calendarList().filter { it.hasVisibilitySwitch && !it.isVisibleInSystem }
|
|
||||||
// Nothing to write is not a write that landed: a list that came back
|
|
||||||
// empty (a failed read) would otherwise report success and leave the
|
|
||||||
// step sitting there with no rows and no explanation.
|
|
||||||
if (hidden.isEmpty()) return@visibilityWrite false
|
|
||||||
repository.setCalendarsVisible(hidden.map { it.id }, true)
|
|
||||||
calendarList().any { it.isVisibleInSystem }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run one of the step's visibility writes, whose lambda answers whether it
|
|
||||||
* took. Checked rather than assumed: with read-only calendar access the
|
|
||||||
* choice is kept app-side (#75) and nothing the grid shows would change.
|
|
||||||
*/
|
|
||||||
private fun visibilityWrite(landed: suspend () -> Boolean) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
hiddenTouched.value = true
|
|
||||||
_hiddenWriteFailed.value = !runCatching { landed() }.getOrDefault(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun calendarList(): List<CalendarSource> = repository.calendars().first()
|
|
||||||
|
|
||||||
/** Close the hidden-calendars step, switched on or left as they were (#287). */
|
|
||||||
fun finishHiddenCalendars() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
prefs.setOnboardingHiddenCalendarsDone()
|
|
||||||
// The error belongs to the attempt, not to the step: stepping back
|
|
||||||
// into it later must not find a refusal nothing has asked for yet.
|
|
||||||
_hiddenWriteFailed.value = false
|
|
||||||
val list = calendarList()
|
|
||||||
// Switching them all back on answers the one-time notice too (#75) —
|
|
||||||
// it would otherwise announce, at the end of this same flow, that the
|
|
||||||
// device is hiding calendars it no longer hides.
|
|
||||||
if (list.isNotEmpty() && list.all { it.isVisibleInSystem }) retireVisibilityNotice()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Acknowledge the calendar-visibility notice (#75). */
|
/** Acknowledge the calendar-visibility notice (#75). */
|
||||||
fun finishVisibility() {
|
fun finishVisibility() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -497,20 +295,6 @@ private data class OnboardingFlags(
|
|||||||
val monthStyleDone: Boolean,
|
val monthStyleDone: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** How the calendar step's create attempt ended (#287). */
|
|
||||||
sealed interface CalendarCreation {
|
|
||||||
data class Created(val name: String) : CalendarCreation
|
|
||||||
data object Failed : CalendarCreation
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Both calendar steps' inputs (#287). */
|
|
||||||
private data class CalendarsFlags(
|
|
||||||
val applies: Boolean?,
|
|
||||||
val done: Boolean,
|
|
||||||
val hiddenApplies: Boolean?,
|
|
||||||
val hiddenDone: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The tail of the flow: the visibility notice and the closing screen. */
|
/** The tail of the flow: the visibility notice and the closing screen. */
|
||||||
private data class ClosingFlags(
|
private data class ClosingFlags(
|
||||||
val visibilityArmed: Boolean,
|
val visibilityArmed: Boolean,
|
||||||
@@ -525,15 +309,6 @@ data class ViewChoice(
|
|||||||
val weekStart: WeekStartPref,
|
val weekStart: WeekStartPref,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* True when this device would show nothing and the step could change that: no
|
|
||||||
* calendar is visible, and at least one of them has a switch worth flipping —
|
|
||||||
* a non-syncing calendar holds no events on the device, so switching it on
|
|
||||||
* leaves the grid exactly as empty (#287).
|
|
||||||
*/
|
|
||||||
private fun List<CalendarSource>.allHidden(): Boolean =
|
|
||||||
any { it.hasVisibilitySwitch } && none { it.isVisibleInSystem }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when this device holds events that live nowhere else: at least one local
|
* True when this device holds events that live nowhere else: at least one local
|
||||||
* calendar worth exporting, and no writable synced calendar to carry them.
|
* calendar worth exporting, and no writable synced calendar to carry them.
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ import de.jeanlucmakiola.calendula.domain.MatchSpan
|
|||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchHit
|
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||||
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
||||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
@@ -509,9 +508,6 @@ private fun SearchResultRow(
|
|||||||
onLongClick: (() -> Unit)? = null,
|
onLongClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val event = hit.event
|
val event = hit.event
|
||||||
// Spans are empty for a blank title — the query never matches the
|
|
||||||
// placeholder — so marking the substitute is safe.
|
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
// On a picked row the headline is already recoloured for the secondary
|
// On a picked row the headline is already recoloured for the secondary
|
||||||
@@ -524,7 +520,7 @@ private fun SearchResultRow(
|
|||||||
// Faded like a past event anywhere else in the app — search reaches back
|
// Faded like a past event anywhere else in the app — search reaches back
|
||||||
// through the whole history.
|
// through the whole history.
|
||||||
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isDeclined),
|
title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined),
|
||||||
summary = searchSummary(hit, highlight),
|
summary = searchSummary(hit, highlight),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ internal fun EventFormScreen(
|
|||||||
durationCalendars.forEachIndexed { index, calendar ->
|
durationCalendars.forEachIndexed { index, calendar ->
|
||||||
val override = state.perCalendarEventDuration[calendar.id]
|
val override = state.perCalendarEventDuration[calendar.id]
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName,
|
||||||
summary = override?.let { durationLabel(it) }
|
summary = override?.let { durationLabel(it) }
|
||||||
?: stringResource(
|
?: stringResource(
|
||||||
R.string.settings_calendar_duration_inherits,
|
R.string.settings_calendar_duration_inherits,
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ internal fun NotificationsScreen(
|
|||||||
// own section — link there instead.
|
// own section — link there instead.
|
||||||
if (calendar.id in state.managedCalendarIds) {
|
if (calendar.id in state.managedCalendarIds) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName,
|
||||||
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
@@ -198,7 +198,7 @@ internal fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
val expanded = calendar.id in expandedCalendars
|
val expanded = calendar.id in expandedCalendars
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
title = calendar.displayName,
|
||||||
position = if (expanded) Position.Top else Position.Alone,
|
position = if (expanded) Position.Top else Position.Alone,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
trailing = {
|
trailing = {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import androidx.compose.material.icons.filled.Backup
|
|||||||
import androidx.compose.material.icons.filled.BugReport
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
import androidx.compose.material.icons.filled.Cake
|
import androidx.compose.material.icons.filled.Cake
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Code
|
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
import androidx.compose.material.icons.filled.Gavel
|
import androidx.compose.material.icons.filled.Gavel
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
@@ -55,7 +54,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.licences.LicencesScreen
|
|
||||||
import de.jeanlucmakiola.floret.components.AboutCard
|
import de.jeanlucmakiola.floret.components.AboutCard
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
@@ -69,9 +67,7 @@ import de.jeanlucmakiola.floret.crash.submitCrashReport
|
|||||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
private enum class SettingsSection {
|
private enum class SettingsSection { Appearance, Views, EventForm, Notifications, SpecialDates, Widgets }
|
||||||
Appearance, Views, EventForm, Notifications, SpecialDates, Widgets, Licences
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings hub: three labelled groups of category rows, each opening a
|
* Settings hub: three labelled groups of category rows, each opening a
|
||||||
@@ -150,13 +146,6 @@ fun SettingsScreen(
|
|||||||
) {
|
) {
|
||||||
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
|
||||||
visible = section == SettingsSection.Licences,
|
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
|
||||||
) {
|
|
||||||
LicencesScreen(onBack = { section = null })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,13 +253,6 @@ private fun SettingsHub(
|
|||||||
icon = Icons.Default.Gavel,
|
icon = Icons.Default.Gavel,
|
||||||
position = Position.Middle,
|
position = Position.Middle,
|
||||||
)
|
)
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_licences),
|
|
||||||
summary = stringResource(R.string.settings_licences_subtitle),
|
|
||||||
position = Position.Middle,
|
|
||||||
leading = { CategoryIcon(Icons.Default.Code, ChipAccent.Neutral) },
|
|
||||||
onClick = { onOpenSection(SettingsSection.Licences) },
|
|
||||||
)
|
|
||||||
AboutLinkRow(
|
AboutLinkRow(
|
||||||
label = stringResource(R.string.settings_about_privacy),
|
label = stringResource(R.string.settings_about_privacy),
|
||||||
url = stringResource(R.string.about_privacy_url),
|
url = stringResource(R.string.about_privacy_url),
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ data class SettingsUiState(
|
|||||||
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
||||||
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
|
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
|
||||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||||
/** Whether the week/day timeline seats each hour in its own cell (v2.11). */
|
/** Whether the week/day timeline draws an hour separator line (v2.11). */
|
||||||
val showHourGrid: Boolean = false,
|
val showHourLines: Boolean = false,
|
||||||
/** How the Agenda screen treats events that already ended today. */
|
/** How the Agenda screen treats events that already ended today. */
|
||||||
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
||||||
/** Whether the month/week grids fade events that have already finished. */
|
/** Whether the month/week grids fade events that have already finished. */
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
// Display toggles folded into one flow so they fit this group —
|
// Display toggles folded into one flow so they fit this group —
|
||||||
// the outer combine is already at its five-arg limit.
|
// the outer combine is already at its five-arg limit.
|
||||||
combine(
|
combine(
|
||||||
prefs.showHourGrid,
|
prefs.showHourLines,
|
||||||
prefs.showWeekNumbers,
|
prefs.showWeekNumbers,
|
||||||
prefs.agendaShowToday,
|
prefs.agendaShowToday,
|
||||||
prefs.softenCalendarColors,
|
prefs.softenCalendarColors,
|
||||||
@@ -158,7 +158,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||||
ViewSettings(
|
ViewSettings(
|
||||||
view, screenRange, widgetRange, timeFormat,
|
view, screenRange, widgetRange, timeFormat,
|
||||||
showHourGrid = toggles.showHourGrid,
|
showHourLines = toggles.showHourLines,
|
||||||
showWeekNumbers = toggles.showWeekNumbers,
|
showWeekNumbers = toggles.showWeekNumbers,
|
||||||
agendaShowToday = toggles.agendaShowToday,
|
agendaShowToday = toggles.agendaShowToday,
|
||||||
softenColors = toggles.softenColors,
|
softenColors = toggles.softenColors,
|
||||||
@@ -191,7 +191,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
agendaScreenRange = views.agendaScreenRange,
|
agendaScreenRange = views.agendaScreenRange,
|
||||||
agendaWidgetRange = views.agendaWidgetRange,
|
agendaWidgetRange = views.agendaWidgetRange,
|
||||||
timeFormat = views.timeFormat,
|
timeFormat = views.timeFormat,
|
||||||
showHourGrid = views.showHourGrid,
|
showHourLines = views.showHourLines,
|
||||||
showWeekNumbers = views.showWeekNumbers,
|
showWeekNumbers = views.showWeekNumbers,
|
||||||
agendaShowToday = views.agendaShowToday,
|
agendaShowToday = views.agendaShowToday,
|
||||||
softenColors = views.softenColors,
|
softenColors = views.softenColors,
|
||||||
@@ -292,7 +292,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val agendaScreenRange: AgendaRange,
|
val agendaScreenRange: AgendaRange,
|
||||||
val agendaWidgetRange: AgendaRange,
|
val agendaWidgetRange: AgendaRange,
|
||||||
val timeFormat: TimeFormatPref,
|
val timeFormat: TimeFormatPref,
|
||||||
val showHourGrid: Boolean,
|
val showHourLines: Boolean,
|
||||||
val showWeekNumbers: Boolean,
|
val showWeekNumbers: Boolean,
|
||||||
val agendaShowToday: Boolean,
|
val agendaShowToday: Boolean,
|
||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
@@ -301,7 +301,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private data class DisplayToggles(
|
private data class DisplayToggles(
|
||||||
val showHourGrid: Boolean,
|
val showHourLines: Boolean,
|
||||||
val showWeekNumbers: Boolean,
|
val showWeekNumbers: Boolean,
|
||||||
val agendaShowToday: Boolean,
|
val agendaShowToday: Boolean,
|
||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
@@ -532,8 +532,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setShowHourGrid(enabled: Boolean) {
|
fun setShowHourLines(enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setShowHourGrid(enabled) }
|
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setShowWeekNumbers(enabled: Boolean) {
|
fun setShowWeekNumbers(enabled: Boolean) {
|
||||||
@@ -591,15 +591,15 @@ class SettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable or disable [view] in the quick-switch cycle, via an atomic
|
* Enable or disable [view] in the quick-switch cycle, via an atomic
|
||||||
* read-modify-write so a concurrent reorder can't clobber it. Any number of
|
* read-modify-write so a concurrent reorder can't clobber it. The
|
||||||
* views may be turned off; below two the pill hides itself (#150).
|
* MIN_ENABLED floor is re-checked inside the transform, because the screen's
|
||||||
|
* own guard reads an async-echoed snapshot.
|
||||||
*/
|
*/
|
||||||
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.updateQuickSwitch { config ->
|
prefs.updateQuickSwitch { config ->
|
||||||
config.copy(
|
val next = if (enabled) config.enabled + view else config.enabled - view
|
||||||
enabled = if (enabled) config.enabled + view else config.enabled - view,
|
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
|
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
|
||||||
@@ -54,8 +55,8 @@ import java.time.format.TextStyle as JavaTextStyle
|
|||||||
* it belongs to, plus the two cross-view ordering lists (#24, #69).
|
* it belongs to, plus the two cross-view ordering lists (#24, #69).
|
||||||
*
|
*
|
||||||
* The quick-switch cycle and the drawer list are independent orders — a view
|
* The quick-switch cycle and the drawer list are independent orders — a view
|
||||||
* off in the cycle is still reachable from the drawer, including when the cycle
|
* off in the cycle is still reachable from the drawer. The cycle needs at least
|
||||||
* is emptied and the pill disappears (#150).
|
* [QuickSwitchConfig.MIN_ENABLED] targets.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ViewsScreen(
|
internal fun ViewsScreen(
|
||||||
@@ -170,11 +171,11 @@ internal fun ViewsScreen(
|
|||||||
position = Position.Bottom,
|
position = Position.Bottom,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = state.showHourGrid,
|
checked = state.showHourLines,
|
||||||
onCheckedChange = viewModel::setShowHourGrid,
|
onCheckedChange = viewModel::setShowHourLines,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { viewModel.setShowHourGrid(!state.showHourGrid) },
|
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -220,6 +221,8 @@ internal fun ViewsScreen(
|
|||||||
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||||
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// Turning a view off is blocked once only the minimum remain enabled.
|
||||||
|
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
|
||||||
ReorderableColumn(
|
ReorderableColumn(
|
||||||
items = config.order,
|
items = config.order,
|
||||||
keyOf = { it },
|
keyOf = { it },
|
||||||
@@ -235,6 +238,8 @@ internal fun ViewsScreen(
|
|||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = checked,
|
checked = checked,
|
||||||
|
// Keep the last two on: with fewer, the pill can't switch.
|
||||||
|
enabled = !checked || canDisable,
|
||||||
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.theme
|
package de.jeanlucmakiola.calendula.ui.theme
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracking the two label roles the calendar grids are set in. Material gives
|
* Default Material 3 Expressive typography. Custom font + tuned scale will
|
||||||
* both 0.5sp, tuned for isolated UI labels with room around them; a month chip
|
* land in a later UI-design iteration; the defaults are intentional for V1
|
||||||
* is a text box some 37dp wide, where 0.5sp on an 11sp glyph spends most of a
|
* scaffolding to keep the foundation lean.
|
||||||
* character on spacing alone. 0.1sp is what Material itself sets labelLarge to,
|
|
||||||
* so the label family stays coherent (#190).
|
|
||||||
*/
|
*/
|
||||||
private val LabelTracking = 0.1.sp
|
val CalendulaTypography = Typography()
|
||||||
|
|
||||||
/**
|
|
||||||
* 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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -59,7 +60,9 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -69,6 +72,7 @@ import androidx.compose.ui.semantics.contentDescription
|
|||||||
import androidx.compose.ui.semantics.customActions
|
import androidx.compose.ui.semantics.customActions
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -77,9 +81,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventPaint
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventSurface
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||||
@@ -89,13 +90,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
|
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
@@ -104,34 +100,30 @@ import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||||
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
|
import de.jeanlucmakiola.calendula.ui.common.beginsOn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ChipCuts
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||||
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
|
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
import de.jeanlucmakiola.calendula.ui.common.hourHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
@@ -139,8 +131,7 @@ import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
@@ -156,8 +147,7 @@ import kotlin.time.Clock
|
|||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
||||||
private val COLUMN_GAP = 2.dp
|
private val COLUMN_GAP = 2.dp
|
||||||
@@ -196,9 +186,21 @@ fun WeekScreen(
|
|||||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// The static header + all-day strip share the app bar's scrolled colour so
|
||||||
|
// the whole top region elevates together once the timeline scrolls under it.
|
||||||
|
val topSectionColor by animateColorAsState(
|
||||||
|
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
label = "week-top-section-color",
|
||||||
|
)
|
||||||
|
|
||||||
val isOnCurrentWeek = when (val s = state) {
|
val isOnCurrentWeek = when (val s = state) {
|
||||||
// True when today falls inside the displayed week — independent of which
|
// True when today falls inside the displayed week — independent of which
|
||||||
// weekday the user picked as the first day.
|
// weekday the user picked as the first day.
|
||||||
@@ -259,19 +261,19 @@ fun WeekScreen(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
WeekTopBar(
|
WeekTopBar(
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onJumpToDate = jumpToDate,
|
onJumpToDate = jumpToDate,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@@ -292,6 +294,7 @@ fun WeekScreen(
|
|||||||
WeekContent(
|
WeekContent(
|
||||||
state = state,
|
state = state,
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
|
topSectionColor = topSectionColor,
|
||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
@@ -311,6 +314,7 @@ fun WeekScreen(
|
|||||||
private fun WeekContent(
|
private fun WeekContent(
|
||||||
state: WeekUiState,
|
state: WeekUiState,
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
|
topSectionColor: Color,
|
||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
@@ -375,6 +379,7 @@ private fun WeekContent(
|
|||||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
is WeekUiState.Success -> WeekSuccess(
|
is WeekUiState.Success -> WeekSuccess(
|
||||||
state = s,
|
state = s,
|
||||||
|
topSectionColor = topSectionColor,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
@@ -382,7 +387,7 @@ private fun WeekContent(
|
|||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
onDrop = { drop ->
|
onDrop = { drop ->
|
||||||
move?.move(
|
val took = move?.move(
|
||||||
MoveRequest(
|
MoveRequest(
|
||||||
eventId = drop.event.eventId,
|
eventId = drop.event.eventId,
|
||||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
@@ -390,6 +395,9 @@ private fun WeekContent(
|
|||||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// Refused, so nothing will land: let the copy go now
|
||||||
|
// rather than hold it out for a settle that never comes.
|
||||||
|
if (took != true) dragController.release()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -401,6 +409,7 @@ private fun WeekContent(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun WeekSuccess(
|
internal fun WeekSuccess(
|
||||||
state: WeekUiState.Success,
|
state: WeekUiState.Success,
|
||||||
|
topSectionColor: Color,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
@@ -413,19 +422,13 @@ internal fun WeekSuccess(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colorScheme.surface),
|
.background(topSectionColor),
|
||||||
) {
|
) {
|
||||||
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
|
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
|
||||||
// This strip is painted on `surface`, so an outlined chip has to
|
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||||
// fill with that and not with the columns' container (#230).
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalChipGround provides MaterialTheme.colorScheme.surface,
|
|
||||||
) {
|
|
||||||
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Breathing room between the top section and the scrolling timeline
|
// Breathing room between the (colour-shifting) top section and the
|
||||||
// below.
|
// scrolling timeline below.
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
@@ -445,25 +448,20 @@ private fun WeekTopBar(
|
|||||||
currentYear: Int,
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
quickSwitchViews: List<CalendarView>,
|
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val (title, shortTitle) = remember(weekStart, locale, currentYear) {
|
|
||||||
formatWeekTitle(weekStart, locale, currentYear) to
|
|
||||||
formatWeekTitle(weekStart, locale, currentYear, abbreviated = true)
|
|
||||||
}
|
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
CalendarTitleButton(
|
CalendarTitleButton(
|
||||||
title = title,
|
title = formatWeekTitle(weekStart, locale, currentYear),
|
||||||
currentDate = weekStart,
|
currentDate = weekStart,
|
||||||
onJumpToDate = onJumpToDate,
|
onJumpToDate = onJumpToDate,
|
||||||
shortTitle = shortTitle,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -484,17 +482,17 @@ private fun WeekTopBar(
|
|||||||
}
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
cycle = quickSwitchViews,
|
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
// Match the static top section exactly: plain surface, lifting to
|
||||||
// it, but here the bar meets the header on the same surface and the
|
// surfaceContainer once content scrolls under the bar.
|
||||||
// tint is what makes that seam look like a separate block (#186).
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,7 +571,7 @@ private fun WeekDayHeader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
|
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
|
||||||
* filled box and bold number — at the month grid's size, so the two agree (#213). */
|
* filled box and bold number. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
|
private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
|
||||||
val label = stringResource(R.string.week_number_label)
|
val label = stringResource(R.string.week_number_label)
|
||||||
@@ -585,9 +583,9 @@ private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = weekNumber.toString(),
|
text = weekNumber.toString(),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -654,25 +652,23 @@ private fun AllDayBar(
|
|||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||||
val paint = eventPaint(event, dark)
|
val soften = LocalSoftenColors.current
|
||||||
|
val fill = eventFill(event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.eventSurface(paint, EventChipShape)
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
val titleOverflow = eventTitleOverflow()
|
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall.trimmedLines(),
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = titleOverflow.overflow,
|
overflow = TextOverflow.Ellipsis,
|
||||||
softWrap = titleOverflow.softWrap,
|
color = eventInk(fill),
|
||||||
color = paint.titleInk,
|
textDecoration = declinedDecoration(event.isDeclined),
|
||||||
fontWeight = paint.titleWeight,
|
|
||||||
textDecoration = paint.decoration,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -690,12 +686,6 @@ private fun Timeline(
|
|||||||
val zoom = LocalTimelineZoom.current
|
val zoom = LocalTimelineZoom.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||||
// What the hour grid takes off a block's ends, for the floating copy to
|
|
||||||
// take off its own. Per-end, a cut edge keeps it — that much the drag
|
|
||||||
// geometry decides itself, from the slice it is drawing.
|
|
||||||
val blockInsetPx = with(density) {
|
|
||||||
hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx()
|
|
||||||
}
|
|
||||||
|
|
||||||
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
|
// 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
|
// timeline's own viewport height, which is only known here — below the top
|
||||||
@@ -742,7 +732,6 @@ private fun Timeline(
|
|||||||
it.grid = coords
|
it.grid = coords
|
||||||
it.scroll = scrollState
|
it.scroll = scrollState
|
||||||
it.hourPx = with(density) { hourHeight.toPx() }
|
it.hourPx = with(density) { hourHeight.toPx() }
|
||||||
it.blockInsetPx = blockInsetPx
|
|
||||||
it.columnGapPx = gap
|
it.columnGapPx = gap
|
||||||
it.columnWidthPx = (coords.size.width + gap) / state.days.size
|
it.columnWidthPx = (coords.size.width + gap) / state.days.size
|
||||||
it.days = state.days
|
it.days = state.days
|
||||||
@@ -787,8 +776,8 @@ private fun DayColumnCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||||
val showHourGrid = LocalShowHourGrid.current
|
val showHourLines = LocalShowHourLines.current
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
// Tells a settled drop when this column has caught up with it.
|
// Tells a settled drop when this column has caught up with it.
|
||||||
LaunchedEffect(blocks, dragController.settling) {
|
LaunchedEffect(blocks, dragController.settling) {
|
||||||
dragController.noteGrid(date, blocks)
|
dragController.noteGrid(date, blocks)
|
||||||
@@ -804,24 +793,16 @@ private fun DayColumnCard(
|
|||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
shape = RectangleShape,
|
shape = RectangleShape,
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
// With the grid on, the container colour moves onto the hour cells
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
// and the column behind them recedes to surface, so the gap between
|
|
||||||
// two cells reads as the same negative space that separates the
|
|
||||||
// week's columns.
|
|
||||||
containerColor = if (showHourGrid) {
|
|
||||||
MaterialTheme.colorScheme.surface
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
// The hour cells sit over the column background but under the
|
// Faint hour separators sit over the column background but under
|
||||||
// event blocks (drawBehind paints before the children).
|
// the event blocks (drawBehind paints before the children).
|
||||||
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer)
|
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
|
||||||
// Tap an empty slot to create an event there; taps on event
|
// Tap an empty slot to create an event there; taps on event
|
||||||
// blocks are consumed by their own handler first. Snaps to hour.
|
// blocks are consumed by their own handler first. Snaps to hour.
|
||||||
.pointerInput(date) {
|
.pointerInput(date) {
|
||||||
@@ -852,34 +833,20 @@ private fun DayColumnCard(
|
|||||||
width = laneWidth,
|
width = laneWidth,
|
||||||
height = height,
|
height = height,
|
||||||
)
|
)
|
||||||
// Where this block is cut at midnight, which decides both
|
|
||||||
// its shape and the half-gap it gives up to the hour cells.
|
|
||||||
val cuts = remember(block, date, zone) {
|
|
||||||
timedBlockCuts(
|
|
||||||
block.continuesBefore(date, zone),
|
|
||||||
block.continuesAfter(date, zone),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
|
|
||||||
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
|
|
||||||
val blockHeight = (place.height - topInset - bottomInset)
|
|
||||||
.coerceAtLeast(0.dp)
|
|
||||||
EventBlock(
|
EventBlock(
|
||||||
block = block,
|
block = block,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
height = blockHeight,
|
height = place.height,
|
||||||
width = place.width,
|
width = place.width,
|
||||||
date = date,
|
date = date,
|
||||||
cuts = cuts,
|
|
||||||
topInset = topInset,
|
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
onClick = { onEventClick(block.event) },
|
onClick = { onEventClick(block.event) },
|
||||||
onDrop = onDrop,
|
onDrop = onDrop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = place.x, y = place.y + topInset)
|
.offset(x = place.x, y = place.y)
|
||||||
.width(place.width)
|
.width(place.width)
|
||||||
.height(blockHeight)
|
.height(place.height)
|
||||||
.padding(horizontal = BLOCK_OUTER_INSET),
|
.padding(horizontal = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -898,8 +865,6 @@ private fun EventBlock(
|
|||||||
height: Dp,
|
height: Dp,
|
||||||
width: Dp,
|
width: Dp,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
cuts: ChipCuts,
|
|
||||||
topInset: Dp,
|
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onDrop: (TimelineDrop) -> Unit,
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
@@ -911,83 +876,65 @@ private fun EventBlock(
|
|||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val metrics = rememberBlockTextMetrics(height)
|
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.
|
||||||
|
val available = height - 4.dp
|
||||||
// Only full-width (non-overlapping) blocks that are tall enough show the
|
// Only full-width (non-overlapping) blocks that are tall enough show the
|
||||||
// time. On narrow overlapping columns we drop it so the title can wrap to
|
// time. On narrow overlapping columns we drop it so the title can wrap to
|
||||||
// fill the whole block, mirroring Google Calendar — and a block that cannot
|
// fill the whole block, mirroring Google Calendar — and a block that cannot
|
||||||
// afford both lines spends its space on the title. Height decides that on
|
// afford both lines spends its space on the title.
|
||||||
// its own: a duration threshold would keep hiding the time on a half-hour
|
val showTime = block.endMin - block.startMin >= 45 &&
|
||||||
// block the user has pinched open to three times the room it needs.
|
block.laneCount == 1 &&
|
||||||
val showTime = block.laneCount == 1 &&
|
available >= titleLineHeight + timeLineHeight
|
||||||
metrics.available >= metrics.titleLine + metrics.timeLine
|
|
||||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
|
||||||
// A short block drops the title rather than serving a horizontally sliced
|
// A short block drops the title rather than serving a horizontally sliced
|
||||||
// one: half a letter reads as a rendering fault, while a bare colour chip
|
// one: half a letter reads as a rendering fault, while a bare colour chip
|
||||||
// reads as what it is — an event too brief to label. Tap still opens it, and
|
// reads as what it is — an event too brief to label. Tap still opens it, and
|
||||||
// the semantics description carries the full title either way.
|
// the semantics description carries the full title either way.
|
||||||
val showTitle = metrics.fitsTitle
|
val showTitle = available >= titleLineHeight
|
||||||
// The title is served first, out of everything the block has left once the
|
// Wrap the title across as many lines as the block can fit — but only once a
|
||||||
// time is down to one line — but only takes the lines it will actually use,
|
// line is wide enough to hold more than a syllable. Below that the extra
|
||||||
// and only wraps at all once a line is wide enough to hold more than a
|
// lines just stack fragments of the word, and one ellipsised line reads
|
||||||
// syllable. Below that the extra lines just stack fragments of the word.
|
// better.
|
||||||
val contentHeight = metrics.available - if (showTime) metrics.timeLine else 0.dp
|
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
|
||||||
val titleBudget = metrics.titleBudget(contentHeight).coerceAtLeast(1)
|
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
|
||||||
val paint = eventPaint(block.event, dark)
|
1
|
||||||
// Every line the height affords, however narrow the lane: two events side by
|
} else {
|
||||||
// side leave columns well under a word wide, and cutting the title to one
|
(contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||||
// line there lost it outright where the block had the room to wrap it.
|
}
|
||||||
val titleMaxLines = blockTextLines(
|
|
||||||
text = title,
|
|
||||||
// At the weight BlockTitle will set it in, or an invited block — drawn a
|
|
||||||
// weight lighter — is budgeted a line it never fills (#230).
|
|
||||||
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
|
|
||||||
textWidth = textWidth,
|
|
||||||
max = titleBudget,
|
|
||||||
)
|
|
||||||
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
|
|
||||||
if (showTime) metrics.timeLine else 0.dp
|
|
||||||
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
val moveAction = eventMoveAction(block.event)
|
val moveAction = eventMoveAction(block.event)
|
||||||
val draggable = eventDragAllowed(block.event)
|
// A block clipped at the top continues from the previous day: its top edge
|
||||||
// The drop takes this offset back off, so a tail clipped at midnight lands
|
// is midnight, not the event's start, so dragging it would invent a time.
|
||||||
// where the event's own start belongs (#253).
|
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
|
||||||
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
|
|
||||||
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
|
|
||||||
val topInsetPx = with(density) { topInset.toPx() }
|
|
||||||
val dragModifier = rememberEventDragSource(
|
val dragModifier = rememberEventDragSource(
|
||||||
enabled = draggable,
|
enabled = draggable,
|
||||||
key = block.event.instanceId,
|
key = block.event.instanceId,
|
||||||
onPickUp = { pointer, blockRoot, _ ->
|
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
|
||||||
dragController.begin(
|
|
||||||
block = block,
|
|
||||||
clipOffsetMin = clipOffset,
|
|
||||||
titleLines = if (showTitle) titleMaxLines else 0,
|
|
||||||
pointerInRoot = pointer,
|
|
||||||
// Measured off the block's placement rather than off where the
|
|
||||||
// hour grid seats it: the copy is drawn from that placement and
|
|
||||||
// re-seated the same half-gap down (#267).
|
|
||||||
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onMove = dragController::move,
|
onMove = dragController::move,
|
||||||
onDrop = { dragController.finish()?.let(onDrop) },
|
onDrop = { dragController.finish()?.let(onDrop) },
|
||||||
onCancel = dragController::cancel,
|
onCancel = dragController::cancel,
|
||||||
)
|
)
|
||||||
val lifted = draggable && dragController.ghosts(block)
|
val lifted = draggable && dragController.ghosts(block, date)
|
||||||
val ghost = ghostAlpha(lifted)
|
val ghost = ghostAlpha(lifted)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
// The source stays put as a ghost while its floating copy travels.
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.eventSurface(paint, shape, cuts)
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
// After clickable, so it is the inner node and wins the main pass;
|
// After clickable, so it is the inner node and wins the main pass;
|
||||||
// the tap still works, since a drag consumes the up.
|
// the tap still works, since a drag consumes the up.
|
||||||
.then(dragModifier)
|
.then(dragModifier)
|
||||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = "$title, $timeLabel"
|
contentDescription = "$title, $timeLabel"
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
@@ -995,19 +942,19 @@ private fun EventBlock(
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (showTitle) {
|
if (showTitle) {
|
||||||
BlockTitle(
|
Text(
|
||||||
title = title,
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
maxLines = titleMaxLines,
|
maxLines = titleMaxLines,
|
||||||
color = paint.titleInk,
|
overflow = TextOverflow.Ellipsis,
|
||||||
textDecoration = paint.decoration,
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
fontWeight = paint.titleWeight,
|
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
BlockTimeLabel(
|
BlockTimeLabel(
|
||||||
label = timeLabel,
|
label = timeLabel,
|
||||||
color = paint.secondaryInk,
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
maxLines = timeMaxLines,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1038,14 +985,7 @@ private fun WeekLoading() {
|
|||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
// Same scale resolution as the loaded timeline, so the skeleton's
|
// Same scale resolution as the loaded timeline, so the skeleton's
|
||||||
// columns don't resize the moment the real week arrives.
|
// columns don't resize the moment the real week arrives.
|
||||||
val hourHeight = scale.hourHeight(maxHeight)
|
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||||
val totalHeight = hourHeight * 24
|
|
||||||
// The skeleton wears the loaded columns' own ground, so the arrival
|
|
||||||
// of the real week doesn't flash solid blocks into a gapped grid.
|
|
||||||
val showHourGrid = LocalShowHourGrid.current
|
|
||||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
|
||||||
val columnGround = if (showHourGrid) MaterialTheme.colorScheme.surface
|
|
||||||
else MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -1059,12 +999,7 @@ private fun WeekLoading() {
|
|||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(totalHeight)
|
.height(totalHeight)
|
||||||
.padding(horizontal = 2.dp)
|
.padding(horizontal = 2.dp)
|
||||||
.background(columnGround)
|
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||||
.hourGridCells(
|
|
||||||
showHourGrid,
|
|
||||||
hourPx,
|
|
||||||
MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1091,18 +1026,13 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
|
|||||||
* "December" with no year would be actively misleading while the reader is
|
* "December" with no year would be actively misleading while the reader is
|
||||||
* looking straight at January dates.
|
* looking straight at January dates.
|
||||||
*/
|
*/
|
||||||
private fun formatWeekTitle(
|
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String {
|
||||||
weekStart: LocalDate,
|
|
||||||
locale: Locale,
|
|
||||||
currentYear: Int,
|
|
||||||
abbreviated: Boolean = false,
|
|
||||||
): String {
|
|
||||||
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||||
return formatCalendarTitle(
|
return formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
skeleton = if (abbreviated) "LLL" else "LLLL",
|
skeleton = "LLLL",
|
||||||
forceYear = weekEnd.year != weekStart.year,
|
forceYear = weekEnd.year != weekStart.year,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
|
||||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -127,7 +125,9 @@ class WeekViewModel @Inject constructor(
|
|||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
): WeekUiState {
|
): WeekUiState {
|
||||||
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) }
|
if (calendars.isEmpty()) {
|
||||||
|
return WeekUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
||||||
val allDay = instances.filter { it.isAllDay }
|
val allDay = instances.filter { it.isAllDay }
|
||||||
val timed = instances.filterNot { it.isAllDay }
|
val timed = instances.filterNot { it.isAllDay }
|
||||||
@@ -144,7 +144,7 @@ class WeekViewModel @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* Lay out all-day events as connected horizontal spans across the visible week.
|
* Lay out all-day events as connected horizontal spans across the visible week.
|
||||||
* Each event becomes one [AllDaySpan] from its first to its last covered column;
|
* Each event becomes one [AllDaySpan] from its first to its last covered column;
|
||||||
* overlapping spans are stacked on separate lanes (greedy first-fit).
|
* overlapping spans are stacked on separate lanes (greedy first-fit by start).
|
||||||
*/
|
*/
|
||||||
internal fun layoutAllDay(
|
internal fun layoutAllDay(
|
||||||
events: List<EventInstance>,
|
events: List<EventInstance>,
|
||||||
@@ -158,35 +158,21 @@ internal fun layoutAllDay(
|
|||||||
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
|
val covered = days.indices.filter { ev.coversDay(days[it], zone) }
|
||||||
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
|
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
|
||||||
}
|
}
|
||||||
// Declined bars are packed after every other one, so on any day they
|
.sortedWith(compareBy({ it.startCol }, { it.endCol }))
|
||||||
// cover they land in a lane below it (#230).
|
|
||||||
.sortedWith(compareBy({ it.event.isDeclined }, { it.startCol }, { it.endCol }))
|
|
||||||
|
|
||||||
// What each lane already holds, rather than just how far right it reaches.
|
val laneEnd = ArrayList<Int>() // last occupied column per lane
|
||||||
// A single "last occupied column" only answers correctly while spans arrive
|
|
||||||
// in non-decreasing start order, which the declined-last rule above breaks:
|
|
||||||
// a Monday bar seated after a Wednesday one would be refused a lane it is
|
|
||||||
// nowhere near, and each wasted lane costs the all-day strip a whole row and
|
|
||||||
// pushes a bar closer to the month grid's lane cap. Seven columns
|
|
||||||
// and a handful of bars, so the scan is cheaper than the sort above it.
|
|
||||||
val laneCols = ArrayList<MutableList<IntRange>>()
|
|
||||||
return raw.map { r ->
|
return raw.map { r ->
|
||||||
val cols = r.startCol..r.endCol
|
var lane = laneEnd.indexOfFirst { it < r.startCol }
|
||||||
var lane = laneCols.indexOfFirst { seated -> seated.none { it overlaps cols } }
|
|
||||||
if (lane == -1) {
|
if (lane == -1) {
|
||||||
laneCols.add(mutableListOf(cols))
|
laneEnd.add(r.endCol)
|
||||||
lane = laneCols.size - 1
|
lane = laneEnd.size - 1
|
||||||
} else {
|
} else {
|
||||||
laneCols[lane].add(cols)
|
laneEnd[lane] = r.endCol
|
||||||
}
|
}
|
||||||
AllDaySpan(r.event, r.startCol, r.endCol, lane)
|
AllDaySpan(r.event, r.startCol, r.endCol, lane)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether two column ranges share a column, so they cannot share a lane. */
|
|
||||||
private infix fun IntRange.overlaps(other: IntRange): Boolean =
|
|
||||||
first <= other.last && other.first <= last
|
|
||||||
|
|
||||||
/** Beginning of the week (at [weekStart]) that contains this date. */
|
/** Beginning of the week (at [weekStart]) that contains this date. */
|
||||||
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
|
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
|
||||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||||
@@ -220,17 +206,6 @@ internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
|
|||||||
return start < dayEnd && end > dayStart
|
return start < dayEnd && end > dayStart
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* True if this event touches more than one calendar day in [zone] — an all-day
|
|
||||||
* event covering a range, or a timed one crossing midnight. Occurrences are
|
|
||||||
* contiguous, so covering the day after the first is the whole question.
|
|
||||||
*/
|
|
||||||
internal fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean {
|
|
||||||
val anchor = if (isAllDay) TimeZone.UTC else zone
|
|
||||||
val secondDay = start.toLocalDateTime(anchor).date.plus(1, DateTimeUnit.DAY)
|
|
||||||
return coversDay(secondDay, zone)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clip [events] to a single [day] and assign lanes so overlapping events render
|
* Clip [events] to a single [day] and assign lanes so overlapping events render
|
||||||
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of
|
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user