Compare commits

...
29 Commits
Author SHA1 Message Date
Jean-Luc Makiolaandmakiolaj 17b3753ad3 Merge the month seating copies, resize state and page grids (#418)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Step 8 of the simplification review: the three behaviour-sensitive merges. Each lands behind a characterization test written and passed against the old code first.

**Month row seating**
- `MonthWeek.seating(col, laneCap)` is the one source for which event sits in which lane and what overflows. `laneEvents`, `chipAt`, `overflowEvents` and MonthWeekRow's inline copy all build on it, so dots, bars and pills can't drift apart (#53).
- MonthWeekRow computes the seating once per row (`remember(week, laneCap)`) instead of re-filtering every column on each recomposition.
- `OverflowDots.total` and `shownLanes` are gone; they always equalled `events.size` and `laneCap`.
- MonthSeatingTest pins the four old copies against each other over 400 generated weeks × lane caps 0–6; they agreed everywhere, and `seating()` matches them.

**Timeline resize**
- TimelineDragController's eight loose `resize*` fields are one `ResizeSession?`, so cancel clears it in one assignment. Moves mutate it in place; no per-frame allocation. None of the fields were Compose state.
- Only difference: a resize starting without the previous one ending used to inherit the old held slot; it now starts clean.

**Timeline paging**
- Week, multi-day and day page through one `DaysGrid` with `weeks(firstDay)`, `days` and `startingAt(date, count)` factories; the PageGrid interface, WeekGrid and DayGrid are gone. Page numbers and grid equality (used by pager keys) are unchanged; PageNumberingTest pins them.
- jumpToDate no longer detects the multi-day view by class; it re-frames when the target would open mid-page, which a test checks against the old rule for every in-range date.

Not yet checked on a device. Worth a look: month bars/pills/+N dots on crowded days in paged and split styles plus the split morph, chip taps and month drag; resize of both edges incl. across midnight, cancel then plain drag, RTL; paging, view switching, jump to date/today, week-start and multi-day count changes.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/418
2026-10-16 14:42:31 +02:00
Jean-Luc Makiolaandmakiolaj efc36f1bff Restructure the editor, timeline and calendar host (#417)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Step 7 of the simplification review: structure changes in the three biggest UI files. No intended visible change, except one fix noted under the host.

**Editor** (EventEditScreen)
- Pass-through wrappers removed: InlineField → floret InlineTextField, SaveScopeDialog → RecurringScopeDialog, REMINDER_QUICK_PICKS → REMINDER_PRESETS, reminderLabel/reminderLeadText → reminderLeadTimeLabel, currentDetailLocale → currentLocale.
- One PickerCardBody draws the time zone, recurrence, visibility and colour cards.
- The seven saveable showXPicker booleans are one saveable `EditSheet?` slot, so two sheets can't be open at once. EventEditContent goes from ~770 to ~250 lines; each card section and the sheet host are their own composables.
- Not done: GroupedSurface on the grouped form cards, since it adds a visible press-morph.

**Timeline** (WeekScreen, TimelineDrag)
- A remembered TimelineActions holder (scroll state, drag controller, five callbacks) replaces the seven values threaded through nine composables. Callbacks go through rememberUpdatedState, so the holder stays stable and pages don't recompose.
- TimelineDragController shares pieces(), settle(), column() (the RTL flip, was 5 copies) and snapToSlot() between move and resize. No extra per-frame work.

**Calendar host** (CalendarHost, RootScreen, MainActivity, view models)
- SlideOverlay replaces seven copies of the overlay enter/exit block; rememberHeld replaces the hand-paired held keys; one saveable CreateRequest replaces three create fields, and the widget create path calls openCreate.
- An ExternalRequests holder built in MainActivity replaces 13 parameters declared twice in RootScreen and CalendarHost.
- Month, week and agenda view models share one rollover-following today flow. CalendarHost keeps its own, which survives recreation.
- Fix: after rotation or process death with the detail or edit overlay open, closing it slid out a blank screen, because the held key was only set by in-app opens. It now keeps its content through the slide-out, as ARCHITECTURE.md describes.

Not yet checked on a device. Worth a look before release: open/close of detail, edit and create from every entry point (tap, notification, widget, QS tile, ACTION_INSERT/EDIT, .ics), rotation with an overlay open, every editor picker incl. rotation, and timeline move/resize/drag-to-create incl. RTL and after pinch-zoom.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/417
2026-10-16 14:26:53 +02:00
Jean-Luc Makiolaandmakiolaj f55cfffa3a Extract repeated helpers in data, domain and UI layers (#416)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Step 6 of the simplification review: mechanical helper extractions, one commit per group. Widget code is untouched.

**Data layer** (CalendarDataSource and the mappers)
- `updateEventRow`/`eventUri` replace the seven "update, throw if no row" copies and the loose `withAppendedId` spellings.
- `SeriesRow.timeColumns()` / `exdateValues()` define the DTSTART/RRULE/DURATION/TZ/ALL_DAY set once for truncate, split, detach, delete and exception copy.
- One `insertReminderRow` for the four reminder loops, `insertLocalCalendar` for local and managed creates, `applyManagedCalendarReminders` iterates `queryManagedEvents`, `reconcileAttendees` reuses `insertAttendees`, plus `mapRows`, `instancesUri`, `mintUid`, shared `eventColorColumns` and `switchedOffCalendarIds`.
- The read-side mappers share `ColumnReader` helpers (end time, status, all-day UTC date); each keeps its own end-time clamp. `importDuration` goes through `rfc2445Duration` with identical output. The `eventColorPalette` data-source member is gone; the repository calls the pure function.
- One behaviour difference: a provider *exception* while inserting a reminder row was only swallowed on import. It is now logged and skipped on every path (create, update, reconcile, move), as the existing comments already describe it, so it no longer fails the event write.

**Domain**
- `BasicFormat.kt` replaces the four `%04d…` formatters and three substring parsers (ICS writer/parser, Recurrence, RecurrenceText). Parsing stays as lenient as before.
- `rruleParts()` for the RRULE tokenising in five places; each caller keeps its own malformed-part handling. `rruleTruncatedAt` keeps its own tokeniser since it preserves garbage parts verbatim.
- `allDayFormTimes` and `normalizedRrule` for the three form-prefill copies, `zoneOrNull` for the eight zone guards, EventSearch orders months by `YearMonth`, IcsParser uses duration arithmetic and `firstOrNull`.
- FREQ matching no longer depends on the device locale (a lowercase rule failed to parse on Turkish-locale phones).
- Skipped: the contact-date parser rewrite (tests don't pin enough inputs) and the writer-side enum tables (their exhaustive `when` is a useful compile check).

**UI / view models**
- `dayRange(first, last, zone)` and one `startOfWeek` replace four start..end-of-day helpers and two identical week-start functions.
- `orUnnamed()` replaces 17 `ifBlank { calendar_unnamed }` spellings; a shared `CalendarVisibilitySwitch` and `CalendarManagerRow`.
- Filter uses `CalendarAccountGroup` plus a hidden-id set instead of its own copy of the grouping model; the grouping test moved to `AccountGroupsTest`.
- `agendaDays` is shared by the Agenda screen and the split-month pane.
- kotlinx-datetime's `yearMonth`, `firstDay`/`lastDay` and java.time converters replace about 20 hand-written bridges; one `formatTimeOfDay` for agenda rows and month chips.
- No visible UI change.

Net about −240 lines.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/416
2026-10-16 13:56:17 +02:00
Jean-Luc Makiolaandmakiolaj bb8c91acc5 Move settings storage onto floret's PrefStore (#415)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Step 5 of the simplification review (preferences pipeline). Needs floret-kit#15 merged first; the submodule pin (3b376ac) is on that PR's branch, not on floret main yet.

- **SettingsPrefs, CalendarPrefs, ReminderStatePrefs** read and write through floret core-prefs (new dependency). Every setting is one `Pref` in a new `SettingsKeys` object, so each flow and setter is a single line. Stored key names, defaults and clamping are unchanged; the old and new key lists diff clean. Flows are now distinct-until-changed, so an unrelated key's write no longer re-emits them.
- **ThemeMode** is floret's (same values, same `theme_mode`/`dynamic_color` keys). MainActivity uses floret's `isDark`/`isPureBlack` instead of its two hand-written `when`s.
- **Onboarding flags**: the eight per-step flow/setter pairs are one `OnboardingStep` → pref map, with `onboardingProgress` and `setOnboardingDone(step, done)`. Same key names. OnboardingViewModel loses its three carrier classes and the back-navigation `when`.
- **SettingsViewModel** builds `SettingsUiState` from one prefs snapshot instead of the four-level nested `combine` and its seven carrier classes; the font and special-dates states do the same. The 30 one-line setters use a `save {}` helper. Setters with side effects (widgets, scan, scheduler) keep their bodies; the widget refresh code is untouched.

Net about −550 lines. Unit tests and lint pass; the prefs tests now seed raw on-disk key names instead of the removed internal key constants.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/415
2026-10-16 13:17:49 +02:00
Jean-Luc Makiolaandmakiolaj b627ed04b7 Move onto floret's shared rows, onboarding pieces and Android helpers (#410)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Stacked on #409 (its commit shows here until it merges). Bumps floret-kit to 5093e1c (floret main, floret-kit#14) and deletes the local copies of what moved there. Widget code untouched.

- `GroupedRow(checked)` for the check-mark rows; `GroupedSwitchRow` for 16 switch rows; `OptionRows` in the three settings preview pickers; `CustomExpandRow` for the three "Custom" picker rows
- `IconInfoCard` replaces `EditCard` and `DetailCard`; `GroupedHint` replaces `SettingsHint` / `HintText` / `PickerDescription`
- Onboarding steps use `OnboardingScaffold(headline, supporting)` and `OnboardingPrimaryButton`
- `LanguagePickerRow(header, predictiveBack)` replaces the local `LanguageRow`
- `relativeTimeAgo`, `ReminderOverride.minutesOrEmpty()`
- New `core-android` dependency: `goAsync { }` in the reminder receivers, `hasPermission` for all non-widget permission checks, `broadcastTicks` behind TodaySource / ZoneSource, `setWakeup` in the reminder and snooze alarms, `stateWhileSubscribed` for the 39 `WhileSubscribed(5_000)` sites

One visible change: the expand rows in Event form and Notification settings show floret's rotating chevron instead of swapping ExpandLess/ExpandMore.

Kept local on purpose: the Reliable delivery row (no selected highlight today), the Dynamic colour switch (isn't dimmed when unavailable), HiddenCalendarsStep's headline (needs its inset padding) and ViewStep's onboarding block (headlineSmall).

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/410
2026-10-15 23:06:26 +02:00
Jean-Luc Makiolaandmakiolaj d8e3250af9 Share test event builders and repository wiring (#408)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped
Step 3 of the simplification review. Tests only, no production code changes.

- `TestEvents.kt`: defaulted `testEvent` / `allDayEvent` / `timedEvent` / `testDetail` builders replace the full `EventInstance(...)` / `EventDetail(...)` spellings and the per-file `allDay`/`timed` helpers. Helpers with test-specific titles, colours or zones stay as one-line wrappers, so every built event is unchanged.
- `TestRepositories.kt`: `testDataStore` / `testPrefs` / `testSettings` / `testRepository` plus a JUnit 5 `MainDispatcherExtension` replace the repeated DataStore, repository and setMain/resetMain wiring.

Same 1302 tests before and after; widget tests untouched.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/408
2026-10-15 16:37:46 +02:00
Jean-Luc Makiolaandmakiolaj 8bd2ac7a9a Remove dead code and unused imports (#407)
Beta — Codeberg pre-release / detect (push) Successful in 51s
Beta — Codeberg pre-release / beta (push) Skipped
First step of the simplification review. No behaviour change.

- Unreachable paged-month path (`MonthContent` / `MonthPager` / `MonthPage`); the month branch is now scrolling vs split
- `ui/day/` and its test, `MonthViewModel.anchorPage`, `PagedTimelineViewModel.goToDate`, `SettingsPrefs.setQuickSwitchConfig`, `TimelineHours.span`
- `PermissionViewModel` / `PermissionUiState`, replaced by a saveable `denied` flag in `PermissionScreen`
- `ReminderNotifier.post` no longer returns a value nobody read
- 38 unused imports outside the widget code

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/407
2026-10-15 16:36:08 +02:00
Jean-Luc Makiolaandmakiolaj c1c529d4bd Keep deleted and edited occurrences when moving a series (#404)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped
Moving a recurring event to another calendar re-inserted its exception rows under the new event. That event has no `_sync_id` yet, so the rows never attached: deleted occurrences came back, and edited ones showed twice (original plus the edited copy).

The move now does what delete and edit-this-occurrence already do for unsynced series: cancelled occurrences become EXDATE entries on the moved series, edited ones become EXDATE plus a standalone event on the target calendar. The standalone copies are rolled back with the rest if the move fails.

`buildCopiedExceptionValues` is gone with its tests, and `ExceptionRowSnapshot`/`ExceptionProjection` only read the columns still used.

Adds `MoveEventExceptionsTest` (instrumented), which fails on the old code and passes now.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/404
2026-10-15 16:25:34 +02:00
Jean-Luc Makiolaandmakiolaj aca048e593 Open the until-date picker on today's local date (#405)
Beta — Codeberg pre-release / detect (push) Successful in 10s
Beta — Codeberg pre-release / beta (push) Skipped
The repeat-until picker defaulted to today's UTC date, so near midnight it opened on yesterday or tomorrow depending on the zone. It now uses the device zone's today.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/405
2026-10-15 16:25:12 +02:00
Jean-Luc Makiolaandmakiolaj 440f85d89f Predictive back for the export calendar picker (#406)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
ExportCalendarPicker was the only full-screen picker without `predictiveBack = true`.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/406
2026-10-15 16:24:55 +02:00
Jean-Luc Makiolaandmakiolaj bf2e59a14e Update the selection bar's times live while moving or resizing (#141) (#403)
Beta — Codeberg pre-release / detect (push) Successful in 9s
Beta — Codeberg pre-release / beta (push) Skipped
Feedback from #141: when resizing with the left hand, the finger covers both the event's own time and the hour-column badge.

While a selected event is moved or resized, the time under its title in the top bar now follows the gesture. It shows what letting go would save, using the same calculation as the drop (real duration for a move, the untouched edge kept for a resize, DST-safe), and stays on the saved times until the event leaves its starting slot. It only recalculates when the snapped slot changes.

Closes #141

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/403
2026-10-15 16:21:25 +02:00
Jean-Luc Makiolaandmakiolaj 19f0dcae5d Say Create instead of Save when the editor makes a new event (#141) (#402)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped
Feedback from the #141 beta: a duplicate opened the editor with the same title and a plain Save button, so it looked like editing the original.

The editor button now says Create for every new event (new, duplicate, shared text, .ics, long-press draft) and Save only when editing an existing event. The label is taken from how the editor was opened, so it doesn't flip while the form loads.

The other two feedback points (handle shape, start time under the finger) are left as they are, see the comment on #141.

Closes #141

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/402
2026-10-15 14:42:39 +02:00
Jean-Luc Makiolaandmakiolaj c3401ed61c Add haptics to split-pane swipes and event drops (#116) (#401)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
- Swiping between days in the split month pane now ticks, like pulling the pane open and closed already did.
- Dropping a moved or resized event gives a confirm buzz. It only fires on a real drop; putting an event back where it was, cancelling, or undoing stays silent.
- Fixes a #141 flicker: after a resize, the block dimmed briefly as the floating copy faded out. It now takes over at full opacity, as a block released in place already did.

Deviation from the issue: the original ask (haptics when swiping the split view up and down) shipped in 2.22.0, so this covers the remaining gaps from the analysis. Month and week paging stays silent on purpose, and settings switches are left for a later floret-kit change.

Closes #116

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/401
2026-10-15 14:22:47 +02:00
makiolaj e880500ed8 Release v2.23.0-beta.4 (#178, #181, #271, #306, #339, #387)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-14 17:55:05 +02:00
Jean-Luc Makiolaandmakiolaj 0121aa5b1d Keep the edit form's results from landing on the next one (#178) (#397)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped
Follow-up to #394, from two review passes on the tap-opens-editor change.

- The edit form's view model is reused between opens, so work still running for a closed form could land on the next one. Backing out of a slow load and tapping another event showed the old event, and Save wrote to it. A delete finishing after back closed the next editor. Results from a closed form are now dropped.
- Back is ignored while a save or delete runs. The write can't be called back, so the form waits for the result and then closes or reports the error.
- Unsaved changes are now measured against the form as it opened, defaults included, instead of a flag every setter had to set.
- Taps read the setting and whether the calendar takes writes when they happen, so a tap right after a cold start or a return to the app opens the right screen.
- Without write access, the first tap asks once. A refusal opens the details from then on, until the permission is granted.
- Pinning an event to the tapped occurrence lives in one helper (`EventDetail.atOccurrence`), used by the detail, edit and selection-bar paths.

Closes #178

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/397
2026-10-14 17:49:53 +02:00
Jean-Luc Makiolaandmakiolaj cf3c4e96cd Add a black theme for AMOLED screens (#181) (#399)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped
Settings → Appearance → Theme gets a fourth option, Black, next to System, Light and Dark. It's the dark theme with pure black backgrounds, and it works with Material You as well as Calendula's own palette.

- The month cells and the hour grid are `surfaceContainer` tiles on a `surface` ground, so making every container black would erase the grid lines. floret-kit's `toPureBlack()` sets the ground to #000 and scales the containers down to about #13, #1A, #25 and #30. The tiles stay a shade above black, and sheets and menus still stand out from the page.
- Black always means dark. It's an option in the theme picker, so it doesn't follow the system's light/dark setting.
- Event colours read `isSystemInDarkTheme()` in 15 places, so an in-app Dark or Black on a phone in light mode got light-theme tints. They now read `LocalFloretDarkTheme`, the theme's own decision. The bug also affected Dark, so the changelog has a Fixed entry for it.
- The window backdrop came from the night resource qualifier, so an in-app theme flashed the system's light or dark background before the first frame. `WindowThemeCache` keeps a SharedPreferences copy of the theme choice, and `onCreate` reads it to set the backdrop and bar icons. The copy is written when the stored setting loads, so the first cold start after updating still shows the old backdrop.
- Widgets keep their current themes. They get their theme work in the next milestone.

Needs jlmakiola/floret-kit#13, which is merged. The submodule is pinned to its merge commit on floret-kit `main` (`b1905da`).

Tested with new unit tests for the transform and for saving and reloading `BLACK`, and by hand on the emulator.

Closes #181

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/399
2026-10-14 17:41:34 +02:00
Jean-Luc Makiola 0dd7e07aa0 Show HTML event descriptions as formatted text (#271) (#398)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Event descriptions from Exchange/Outlook and Google Calendar are often HTML. The detail view now renders them instead of showing the raw markup.

- A new converter in `domain/HtmlText.kt` turns HTML into text plus styles and links. It only kicks in when the description has real tags, so plain text with `<3` or `<https://…>` is left alone.
- Kept: bold, italic, underline, strikethrough, super/subscript, monospace, line breaks, paragraphs, lists (bullets, numbers, `start`, nesting) and `<a href>` links (http(s), mailto, tel). Dropped: fonts, sizes, colours, images, and `<head>`/`<style>`/`<script>`/comments.
- Each table row becomes one line with its cells joined by " · ", and empty spacer cells disappear. There's no real grid yet; one can go on top later if invites need it.
- URL and phone autolinking runs on the rendered text and skips text that's already a link.
- Long-press copy gives the rendered text, with each link's address in brackets after it.
- Search matches the rendered text and link addresses, and snippets show clean text.
- The edit form still shows the raw HTML, as discussed in the issue, so saving never breaks the formatting.

The issue suggested a settings toggle that strips tags. Following the discussion in the comments, this renders HTML automatically instead, with no setting.

Known limit: the SQL pre-filter in search still runs on the raw column. A word that's stored as an entity (`R&amp;D`) or split by tags (`<b>Kick</b>off`) isn't found.

I wrote my own parser instead of using `AnnotatedString.fromHtml`, because that goes through `android.text.Html`, which is stubbed in JVM unit tests, and search lives in the Compose-free domain layer.

Closes #271
2026-10-14 17:30:16 +02:00
Jean-Luc Makiolaandmakiolaj 2b2a31ecda Create an event from shared or selected text (#306) (#396)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Shared text, or text picked with the new "Create event" entry in the selection menu, opens the new-event form prefilled. The share's subject becomes the title, or the first line if there is no subject, and the rest of the text goes into the description. Start and length use the usual defaults (next full hour, default duration). Calendula doesn't try to read a date out of the text.

Differences from the plan in the issue comment:
- The line used as the title is left out of the description, so it doesn't show up twice.
- Titles are capped at 255 characters. If a title gets cut, the full text stays in the description.
- Sharing a plain-text file no longer sends it to the .ics importer.
- A saved or dismissed prefilled form no longer reopens after rotating the phone. This also fixes it for "add to calendar" requests from other apps.
- If a second request comes in while a prefilled form is open, it replaces the form when nothing was changed. Otherwise the save / discard / keep editing prompt comes first.
- If Calendula was started just for the request, closing or saving the form takes you back to the app you came from.

Closes #306

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/396
2026-10-14 15:57:32 +02:00
Jean-Luc Makiolaandmakiolaj 0f75d2638b Show events at their real time on days the clocks change (#387) (#393)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
On the day the clocks change, the day, 3-day and week views placed events by the time elapsed since midnight, so every event after the change sat an hour off. A 14:45 event on 25 October showed at 15:45. Blocks are now placed by wall clock.

When the clocks go back, 02:00 to 03:00 happens twice, and a wall-clock time alone can't say which of the two it means. This branch keeps track of it:

- The event form holds each time as a `FormTime`, the wall clock plus its UTC offset when that time is in the repeated hour. Validation and saving read the real instants from it, so a span ending in the repeated hour is valid and switching between the two passes is saved.
- A time picked in the form or dragged with a resize handle goes back to the event's original pass when it's set back to its original time. Otherwise it moves in the direction it was moved, stays after the start, and takes a pass the grid can draw where it was dropped.
- A drop onto a repeated time lands on the first pass, unless the event was already on the second.
- A block that runs into the repeated hour from before it is drawn to 03:00, and its label shows its real end.
- The editor, the event details and the pinned zone's "local time" line add the zone abbreviation (CEST or CET) to a time in the repeated hour.
- A recurring series that starts in the repeated hour keeps its pass when it's edited.

Also in this PR:

- `ZoneSource`, provided through Hilt, gives the timeline, the event form and reschedules the device zone. The timeline lays its pages out again when the zone changes.
- A drafted block (#153) opens the form at its exact start and end.
- A day move onto the spring-forward day keeps its wall-clock start, so moving a weekly series there is still offered for all events.

## Testing

The 1,258 unit tests pass. The instrumented suite has the same two failures as `release/v2.23.0` (`CalendarRepositorySmokeTest` and `PermissionScreenTest`), so nothing new.

I also checked it by hand on an emulator in Europe/Berlin, with the clock moved to each DST day:

- 25 October: the event from the issue (14:45, Europe/Brussels) is drawn at 14:45. A run from 02:10 CEST to 02:20 CET is drawn to 03:00 and labelled with its real end.
- Pulling the end of a 02:30 to 03:30 CET event up to 02:45 saves 02:45 CET, after its start.
- Pulling the end of a 01:00 CEST to 04:00 CET event up to 02:15 saves 02:15 CEST, and the block stays where it was dropped.
- 29 March: a 01:00 to 04:00 run and a 14:00 event both sit at their own times.

Picking times in the form and moving a series in the month view are covered by unit tests only.

Closes #387

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/393
2026-10-14 15:54:14 +02:00
Jean-Luc Makiolaandmakiolaj 9fe1ebb0fb Tap a phone number in an event to call it (#339) (#395)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Phone numbers in an event's title, location and description are now links. Tapping one opens the dialer with the number filled in. Nothing is called until you press call, so the app needs no new permission.

- Android's own matcher finds the numbers (Linkify, backed by libphonenumber and the SIM's region). Its loose setting links too much in event text, so a cleanup step drops meeting IDs and PINs after a label, dates, dotted times like "9.30 – 12.00", amounts, anything under 7 digits and digits inside URLs.
- "+49 (0)151 …" dials without the (0). An extension ("ext. 567") becomes a pause, and a Zoom-style dial-in suffix (",,850…#") is kept.
- Detection runs in the detail ViewModel while the event loads, so the links are there on the first frame and survive rotation.
- Long-pressing a field copies it even when the press lands on a link. Before, the link swallowed the press.
- PhoneLinksTest covers the cleanup on the JVM. PhoneLinksPlatformTest (androidTest) runs the real matcher and passes on an emulator with a US SIM.

Known limit: on a device without a SIM (Wi-Fi tablets, some emulators) Android gives the matcher no region, so only numbers starting with + are linked.

Closes #339

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/395
2026-10-14 15:24:03 +02:00
Jean-Luc Makiolaandmakiolaj 951ad9cd6d Tapping an event can open the edit form (#178) (#394)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
New setting under Settings → Views → Events: "Tapping an event opens" Details (default) or Edit form. The long-press toggle moves into the same group.

- Taps in the views and in search open the editor directly for events in writable calendars. Read-only events, reminder notifications and widgets always open the details.
- Installs without write access are asked for it on the tap; refusing opens the details instead.
- The editor gets a back guard: with unsaved changes it asks to save, discard or keep editing; otherwise it closes silently.
- The editor gets Duplicate, Share and Delete in an overflow menu, so skipping the details doesn't lose them.
- If the event can't be loaded, the details screen opens and shows why.
- Each editor overlay (create, edit, duplicate/import) now has its own ViewModel. Duplicating from an edited form previously reused the edit form's state.
- Deletes from the details screen, the editor and the selection bar share one error mapping. Deleting an event that's already gone counts as deleted in the details screen and the editor.

Deviation from the issue: it asked for back to save automatically. Following the discussion in the comments, auto-save isn't in this PR. Back asks only when something actually changed, and Details stays the default.

Closes #178

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/394
2026-10-14 14:33:38 +02:00
makiolaj 6cb2f62627 Release v2.23.0-beta.3 (#153)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 36m8s
2026-10-12 22:48:23 +02:00
Jean-Luc Makiolaandmakiolaj 427e87c6e6 Long press an empty slot to draft an event (#153) (#390)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Long press on free time in the day, 3-day and week views drops a block of the default event length there. It can be moved while still held, or dragged and resized with its handles after letting go. The tick opens the new-event form at that span; the ✕, a tap elsewhere or back discards it. The draft is a stand-in event that reuses the #141 selection, drag and resize machinery, so nothing is written until the form saves.

Also smooths the lift handover for all selected blocks: no one-frame dip when letting go, no bounce on the handles or on a block landing after a drop or resize, and the floating copy's time label now matches the block's so the text doesn't shift on pickup.

Deviations from the issue / agreed design:
- An unresized draft opens the form with the length it shows (the global default) rather than the per-calendar default, so the form never opens at a different time than the block on screen.
- A draft dropped on a day off screen snaps back instead of moving there, since nothing would draw it.
- The in-editor timeline picker from the issue isn't included; per the issue comments it isn't needed for now.

Also moves the Gitea release/beta jobs onto the light/gradle runners.

Closes #153

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/390
2026-10-12 22:48:13 +02:00
makiolaj dce83005ea Release v2.23.0-beta.2 (#141)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 8m15s
2026-10-09 22:23:14 +02:00
Jean-Luc Makiola 1ac486cc4b Select, move and resize timeline events with a long press (#141) (#384)
Beta — Codeberg pre-release / detect (push) Successful in 12s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-09 22:23:06 +02:00
makiolaj 549ef1dfbf Release v2.23.0-beta.1 (#367)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 9m45s
2026-10-09 10:25:48 +02:00
Jean-Luc Makiolaandmakiolaj 164b5fb25d Scroll back into past days in the agenda (#367) (#382)
Beta — Codeberg pre-release / detect (push) Successful in 4s
Beta — Codeberg pre-release / beta (push) Skipped
The agenda opens on today, and scrolling up loads earlier days 30 at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today.

The agenda no longer picks up the day the month or day view was on, so "Next 30 days" always counts from today. That fixes the bug from the issue comment. It also means the agenda no longer follows the shared view date from #184 when you switch to it. Backing out of a day opened from the agenda keeps your scroll position.

Not in the issue: multi-day events that are still running now dim on the days already behind you, in the agenda, month, week and day views. Before, they stayed at full strength until the whole event ended. A spanning bar dims once the last day it covers in that row has passed.

The widget is unchanged and still starts at today.

Closes #367

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/382
2026-10-09 10:25:25 +02:00
Jean-Luc Makiolaandmakiolaj 6afcfd49f1 Release v2.22.5 (#381)
Renovate / renovate (push) Successful in 1m13s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 19m27s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m2s
When you reopened the app the next morning, the month view still marked yesterday as today and still had the day you picked the evening before selected.

- New `TodaySource` gives the current date. It rechecks once a minute while a calendar is visible, and also reacts to the system's date, time and time-zone broadcasts. Month, Week/Day/3-day, Agenda and the shared date that every view opens on all use it.
- On a day change, a view still showing the old today moves to the new one, and Split clears the previous day's selection. A view you navigated elsewhere stays where it is.
- Month and Week take today from the live value instead of their cached page data.
- The issue only mentions the month view. The same stale today also affected Week, Day, Agenda and switching between views, so all of them are fixed.
- versionName 2.22.5 / versionCode 2220599, CHANGELOG, What's New in all 17 locales.

Closes #373

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/381
2026-10-07 20:22:50 +02:00
Jean-Luc Makiolaandmakiolaj 5f77cac7ea Move to floret-kit 0.7.0 (#375)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m11s
Repoints the floret-kit submodule at the convergence merge (jlmakiola/floret-kit#11), which folds the clockula/vouch/glance branches back into floret-kit main so all four apps share one line again.

floret-kit's dependency bump requires Gradle >= 9.6 in the consuming build, so this also bumps Gradle 9.5.1 → 9.8.0, AGP 9.2.1 → 9.4.1 and Kotlin 2.3.21 → 2.4.20. No source changes were needed.

**Merge jlmakiola/floret-kit#11 first** so the pinned commit is on floret-kit main.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/375
2026-10-07 09:56:00 +02:00
223 changed files with 10830 additions and 6772 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
detect: detect:
# Gitea only; see the same guard in release.yaml. # Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj' if: github.repository_owner == 'makiolaj'
runs-on: docker runs-on: light
outputs: outputs:
is_beta: ${{ steps.v.outputs.is_beta }} is_beta: ${{ steps.v.outputs.is_beta }}
version: ${{ steps.v.outputs.version }} version: ${{ steps.v.outputs.version }}
@@ -41,7 +41,7 @@ jobs:
beta: beta:
needs: detect needs: detect
if: needs.detect.outputs.is_beta == 'true' if: needs.detect.outputs.is_beta == 'true'
runs-on: docker runs-on: gradle
env: env:
ANDROID_HOME: /opt/android-sdk ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk
+2 -2
View File
@@ -43,7 +43,7 @@ jobs:
# secrets. repository_owner differs between the two forges regardless of # secrets. repository_owner differs between the two forges regardless of
# URL, proxy or instance rename, so this closes it permanently. # URL, proxy or instance rename, so this closes it permanently.
if: github.repository_owner == 'makiolaj' if: github.repository_owner == 'makiolaj'
runs-on: docker runs-on: light
outputs: outputs:
is_release: ${{ steps.v.outputs.is_release }} is_release: ${{ steps.v.outputs.is_release }}
version: ${{ steps.v.outputs.version }} version: ${{ steps.v.outputs.version }}
@@ -85,7 +85,7 @@ jobs:
release: release:
needs: detect needs: detect
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch' if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
runs-on: docker runs-on: gradle
env: env:
ANDROID_HOME: /opt/android-sdk ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk
+31
View File
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. Read-only events can be selected but not moved or resized ([#141]).
- **Long press an empty slot to draft an event.** In the day, 3-day and week views, a long press on free time drops a block of your default event length there. Move it while still holding, or let go and drag it or its handles into place, then tap the tick to open a new event at that time. The ✕, a tap elsewhere or going back throws the draft away ([#153]).
- **Tap a phone number in an event to call it.** Phone numbers in an event's title, location and description are now links. Tapping one opens your phone app with the number filled in, ready to call. A conference dial-in like "+1 646 558 8656,,850123#" brings its meeting ID along. Meeting IDs and PINs on their own, dates, times and amounts stay plain text. Long-pressing a field copies it, even when you press on a link ([#339]).
- **Formatted event descriptions read properly.** Descriptions from Outlook, Exchange and Google Calendar are often stored as HTML, which used to show up as a wall of tags. The event view now shows them as formatted text: bold, italics, lists and links work, table rows sit on one line each, and the fonts and colours the sender picked are left out so the text matches the app. Search matches the text you see. The edit form still shows the original, so saving an event never changes its formatting ([#271]).
- **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]).
- **Create an event from shared or selected text.** Share text to Calendula, or select text in another app and pick Create event from the selection menu, and the new-event form opens with it filled in. A shared subject becomes the title, otherwise the first line does, and the rest goes into the description. The event starts at the next full hour with your default length; Calendula doesn't try to read a date out of the text. If Calendula was opened just for this, saving or closing the form takes you back to the app you came from ([#306]).
- **Tapping an event can open the edit form.** Settings → Views → Events → Tapping an event opens can now be set to Edit form instead of Details. Taps in the views and in search then go straight to the editor for events you can edit; read-only events, reminder notifications and widgets still open the details. The editor has Duplicate, Share and Delete in its menu, and going back with unsaved changes asks whether to save, discard or keep editing ([#178]).
- **A black theme for AMOLED screens.** Settings → Appearance → Theme now has Black next to System, Light and Dark. It's the dark theme with pure black backgrounds, which saves battery on AMOLED screens and makes event colours stand out more. Day cells and the hour grid stay a shade above black so the grid lines still show, and sheets and menus still stand out from the page. It works with Material You colours too. The home-screen widgets aren't affected ([#181]).
### Fixed
- **Event colours follow the theme you picked in Calendula.** With the app set to Dark on a phone in light mode, or the other way round, events took their colours from the phone's setting, so they could come out too pale or too dark. They now match the app's theme. Starting the app with a theme set in Calendula also no longer flashes the phone's light or dark background first ([#181]).
- **Events on the day the clocks change show at their real time.** In the day, 3-day and week views, events later on the day of a daylight saving change were drawn an hour off, so a 14:45 event showed at 15:45. They now sit at their own time. When the clocks go back, 02:00 to 03:00 happens twice: an event in that hour now stays in the right one when you move, resize or edit it, and the editor and event details add the zone, like CEST or CET, so you can tell the two apart ([#387]).
- **The agenda always starts today.** Coming from the month or day view, the agenda used to start at the day you had open there, so "Next 30 days" could show the 1st to the 30th of the month. It now opens on today with your chosen range. Backing out of a day you opened from the agenda still returns you to where you were ([#367]).
- **Multi-day events dim on the days already behind you.** With past events dimmed, an event running over several days stayed at full strength on its earlier days until it ended. Those days now dim in the agenda, month, week and day views ([#367]).
## [2.22.5] — 2026-10-07
### Fixed
- **Reopening the app the next morning shows the right day.** The month view used to mark yesterday as today and keep the day you picked the evening before selected, so you had to tap today to see its events. Every view now moves on to the new day at midnight and when you come back to the app. A view you browsed to another date stays where it is ([#373]).
## [2.22.4] — 2026-10-07 ## [2.22.4] — 2026-10-07
### Fixed ### Fixed
@@ -1843,3 +1864,13 @@ automatically, with zero telemetry and no internet permission.
[#361]: https://codeberg.org/jlmakiola/calendula/issues/361 [#361]: https://codeberg.org/jlmakiola/calendula/issues/361
[#363]: https://codeberg.org/jlmakiola/calendula/issues/363 [#363]: https://codeberg.org/jlmakiola/calendula/issues/363
[#364]: https://codeberg.org/jlmakiola/calendula/issues/364 [#364]: https://codeberg.org/jlmakiola/calendula/issues/364
[#367]: https://codeberg.org/jlmakiola/calendula/issues/367
[#373]: https://codeberg.org/jlmakiola/calendula/issues/373
[#141]: https://codeberg.org/jlmakiola/calendula/issues/141
[#153]: https://codeberg.org/jlmakiola/calendula/issues/153
[#339]: https://codeberg.org/jlmakiola/calendula/issues/339
[#387]: https://codeberg.org/jlmakiola/calendula/issues/387
[#271]: https://codeberg.org/jlmakiola/calendula/issues/271
[#181]: https://codeberg.org/jlmakiola/calendula/issues/181
[#306]: https://codeberg.org/jlmakiola/calendula/issues/306
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178
+5 -2
View File
@@ -32,8 +32,8 @@ android {
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001, // (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match. // 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md. // See docs/RELEASING.md.
versionCode = 2220499 versionCode = 2230004
versionName = "2.22.4" versionName = "2.23.0-beta.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -175,7 +175,10 @@ dependencies {
implementation("de.jeanlucmakiola.floret:core-time") implementation("de.jeanlucmakiola.floret:core-time")
implementation("de.jeanlucmakiola.floret:core-locale") implementation("de.jeanlucmakiola.floret:core-locale")
implementation("de.jeanlucmakiola.floret:core-crash") implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:core-di")
implementation("de.jeanlucmakiola.floret:core-prefs")
implementation("de.jeanlucmakiola.floret:core-reminders") implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:core-android")
implementation("de.jeanlucmakiola.floret:identity") implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components") implementation("de.jeanlucmakiola.floret:components")
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
@@ -0,0 +1,91 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.Manifest
import android.content.ContentUris
import android.content.ContentValues
import android.provider.CalendarContract
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.datetime.TimeZone
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Moving a synced series keeps its deleted and edited occurrences, although the
* moved row has no `_sync_id` for exception rows to attach to.
*/
@RunWith(AndroidJUnit4::class)
class MoveEventExceptionsTest {
@get:Rule
val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
Manifest.permission.READ_CALENDAR,
Manifest.permission.WRITE_CALENDAR,
)
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val resolver = context.contentResolver
private val ds = AndroidCalendarDataSource(context)
private val zone = TimeZone.currentSystemDefault()
private val day = 86_400_000L
private val start = 1_893_492_000_000L // 2030-01-01T10:00Z
private val hour = 3_600_000L
/** "day offset:title" of every instance on [calendarId]. */
private fun occurrences(calendarId: Long): List<String> =
ds.instances(start - day, start + 10 * day)
.filter { it.calendarId == calendarId }
.map { "${(it.start.toEpochMilliseconds() - start) / day}:${it.title}" }
.sorted()
@Test
fun movedSeriesKeepsCancelledAndModifiedOccurrences() {
val a = ds.createLocalCalendar("move-test-a", 0xFF336699.toInt(), null)
val b = ds.createLocalCalendar("move-test-b", 0xFF993366.toInt(), null)
try {
val src = insertSyncedSeries(a)
ds.deleteOccurrence(src, start + 2 * day)
val occurrence = ds.eventDetail(src, 540)!!.toEditForm(start + 3 * day, start + 3 * day + hour, zone)
ds.updateOccurrence(src, start + 3 * day, occurrence, occurrence.copy(title = "edited"), 540)
val expected = listOf("0:series", "1:series", "3:edited", "4:series")
assertThat(occurrences(a)).isEqualTo(expected)
val form = ds.eventDetail(src, 540)!!.toEditForm(start, start + hour, zone)
ds.moveEvent(src, b, form, form, 540)
assertThat(occurrences(a)).isEmpty()
assertThat(occurrences(b)).isEqualTo(expected)
} finally {
ds.deleteCalendar(a)
ds.deleteCalendar(b)
}
}
/** A daily series carrying a `_sync_id`, so its exceptions are real exception rows. */
private fun insertSyncedSeries(calendarId: Long): Long {
val accountName = resolver.query(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
arrayOf(CalendarContract.Calendars.ACCOUNT_NAME), null, null, null,
)!!.use { it.moveToFirst(); it.getString(0) }
val uri = CalendarContract.Events.CONTENT_URI.buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, accountName)
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
.build()
val values = ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.TITLE, "series")
put(CalendarContract.Events.DTSTART, start)
put(CalendarContract.Events.DURATION, "P3600S")
put(CalendarContract.Events.RRULE, "FREQ=DAILY;COUNT=5")
put(CalendarContract.Events.EVENT_TIMEZONE, "UTC")
put(CalendarContract.Events._SYNC_ID, "move-test-series")
}
return ContentUris.parseId(resolver.insert(uri, values)!!)
}
}
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.calendula.ui.detail
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
/**
* The platform phone matcher on real text. It reads the region off the SIM,
* so these stick to international numbers and to text that must never match
* anywhere — domestic formats depend on the device they run on.
*/
@RunWith(AndroidJUnit4::class)
class PhoneLinksPlatformTest {
private fun numbers(text: String) = findPhoneLinks(text).map { it.number }
@Test
fun internationalNumbers_areFound() {
assertThat(numbers("Call +49 30 1234567 or +1 646-558-8656"))
.containsExactly("+49301234567", "+16465588656")
.inOrder()
}
@Test
fun trunkZero_isDroppedWithOrWithoutSpace() {
assertThat(numbers("+49 (0)151 2345678")).containsExactly("+491512345678")
assertThat(numbers("+49 (0) 151 2345678")).containsExactly("+491512345678")
}
@Test
fun neighbouringNumbers_doNotJoinTheNumber() {
assertThat(numbers("Start 10:00 +49 151 2345678")).containsExactly("+491512345678")
assertThat(numbers("Call +49 151 2345678 2 times")).containsExactly("+491512345678")
}
@Test
fun conferenceDialIn_keepsMeetingId() {
assertThat(numbers("One tap mobile: +16465588656,,85012345678# US"))
.containsExactly("+16465588656,,85012345678#")
}
@Test
fun meetingIds_areNotNumbers() {
assertThat(numbers("Meeting ID: 850 1234 5678")).isEmpty()
assertThat(numbers("Conference ID: 123 456 789#")).isEmpty()
}
@Test
fun datesAndTimes_areNotNumbers() {
assertThat(numbers("2026-10-14 10:00")).isEmpty()
assertThat(numbers("Am 14.10.2026 10:00 Uhr")).isEmpty()
assertThat(numbers("14.10.26 - 16.10.26")).isEmpty()
assertThat(numbers("10:00 - 11:30")).isEmpty()
assertThat(numbers("Schuljahr 2025/2026, Season 2025-2026")).isEmpty()
assertThat(numbers("1.10.2026 - 3.10.2026")).isEmpty()
}
@Test
fun amounts_areNotNumbers() {
assertThat(numbers("Budget 1.250.000 EUR, 1 250 000 EUR")).isEmpty()
}
@Test
fun isbn_isNotANumber() {
assertThat(numbers("ISBN 978-3-16-148410-0")).isEmpty()
}
@Test
fun extension_becomesAPause() {
assertThat(numbers("+1 212 555 1234 ext. 567")).containsExactly("+12125551234,567")
}
@Test
fun webexAccessCode_isNotANumber() {
assertThat(numbers("Meeting number (access code): 2732 123 4567")).isEmpty()
}
@Test
fun commaSeparatedNumbers_stayApart() {
assertThat(numbers("+49 30 1234567,+49 151 2345678"))
.containsExactly("+49301234567", "+491512345678")
.inOrder()
}
@Test
fun dottedTimes_areNotNumbers() {
assertThat(numbers("Beginn 9.30 – 12.00 Uhr")).isEmpty()
assertThat(numbers("10.00-11.30")).isEmpty()
}
@Test
fun schemelessUrlDigits_areNotNumbers() {
assertThat(numbers("zoom.us/j/85012345678")).isEmpty()
}
@Test
fun webLinkDigits_areNotNumbers() {
assertThat(numbers("https://zoom.us/j/85012345678?pwd=1")).isEmpty()
}
}
+13
View File
@@ -151,6 +151,19 @@
<data android:mimeType="application/ics" /> <data android:mimeType="application/ics" />
</intent-filter> </intent-filter>
<!-- Text shared or selected in another app opens a prefilled new
event (MainActivity.sharedTextExtrasOrNull, issue #306). -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:label="@string/process_text_create_event">
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us <!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
to create a new event, the way the AOSP calendar accepts it: to create a new event, the way the AOSP calendar accepts it:
ACTION_INSERT on the events *dir* mime type, carrying the new ACTION_INSERT on the events *dir* mime type, carrying the new
@@ -25,12 +25,19 @@ import androidx.core.content.IntentCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.isDark
import de.jeanlucmakiola.floret.prefs.isPureBlack
import de.jeanlucmakiola.calendula.data.prefs.is24Hour 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.domain.sharedTextOrNull
import de.jeanlucmakiola.calendula.ui.ExternalRequests
import de.jeanlucmakiola.calendula.ui.InsertRequest
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.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
@@ -50,8 +57,11 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
import de.jeanlucmakiola.calendula.ui.theme.windowBackgroundRes
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
@@ -59,41 +69,31 @@ import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
/** A prefilled create form from an external launch, with the source it came from. */
private data class InsertRequest(val form: EventForm, val source: ImportSource)
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@Inject lateinit var calendarRepository: CalendarRepository @Inject lateinit var calendarRepository: CalendarRepository
@Inject lateinit var settingsPrefs: SettingsPrefs
// Which of light/dark the system bars are drawn for. The styles installed // Which of light/dark the system bars are drawn for. The styles installed
// in onCreate read this field live, so androidx's config-change replay // in onCreate read this field live, so androidx's config-change replay
// picks up the in-app override instead of the night resource qualifier. // picks up the in-app override instead of the night resource qualifier.
private var systemBarsDark = false private var systemBarsDark = false
// The occurrence a reminder notification was tapped for (eventId, begin, private val requests = ExternalRequests(
// end — the detail screen's key shape). singleTop + onNewIntent route a onExternalFormClosed = {
// tap into the running activity; CalendarHost consumes and clears it. insertFormOpen = false
private var requestedDetailKey by mutableStateOf<LongArray?>(null) if (launchedForInsert) finish()
},
)
// A navigation a home-screen widget asked for (open a date / start a // Whether such a form is still open. Only then does a recreation re-read the
// create). Consumed once by CalendarHost, same pattern as the detail key. // intent to bring it back; a saved or dismissed one stays closed.
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null) private var insertFormOpen = false
// An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once // Started just for an external create, so closing its form returns to the
// by CalendarHost's import flow. // app that asked instead of leaving the calendar on top of it.
private var requestedImportUri by mutableStateOf<Uri?>(null) private var launchedForInsert = false
// A prefilled new-event form from an external ACTION_INSERT launch (another
// app/widget asking us to create an event, issue #30). Consumed once by
// CalendarHost, which opens it in the create form for review.
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
// opens the occurrence in the edit form. Same occurrence-key shape as the
// detail channel; consumed once by CalendarHost.
private var requestedEditKey by mutableStateOf<LongArray?>(null)
// A captured crash report awaiting the user's decision, surfaced as a dialog // A captured crash report awaiting the user's decision, surfaced as a dialog
// over the calendar on the next launch (the single-crash path). A startup // over the calendar on the next launch (the single-crash path). A startup
@@ -115,9 +115,21 @@ class MainActivity : AppCompatActivity() {
return return
} }
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == val systemDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_YES
// The stored theme only arrives after the first frame, so a Light, Dark
// or Black override starts from its last mirrored copy: the matching
// backdrop and bar icons instead of the night qualifier's (#181).
val cachedTheme = WindowThemeCache.read(this)
cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) }
systemBarsDark = (cachedTheme ?: ThemeMode.SYSTEM).isDark(systemDark)
applyEdgeToEdge() applyEdgeToEdge()
lifecycleScope.launch {
settingsPrefs.themeMode.collect { mode ->
WindowThemeCache.write(this@MainActivity, mode)
window.setBackgroundDrawableResource(mode.windowBackgroundRes() ?: R.color.window_background)
}
}
// Android hands the intent that started the task back to onCreate every // Android hands the intent that started the task back to onCreate every
// time the activity is recreated — on a rotation, or on a restart after // 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 // the system reclaimed the process. Re-running a launch that opens one
@@ -128,27 +140,28 @@ class MainActivity : AppCompatActivity() {
// //
// Only those channels are held back. An .ics import or a prefilled // 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 // create form keeps no saved state of its own, so re-reading the intent
// is what carries one through a recreation. // is what carries one through a recreation — the create form only while
// it's still open.
val replaying = savedInstanceState != null || intent.isRelaunch() val replaying = savedInstanceState != null || intent.isRelaunch()
if (!replaying) { if (!replaying) {
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull() requests.detailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedEditKey = intent.editEventKeyOrNull() requests.editKey = intent.editEventKeyOrNull()
} }
requestedNav = intent.navRequestOrNull() requests.nav = intent.navRequestOrNull()
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent } ?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
requestedImportUri = intent.importUriOrNull() requests.importUri = intent.importUriOrNull()
requestedInsert = intent.insertRequestOrNull() val insertOpenBefore = savedInstanceState?.getBoolean(KEY_INSERT_FORM_OPEN) == true
requests.insert = intent.createRequestOrNull()?.takeIf { !replaying || insertOpenBefore }
insertFormOpen = requests.insert != null
launchedForInsert = savedInstanceState?.getBoolean(KEY_LAUNCHED_FOR_INSERT)
?: (requests.insert != null)
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
// and the Settings screen, so a theme change applies app-wide at once. // and the Settings screen, so a theme change applies app-wide at once.
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val settings by settingsViewModel.state.collectAsStateWithLifecycle() val settings by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = when (settings.themeMode) { val darkTheme = settings.themeMode.isDark(isSystemInDarkTheme())
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
// onCreate can only see the night resource qualifier, not the in-app // onCreate can only see the night resource qualifier, not the in-app
// override — re-apply from the resolved theme so the bar icons follow // override — re-apply from the resolved theme so the bar icons follow
// the app's light/dark choice. // the app's light/dark choice.
@@ -187,6 +200,7 @@ class MainActivity : AppCompatActivity() {
CalendulaTheme( CalendulaTheme(
darkTheme = darkTheme, darkTheme = darkTheme,
dynamicColor = settings.dynamicColor, dynamicColor = settings.dynamicColor,
pureBlack = settings.themeMode.isPureBlack,
typography = typography, typography = typography,
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@@ -199,17 +213,7 @@ class MainActivity : AppCompatActivity() {
) { ) {
RootScreen( RootScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
requestedDetailKey = requestedDetailKey, requests = requests,
onDetailKeyConsumed = { requestedDetailKey = null },
widgetNavRequest = requestedNav,
onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsert?.form,
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
onInsertConsumed = { requestedInsert = null },
requestedEditKey = requestedEditKey,
onEditKeyConsumed = { requestedEditKey = null },
) )
} }
// A persistent corner marker so a debug build is never // A persistent corner marker so a debug build is never
@@ -261,13 +265,22 @@ class MainActivity : AppCompatActivity() {
CrashReporter.markHealthy(this) CrashReporter.markHealthy(this)
} }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(KEY_INSERT_FORM_OPEN, insertFormOpen)
outState.putBoolean(KEY_LAUNCHED_FOR_INSERT, launchedForInsert)
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it } (intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it } intent.navRequestOrNull()?.let { requests.nav = it }
intent.importUriOrNull()?.let { requestedImportUri = it } intent.importUriOrNull()?.let { requests.importUri = it }
intent.insertRequestOrNull()?.let { requestedInsert = it } intent.createRequestOrNull()?.let {
intent.editEventKeyOrNull()?.let { requestedEditKey = it } requests.insert = it
insertFormOpen = true
}
intent.editEventKeyOrNull()?.let { requests.editKey = it }
} }
/** /**
@@ -288,7 +301,9 @@ class MainActivity : AppCompatActivity() {
private fun Intent.importUriOrNull(): Uri? { private fun Intent.importUriOrNull(): Uri? {
val uri = when (action) { val uri = when (action) {
Intent.ACTION_VIEW -> data Intent.ACTION_VIEW -> data
// Plain text is a create (#306), never a file to import.
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java) Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
.takeUnless { type == SHARED_TEXT_TYPE }
else -> null else -> null
} ?: return null } ?: return null
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content; // The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
@@ -333,6 +348,36 @@ class MainActivity : AppCompatActivity() {
return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded) return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded)
} }
/** Any external request to create an event: shared text or an insert intent. */
private fun Intent.createRequestOrNull(): InsertRequest? = sharedTextRequestOrNull() ?: insertRequestOrNull()
/**
* Plain text shared into us (`ACTION_SEND`) or picked from a text-selection
* menu (`ACTION_PROCESS_TEXT`), as a new event at the default time (#306).
*/
private fun Intent.sharedTextRequestOrNull(): InsertRequest? {
val shared = when (action) {
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE) {
sharedTextOrNull(
subject = getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString(),
text = getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString(),
)
} else {
null
}
Intent.ACTION_PROCESS_TEXT ->
sharedTextOrNull(subject = null, text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString())
else -> null
} ?: return null
val form = buildInsertEventForm(
title = shared.title,
description = shared.description,
zone = TimeZone.currentSystemDefault(),
now = Clock.System.now(),
)
return InsertRequest(form, ImportSource.InsertOpenEnded)
}
/** A Long extra's value, or null when the extra is absent. */ /** A Long extra's value, or null when the extra is absent. */
private fun Intent.longExtraOrNull(key: String): Long? = private fun Intent.longExtraOrNull(key: String): Long? =
if (hasExtra(key)) getLongExtra(key, 0L) else null if (hasExtra(key)) getLongExtra(key, 0L) else null
@@ -458,6 +503,9 @@ class MainActivity : AppCompatActivity() {
// The calendar provider's authority/host. A date tap arrives as // The calendar provider's authority/host. A date tap arrives as
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>. // ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar" private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
private const val SHARED_TEXT_TYPE = "text/plain"
private const val KEY_INSERT_FORM_OPEN = "insert_form_open"
private const val KEY_LAUNCHED_FOR_INSERT = "launched_for_insert"
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID" private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN" private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
@@ -5,12 +5,10 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
@@ -28,6 +26,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.floret.android.hasPermission
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.time.Clock import kotlin.time.Clock
@@ -133,8 +132,7 @@ class BackupWorker(
private fun notifyFailure(context: Context) { private fun notifyFailure(context: Context) {
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
PackageManager.PERMISSION_GRANTED
if (!canPost) return if (!canPost) return
val manager = NotificationManagerCompat.from(context) val manager = NotificationManagerCompat.from(context)
manager.createNotificationChannel( manager.createNotificationChannel(
@@ -1,13 +1,12 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toEpochMillis import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import android.Manifest import android.Manifest
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.database.ContentObserver import android.database.ContentObserver
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
@@ -15,7 +14,6 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.CalendarContract import android.provider.CalendarContract
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
@@ -28,7 +26,6 @@ import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventSearch import de.jeanlucmakiola.calendula.domain.EventSearch
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.SearchCandidate import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
@@ -36,9 +33,11 @@ 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.ics.semanticReminderMinutes
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -76,21 +75,13 @@ interface CalendarDataSource {
/** /**
* The event-colour palette the calendar's account publishes * The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped, * (`CalendarContract.Colors`, `TYPE_EVENT`), every key in provider order.
* thinned to visually distinct swatches when oversized (CalDAV adapters
* publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker].
* Empty when the account exposes no palette (most local calendars, some * Empty when the account exposes no palette (most local calendars, some
* CalDAV) — the signal that a custom colour can only be written as a raw * CalDAV) — the signal that a custom colour can only be written as a raw
* `EVENT_COLOR`, which a synced calendar may drop on its next sync. * `EVENT_COLOR`, which a synced calendar may drop on its next sync. The
*/ * picker shows it curated ([curatedForPicker]); matching a colour that came
fun eventColorPalette(calendarId: Long): List<EventColorOption> * from outside the account runs against this full set, since those keys are
* all the calendar accepts.
/**
* 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> fun publishedEventColors(calendarId: Long): List<EventColorOption>
@@ -238,8 +229,10 @@ interface CalendarDataSource {
* and cancelled occurrences) to [targetCalendarId], returning the new * and cancelled occurrences) to [targetCalendarId], returning the new
* `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in * `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in
* place, so this is copy+delete: the row is re-inserted on the target (its * place, so this is copy+delete: the row is re-inserted on the target (its
* `UID_2445` preserved), its exceptions, reminders and editable guests * `UID_2445` preserved), its reminders and editable guests copied, its
* replayed, the [original]→[updated] field edits applied, then the source * exceptions turned into EXDATEs (modified ones also into standalone events,
* as the new row has no `_sync_id` to attach them to), the
* [original]→[updated] field edits applied, then the source
* deleted. The source is removed only once the copy fully succeeds; a failure * deleted. The source is removed only once the copy fully succeeds; a failure
* before that rolls the new event back, leaving the original untouched. * before that rolls the new event back, leaving the original untouched.
* [allDayReminderTimeMinutes]: see [insertEvent]. * [allDayReminderTimeMinutes]: see [insertEvent].
@@ -323,8 +316,7 @@ class AndroidCalendarDataSource @Inject constructor(
private val registeredObservers = mutableSetOf<ContentObserver>() private val registeredObservers = mutableSetOf<ContentObserver>()
private fun hasCalendarPermission(): Boolean = private fun hasCalendarPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) == context.hasPermission(Manifest.permission.READ_CALENDAR)
PackageManager.PERMISSION_GRANTED
/** /**
* Attach any not-yet-registered observers to the provider, but only once the * Attach any not-yet-registered observers to the provider, but only once the
@@ -358,7 +350,7 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarProjection.COLUMNS, CalendarProjection.COLUMNS,
null, null, null, null,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC", CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList() )?.use { c -> c.mapRows { toCalendarSource() } } ?: emptyList()
} }
/** /**
@@ -375,7 +367,20 @@ 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 =
insertLocalCalendar(displayName, color, "create local calendar") { putDescription(description) }
/**
* Insert an owned, visible, syncing calendar on the Calendula local account;
* [extra] adds the columns that tell the kinds apart. [what] names the write
* in the failure — never the calendar name, which can reach a crash report.
*/
private fun insertLocalCalendar(
displayName: String,
color: Int,
what: String,
extra: ContentValues.() -> Unit,
): Long {
val name = displayName.trim() val name = displayName.trim()
val values = ContentValues().apply { val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME) put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
@@ -391,11 +396,9 @@ class AndroidCalendarDataSource @Inject constructor(
) )
put(CalendarContract.Calendars.VISIBLE, 1) put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1) put(CalendarContract.Calendars.SYNC_EVENTS, 1)
putDescription(description) extra()
} }
val uri = resolver.insert(localCalendarsUri(), values) val uri = resolver.insert(localCalendarsUri(), values) ?: throw WriteFailedException(what)
// No calendar name in the message — it can reach a crash report.
?: throw WriteFailedException("create local calendar")
return ContentUris.parseId(uri) return ContentUris.parseId(uri)
} }
@@ -460,33 +463,15 @@ class AndroidCalendarDataSource @Inject constructor(
} }
override fun canWriteCalendars(): Boolean = override fun canWriteCalendars(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) == context.hasPermission(Manifest.permission.WRITE_CALENDAR)
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() insertLocalCalendar(displayName, color, "create managed calendar") {
val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.NAME, name)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
put(
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
CalendarContract.Calendars.CAL_ACCESS_OWNER,
)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
// The marker that identifies this as our special-dates calendar, // The marker that identifies this as our special-dates calendar,
// independent of the (user-editable) display name. CAL_SYNC1 already // independent of the (user-editable) display name. CAL_SYNC1 already
// holds the description, so the marker lives in CAL_SYNC2. // holds the description, so the marker lives in CAL_SYNC2.
put(MANAGED_MARKER_COLUMN, markerFor(type)) put(MANAGED_MARKER_COLUMN, markerFor(type))
} }
val uri = resolver.insert(localCalendarsUri(), values)
?: throw WriteFailedException("create managed calendar")
return ContentUris.parseId(uri)
}
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query( override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
CalendarContract.Calendars.CONTENT_URI, CalendarContract.Calendars.CONTENT_URI,
@@ -550,22 +535,16 @@ class AndroidCalendarDataSource @Inject constructor(
// Seeded once, then never re-touched by sync — so a user who moves a // Seeded once, then never re-touched by sync — so a user who moves a
// birthday reminder keeps their change. Encoded against the upcoming // birthday reminder keeps their change. Encoded against the upcoming
// occurrence, not the ancient DTSTART anchor (wrong timezone offset). // occurrence, not the ancient DTSTART anchor (wrong timezone offset).
val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate()
insertReminderRows( insertReminderRows(
eventId, eventId,
managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes), managedAllDayReminderMinutes(utcDate(times.dtStartMillis), form.reminders, allDayReminderTimeMinutes),
) )
return eventId return eventId
} }
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) { override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
if (values.isEmpty()) return if (values.isEmpty()) return
val rows = resolver.update( updateEventRow(eventId, values.toContentValues(), "update managed event id=$eventId")
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(),
null, null,
)
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
} }
override fun applyManagedCalendarReminders( override fun applyManagedCalendarReminders(
@@ -573,38 +552,21 @@ class AndroidCalendarDataSource @Inject constructor(
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
minutes: List<Int>, minutes: List<Int>,
) { ) {
resolver.query(
CalendarContract.Events.CONTENT_URI,
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
// Only our own mirror events, so a stray user event in this calendar // Only our own mirror events, so a stray user event in this calendar
// isn't all-day-re-encoded or stripped of its own reminders. // isn't all-day-re-encoded or stripped of its own reminders.
"${CalendarContract.Events.CALENDAR_ID} = ? AND " + queryManagedEvents(calendarId).forEach { event ->
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.UID_2445} LIKE ?",
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
null,
)?.use { c ->
while (c.moveToNext()) {
val eventId = c.getLong(0)
// Managed events are all-day: DTSTART is a UTC midnight anchor. // Managed events are all-day: DTSTART is a UTC midnight anchor.
val anchor = Instant.ofEpochMilli(c.getLong(1))
.atZone(ZoneOffset.UTC).toLocalDate()
reconcileReminders( reconcileReminders(
eventId, event.eventId,
managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes), managedAllDayReminderMinutes(utcDate(event.dtStartMillis), minutes, allDayReminderTimeMinutes),
) )
} }
} }
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> { override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
ensureObserversRegistered() ensureObserversRegistered()
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
return resolver.query( return resolver.query(
uri, instancesUri(beginMillis, endMillis),
InstanceProjection.COLUMNS, InstanceProjection.COLUMNS,
// Hide cancelled occurrences: "delete only this event" writes a // Hide cancelled occurrences: "delete only this event" writes a
// cancelled exception for the one instance (#47). A NULL status is a // cancelled exception for the one instance (#47). A NULL status is a
@@ -617,7 +579,7 @@ class AndroidCalendarDataSource @Inject constructor(
"${CalendarContract.Events.DELETED} = 0", "${CalendarContract.Events.DELETED} = 0",
null, null,
CalendarContract.Instances.BEGIN + " ASC", CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList() )?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
} }
override fun searchEvents(query: String): List<SearchCandidate> { override fun searchEvents(query: String): List<SearchCandidate> {
@@ -721,13 +683,9 @@ class AndroidCalendarDataSource @Inject constructor(
endMillis: Long, endMillis: Long,
soonestFirst: Boolean, soonestFirst: Boolean,
): Pair<Long, Long>? { ): Pair<Long, Long>? {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC" val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC"
return resolver.query( return resolver.query(
uri, instancesUri(beginMillis, endMillis),
arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END), arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
"${CalendarContract.Instances.EVENT_ID} = ?", "${CalendarContract.Instances.EVENT_ID} = ?",
arrayOf(eventId.toString()), arrayOf(eventId.toString()),
@@ -741,7 +699,7 @@ class AndroidCalendarDataSource @Inject constructor(
val attendees = queryAttendees(eventId) val attendees = queryAttendees(eventId)
val reminders = queryReminders(eventId) val reminders = queryReminders(eventId)
return resolver.query( return resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), eventUri(eventId),
EventDetailProjection.COLUMNS, EventDetailProjection.COLUMNS,
null, null, null, null, null, null,
)?.use { c -> )?.use { c ->
@@ -752,9 +710,6 @@ class AndroidCalendarDataSource @Inject constructor(
} }
} }
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
publishedEventColors(calendarId).curatedForPicker()
override fun publishedEventColors(calendarId: Long): List<EventColorOption> { 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(
@@ -807,11 +762,10 @@ class AndroidCalendarDataSource @Inject constructor(
null, null,
CalendarContract.Events.DTSTART + " ASC", CalendarContract.Events.DTSTART + " ASC",
)?.use { c -> )?.use { c ->
c.mapAll { c.mapRows {
val reader = CursorColumnReader(c) val eventId = getLong(EventExportProjection.IDX_ID)
val eventId = reader.getLong(EventExportProjection.IDX_ID) val calendarId = getLong(EventExportProjection.IDX_CALENDAR_ID)
val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID) toIcsEvent(
reader.toIcsEvent(
reminderMinutes = queryReminders(eventId).map { it.minutes }, reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId], calendarName = names[calendarId],
allDayReminderTimeMinutes = allDayReminderTimeMinutes, allDayReminderTimeMinutes = allDayReminderTimeMinutes,
@@ -846,7 +800,7 @@ class AndroidCalendarDataSource @Inject constructor(
calendarId = calendarId, 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", uid = event.uid?.takeIf { it.isNotBlank() } ?: mintUid(),
palette = colorPalette, palette = colorPalette,
) )
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues()) val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
@@ -859,8 +813,7 @@ class AndroidCalendarDataSource @Inject constructor(
// (see [importedAllDayReminderDate]). // (see [importedAllDayReminderDate]).
val reminderDate = if (event.isAllDay) { val reminderDate = if (event.isAllDay) {
importedAllDayReminderDate( importedAllDayReminderDate(
startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds()) startDate = utcDate(event.start.toEpochMilliseconds()),
.atZone(ZoneOffset.UTC).toLocalDate(),
recurrenceRule = event.recurrenceRule, recurrenceRule = event.recurrenceRule,
today = LocalDate.now(zone), today = LocalDate.now(zone),
) )
@@ -878,24 +831,7 @@ class AndroidCalendarDataSource @Inject constructor(
} else { } else {
minutes minutes
} }
val reminder = ContentValues().apply { insertReminderRow(eventId, providerMinutes)
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, providerMinutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
// The event row is already in. A reminder that won't attach costs
// 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")
}
} }
return eventId return eventId
} }
@@ -951,18 +887,13 @@ class AndroidCalendarDataSource @Inject constructor(
// back to a stable synthesised UID at export time (deriveIcsUid). // back to a stable synthesised UID at export time (deriveIcsUid).
val values = buildEventInsertValues( val values = buildEventInsertValues(
form, form,
uid = "${UUID.randomUUID()}@calendula", uid = mintUid(),
times = form.toWriteTimes(ZoneId.systemDefault()), times = form.toWriteTimes(ZoneId.systemDefault()),
).toContentValues().apply { ) + eventColorColumns(form.colorKey, form.color)
// A null colour just leaves both columns unset (the event inherits // A new row has nothing to clear: unset columns inherit the
// its calendar's colour), so only the key/raw cases are written. // calendar's colour, so only the key/raw values are written.
when { .filterValues { it != null }
form.colorKey != null -> val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
put(CalendarContract.Events.EVENT_COLOR_KEY, form.colorKey)
form.color != null -> put(CalendarContract.Events.EVENT_COLOR, form.color)
}
}
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}") ?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
val eventId = ContentUris.parseId(uri) val eventId = ContentUris.parseId(uri)
seedReminders(eventId, form, allDayReminderTimeMinutes) seedReminders(eventId, form, allDayReminderTimeMinutes)
@@ -983,16 +914,32 @@ class AndroidCalendarDataSource @Inject constructor(
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */ /** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) { private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
encodedMinutes.forEach { minutes -> encodedMinutes.forEach { insertReminderRow(eventId, it) }
}
/**
* Best effort: the event row is already in, so a reminder that won't attach
* costs that one alarm, not the event. Throwing would report an event that
* was created as failed — and an import retry would then skip it as a
* duplicate.
*/
private fun insertReminderRow(
eventId: Long,
minutes: Int,
method: Int = CalendarContract.Reminders.METHOD_ALERT,
) {
val reminder = ContentValues().apply { val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId) put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes) put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) put(CalendarContract.Reminders.METHOD, method)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
} }
val attached = try {
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
} catch (e: Exception) {
Log.w(TAG, "Reminder insert threw for event $eventId", e)
null
} }
if (attached == null) Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
} }
/** /**
@@ -1029,12 +976,7 @@ class AndroidCalendarDataSource @Inject constructor(
zone = ZoneId.systemDefault(), zone = ZoneId.systemDefault(),
) )
if (values.isNotEmpty()) { if (values.isNotEmpty()) {
val rows = resolver.update( updateEventRow(eventId, values.toContentValues(), "update event id=$eventId")
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(),
null, null,
)
if (rows == 0) throw WriteFailedException("update event id=$eventId")
} }
// Untouched reminder sets are left alone so unrelated edits can't // Untouched reminder sets are left alone so unrelated edits can't
// disturb provider rows the form never knew about. The diff is on the // disturb provider rows the form never knew about. The diff is on the
@@ -1067,16 +1009,17 @@ class AndroidCalendarDataSource @Inject constructor(
?: throw WriteFailedException("read event to move id=$eventId") ?: throw WriteFailedException("read event to move id=$eventId")
// Keep the source UID so an .ics backup dedups and a sync adapter can // Keep the source UID so an .ics backup dedups and a sync adapter can
// recognise the moved event; mint one only if the source carried none. // recognise the moved event; mint one only if the source carried none.
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula" val uid = master.uid?.takeIf { it.isNotEmpty() } ?: mintUid()
val newEventId = resolver.insert( val newEventId = resolver.insert(
CalendarContract.Events.CONTENT_URI, CalendarContract.Events.CONTENT_URI,
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(), buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
)?.let(ContentUris::parseId) )?.let(ContentUris::parseId)
?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId") ?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId")
val detached = mutableListOf<Long>()
try { try {
copyReminderRows(fromEventId = eventId, toEventId = newEventId) copyReminderRows(fromEventId = eventId, toEventId = newEventId)
insertAttendees(newEventId, editableAttendees(eventId)) insertAttendees(newEventId, editableAttendees(eventId))
copyExceptions(fromEventId = eventId, toEventId = newEventId) copyExceptions(eventId, newEventId, targetCalendarId, allDayReminderTimeMinutes, detached)
// Apply the user's field edits exactly as a same-calendar "all // Apply the user's field edits exactly as a same-calendar "all
// events" save would: the moved master carries the source's values, // events" save would: the moved master carries the source's values,
// so the dirty diff against [original] writes only what changed // so the dirty diff against [original] writes only what changed
@@ -1086,6 +1029,7 @@ class AndroidCalendarDataSource @Inject constructor(
// Undo the partial copy so a failed move leaves nothing behind; the // Undo the partial copy so a failed move leaves nothing behind; the
// source is still intact (it's deleted only past this point). // source is still intact (it's deleted only past this point).
runCatching { deleteEvent(newEventId) } runCatching { deleteEvent(newEventId) }
detached.forEach(::rollBackDetached)
throw t throw t
} }
deleteEvent(eventId) deleteEvent(eventId)
@@ -1094,7 +1038,7 @@ class AndroidCalendarDataSource @Inject constructor(
/** The master row of the event to move, as a verbatim-insert snapshot. */ /** The master row of the event to move, as a verbatim-insert snapshot. */
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query( private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), eventUri(eventId),
MoveMasterProjection.COLUMNS, MoveMasterProjection.COLUMNS,
null, null, null, null, null, null,
)?.use { c -> )?.use { c ->
@@ -1106,8 +1050,7 @@ class AndroidCalendarDataSource @Inject constructor(
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(), title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0, isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART), dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND) dtEndMillis = r.getLongOrNull(MoveMasterProjection.IDX_DTEND),
.takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) },
duration = r.getString(MoveMasterProjection.IDX_DURATION), duration = r.getString(MoveMasterProjection.IDX_DURATION),
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }, rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
rdate = r.getString(MoveMasterProjection.IDX_RDATE), rdate = r.getString(MoveMasterProjection.IDX_RDATE),
@@ -1115,8 +1058,7 @@ class AndroidCalendarDataSource @Inject constructor(
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE), timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY), availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL), accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
status = r.getInt(MoveMasterProjection.IDX_STATUS) status = r.getIntOrNull(MoveMasterProjection.IDX_STATUS),
.takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) },
location = r.getString(MoveMasterProjection.IDX_LOCATION), location = r.getString(MoveMasterProjection.IDX_LOCATION),
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION), description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
), ),
@@ -1133,16 +1075,7 @@ class AndroidCalendarDataSource @Inject constructor(
arrayOf(fromEventId.toString()), arrayOf(fromEventId.toString()),
null, null,
)?.use { c -> )?.use { c ->
while (c.moveToNext()) { while (c.moveToNext()) insertReminderRow(toEventId, minutes = c.getInt(0), method = c.getInt(1))
val values = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, toEventId)
put(CalendarContract.Reminders.MINUTES, c.getInt(0))
put(CalendarContract.Reminders.METHOD, c.getInt(1))
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) {
Log.w(TAG, "Failed to copy a reminder to moved event $toEventId")
}
}
} ?: Unit } ?: Unit
/** /**
@@ -1162,44 +1095,33 @@ class AndroidCalendarDataSource @Inject constructor(
} }
/** /**
* Replay every exception of the series [fromEventId] against the moved series * Carry every exception of the series [fromEventId] over to the moved series
* [toEventId]. The copy preserved the recurrence skeleton, so each occurrence * [toEventId]. The moved row has no `_sync_id` yet, so exception rows would
* time still resolves: a cancelled occurrence is re-hidden with a cancelled * never attach to it ([deleteOccurrence], [detachOccurrence]): a cancelled
* exception, a modified one is re-inserted with its fields (then its reminders * occurrence becomes an EXDATE, a modified one an EXDATE plus a standalone
* reconciled and editable guests copied). No-op for a non-recurring event. * event on [targetCalendarId], its id added to [detached] for rollback.
*/ */
private fun copyExceptions(fromEventId: Long, toEventId: Long) { private fun copyExceptions(
queryExceptionRows(fromEventId).forEach { ex -> fromEventId: Long,
if (ex.isCancelled) { toEventId: Long,
val values = ContentValues().apply { targetCalendarId: Long,
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, ex.originalInstanceMillis) allDayReminderTimeMinutes: Int,
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED) detached: MutableList<Long>,
} ) {
resolver.insert( val exceptions = queryExceptionRows(fromEventId)
ContentUris.withAppendedId( if (exceptions.isEmpty()) return
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId, val zone = TimeZone.currentSystemDefault()
), exceptions.filterNot { it.isCancelled }.forEach { ex ->
values, val detail = eventDetail(ex.exceptionEventId, allDayReminderTimeMinutes)
) ?: throw WriteFailedException("copy cancelled occurrence to event id=$toEventId") ?: throw WriteFailedException("read modified occurrence id=${ex.exceptionEventId}")
} else { val endMillis = ex.dtEndMillis ?: (ex.dtStartMillis + parseRfc2445DurationMillis(ex.duration))
val uri = resolver.insert( val form = detail.toEditForm(ex.dtStartMillis, endMillis, zone)
ContentUris.withAppendedId( .copy(calendarId = targetCalendarId)
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId, .toDetachedOccurrence()
), detached += insertEvent(form, allDayReminderTimeMinutes)
buildCopiedExceptionValues(ex).toContentValues(),
) ?: throw WriteFailedException("copy modified occurrence to event id=$toEventId")
val newExceptionId = ContentUris.parseId(uri)
// The provider may clone the parent's reminders onto the new
// exception; reconcile to the source exception's exact set so
// they neither double nor drop. Same DTSTART → same all-day
// encoding, so the stored offsets match directly.
reconcileReminders(
newExceptionId,
queryReminders(ex.exceptionEventId).map { it.minutes },
)
insertAttendees(newExceptionId, editableAttendees(ex.exceptionEventId))
}
} }
val values = querySeriesRow(toEventId).exdateValues(exceptions.map { it.originalInstanceMillis })
updateEventRow(toEventId, values, "exdate moved exceptions event id=$toEventId")
} }
/** The series' exception rows (modified + cancelled), oldest occurrence first. */ /** The series' exception rows (modified + cancelled), oldest occurrence first. */
@@ -1210,26 +1132,14 @@ class AndroidCalendarDataSource @Inject constructor(
arrayOf(seriesEventId.toString()), arrayOf(seriesEventId.toString()),
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC", CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
)?.use { c -> )?.use { c ->
c.mapAll { c.mapRows {
val r = CursorColumnReader(c)
val status = r.getInt(ExceptionProjection.IDX_STATUS)
.takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) }
ExceptionRowSnapshot( ExceptionRowSnapshot(
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID), exceptionEventId = getLong(ExceptionProjection.IDX_ID),
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME), originalInstanceMillis = getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
isCancelled = status == CalendarContract.Events.STATUS_CANCELED, isCancelled = getIntOrNull(ExceptionProjection.IDX_STATUS) == CalendarContract.Events.STATUS_CANCELED,
status = status, dtStartMillis = getLong(ExceptionProjection.IDX_DTSTART),
title = r.getString(ExceptionProjection.IDX_TITLE).orEmpty(), dtEndMillis = getLongOrNull(ExceptionProjection.IDX_DTEND),
isAllDay = r.getInt(ExceptionProjection.IDX_ALL_DAY) != 0, duration = getString(ExceptionProjection.IDX_DURATION),
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART),
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND)
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) },
duration = r.getString(ExceptionProjection.IDX_DURATION),
timezone = r.getString(ExceptionProjection.IDX_EVENT_TIMEZONE),
availability = r.getInt(ExceptionProjection.IDX_AVAILABILITY),
accessLevel = r.getInt(ExceptionProjection.IDX_ACCESS_LEVEL),
location = r.getString(ExceptionProjection.IDX_LOCATION),
description = r.getString(ExceptionProjection.IDX_DESCRIPTION),
) )
} }
} ?: emptyList() } ?: emptyList()
@@ -1266,7 +1176,7 @@ class AndroidCalendarDataSource @Inject constructor(
val exceptionId = ContentUris.parseId(uri) val exceptionId = ContentUris.parseId(uri)
form.colorKey?.let { key -> form.colorKey?.let { key ->
resolver.update( resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, exceptionId), eventUri(exceptionId),
ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) }, ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) },
null, null, null, null,
) )
@@ -1320,34 +1230,19 @@ class AndroidCalendarDataSource @Inject constructor(
// a fresh UID — the detached row is a separate event now, and sharing the // a fresh UID — the detached row is a separate event now, and sharing the
// parent's would collide with it in .ics restore dedup. // parent's would collide with it in .ics restore dedup.
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes) 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 // Rows touched, not occurrences excluded — 1 whenever the series row still
// exists. It catches the row disappearing under us, not an EXDATE the // exists. It catches the row disappearing under us, not an EXDATE the
// provider's expansion fails to match. // provider's expansion fails to match.
val updatedRows = try { try {
resolver.update( updateEventRow(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), eventId,
values.toContentValues(), null, null, row.exdateValues(listOf(beginMillis)),
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
) )
} catch (t: Throwable) { } catch (t: Throwable) {
rollBackDetached(detachedId) rollBackDetached(detachedId)
throw t throw t
} }
if (updatedRows == 0) {
rollBackDetached(detachedId)
throw WriteFailedException(
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
)
}
return detachedId return detachedId
} }
@@ -1420,23 +1315,11 @@ class AndroidCalendarDataSource @Inject constructor(
updated = updated, updated = updated,
zone = ZoneId.systemDefault(), zone = ZoneId.systemDefault(),
) ?: return ) ?: return
val row = querySeriesRow(newEventId) val values = querySeriesRow(newEventId).timeColumns().apply {
val values = ContentValues().apply {
put(CalendarContract.Events.EXDATE, carried) 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 { try {
val rows = resolver.update( updateEventRow(newEventId, values, "carry exdate onto split series id=$newEventId")
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) { } catch (t: Throwable) {
runCatching { deleteEvent(newEventId) } runCatching { deleteEvent(newEventId) }
throw t throw t
@@ -1464,29 +1347,18 @@ class AndroidCalendarDataSource @Inject constructor(
*/ */
private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) { private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) {
requireNotNull(row.rrule) { "truncateSeries needs a recurring row" } requireNotNull(row.rrule) { "truncateSeries needs a recurring row" }
val values = ContentValues().apply { val values = row.timeColumns().apply {
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
put(CalendarContract.Events.DURATION, row.duration)
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
put(CalendarContract.Events.ALL_DAY, row.allDay)
put( put(
CalendarContract.Events.RRULE, CalendarContract.Events.RRULE,
rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)), rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)),
) )
} }
val rows = resolver.update( updateEventRow(eventId, values, "truncate series event id=$eventId begin=$beginMillis")
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values,
null, null,
)
if (rows == 0) {
throw WriteFailedException("truncate series event id=$eventId begin=$beginMillis")
}
} }
/** The series anchor: every time-related column of the Events row. */ /** The series anchor: every time-related column of the Events row. */
private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query( private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), eventUri(eventId),
arrayOf( arrayOf(
CalendarContract.Events.DTSTART, CalendarContract.Events.DTSTART,
CalendarContract.Events.RRULE, CalendarContract.Events.RRULE,
@@ -1534,6 +1406,30 @@ class AndroidCalendarDataSource @Inject constructor(
*/ */
val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank() val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
/**
* The time/recurrence set a series write carries together: the provider
* re-expands instances only from what the update itself holds.
*/
fun timeColumns(): ContentValues = ContentValues().apply {
put(CalendarContract.Events.DTSTART, dtStartMillis)
put(CalendarContract.Events.RRULE, rrule)
put(CalendarContract.Events.DURATION, duration)
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
put(CalendarContract.Events.ALL_DAY, allDay)
}
/** This row's columns with [occurrences] added to EXDATE ([buildOccurrencesExdateValues]). */
fun exdateValues(occurrences: List<Long>): Map<String, Any?> = buildOccurrencesExdateValues(
existingExdate = exdate,
occurrences = occurrences,
dtStartMillis = dtStartMillis,
rrule = rrule,
rdate = rdate,
duration = duration,
timezone = timezone,
allDay = allDay,
)
/** 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,
@@ -1559,20 +1455,10 @@ class AndroidCalendarDataSource @Inject constructor(
arrayOf(eventId.toString(), minutes.toString()), arrayOf(eventId.toString(), minutes.toString()),
) )
} }
(target - existing).forEach { minutes -> insertReminderRows(eventId, (target - existing).toList())
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
} }
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */ private fun EventAttendee.key(): String = emailKey(email)
private fun EventAttendee.key(): String = email.trim().lowercase()
/** Distinct guests by email; blanks dropped (we key and write on the address). */ /** Distinct guests by email; blanks dropped (we key and write on the address). */
private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> { private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> {
@@ -1624,7 +1510,7 @@ class AndroidCalendarDataSource @Inject constructor(
val relationship: Int, val relationship: Int,
val type: Int, val type: Int,
) { ) {
val key: String get() = email.trim().lowercase() val key: String get() = emailKey(email)
} }
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query( private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
@@ -1689,17 +1575,20 @@ class AndroidCalendarDataSource @Inject constructor(
) )
} }
} }
target.filterNot { it.key() in editableKeys }.forEach { attendee -> insertAttendees(eventId, target.filterNot { it.key() in editableKeys })
if (resolver.insert(
CalendarContract.Attendees.CONTENT_URI,
attendeeValues(eventId, attendee),
) == null
) {
Log.w(TAG, "Failed to attach a guest to event $eventId")
}
} }
private fun eventUri(eventId: Long): Uri =
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
/** Update the Events row [eventId]; [what] names the write when no row was touched. */
private fun updateEventRow(eventId: Long, values: ContentValues, what: String) {
if (resolver.update(eventUri(eventId), values, null, null) == 0) throw WriteFailedException(what)
} }
private fun updateEventRow(eventId: Long, values: Map<String, Any?>, what: String) =
updateEventRow(eventId, values.toContentValues(), what)
private fun Map<String, Any?>.toContentValues(): ContentValues = private fun Map<String, Any?>.toContentValues(): ContentValues =
ContentValues().also { cv -> ContentValues().also { cv ->
forEach { (column, value) -> forEach { (column, value) ->
@@ -1716,7 +1605,7 @@ class AndroidCalendarDataSource @Inject constructor(
override fun deleteEvent(eventId: Long) { override fun deleteEvent(eventId: Long) {
val deleted = resolver.delete( val deleted = resolver.delete(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), eventUri(eventId),
null, null, null, null,
) )
if (deleted == 0) throw WriteFailedException("delete event id=$eventId") if (deleted == 0) throw WriteFailedException("delete event id=$eventId")
@@ -1738,23 +1627,11 @@ class AndroidCalendarDataSource @Inject constructor(
// occurrence with it (#47 on a local calendar). EXDATE needs no link. // occurrence with it (#47 on a local calendar). EXDATE needs no link.
// Calendula's own contact special-date calendars are local and hold // Calendula's own contact special-date calendars are local and hold
// yearly series, so this path is reached in normal use. // yearly series, so this path is reached in normal use.
val values = buildOccurrenceExdateValues( updateEventRow(
existingExdate = row.exdate, eventId,
occurrenceMillis = beginMillis, row.exdateValues(listOf(beginMillis)),
dtStartMillis = row.dtStartMillis, "exdate occurrence event id=$eventId begin=$beginMillis",
rrule = row.rrule,
rdate = row.rdate,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
) )
val updated = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(), null, null,
)
if (updated == 0) {
throw WriteFailedException("exdate occurrence event id=$eventId begin=$beginMillis")
}
return return
} }
// A cancelled exception row hides exactly this occurrence; the sync // A cancelled exception row hides exactly this occurrence; the sync
@@ -1804,7 +1681,7 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Attendees.EVENT_ID + " = ?", CalendarContract.Attendees.EVENT_ID + " = ?",
arrayOf(eventId.toString()), arrayOf(eventId.toString()),
null, null,
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList() )?.use { c -> c.mapRows { toAttendee() } } ?: emptyList()
private fun queryReminders(eventId: Long): List<Reminder> = resolver.query( private fun queryReminders(eventId: Long): List<Reminder> = resolver.query(
CalendarContract.Reminders.CONTENT_URI, CalendarContract.Reminders.CONTENT_URI,
@@ -1812,19 +1689,15 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Reminders.EVENT_ID + " = ?", CalendarContract.Reminders.EVENT_ID + " = ?",
arrayOf(eventId.toString()), arrayOf(eventId.toString()),
null, null,
)?.use { c -> c.mapAll { CursorColumnReader(c).toReminder() } } ?: emptyList() )?.use { c -> c.mapRows { toReminder() } } ?: emptyList()
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
/** Iterate every row and map; skips nothing. */ /** Iterate every row and map; skips nothing. */
private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList { private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
while (moveToNext()) add(mapper(this@mapAll)) while (moveToNext()) add(mapper(this@mapAll))
} }
/** Iterate every row and map; drops nulls. */ /** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList { private fun mintUid(): String = "${UUID.randomUUID()}@calendula"
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
}
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */ /** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
private fun markerFor(type: SpecialDateType): String = when (type) { private fun markerFor(type: SpecialDateType): String = when (type) {
@@ -1840,6 +1713,9 @@ class AndroidCalendarDataSource @Inject constructor(
private companion object { private companion object {
const val TAG = "CalendarDataSource" const val TAG = "CalendarDataSource"
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
fun emailKey(email: String): String = email.trim().lowercase()
/** /**
* Shared account for every app-created local calendar, so they group * Shared account for every app-created local calendar, so they group
* together (by account) in the filter sheet and calendar manager. * together (by account) in the filter sheet and calendar manager.
@@ -29,7 +29,7 @@ interface CalendarRepository {
/** /**
* The event-colour palette a calendar's account publishes; empty when it * The event-colour palette a calendar's account publishes; empty when it
* exposes none (see [CalendarDataSource.eventColorPalette]). * exposes none (see [CalendarDataSource.publishedEventColors]).
*/ */
suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> suspend fun eventColorPalette(calendarId: Long): List<EventColorOption>
@@ -1,10 +1,11 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.util.Log import android.util.Log
import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.floret.time.toEpochMillis import de.jeanlucmakiola.floret.time.toEpochMillis
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
@@ -125,8 +126,7 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis = range.start.toEpochMillis(), beginMillis = range.start.toEpochMillis(),
endMillis = range.endInclusive.toEpochMillis(), endMillis = range.endInclusive.toEpochMillis(),
), ),
switchedOffCalendarIds = invisibleCalendarIds() + switchedOffCalendarIds = switchedOffCalendarIds(),
prefs.pendingDisabledCalendarIds.first(),
) )
}, },
prefs.hiddenCalendarIds, prefs.hiddenCalendarIds,
@@ -151,6 +151,10 @@ class CalendarRepositoryImpl @Inject constructor(
.filterNot { it.isVisibleInSystem } .filterNot { it.isVisibleInSystem }
.mapTo(mutableSetOf()) { it.id } .mapTo(mutableSetOf()) { it.id }
/** [invisibleCalendarIds] plus the switch-offs still waiting to be written. */
private suspend fun switchedOffCalendarIds(): Set<Long> =
invisibleCalendarIds() + prefs.pendingDisabledCalendarIds.first()
private val calendarsLock = Mutex() private val calendarsLock = Mutex()
private var cachedGeneration = -1L private var cachedGeneration = -1L
private var cachedPending: Set<Long>? = null private var cachedPending: Set<Long>? = null
@@ -184,15 +188,13 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) { override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
if (query.isBlank()) return@withContext emptyList() if (query.isBlank()) return@withContext emptyList()
val excluded = prefs.hiddenCalendarIds.first() + val excluded = prefs.hiddenCalendarIds.first() + switchedOffCalendarIds()
prefs.pendingDisabledCalendarIds.first() +
invisibleCalendarIds()
dataSource.searchEvents(query) dataSource.searchEvents(query)
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } } .let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
} }
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> = override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
withContext(io) { dataSource.eventColorPalette(calendarId) } withContext(io) { dataSource.publishedEventColors(calendarId).curatedForPicker() }
override suspend fun createLocalCalendar( override suspend fun createLocalCalendar(
displayName: String, displayName: String,
@@ -214,16 +216,15 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) = override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
write { write {
if (dataSource.canWriteCalendars()) { val canWrite = dataSource.canWriteCalendars()
ids.forEach { dataSource.setCalendarVisible(it, visible) } if (canWrite) ids.forEach { dataSource.setCalendarVisible(it, visible) }
// Nothing of ours is left waiting for the provider once the // Nothing of ours is left waiting for the provider once the write
// write lands (and switching one back on retires its entry). // lands, and switching one back on retires its entry. With read-only
prefs.removePendingDisabledCalendarIds(ids) // permission a switch-off still works, app-side, and the reconciler
} else if (visible) { // flushes it if WRITE_CALENDAR ever arrives.
if (canWrite || visible) {
prefs.removePendingDisabledCalendarIds(ids) prefs.removePendingDisabledCalendarIds(ids)
} else { } else {
// Read-only permission: the switch still works, app-side, and
// the reconciler flushes it if WRITE_CALENDAR ever arrives.
prefs.addPendingDisabledCalendarIds(ids) prefs.addPendingDisabledCalendarIds(ids)
} }
} }
@@ -4,15 +4,15 @@ import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.EntryPoint import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
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.calendarVisibilityPlan import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -47,7 +47,7 @@ class CalendarVisibilityReconciler @Inject constructor(
// Settled ahead of the permission gate, so an update installed // Settled ahead of the permission gate, so an update installed
// before the first grant can't later look like an upgrade. // before the first grant can't later look like an upgrade.
if (!isUpgradeInstall()) settleNoticeOnce(pending = false) if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext if (!context.hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
val pending = prefs.pendingDisabledCalendarIds.first() val pending = prefs.pendingDisabledCalendarIds.first()
val noticeSettled = prefs.visibilityNoticePending.first() != null val noticeSettled = prefs.visibilityNoticePending.first() != null
if (pending.isEmpty() && noticeSettled) return@withContext if (pending.isEmpty() && noticeSettled) return@withContext
@@ -56,7 +56,7 @@ class CalendarVisibilityReconciler @Inject constructor(
// Both decisions below are one-way, so leave them to the next run. // Both decisions below are one-way, so leave them to the next run.
if (calendars.isEmpty()) return@withContext if (calendars.isEmpty()) return@withContext
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending)) settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) { if (pending.isEmpty() || !context.hasPermission(Manifest.permission.WRITE_CALENDAR)) {
return@withContext return@withContext
} }
val plan = calendarVisibilityPlan(calendars, pending) val plan = calendarVisibilityPlan(calendars, pending)
@@ -97,9 +97,6 @@ class CalendarVisibilityReconciler @Inject constructor(
false false
} }
private fun hasPermission(permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
/** Lets non-injectable entry points (the Application) reach the reconciler. */ /** Lets non-injectable entry points (the Application) reach the reconciler. */
@EntryPoint @EntryPoint
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -1,6 +1,11 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.database.Cursor import android.database.Cursor
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
/** /**
* Read-only view over a single row's columns by index. Lets the mappers work * Read-only view over a single row's columns by index. Lets the mappers work
@@ -20,3 +25,33 @@ internal class CursorColumnReader(private val cursor: Cursor) : ColumnReader {
override fun getInt(index: Int): Int = cursor.getInt(index) override fun getInt(index: Int): Int = cursor.getInt(index)
override fun isNull(index: Int): Boolean = cursor.isNull(index) override fun isNull(index: Int): Boolean = cursor.isNull(index)
} }
internal fun ColumnReader.getIntOrNull(index: Int): Int? = if (isNull(index)) null else getInt(index)
internal fun ColumnReader.getLongOrNull(index: Int): Long? = if (isNull(index)) null else getLong(index)
/**
* The row's end: DTEND, or DTSTART + DURATION for a recurring row, which stores
* no DTEND. Unclamped; a missing/blank both means a zero-length event.
*/
internal fun ColumnReader.endMillis(dtStartMillis: Long, dtEndIndex: Int, durationIndex: Int): Long =
getLongOrNull(dtEndIndex) ?: (dtStartMillis + parseRfc2445DurationMillis(getString(durationIndex)))
/** STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed. */
internal fun ColumnReader.eventStatus(index: Int): EventStatus =
getIntOrNull(index)?.let(::mapEventStatus) ?: EventStatus.Confirmed
/** Map every row, reading each through one [CursorColumnReader]. */
internal inline fun <T> Cursor.mapRows(map: ColumnReader.() -> T): List<T> {
val reader = CursorColumnReader(this)
return buildList { while (moveToNext()) add(reader.map()) }
}
/** [mapRows], dropping the rows [map] returns null for. */
internal inline fun <T : Any> Cursor.mapRowsNotNull(map: ColumnReader.() -> T?): List<T> {
val reader = CursorColumnReader(this)
return buildList { while (moveToNext()) reader.map()?.let(::add) }
}
/** The UTC calendar day of [millis] — an all-day row's date, since it sits at UTC midnight. */
internal fun utcDate(millis: Long): LocalDate = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
@@ -15,9 +15,7 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod import de.jeanlucmakiola.calendula.domain.ReminderMethod
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.ZoneId
import java.time.ZoneOffset
private const val TAG = "EventDetailMapper" private const val TAG = "EventDetailMapper"
@@ -48,11 +46,8 @@ internal fun ColumnReader.toEventDetailCore(
// — the same trap as the pre-1970 DTSTART bug above (issue #34): it would // — the same trap as the pre-1970 DTSTART bug above (issue #34): it would
// surface as the generic error screen with no way to open the event and fix // surface as the generic error screen with no way to open the event and fix
// it. Clamp to a zero-length event instead (matching SearchMapper). // it. Clamp to a zero-length event instead (matching SearchMapper).
val end = if (isNull(EventDetailProjection.IDX_DTEND)) { val end = getLongOrNull(EventDetailProjection.IDX_DTEND)?.coerceAtLeast(begin)
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION)) ?: (begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION)))
} else {
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
}
// Kept raw (no untitled fallback): the detail screen substitutes its own // Kept raw (no untitled fallback): the detail screen substitutes its own
// localized placeholder, and the edit form must prefill the true value. // localized placeholder, and the edit form must prefill the true value.
@@ -61,11 +56,7 @@ internal fun ColumnReader.toEventDetailCore(
// The event's own colour (null = inherits the calendar's) is kept apart // The event's own colour (null = inherits the calendar's) is kept apart
// from the resolved display colour: the edit form needs to tell the two // from the resolved display colour: the edit form needs to tell the two
// cases apart, while the instance carries the calendar fallback for display. // cases apart, while the instance carries the calendar fallback for display.
val eventColor = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) { val eventColor = getIntOrNull(EventDetailProjection.IDX_EVENT_COLOR)
null
} else {
getInt(EventDetailProjection.IDX_EVENT_COLOR)
}
val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY) val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY)
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR) val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
@@ -88,7 +79,7 @@ internal fun ColumnReader.toEventDetailCore(
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the // and detail screen speak. DTSTART is UTC midnight for all-day events, so the
// event's date is its UTC date. // event's date is its UTC date.
val displayReminders = if (isAllDay) { val displayReminders = if (isAllDay) {
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate() val startDate = utcDate(begin)
val zone = ZoneId.systemDefault() val zone = ZoneId.systemDefault()
reminders.map { reminders.map {
it.copy( it.copy(
@@ -101,14 +92,6 @@ internal fun ColumnReader.toEventDetailCore(
reminders reminders
} }
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
// be distinguished from a present 0 — an absent status means "just confirmed".
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventDetailProjection.IDX_STATUS))
}
return EventDetail( return EventDetail(
instance = instance, instance = instance,
description = getString(EventDetailProjection.IDX_DESCRIPTION), description = getString(EventDetailProjection.IDX_DESCRIPTION),
@@ -116,7 +99,7 @@ internal fun ColumnReader.toEventDetailCore(
attendees = attendees, attendees = attendees,
rrule = getString(EventDetailProjection.IDX_RRULE), rrule = getString(EventDetailProjection.IDX_RRULE),
reminders = displayReminders, reminders = displayReminders,
status = status, status = eventStatus(EventDetailProjection.IDX_STATUS),
// BUSY and DEFAULT are both 0, so a null column folds into the same // BUSY and DEFAULT are both 0, so a null column folds into the same
// default these mappers already return — no isNull guard needed. // default these mappers already return — no isNull guard needed.
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)), availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
@@ -1,15 +1,19 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.instantIn
import kotlinx.datetime.toKotlinTimeZone
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.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.nearestTo import de.jeanlucmakiola.calendula.domain.nearestTo
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toJavaZoneOffset
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
@@ -48,8 +52,8 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
} else { } else {
val writeZone = writeZone(zone) val writeZone = writeZone(zone)
EventWriteTimes( EventWriteTimes(
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), dtStartMillis = start.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(), dtEndMillis = end.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
timezone = writeZone.id, timezone = writeZone.id,
) )
} }
@@ -75,7 +79,7 @@ private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) { private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
start.date.toJavaLocalDate().atStartOfDay() start.date.toJavaLocalDate().atStartOfDay()
} else { } else {
start.toJavaLocalDateTime() start.dateTime.toJavaLocalDateTime()
} }
/** /**
@@ -181,7 +185,8 @@ internal fun buildEventUpdateValues(
// A zone change counts as a time change even when the wall-clock is // A zone change counts as a time change even when the wall-clock is
// untouched: the same 09:00 in another zone is a different instant, so // untouched: the same 09:00 in another zone is a different instant, so
// DTSTART has to move with it. // DTSTART has to move with it. So does a switch between the two passes of
// the fall-back hour, which each [FormTime] carries (#387).
val timesChanged = updated.start != original.start || val timesChanged = updated.start != original.start ||
updated.end != original.end || updated.end != original.end ||
updated.isAllDay != original.isAllDay || updated.isAllDay != original.isAllDay ||
@@ -209,15 +214,23 @@ internal fun buildEventUpdateValues(
// is a different offset from the series anchor's whenever a DST boundary // is a different offset from the series anchor's whenever a DST boundary
// sits between them, or whenever the zone itself changed. Working in wall // sits between them, or whenever the zone itself changed. Working in wall
// clock keeps "09:00" meaning 09:00 at both ends. // clock keeps "09:00" meaning 09:00 at both ends.
val seriesLocal = Instant.ofEpochMilli(seriesDtStartMillis) val seriesZoned = Instant.ofEpochMilli(seriesDtStartMillis).atZone(original.writeZone(zone))
.atZone(original.writeZone(zone)).toLocalDateTime() val seriesLocal = seriesZoned.toLocalDateTime()
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal()) val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
val shifted = seriesLocal.plus(wallClockShift) val shifted = seriesLocal.plus(wallClockShift)
// An all-day series anchor must sit on a UTC midnight. A pure day move // An all-day series anchor must sit on a UTC midnight. A pure day move
// already lands there (both ends are midnights), but *switching* a // already lands there (both ends are midnights), but *switching* a
// recurring event to all-day shifts by a time-of-day too, so snap. // recurring event to all-day shifts by a time-of-day too, so snap.
val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted
val newSeriesStart = newSeriesLocal.atZone(updated.writeZone(zone)) // A wall clock the fall-back hour repeats keeps the anchor's own pass, or,
// when the anchor is the edited occurrence itself, the pass the form now
// holds: a bare wall clock would always take the earlier one (#387).
val pass = if (seriesLocal == original.anchorLocal()) {
updated.start.offset?.toJavaZoneOffset()
} else {
seriesZoned.offset
}
val newSeriesStart = ZonedDateTime.ofLocal(newSeriesLocal, updated.writeZone(zone), pass)
put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli()) put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli())
put(CalendarContract.Events.DTEND, null) put(CalendarContract.Events.DTEND, null)
put(CalendarContract.Events.RRULE, updated.rrule) put(CalendarContract.Events.RRULE, updated.rrule)
@@ -359,48 +372,11 @@ internal data class ExceptionRowSnapshot(
val exceptionEventId: Long, val exceptionEventId: Long,
val originalInstanceMillis: Long, val originalInstanceMillis: Long,
val isCancelled: Boolean, val isCancelled: Boolean,
val status: Int?,
val title: String,
val isAllDay: Boolean,
val dtStartMillis: Long, val dtStartMillis: Long,
val dtEndMillis: Long?, val dtEndMillis: Long?,
val duration: String?, val duration: String?,
val timezone: String?,
val availability: Int,
val accessLevel: Int,
val location: String?,
val description: String?,
) )
/**
* Column values replaying a *modified* occurrence [snapshot] against the moved
* series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the
* length travels as DURATION (the provider rejects DTEND on an exception). A
* cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME +
* STATUS_CANCELED) — this builder is only for the modified case.
*/
internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map<String, Any?> =
buildMap {
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis)
put(CalendarContract.Events.TITLE, snapshot.title)
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
put(
CalendarContract.Events.DURATION,
snapshot.duration?.takeIf { it.isNotBlank() }
?: rfc2445Duration(
(snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis,
snapshot.isAllDay,
),
)
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
}
/** /**
* Column values for a *cancelled*-occurrence exception row ("delete only this * Column values for a *cancelled*-occurrence exception row ("delete only this
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the * event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
@@ -466,9 +442,24 @@ internal fun buildOccurrenceExdateValues(
duration: String?, duration: String?,
timezone: String?, timezone: String?,
allDay: Int, allDay: Int,
): Map<String, Any?> = buildOccurrencesExdateValues(
existingExdate, listOf(occurrenceMillis), dtStartMillis, rrule, rdate, duration, timezone, allDay,
)
/** [buildOccurrenceExdateValues] excluding every one of [occurrences] in one write. */
internal fun buildOccurrencesExdateValues(
existingExdate: String?,
occurrences: List<Long>,
dtStartMillis: Long,
rrule: String?,
rdate: String?,
duration: String?,
timezone: String?,
allDay: Int,
): Map<String, Any?> = mapOf( ): Map<String, Any?> = mapOf(
CalendarContract.Events.EXDATE to CalendarContract.Events.EXDATE to occurrences.fold(existingExdate) { exdate, millis ->
mergedExdate(existingExdate, occurrenceMillis, allDay != 0, timezone), mergedExdate(exdate, millis, allDay != 0, timezone)
},
CalendarContract.Events.DTSTART to dtStartMillis, CalendarContract.Events.DTSTART to dtStartMillis,
CalendarContract.Events.RRULE to rrule, CalendarContract.Events.RRULE to rrule,
CalendarContract.Events.RDATE to rdate, CalendarContract.Events.RDATE to rdate,
@@ -618,7 +609,7 @@ internal fun exdateAfter(
* optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of * optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
* a zoned one ([exdateListZone] reads that half). * a zoned one ([exdateListZone] reads that half).
*/ */
private fun exdateStamps(exdate: String?): List<String> = exdate internal fun exdateStamps(exdate: String?): List<String> = exdate
?.withoutExdateParameters() ?.withoutExdateParameters()
?.split(',') ?.split(',')
?.map { it.trim() } ?.map { it.trim() }
@@ -888,7 +879,7 @@ internal fun buildImportedEventValues(
*/ */
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String { private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
val span = (endMillis - startMillis).coerceAtLeast(0) val span = (endMillis - startMillis).coerceAtLeast(0)
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S" return rfc2445Duration(if (isAllDay) span.coerceAtLeast(MILLIS_PER_DAY) else span, isAllDay)
} }
private fun EventStatus.toProviderStatus(): Int = when (this) { private fun EventStatus.toProviderStatus(): Int = when (this) {
@@ -1,13 +1,9 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import 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 java.time.Instant
import java.time.ZoneId 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]
@@ -33,19 +29,8 @@ internal fun ColumnReader.toIcsEvent(
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 val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it // Unclamped: the writer renders DTEND as stored.
// so the writer can render DTEND. A missing/blank both means a zero-length event. val end = endMillis(dtStart, EventExportProjection.IDX_DTEND, EventExportProjection.IDX_DURATION)
val end = when {
!isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION))
}
// STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed.
val status = if (isNull(EventExportProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventExportProjection.IDX_STATUS))
}
return IcsEvent( return IcsEvent(
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart), uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
@@ -68,14 +53,14 @@ internal fun ColumnReader.toIcsEvent(
location = getString(EventExportProjection.IDX_LOCATION), location = getString(EventExportProjection.IDX_LOCATION),
description = getString(EventExportProjection.IDX_DESCRIPTION), description = getString(EventExportProjection.IDX_DESCRIPTION),
reminderMinutes = if (isAllDay) { reminderMinutes = if (isAllDay) {
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate() val startDate = utcDate(dtStart)
reminderMinutes.map { reminderMinutes.map {
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes) fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
} }
} else { } else {
reminderMinutes reminderMinutes
}, },
status = status, status = eventStatus(EventExportProjection.IDX_STATUS),
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)), availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName, calendarName = calendarName,
) )
@@ -112,11 +97,7 @@ internal fun exportExDates(
): List<String> { ): List<String> {
if (exdate.isNullOrBlank()) return emptyList() if (exdate.isNullOrBlank()) return emptyList()
val zone = exdateListZone(exdate, eventTimezone) val zone = exdateListZone(exdate, eventTimezone)
return exdate return exdateStamps(exdate)
.withoutExdateParameters()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { stamp -> .map { stamp ->
if (isAllDay) { if (isAllDay) {
stamp.substringBefore('T') stamp.substringBefore('T')
@@ -25,11 +25,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
// string, so it belongs to the UI layer where it can be localised (#321). // string, so it belongs to the UI layer where it can be localised (#321).
val title = getString(InstanceProjection.IDX_TITLE).orEmpty() val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) { val color = getIntOrNull(InstanceProjection.IDX_EVENT_COLOR) ?: getInt(InstanceProjection.IDX_CALENDAR_COLOR)
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
} else {
getInt(InstanceProjection.IDX_EVENT_COLOR)
}
return EventInstance( return EventInstance(
instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID), instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID),
@@ -1,7 +1,16 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.content.ContentUris
import android.net.Uri
import android.provider.CalendarContract import android.provider.CalendarContract
/** The `Instances` table over the window [beginMillis]..[endMillis]. */
internal fun instancesUri(beginMillis: Long, endMillis: Long): Uri =
CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
internal object CalendarProjection { internal object CalendarProjection {
val COLUMNS: Array<String> = arrayOf( val COLUMNS: Array<String> = arrayOf(
CalendarContract.Calendars._ID, CalendarContract.Calendars._ID,
@@ -251,40 +260,26 @@ internal object MoveMasterProjection {
/** /**
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to * The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
* replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified * carry them over to a moved copy (see [ExceptionRowSnapshot]). Both modified
* occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters * occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters
* `DELETED = 0` so provider tombstones aren't replayed. * `DELETED = 0` so provider tombstones aren't carried over.
*/ */
internal object ExceptionProjection { internal object ExceptionProjection {
val COLUMNS: Array<String> = arrayOf( val COLUMNS: Array<String> = arrayOf(
CalendarContract.Events._ID, CalendarContract.Events._ID,
CalendarContract.Events.ORIGINAL_INSTANCE_TIME, CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
CalendarContract.Events.STATUS, CalendarContract.Events.STATUS,
CalendarContract.Events.TITLE,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.DTSTART, CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND, CalendarContract.Events.DTEND,
CalendarContract.Events.DURATION, CalendarContract.Events.DURATION,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.AVAILABILITY,
CalendarContract.Events.ACCESS_LEVEL,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.DESCRIPTION,
) )
const val IDX_ID = 0 const val IDX_ID = 0
const val IDX_ORIGINAL_INSTANCE_TIME = 1 const val IDX_ORIGINAL_INSTANCE_TIME = 1
const val IDX_STATUS = 2 const val IDX_STATUS = 2
const val IDX_TITLE = 3 const val IDX_DTSTART = 3
const val IDX_ALL_DAY = 4 const val IDX_DTEND = 4
const val IDX_DTSTART = 5 const val IDX_DURATION = 5
const val IDX_DTEND = 6
const val IDX_DURATION = 7
const val IDX_EVENT_TIMEZONE = 8
const val IDX_AVAILABILITY = 9
const val IDX_ACCESS_LEVEL = 10
const val IDX_LOCATION = 11
const val IDX_DESCRIPTION = 12
} }
internal object AttendeeProjection { internal object AttendeeProjection {
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.data.calendar
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
/** /**
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the * Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
@@ -16,17 +15,11 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
// long-running birthdays/anniversaries still surface in search. // long-running birthdays/anniversaries still surface in search.
if (isNull(SearchProjection.IDX_DTSTART)) return null if (isNull(SearchProjection.IDX_DTSTART)) return null
val dtStart = getLong(SearchProjection.IDX_DTSTART) val dtStart = getLong(SearchProjection.IDX_DTSTART)
val end = when { val end = endMillis(dtStart, SearchProjection.IDX_DTEND, SearchProjection.IDX_DURATION)
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND) .coerceAtLeast(dtStart)
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
}.coerceAtLeast(dtStart)
val title = getString(SearchProjection.IDX_TITLE).orEmpty() val title = getString(SearchProjection.IDX_TITLE).orEmpty()
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) { val color = getIntOrNull(SearchProjection.IDX_EVENT_COLOR) ?: getInt(SearchProjection.IDX_CALENDAR_COLOR)
getInt(SearchProjection.IDX_CALENDAR_COLOR)
} else {
getInt(SearchProjection.IDX_EVENT_COLOR)
}
val eventId = getLong(SearchProjection.IDX_ID) val eventId = getLong(SearchProjection.IDX_ID)
return EventInstance( return EventInstance(
@@ -2,16 +2,15 @@ package de.jeanlucmakiola.calendula.data.contacts
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.provider.ContactsContract import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.Event import android.provider.ContactsContract.CommonDataKinds.Event
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.contacts.managedUid import de.jeanlucmakiola.calendula.domain.contacts.managedUid
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
import de.jeanlucmakiola.floret.android.hasPermission
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -31,8 +30,7 @@ interface ContactSpecialDatesDataSource {
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */ /** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
fun Context.hasContactsPermission(): Boolean = fun Context.hasContactsPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) == hasPermission(Manifest.permission.READ_CONTACTS)
PackageManager.PERMISSION_GRANTED
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */ /** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) { internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
@@ -212,8 +213,8 @@ class SpecialDatesSyncEngine @Inject constructor(
calendarId = calendarId, calendarId = calendarId,
title = title, title = title,
isAllDay = true, isAllDay = true,
start = start, start = FormTime(start),
end = start, end = FormTime(start),
reminders = reminderCtx.resolveAllDay(calendarId), reminders = reminderCtx.resolveAllDay(calendarId),
availability = Availability.Free, availability = Availability.Free,
rrule = YEARLY_RRULE, rrule = YEARLY_RRULE,
@@ -20,8 +20,10 @@ import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
import kotlinx.coroutines.CoroutineDispatcher import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
import kotlinx.coroutines.Dispatchers import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import javax.inject.Singleton import javax.inject.Singleton
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore( private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
@@ -61,6 +63,18 @@ abstract class DataBindModule {
abstract fun bindSpecialDatesCalendarSpec( abstract fun bindSpecialDatesCalendarSpec(
impl: AndroidSpecialDatesCalendarSpec, impl: AndroidSpecialDatesCalendarSpec,
): SpecialDatesCalendarSpec ): SpecialDatesCalendarSpec
@Binds
@Singleton
abstract fun bindTodaySource(
impl: AndroidTodaySource,
): TodaySource
@Binds
@Singleton
abstract fun bindZoneSource(
impl: AndroidZoneSource,
): ZoneSource
} }
@Module @Module
@@ -71,8 +85,4 @@ object DataProvideModule {
@Singleton @Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> = fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.calendulaDataStore context.calendulaDataStore
@Provides
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
} }
@@ -1,7 +0,0 @@
package de.jeanlucmakiola.calendula.data.di
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@@ -1,15 +1,13 @@
package de.jeanlucmakiola.calendula.data.prefs package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import de.jeanlucmakiola.floret.prefs.Pref
import androidx.datastore.preferences.core.edit import de.jeanlucmakiola.floret.prefs.PrefStore
import androidx.datastore.preferences.core.longPreferencesKey import de.jeanlucmakiola.floret.prefs.nullableBooleanPref
import androidx.datastore.preferences.core.stringPreferencesKey import de.jeanlucmakiola.floret.prefs.nullableLongPref
import de.jeanlucmakiola.floret.prefs.nullableStringPref
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -26,18 +24,13 @@ import javax.inject.Singleton
*/ */
@Singleton @Singleton
class CalendarPrefs @Inject constructor( class CalendarPrefs @Inject constructor(
private val store: DataStore<Preferences>, store: DataStore<Preferences>,
) { ) {
private val prefs = PrefStore(store)
// Both id sets are deduped: the store is shared with SettingsPrefs, so any val hiddenCalendarIds: Flow<Set<Long>> = prefs.flow(HIDDEN_IDS)
// unrelated write would otherwise re-emit an identical set.
val hiddenCalendarIds: Flow<Set<Long>> = store.data
.map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
.distinctUntilChanged()
suspend fun setHiddenCalendarIds(ids: Set<Long>) { suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(HIDDEN_IDS, ids)
store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
}
/** /**
* Switch-offs the provider does not know about yet, because writing * Switch-offs the provider does not know about yet, because writing
@@ -48,24 +41,18 @@ class CalendarPrefs @Inject constructor(
* second visibility model: `CalendarVisibilityReconciler` drains it entry by * second visibility model: `CalendarVisibilityReconciler` drains it entry by
* entry as soon as the app may write, and nothing adds to it while it may. * entry as soon as the app may write, and nothing adds to it while it may.
*/ */
val pendingDisabledCalendarIds: Flow<Set<Long>> = store.data val pendingDisabledCalendarIds: Flow<Set<Long>> = prefs.flow(DISABLED_IDS)
.map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() }
.distinctUntilChanged()
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) = suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) {
editPendingDisabled { it + ids } prefs.update(DISABLED_IDS) { it + ids }
}
/** /**
* Drop [ids] from the pending set, one at a time as the reconciler flushes * Drop [ids] from the pending set, one at a time as the reconciler flushes
* them, so a run that fails part-way never re-applies what already landed. * them, so a run that fails part-way never re-applies what already landed.
*/ */
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) = suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) {
editPendingDisabled { it - ids.toSet() } prefs.update(DISABLED_IDS) { it - ids.toSet() }
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
store.edit { prefs ->
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
}
} }
/** /**
@@ -73,39 +60,28 @@ class CalendarPrefs @Inject constructor(
* owed. Null until the reconciler has evaluated it (which needs the calendar * owed. Null until the reconciler has evaluated it (which needs the calendar
* permission), false once it has been shown or was never needed. * permission), false once it has been shown or was never needed.
*/ */
val visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs -> val visibilityNoticePending: Flow<Boolean?> = prefs.flow(VISIBILITY_NOTICE)
prefs[VISIBILITY_NOTICE_KEY]
}
suspend fun setVisibilityNoticePending(pending: Boolean) { suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(VISIBILITY_NOTICE, pending)
store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
}
/** /**
* The calendar the user last created an event in; preselected in the * The calendar the user last created an event in; preselected in the
* event form. Null until the first event is created. * event form. Null until the first event is created.
*/ */
val lastUsedCalendarId: Flow<Long?> = store.data.map { prefs -> val lastUsedCalendarId: Flow<Long?> = prefs.flow(LAST_USED_CALENDAR)
prefs[LAST_USED_CALENDAR_KEY]
}
suspend fun setLastUsedCalendarId(id: Long) { suspend fun setLastUsedCalendarId(id: Long) = prefs.set(LAST_USED_CALENDAR, id)
store.edit { prefs -> prefs[LAST_USED_CALENDAR_KEY] = id }
}
companion object { private companion object {
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids") val HIDDEN_IDS = idSetPref("hidden_calendar_ids")
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids") val DISABLED_IDS = idSetPref("disabled_calendar_ids")
internal val VISIBILITY_NOTICE_KEY = booleanPreferencesKey("visibility_notice_pending") val VISIBILITY_NOTICE = nullableBooleanPref("visibility_notice_pending")
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id") val LAST_USED_CALENDAR = nullableLongPref("last_used_calendar_id")
} }
} }
private fun String?.parseIds(): Set<Long> = orEmpty() /** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
.split(',') private fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
.mapNotNull { it.trim().toLongOrNull() } { stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
.toSet() { ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
)
private fun MutablePreferences.writeIds(key: Preferences.Key<String>, ids: Set<Long>) {
if (ids.isEmpty()) remove(key) else set(key, ids.sorted().joinToString(","))
}
@@ -2,10 +2,8 @@ package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import de.jeanlucmakiola.floret.prefs.PrefStore
import androidx.datastore.preferences.core.longPreferencesKey import de.jeanlucmakiola.floret.prefs.nullableLongPref
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -20,17 +18,16 @@ import javax.inject.Singleton
*/ */
@Singleton @Singleton
class ReminderStatePrefs @Inject constructor( class ReminderStatePrefs @Inject constructor(
private val store: DataStore<Preferences>, store: DataStore<Preferences>,
) { ) {
private val prefs = PrefStore(store)
/** The watermark, or `null` before the first scan has ever run. */ /** The watermark, or `null` before the first scan has ever run. */
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first() suspend fun lastScanMillis(): Long? = prefs.get(LAST_SCAN)
suspend fun setLastScanMillis(millis: Long) { suspend fun setLastScanMillis(millis: Long) = prefs.set(LAST_SCAN, millis)
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
}
private companion object { private companion object {
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis") val LAST_SCAN = nullableLongPref("reminder_last_scan_millis")
} }
} }
File diff suppressed because it is too large Load Diff
@@ -6,11 +6,8 @@ import android.content.Intent
import androidx.core.net.toUri import androidx.core.net.toUri
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope import de.jeanlucmakiola.floret.android.goAsync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -44,28 +41,18 @@ class ReminderActionReceiver : BroadcastReceiver() {
// Cancel now so the notification doesn't linger until the alarm; // Cancel now so the notification doesn't linger until the alarm;
// the snooze delay read is the only async work. // the snooze delay read is the only async work.
notifier.cancel(alert) notifier.cancel(alert)
val pendingResult = goAsync() goAsync {
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
val minutes = settingsPrefs.snoozeMinutes.first() val minutes = settingsPrefs.snoozeMinutes.first()
val triggerAt = System.currentTimeMillis() + minutes * 60_000L val triggerAt = System.currentTimeMillis() + minutes * 60_000L
scheduler.schedule(alert, triggerAt) scheduler.schedule(alert, triggerAt)
} finally {
pendingResult.finish()
}
} }
} }
ACTION_SHOW -> { ACTION_SHOW -> {
val pendingResult = goAsync() goAsync {
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) { if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
notifier.post(alert) notifier.post(alert)
} }
} finally {
pendingResult.finish()
}
} }
} }
} }
@@ -4,20 +4,12 @@ import android.app.AlarmManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.setWakeup
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
* calendar app), user-revocable on 31–32.
*/
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
/** /**
* Holds the app's own wake-up for the next reminder (#75). Exactly one alarm * Holds the app's own wake-up for the next reminder (#75). Exactly one alarm
* exists at a time, for the earliest reminder ahead; every firing re-scans and * exists at a time, for the earliest reminder ahead; every firing re-scans and
@@ -31,16 +23,7 @@ class ReminderAlarmScheduler @Inject constructor(
fun scheduleScan(triggerAtMillis: Long) { fun scheduleScan(triggerAtMillis: Long) {
val alarmManager = context.getSystemService<AlarmManager>() ?: return val alarmManager = context.getSystemService<AlarmManager>() ?: return
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT) alarmManager.setWakeup(triggerAtMillis, scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
if (alarmManager.canScheduleExactCompat()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
}
} }
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */ /** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
@@ -1,9 +1,9 @@
package de.jeanlucmakiola.calendula.data.reminders package de.jeanlucmakiola.calendula.data.reminders
import android.content.Context import android.content.Context
import android.content.ContentUris
import android.provider.CalendarContract import android.provider.CalendarContract
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.instancesUri
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -37,10 +37,7 @@ class ProviderReminderInstanceSource @Inject constructor(
) : ReminderInstanceSource { ) : ReminderInstanceSource {
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> { override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { val uri = instancesUri(fromMillis, toMillis)
ContentUris.appendId(this, fromMillis)
ContentUris.appendId(this, toMillis)
}.build()
// `visible` is the flag the app's one visibility model writes (#75). // `visible` is the flag the app's one visibility model writes (#75).
// The status clause mirrors CalendarDataSource.instances: NULL means // The status clause mirrors CalendarDataSource.instances: NULL means
// "normal", so a bare `!= CANCELED` would drop every ordinary event. // "normal", so a bare `!= CANCELED` would drop every ordinary event.
@@ -5,12 +5,10 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
@@ -19,6 +17,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.floret.android.hasPermission
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.datetime.isoDayNumber import kotlinx.datetime.isoDayNumber
import java.time.DayOfWeek import java.time.DayOfWeek
@@ -48,8 +47,7 @@ class ReminderNotifier @Inject constructor(
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */ /** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
fun canPost(): Boolean { fun canPost(): Boolean {
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
PackageManager.PERMISSION_GRANTED
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled() return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
} }
@@ -63,13 +61,9 @@ class ReminderNotifier @Inject constructor(
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() || calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
calendarDataSource.isCalendarVisible(calendarId) == false calendarDataSource.isCalendarVisible(calendarId) == false
/** /** Post [alert], unless its calendar is switched off. */
* Post [alert], unless its calendar is switched off. Returns whether the suspend fun post(alert: ReminderAlert) {
* notification was put up, which the snooze re-show path uses to tell a if (isSilenced(alert.calendarId)) return
* silenced reminder from a delivered one.
*/
suspend fun post(alert: ReminderAlert): Boolean {
if (isSilenced(alert.calendarId)) return false
ensureChannel() ensureChannel()
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) } val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val is24Hour = settingsPrefs.timeFormat.first() val is24Hour = settingsPrefs.timeFormat.first()
@@ -122,8 +116,6 @@ class ReminderNotifier @Inject constructor(
// POST_NOTIFICATIONS was revoked between canPost() and here. // POST_NOTIFICATIONS was revoked between canPost() and here.
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e) Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
} }
// Handled either way — a retry hits the same revoked permission.
return true
} }
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */ /** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
@@ -2,19 +2,18 @@ package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.util.Log import android.util.Log
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.calendar.withoutProviderDefaults
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
import de.jeanlucmakiola.calendula.domain.reminders.planReminders import de.jeanlucmakiola.calendula.domain.reminders.planReminders
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -113,9 +112,7 @@ class ReminderScanner @Inject constructor(
alarms.scheduleScan(schedule.nextAlarmMillis) alarms.scheduleScan(schedule.nextAlarmMillis)
} }
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission( private fun hasReadCalendar(): Boolean = context.hasPermission(Manifest.permission.READ_CALENDAR)
context, Manifest.permission.READ_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
/** /**
* Re-scan when the provider changes, so a saved or deleted event re-arms the * Re-scan when the provider changes, so a saved or deleted event re-arms the
@@ -4,10 +4,7 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import de.jeanlucmakiola.floret.android.goAsync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -29,14 +26,7 @@ class ReminderScheduleReceiver : BroadcastReceiver() {
// exported and the broadcasts it takes are protected, so any other // exported and the broadcasts it takes are protected, so any other
// action did not come from where it claims to. // action did not come from where it claims to.
if (intent.action !in HANDLED_ACTIONS) return if (intent.action !in HANDLED_ACTIONS) return
val pendingResult = goAsync() goAsync { scanner.scan() }
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
scanner.scan()
} finally {
pendingResult.finish()
}
}
} }
companion object { companion object {
@@ -3,9 +3,9 @@ package de.jeanlucmakiola.calendula.data.reminders
import android.app.AlarmManager import android.app.AlarmManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.os.Build
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.setWakeup
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -31,24 +31,6 @@ class ReminderSnoozeScheduler @Inject constructor(
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert), ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
if (canScheduleExact(alarmManager)) { alarmManager.setWakeup(triggerAtMillis, pendingIntent)
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
// Exact alarms revoked (API 31–32); an inexact wake is the best
// available without nagging for SCHEDULE_EXACT_ALARM.
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} }
}
/**
* True on API < 31 (no restriction), and on 31+ when the exact-alarm
* capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+
* (Calendula is a calendar app), user-revocable on 31–32.
*/
private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
} }
@@ -0,0 +1,67 @@
package de.jeanlucmakiola.calendula.data.time
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.broadcastTicks
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transformLatest
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import javax.inject.Inject
import kotlin.time.Clock
/** The current local date, re-emitted whenever it changes (#373). */
interface TodaySource {
val today: Flow<LocalDate>
}
/**
* Re-reads the date on every minute boundary while collected, and at once on the
* system's date, time and zone broadcasts, which can arrive late or not at all.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AndroidTodaySource @Inject constructor(
@ApplicationContext private val context: Context,
) : TodaySource {
override val today: Flow<LocalDate> = context
.broadcastTicks(Intent.ACTION_DATE_CHANGED, Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIMEZONE_CHANGED)
.transformLatest {
while (true) {
emit(currentDate())
delay(MINUTE_MILLIS - Clock.System.now().toEpochMilliseconds() % MINUTE_MILLIS)
}
}
.distinctUntilChanged()
}
private const val MINUTE_MILLIS = 60_000L
fun currentDate(): LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
/**
* Today as a hot flow seeded with [initial]. [onRollover] runs with the previous
* and the new date each time the day changes while collected, so a screen left
* open over midnight can move off yesterday (#373).
*/
fun Flow<LocalDate>.stateFollowingDays(
scope: CoroutineScope,
initial: LocalDate,
onRollover: (old: LocalDate, new: LocalDate) -> Unit,
): StateFlow<LocalDate> {
var last = initial
return onEach { date ->
val old = last
last = date
if (old != date) onRollover(old, date)
}.stateWhileSubscribed(scope, initial)
}
@@ -0,0 +1,36 @@
package de.jeanlucmakiola.calendula.data.time
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.android.broadcastTicks
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.datetime.TimeZone
import javax.inject.Inject
/**
* The zone the device is in. Every screen that turns wall clock into instants
* reads it from here, so the timeline, the event form and a saved move use the
* same zone (#387).
*/
interface ZoneSource {
/** The device's zone, re-emitted whenever it changes. */
val zone: Flow<TimeZone>
/** The device's zone right now. */
fun current(): TimeZone
}
/** Follows the system's zone broadcast. */
class AndroidZoneSource @Inject constructor(
@ApplicationContext private val context: Context,
) : ZoneSource {
override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
.map { current() }
.distinctUntilChanged()
override fun current(): TimeZone = TimeZone.currentSystemDefault()
}
@@ -4,7 +4,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant import kotlinx.datetime.UtcOffset
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
@@ -17,8 +17,8 @@ data class EventForm(
val calendarId: Long?, val calendarId: Long?,
val title: String = "", val title: String = "",
val isAllDay: Boolean = false, val isAllDay: Boolean = false,
val start: LocalDateTime, val start: FormTime,
val end: LocalDateTime, val end: FormTime,
/** /**
* The zone [start]/[end] are wall-clock values in, or null to follow the * The zone [start]/[end] are wall-clock values in, or null to follow the
* device — null is not "no zone", it is "whichever zone the device is in * device — null is not "no zone", it is "whichever zone the device is in
@@ -70,6 +70,19 @@ data class EventForm(
val attendees: List<EventAttendee> = emptyList(), val attendees: List<EventAttendee> = emptyList(),
) )
/**
* One end of a form's span: a wall-clock [dateTime] in the form's zone, and the
* [offset] it was read at when the fall-back hour repeats that wall clock — both
* of the hour's instants share one [LocalDateTime] (#387). Null everywhere else,
* so a time changed and changed back compares equal again. A time picked by hand
* gets its pass from [repicked]; one without an offset reads as the earlier
* pass. One value, so a time never changes without its pass. See [toFormTime].
*/
data class FormTime(val dateTime: LocalDateTime, val offset: UtcOffset? = null) {
val date: LocalDate get() = dateTime.date
val time: LocalTime get() = dateTime.time
}
/** /**
* One editable guest: the user controls the [email] (the identity we dedup and * One editable guest: the user controls the [email] (the identity we dedup and
* write on), an optional display [name], and whether they're [optional] rather * write on), an optional display [name], and whether they're [optional] rather
@@ -135,26 +148,23 @@ enum class EventFormProblem {
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm { fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
// All-day events are date-anchored and carry a nominal "UTC" that is an // All-day events are date-anchored and carry a nominal "UTC" that is an
// anchor, not a location, so they never pin a zone. // anchor, not a location, so they never pin a zone.
val pinnedZone = if (instance.isAllDay) { val pinned = if (instance.isAllDay) {
null null
} else { } else {
eventTimezone eventTimezone
?.takeIf { it != zone.id } ?.takeIf { it != zone.id }
// An unparseable id (a malformed sync row) can't be honoured or // An unparseable id (a malformed sync row) can't be honoured or
// shown; fall back to the device zone rather than failing the open. // shown; fall back to the device zone rather than failing the open.
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess } ?.let { id -> zoneOrNull(id)?.let { id to it } }
} }
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone val pinnedZone = pinned?.first
val formZone = pinned?.second ?: zone
val begin = Instant.fromEpochMilliseconds(beginMillis)
val finish = Instant.fromEpochMilliseconds(endMillis)
val (start, end) = if (instance.isAllDay) { val (start, end) = if (instance.isAllDay) {
val startDate = Instant.fromEpochMilliseconds(beginMillis) allDayFormTimes(begin.toLocalDateTime(TimeZone.UTC).date, finish.toLocalDateTime(TimeZone.UTC).date)
.toLocalDateTime(TimeZone.UTC).date
val endExclusive = Instant.fromEpochMilliseconds(endMillis)
.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else { } else {
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to begin.toFormTime(formZone) to finish.toFormTime(formZone)
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
} }
return EventForm( return EventForm(
calendarId = instance.calendarId, calendarId = instance.calendarId,
@@ -168,7 +178,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
reminders = reminders.map { it.minutes }.distinct().sorted(), reminders = reminders.map { it.minutes }.distinct().sorted(),
availability = availability, availability = availability,
accessLevel = accessLevel, accessLevel = accessLevel,
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, rrule = rrule.normalizedRrule(),
// The provider fills EVENT_COLOR from the key, so [color] is the // The provider fills EVENT_COLOR from the key, so [color] is the
// swatch either way; a null colour means the event inherits its // swatch either way; a null colour means the event inherits its
// calendar's colour. // calendar's colour.
@@ -221,20 +231,30 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
) )
/** /**
* The form's times as they land in [target] — what a pinned event's wall-clock * Form times for an all-day span stored as UTC midnights with an exclusive
* actually means where the user is standing. Null when there's nothing to * end: the last covered day (never before [start]), with placeholder
* disambiguate: an unpinned event (already in [target]), one pinned to [target] * wall-clock times kept in case the user switches the event to timed.
* itself, an all-day event (no zone), or an unparseable pinned zone.
*
* The form edits a pinned event in its own zone, so this is what lets the UI
* show the other side of the pair rather than making the user do the arithmetic.
*/ */
fun EventForm.timesIn(target: TimeZone): Pair<LocalDateTime, LocalDateTime>? { internal fun allDayFormTimes(start: LocalDate, endExclusive: LocalDate?): Pair<FormTime, FormTime> {
if (isAllDay) return null val end = endExclusive?.let { maxOf(start, LocalDate.fromEpochDays(it.toEpochDays() - 1)) } ?: start
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null return FormTime(LocalDateTime(start, LocalTime(9, 0))) to FormTime(LocalDateTime(end, LocalTime(10, 0)))
if (pinned.id == target.id) return null }
return start.toInstant(pinned).toLocalDateTime(target) to
end.toInstant(pinned).toLocalDateTime(target) /** The bare RRULE value the form edits; blank means none. */
internal fun String?.normalizedRrule(): String? = this?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }
/**
* Whether this form's times mean something else in [target] — a timed event
* pinned to another zone. False when there's nothing to disambiguate: an
* unpinned event (already in [target]), one pinned to [target] itself, an
* all-day event (no zone), or an unparseable pinned zone.
*
* The form edits a pinned event in its own zone, so this is what tells the UI
* to show the other side of the pair rather than making the user do the arithmetic.
*/
fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean {
if (isAllDay) return false
return resolvedZone(target).id != target.id
} }
/** /**
@@ -254,9 +274,19 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (attendees.isNotEmpty()) add(EventFormField.Attendees) if (attendees.isNotEmpty()) add(EventFormField.Attendees)
} }
fun EventForm.problems(): Set<EventFormProblem> = buildSet { /**
* What stops this form from saving. A timed span is checked as instants in the
* form's zone (else [deviceZone]): one ending in the fall-back hour's repeat can
* end on an earlier wall clock than it starts (#387).
*/
fun EventForm.problems(deviceZone: TimeZone): Set<EventFormProblem> = buildSet {
if (calendarId == null) add(EventFormProblem.NoCalendar) if (calendarId == null) add(EventFormProblem.NoCalendar)
val endsTooEarly = if (isAllDay) end.date < start.date else end < start val endsTooEarly = if (isAllDay) {
end.date < start.date
} else {
val zone = resolvedZone(deviceZone)
endInstant(zone) < startInstant(zone)
}
if (endsTooEarly) add(EventFormProblem.EndBeforeStart) if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
// An UNTIL before the first day would make the provider generate zero // An UNTIL before the first day would make the provider generate zero
// occurrences — the event would silently vanish from every view. // occurrences — the event would silently vanish from every view.
@@ -265,3 +295,20 @@ fun EventForm.problems(): Set<EventFormProblem> = buildSet {
add(EventFormProblem.RecurrenceEndsBeforeStart) add(EventFormProblem.RecurrenceEndsBeforeStart)
} }
} }
/**
* A create form seeded from this occurrence, to save a copy as a new,
* independent event (#52). The recurrence is dropped — a duplicate is a single
* event. The source calendar is kept only when [canModify], otherwise the id is
* cleared so the create resolves to a writable calendar, which lets a read-only
* event be copied into an editable one. [instance]'s own times carry over.
*/
fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
toEditForm(
beginMillis = instance.start.toEpochMilliseconds(),
endMillis = instance.end.toEpochMilliseconds(),
zone = zone,
).copy(
rrule = null,
calendarId = instance.calendarId.takeIf { canModify },
)
@@ -1,8 +1,8 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number import kotlinx.datetime.number
import kotlinx.datetime.plus import kotlinx.datetime.plus
@@ -101,19 +101,13 @@ object EventSearch {
val today = todayStart.toLocalDateTime(zone).date val today = todayStart.toLocalDateTime(zone).date
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) } val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today } val (upcoming, past) = matched.partition { it.event.spanLastDay(zone) >= today }
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 } // What is left reads forwards; what is behind us reads backwards from
.thenComparator { a, b -> // today, nearest first.
// What is left reads forwards; what is behind us reads backwards val ordered = upcoming.sortedBy { it.event.dayStart(zone) } +
// from today, nearest first. past.sortedByDescending { it.event.dayStart(zone) }
if (upcoming(a)) {
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
} else {
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
}
}
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly } val (inDescriptions, dated) = ordered.partition { it.descriptionOnly }
return SearchResults( return SearchResults(
months = groupByMonth(dated, zone, todayStart), months = groupByMonth(dated, zone, todayStart),
inDescriptions = inDescriptions.map { it.hit }, inDescriptions = inDescriptions.map { it.hit },
@@ -126,33 +120,18 @@ object EventSearch {
zone: TimeZone, zone: TimeZone,
todayStart: Instant, todayStart: Instant,
): List<SearchMonth> { ): List<SearchMonth> {
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date) val current = todayStart.toLocalDateTime(zone).date.let { YearMonth(it.year, it.month) }
return dated val groups = dated.groupBy { m -> m.event.spanFirstDay(zone).let { YearMonth(it.year, it.month) } }
.groupBy { monthKey(it.event.spanFirstDay(zone)) } val (behind, ahead) = groups.keys.partition { it < current }
.entries return (ahead.sorted() + behind.sortedDescending()).map { month ->
.sortedWith(
compareBy<Map.Entry<Int, List<Matched>>> {
when {
it.key == currentKey -> 0
it.key > currentKey -> 1
else -> 2
}
}.thenComparator { a, b ->
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
},
)
.map { (key, entries) ->
SearchMonth( SearchMonth(
year = key / 12, year = month.year,
monthNumber = key % 12 + 1, monthNumber = month.month.number,
hits = entries.map { it.hit }, hits = groups.getValue(month).map { it.hit },
) )
} }
} }
/** Year and month as one comparable number. */
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
private fun match( private fun match(
candidate: SearchCandidate, candidate: SearchCandidate,
tokens: List<String>, tokens: List<String>,
@@ -162,16 +141,20 @@ object EventSearch {
val title = candidate.title?.takeIf { it.isNotBlank() } val title = candidate.title?.takeIf { it.isNotBlank() }
val location = candidate.event.location?.takeIf { it.isNotBlank() } val location = candidate.event.location?.takeIf { it.isNotBlank() }
// Collapsed so a multi-line description matches the way it is drawn. // Collapsed so a multi-line description matches the way it is drawn.
val description = candidate.description val rich = candidate.description?.let(::descriptionRichText)
val description = rich?.text
?.replace(WHITESPACE, " ") ?.replace(WHITESPACE, " ")
?.trim() ?.trim()
?.takeIf { it.isNotEmpty() } ?.takeIf { it.isNotEmpty() }
// A link's address isn't in the drawn text, but "zoom.us" should still find the invite.
val linkUrls = rich?.links.orEmpty().map { it.url }
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin". // Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
val present = { token: String -> val present = { token: String ->
title?.contains(token, ignoreCase = true) == true || title?.contains(token, ignoreCase = true) == true ||
location?.contains(token, ignoreCase = true) == true || location?.contains(token, ignoreCase = true) == true ||
description?.contains(token, ignoreCase = true) == true description?.contains(token, ignoreCase = true) == true ||
linkUrls.any { it.contains(token, ignoreCase = true) }
} }
if (!tokens.all(present)) return null if (!tokens.all(present)) return null
@@ -3,17 +3,137 @@ package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.offsetAt
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toJavaZoneId
import kotlinx.datetime.toJavaZoneOffset
import kotlinx.datetime.toKotlinUtcOffset
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import java.time.ZonedDateTime
import kotlin.time.Instant import kotlin.time.Instant
import kotlin.time.toJavaInstant
import kotlin.time.toKotlinInstant
/** [id] as a zone, or null when the tz database doesn't know it. */
internal fun zoneOrNull(id: String): TimeZone? = runCatching { TimeZone.of(id) }.getOrNull()
/** /**
* The zone this form's wall-clock times mean, matching what the data layer * The zone this form's wall-clock times mean, matching what the data layer
* resolves them in at write time: the form's own pinned zone, else [deviceZone]. * resolves them in at write time: the form's own pinned zone, else [deviceZone].
*/ */
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone = fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone timezone?.let(::zoneOrNull) ?: deviceZone
/**
* This wall-clock time in [zone], at [preferred] when the fall-back hour repeats
* it and that is one of its two offsets — otherwise the earlier one (#387).
*/
fun LocalDateTime.resolveIn(zone: TimeZone, preferred: UtcOffset?): Instant =
ZonedDateTime.ofLocal(toJavaLocalDateTime(), zone.toJavaZoneId(), preferred?.toJavaZoneOffset())
.toInstant().toKotlinInstant()
/** Whether this instant's wall clock in [zone] is one the fall-back hour repeats. */
fun Instant.inRepeatedHour(zone: TimeZone): Boolean {
val zid = zone.toJavaZoneId()
return zid.rules.getTransition(java.time.LocalDateTime.ofInstant(toJavaInstant(), zid))?.isOverlap == true
}
/**
* [at]'s offset in this zone when its wall clock is one the fall-back hour
* repeats, else null: elsewhere the wall clock alone already names the instant.
* What a [FormTime.offset] holds (#387).
*/
fun TimeZone.repeatedHourOffsetAt(at: Instant): UtcOffset? = if (at.inRepeatedHour(this)) offsetAt(at) else null
/** This instant as a [FormTime] in [zone], keeping its pass of a repeated hour. */
fun Instant.toFormTime(zone: TimeZone): FormTime = FormTime(toLocalDateTime(zone), zone.repeatedHourOffsetAt(this))
/** This time as an instant in [zone], on its kept pass of a repeated hour. */
fun FormTime.instantIn(zone: TimeZone): Instant = dateTime.resolveIn(zone, offset)
/**
* This edge of an event moved to [picked] by hand — a picker or a resize handle —
* as a [FormTime] in [zone]. A time in the spring-forward gap moves on to the
* real one, as the provider would read it. A wall clock the fall-back hour
* repeats has two instants; which one it means (#387):
* - [original]'s, when [picked] is the wall clock the event was opened with, so
* a time changed and changed back is the time it was;
* - else the one that moves the edge in the direction the user moved it:
* earlier than this edge's instant when [picked] is before [shownAt] (where
* the edge was on screen), later when after;
* - kept within [notBefore]..[notAfter] (the other edge), and to the ones
* [drawable] accepts — where the screen can show it — when any fit.
*/
fun FormTime.repicked(
picked: LocalDateTime,
zone: TimeZone,
shownAt: LocalDateTime = dateTime,
original: FormTime? = null,
notBefore: Instant? = null,
notAfter: Instant? = null,
drawable: (Instant) -> Boolean = { true },
): FormTime {
if (picked == dateTime && picked == shownAt) return this
val offsets = zone.toJavaZoneId().rules.getValidOffsets(picked.toJavaLocalDateTime())
if (offsets.isEmpty()) return FormTime(picked).instantIn(zone).toFormTime(zone)
if (offsets.size == 1) return FormTime(picked)
val passes = offsets.map { picked.toInstant(it.toKotlinUtcOffset()) }.sorted()
val bounded = passes.filter { (notBefore == null || it >= notBefore) && (notAfter == null || it <= notAfter) }
.ifEmpty { passes }
val fitting = bounded.filter(drawable).ifEmpty { bounded }
if (original?.dateTime == picked && original.offset != null && original.instantIn(zone) in fitting) return original
val from = instantIn(zone)
val chosen = when {
picked < shownAt -> fitting.lastOrNull { it < from }
picked > shownAt -> fitting.firstOrNull { it > from }
else -> null
} ?: fitting.minBy { (it - from).absoluteValue }
return chosen.toFormTime(zone)
}
/** The form's start as an instant in [zone]. */
fun EventForm.startInstant(zone: TimeZone): Instant = start.instantIn(zone)
/** The form's end as an instant in [zone]. */
fun EventForm.endInstant(zone: TimeZone): Instant = end.instantIn(zone)
/** The form's timed span set to these instants, read in [zone]. */
fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm =
copy(start = start.toFormTime(zone), end = end.toFormTime(zone))
/**
* The form pinned to [zoneId] (null: the device). A kept pass of a repeated hour
* was one of the old zone's, so it goes when the zone the times mean changes.
*/
fun EventForm.withTimezone(zoneId: String?, deviceZone: TimeZone): EventForm {
val pinned = copy(timezone = zoneId)
if (pinned.resolvedZone(deviceZone) == resolvedZone(deviceZone)) return pinned
return pinned.withoutKeptPasses()
}
/**
* The form with its start picked by hand, keeping its length. A repeated hour's
* pass is [repicked]: [original] is the start the event was opened with.
*/
fun EventForm.withStart(start: LocalDateTime, zone: TimeZone, original: FormTime? = null): EventForm {
val span = endInstant(zone) - startInstant(zone)
val startAt = this.start.repicked(start, zone, original = original).instantIn(zone)
return withTimes(startAt, startAt + span, zone)
}
/**
* The form with its end picked by hand. A repeated hour's pass is [repicked],
* kept after the start where it can be: [original] is the end the event was
* opened with.
*/
fun EventForm.withEnd(end: LocalDateTime, zone: TimeZone, original: FormTime? = null): EventForm =
copy(end = this.end.repicked(end, zone, original = original, notBefore = startInstant(zone)))
/** The form with no kept passes: its times as the wall clock alone says them. */
fun EventForm.withoutKeptPasses(): EventForm = copy(start = FormTime(start.dateTime), end = FormTime(end.dateTime))
/** /**
* The form moved so it starts at [newStart], keeping its **instant** duration — * The form moved so it starts at [newStart], keeping its **instant** duration —
@@ -26,11 +146,8 @@ fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm { fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this if (isAllDay) return this
val zone = resolvedZone(deviceZone) val zone = resolvedZone(deviceZone)
val span = end.toInstant(zone) - start.toInstant(zone) val span = endInstant(zone) - startInstant(zone)
return copy( return withTimes(newStart, newStart + span, zone)
start = newStart.toLocalDateTime(zone),
end = (newStart + span).toLocalDateTime(zone),
)
} }
/** /**
@@ -43,11 +160,21 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time) val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
if (isAllDay) { if (isAllDay) {
return copy( return copy(
start = newStart, start = FormTime(newStart),
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time), end = FormTime(LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time)),
) )
} }
val zone = resolvedZone(deviceZone) val zone = resolvedZone(deviceZone)
val span = end.toInstant(zone) - start.toInstant(zone) val span = endInstant(zone) - startInstant(zone)
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone)) val startAt = FormTime(newStart, start.offset).instantIn(zone)
// The start keeps the wall clock it was moved to, even one the spring-forward
// gap skips, so a recurring series still sees a whole-day move.
val movedStart = startAt.toFormTime(zone).takeIf { it.dateTime == newStart } ?: FormTime(newStart)
return copy(start = movedStart, end = (startAt + span).toFormTime(zone))
}
/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
if (isAllDay) return this
return withTimes(newStart, newEnd, resolvedZone(deviceZone))
} }
@@ -0,0 +1,387 @@
package de.jeanlucmakiola.calendula.domain
/** Inline formatting an HTML description keeps; fonts, sizes and colours are dropped. */
enum class InlineStyle { Bold, Italic, Underline, Strikethrough, Superscript, Subscript, Monospace }
data class StyleRange(val start: Int, val end: Int, val style: InlineStyle)
/** A link from an `<a href>`, spanning [start] until [end] (exclusive). */
data class LinkRange(val start: Int, val end: Int, val url: String)
/** Text as drawn, with the formatting and links that sit on it. Plain text has neither. */
data class RichText(
val text: String,
val styles: List<StyleRange> = emptyList(),
val links: List<LinkRange> = emptyList(),
) {
/**
* [text] with each link's address after it, where the text doesn't already
* show it, so a copied "Join the meeting" still carries its URL.
*/
fun copyText(): String {
if (links.isEmpty()) return text
val out = StringBuilder(text)
for (link in links.sortedByDescending { it.end }) {
val address = link.url.removePrefix("mailto:").removePrefix("tel:")
if (address !in text.substring(link.start, link.end)) out.insert(link.end, " ($address)")
}
return out.toString()
}
}
fun looksLikeHtml(text: String): Boolean = htmlTagRegex.containsMatchIn(text)
/** [description] as it reads: HTML rendered down to [RichText], plain text as it is. */
fun descriptionRichText(description: String): RichText =
if (looksLikeHtml(description)) parseHtml(description) else RichText(description)
private val BLOCK_TAGS = setOf(
"p", "div", "ul", "ol", "li", "table", "tr", "blockquote", "pre", "hr", "section", "article",
"header", "footer", "dl", "dt", "dd", "address", "center", "h1", "h2", "h3", "h4", "h5", "h6",
)
// Their content is never text to show.
private val SKIPPED_TAGS = setOf("head", "style", "script", "title", "xml", "template")
private val STYLE_TAGS = mapOf(
"b" to InlineStyle.Bold, "strong" to InlineStyle.Bold, "th" to InlineStyle.Bold,
"h1" to InlineStyle.Bold, "h2" to InlineStyle.Bold, "h3" to InlineStyle.Bold,
"h4" to InlineStyle.Bold, "h5" to InlineStyle.Bold, "h6" to InlineStyle.Bold,
"i" to InlineStyle.Italic, "em" to InlineStyle.Italic, "cite" to InlineStyle.Italic,
"u" to InlineStyle.Underline, "ins" to InlineStyle.Underline,
"s" to InlineStyle.Strikethrough, "strike" to InlineStyle.Strikethrough, "del" to InlineStyle.Strikethrough,
"sup" to InlineStyle.Superscript, "sub" to InlineStyle.Subscript,
"code" to InlineStyle.Monospace, "tt" to InlineStyle.Monospace, "pre" to InlineStyle.Monospace,
)
// A real tag the renderer knows, not prose like "<3" or "a < b". Google
// Calendar and Exchange both store their descriptions as HTML.
private val htmlTagRegex = Regex(
(BLOCK_TAGS + SKIPPED_TAGS + STYLE_TAGS.keys + setOf("html", "body", "br", "span", "a", "font", "img", "meta", "td"))
.joinToString("|", prefix = """</?(?:""", postfix = """)(?:\s[^<>]*)?/?>"""),
RegexOption.IGNORE_CASE,
)
private val LINK_SCHEMES = listOf("http://", "https://", "mailto:", "tel:")
private const val NBSP = '\u00A0'
private const val CELL_SEPARATOR = " · "
/**
* Render [html] down to text with its inline formatting and links. Blocks and
* `<br>` become line breaks, list items get a bullet or number, table rows a
* line each with their cells side by side. Lenient about broken markup:
* whatever doesn't parse as a tag stays text.
*/
fun parseHtml(html: String): RichText = HtmlRenderer(html).render()
// Markup that sets its own lines. Without any, the text's newlines are the
// lines: "Agenda:\n1. Intro" with one <a href> in it stays on two lines.
private val lineTagRegex = Regex("""<(?:br|p|div|li|tr|h[1-6]|pre|blockquote|table)\b""", RegexOption.IGNORE_CASE)
private class HtmlRenderer(private val html: String) {
private val out = StringBuilder()
private val styles = mutableListOf<StyleRange>()
private val links = mutableListOf<LinkRange>()
private val openStyles = mutableListOf<Pair<String, Int>>()
private var openLink: Pair<String, Int>? = null
private val lists = mutableListOf<ListState>()
private val keepNewlines = !lineTagRegex.containsMatchIn(html)
private var preDepth = 0
private var rowStart = 0
private var cellNeedsSeparator = false
// Where the current list item's bullet ends, so a <p> inside the item
// doesn't push its text off the bullet's line.
private var markerEnd = -1
private class ListState(val ordered: Boolean, var count: Int = 0)
fun render(): RichText {
var i = 0
while (i < html.length) {
val lt = html.indexOf('<', i)
if (lt < 0) {
text(html.substring(i))
break
}
if (lt > i) text(html.substring(i, lt))
i = tag(lt)
}
return normalize()
}
/** Handle the markup at [lt]; returns where reading continues. */
private fun tag(lt: Int): Int {
if (html.startsWith("<!--", lt)) {
val end = html.indexOf("-->", lt + 4)
return if (end < 0) html.length else end + 3
}
if (html.startsWith("<!", lt) || html.startsWith("<?", lt)) {
val end = html.indexOf('>', lt)
return if (end < 0) html.length else end + 1
}
val closing = html.getOrNull(lt + 1) == '/'
val nameStart = if (closing) lt + 2 else lt + 1
if (html.getOrNull(nameStart)?.isLetter() != true) {
text("<")
return lt + 1
}
val end = tagEnd(lt)
if (end < 0) {
text("<")
return lt + 1
}
var nameEnd = nameStart
while (nameEnd < end && (html[nameEnd].isLetterOrDigit() || html[nameEnd] == ':')) nameEnd++
val name = html.substring(nameStart, nameEnd).lowercase()
val attrs = html.substring(nameEnd, end)
if (closing) {
close(name)
} else if (name in SKIPPED_TAGS && !attrs.trimEnd().endsWith("/")) {
// Never closed (an omitted </head> is valid): just ignore the tag.
val closeAt = html.indexOf("</$name", end, ignoreCase = true)
if (closeAt < 0) return end + 1
val closeEnd = html.indexOf('>', closeAt)
return if (closeEnd < 0) html.length else closeEnd + 1
} else {
open(name, attrs)
}
return end + 1
}
/** The `>` closing the tag at [lt], skipping any inside quoted attributes. */
private fun tagEnd(lt: Int): Int {
var quote: Char? = null
for (j in lt + 1 until html.length) {
val c = html[j]
when {
quote != null -> if (c == quote) quote = null
c == '"' || c == '\'' -> quote = c
c == '>' -> return j
c == '<' -> return -1
}
}
return -1
}
private fun open(name: String, attrs: String) {
when (name) {
"br" -> newline()
"li" -> {
markerEnd = -1
cellNeedsSeparator = false
lineBreak()
val list = lists.lastOrNull()
out.append("$NBSP$NBSP".repeat((lists.size - 1).coerceAtLeast(0)))
if (list?.ordered == true) {
list.count++
out.append("${list.count}. ")
} else {
out.append("• ")
}
markerEnd = out.length
}
"ul", "ol" -> {
cellNeedsSeparator = false
lineBreak()
val start = startRegex.find(attrs)?.groupValues?.get(1)?.toIntOrNull() ?: 1
lists += ListState(ordered = name == "ol", count = start - 1)
}
"tr" -> {
lineBreak()
rowStart = out.length
cellNeedsSeparator = false
}
"td", "th" -> {
// An empty spacer cell leaves nothing behind, not even its &nbsp;.
if (rowStart <= out.length && (rowStart until out.length).none { out[it].isContent() }) {
cut(rowStart)
} else {
cellNeedsSeparator = true
}
}
"a" -> {
closeLink()
hrefRegex.find(attrs)
?.groupValues?.drop(1)?.firstOrNull { it.isNotEmpty() }
?.let(::decodeEntities)
?.trim()
?.let { href ->
val scheme = LINK_SCHEMES.firstOrNull { href.startsWith(it, ignoreCase = true) }
?: return@let
// Intent filters match the scheme case-sensitively.
openLink = scheme + href.substring(scheme.length) to out.length
}
}
else -> if (name in BLOCK_TAGS) lineBreak()
}
if (name == "pre") preDepth++
STYLE_TAGS[name]?.let { openStyles += name to out.length }
}
private fun close(name: String) {
// A row's last cell, if empty, leaves a separator pending; it ends with the row.
if (name == "tr" || name == "table") cellNeedsSeparator = false
val index = openStyles.indexOfLast { it.first == name }
if (index >= 0) {
val (_, start) = openStyles.removeAt(index)
if (out.length > start) styles += StyleRange(start, out.length, STYLE_TAGS.getValue(name))
}
if (name == "pre") preDepth = (preDepth - 1).coerceAtLeast(0)
if (name == "li" || name == "ul" || name == "ol") {
if (out.length == markerEnd) cut(out.lastIndexOf("\n") + 1)
markerEnd = -1
}
when (name) {
"ul", "ol" -> {
lists.removeLastOrNull()
lineBreak()
}
"a" -> closeLink()
else -> if (name in BLOCK_TAGS) lineBreak()
}
}
private fun closeLink() {
val (url, start) = openLink ?: return
if (out.length > start) links += LinkRange(start, out.length, url)
openLink = null
}
private fun text(raw: String) {
val decoded = decodeEntities(raw)
if (preDepth > 0) {
out.append(decoded.replace("\r\n", "\n"))
return
}
for (c in decoded) {
if (c == '\n' && keepNewlines) {
newline()
} else if (c == ' ' || c == '\n' || c == '\t' || c == '\r' || c == '\u000C') {
if (out.isNotEmpty() && out.last() != ' ' && out.last() != '\n') out.append(' ')
} else {
if (cellNeedsSeparator && c.isContent()) separateCell()
out.append(c)
}
}
}
/** Put this cell's text beside the row's last, even after a block in that cell ended its line. */
private fun separateCell() {
cellNeedsSeparator = false
var end = out.length
while (end > rowStart && !out[end - 1].isContent()) end--
cut(end)
out.append(CELL_SEPARATOR)
openStyles.replaceAll { (name, start) -> name to if (start >= end) out.length else start }
openLink = openLink?.let { (url, start) -> url to if (start >= end) out.length else start }
}
private fun Char.isContent() = !isWhitespace() && this != NBSP
private fun newline() {
trimTrailingSpaces()
out.append('\n')
}
/** End the current line, unless it's already ended or holds just a bullet. */
private fun lineBreak() {
if (out.length == markerEnd) return
trimTrailingSpaces()
if (out.isNotEmpty() && out.last() != '\n') out.append('\n')
}
private fun trimTrailingSpaces() {
while (out.isNotEmpty() && out.last() == ' ') out.setLength(out.length - 1)
}
/** Drop the output from [length] on, with whatever formatting lay only there. */
private fun cut(length: Int) {
if (length >= out.length) return
out.setLength(length)
styles.replaceAll { it.copy(end = minOf(it.end, length)) }
styles.removeAll { it.end <= it.start }
links.replaceAll { it.copy(end = minOf(it.end, length)) }
links.removeAll { it.end <= it.start }
openStyles.replaceAll { (name, start) -> name to minOf(start, length) }
openLink = openLink?.let { (url, start) -> url to minOf(start, length) }
}
/**
* Tidy the rendered text: no-break spaces become plain, lines lose trailing
* blanks, runs of empty lines shrink to one and the ends are trimmed. Spans
* move with the text they cover.
*/
private fun normalize(): RichText {
closeLink()
for ((name, start) in openStyles) {
if (out.length > start) styles += StyleRange(start, out.length, STYLE_TAGS.getValue(name))
}
val src = out.toString().replace(NBSP, ' ')
val map = IntArray(src.length + 1)
val result = StringBuilder()
var i = 0
while (i < src.length) {
var lineEnd = src.indexOf('\n', i)
if (lineEnd < 0) lineEnd = src.length
var contentEnd = lineEnd
while (contentEnd > i && src[contentEnd - 1].isWhitespace()) contentEnd--
for (j in i until lineEnd) {
map[j] = result.length
if (j < contentEnd) result.append(src[j])
}
if (lineEnd < src.length) {
map[lineEnd] = result.length
if (!result.endsWith("\n\n")) result.append('\n')
}
i = lineEnd + 1
}
map[src.length] = result.length
val leading = result.length - result.trimStart().length
val text = result.trim().toString()
fun at(index: Int) = (map[index] - leading).coerceIn(0, text.length)
return RichText(
text = text,
styles = styles.mapNotNull { r -> StyleRange(at(r.start), at(r.end), r.style).takeIf { it.end > it.start } },
links = links.mapNotNull { r -> LinkRange(at(r.start), at(r.end), r.url).takeIf { it.end > it.start } },
)
}
}
private val startRegex = Regex("""(?:^|\s)start\s*=\s*["']?(-?\d{1,6})""", RegexOption.IGNORE_CASE)
private val hrefRegex = Regex("""(?:^|\s)href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", RegexOption.IGNORE_CASE)
// Latin-1 (&nbsp; … &yuml;) in code point order, then the common extras.
private val LATIN1_ENTITIES = ("nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr " +
"deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest " +
"Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml " +
"ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig " +
"agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml " +
"eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml").split(' ')
private val ENTITIES: Map<String, String> = LATIN1_ENTITIES.withIndex()
.associate { (i, name) -> name to (0xA0 + i).toChar().toString() } + mapOf(
"amp" to "&", "lt" to "<", "gt" to ">", "quot" to "\"", "apos" to "'",
"ndash" to "–", "mdash" to "—", "hellip" to "…", "bull" to "•",
"lsquo" to "‘", "rsquo" to "’", "ldquo" to "“", "rdquo" to "”", "sbquo" to "‚", "bdquo" to "„",
"euro" to "€", "trade" to "™", "OElig" to "Œ", "oelig" to "œ", "Scaron" to "Š", "scaron" to "š",
"zwnj" to "\u200C", "zwj" to "\u200D", "ensp" to "\u2002", "emsp" to "\u2003", "thinsp" to "\u2009",
)
// A named reference needs its ";": "?id=5&reg=eu" in a link is a query, not "®".
private val entityRegex = Regex("""&(?:(#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6});?|([a-zA-Z][a-zA-Z0-9]{1,31});)""")
/** Decode character references; an unknown name is left as written. */
internal fun decodeEntities(text: String): String {
if ('&' !in text) return text
return entityRegex.replace(text) { match ->
val name = match.groupValues[2]
if (name.isNotEmpty()) return@replace ENTITIES[name] ?: match.value
val body = match.groupValues[1]
val code = when {
body.startsWith("#x", ignoreCase = true) -> body.substring(2).toIntOrNull(16)
else -> body.substring(1).toIntOrNull()
}
if (code == null || code == 0 || !Character.isValidCodePoint(code)) "�" else String(Character.toChars(code))
}
}
@@ -1,10 +1,6 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant import kotlin.time.Instant
@@ -25,37 +21,27 @@ import kotlin.time.Instant
* the extras lives in `MainActivity.insertFormOrNull`. * the extras lives in `MainActivity.insertFormOrNull`.
*/ */
fun buildInsertEventForm( fun buildInsertEventForm(
beginMillis: Long?, beginMillis: Long? = null,
endMillis: Long?, endMillis: Long? = null,
isAllDay: Boolean, isAllDay: Boolean = false,
title: String?, title: String?,
description: String?, description: String?,
location: String?, location: String? = null,
rrule: String?, rrule: String? = null,
zone: TimeZone, zone: TimeZone,
now: Instant, now: Instant,
): EventForm { ): EventForm {
val (start, end) = if (isAllDay) { val (start, end) = if (isAllDay) {
// All-day provider times are UTC midnights with an exclusive end; show
// the last covered day and keep placeholder wall-clock times in case the
// user switches the event to timed (mirrors EventDetail.toEditForm).
val startDate = beginMillis val startDate = beginMillis
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date } ?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
?: now.toLocalDateTime(zone).date ?: now.toLocalDateTime(zone).date
val endDate = endMillis val endExclusive = endMillis?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date } allDayFormTimes(startDate, endExclusive)
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
?: startDate
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else { } else {
val startTime = beginMillis val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) } val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
?: nextFullHour(now, zone) ?: (startAt + 1.hours)
val endTime = endMillis startAt.toFormTime(zone) to endAt.toFormTime(zone)
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
?.takeIf { it >= startTime }
?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone)
startTime to endTime
} }
return EventForm( return EventForm(
calendarId = null, calendarId = null,
@@ -67,12 +53,38 @@ fun buildInsertEventForm(
description = description.orEmpty(), description = description.orEmpty(),
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:" // Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
// some callers include. // some callers include.
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, rrule = rrule.normalizedRrule(),
) )
} }
private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime { /** The first full hour after [now]: may roll into the next day before midnight. */
internal fun nextFullHour(now: Instant): Instant {
val hourMillis = 3_600_000L val hourMillis = 3_600_000L
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis return Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis)
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone) }
/** Title and description for text shared or selected into the app (#306). */
data class SharedText(val title: String, val description: String)
/**
* Split shared text into an event title and description: the share's subject
* becomes the title and the text the description. Without a subject the text's
* first non-blank line is the title and the rest the description. A title longer
* than [maxTitle] is cut, and the full text then stays in the description.
* Null when there is nothing to use.
*/
fun sharedTextOrNull(subject: String?, text: String?, maxTitle: Int = 255): SharedText? {
val body = text?.trim().orEmpty()
val given = subject?.trim()?.takeIf { it.isNotEmpty() }
val (title, rest) = if (given != null) {
given to body
} else {
val lines = body.lines()
val first = lines.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return null
first to lines.drop(1).joinToString("\n").trim()
}
if (title.length <= maxTitle) return SharedText(title, rest)
val cut = title.take(maxTitle).let { if (it.last().isHighSurrogate()) it.dropLast(1) else it }
val full = if (given != null) listOf(given, body).filter { it.isNotEmpty() }.joinToString("\n\n") else body
return SharedText(cut.trimEnd(), full)
} }
@@ -165,6 +165,17 @@ data class EventDetail(
val isException: Boolean = false, val isException: Boolean = false,
) )
/**
* This event on one occurrence's times. The Events row holds the series start,
* so a recurring event needs the tapped occurrence pinned to render and share right.
*/
fun EventDetail.atOccurrence(beginMillis: Long, endMillis: Long): EventDetail = copy(
instance = instance.copy(
start = Instant.fromEpochMilliseconds(beginMillis),
end = Instant.fromEpochMilliseconds(endMillis),
),
)
/** /**
* One selectable event colour published by a calendar's account * One selectable event colour published by a calendar's account
* (`CalendarContract.Colors`, `TYPE_EVENT`): [key] is the account-scoped * (`CalendarContract.Colors`, `TYPE_EVENT`): [key] is the account-scoped
@@ -1,5 +1,8 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDateTime
import de.jeanlucmakiola.calendula.domain.ics.toUtcBasic
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
@@ -57,24 +60,13 @@ fun parseSimpleRecurrence(
rrule: String, rrule: String,
zone: TimeZone = TimeZone.currentSystemDefault(), zone: TimeZone = TimeZone.currentSystemDefault(),
): SimpleRecurrence? { ): SimpleRecurrence? {
val parts = rrule.removePrefix("RRULE:").split(';') val parts = rruleParts(rrule).associate { part -> (part.key ?: return null) to part.value }
.filter { it.isNotBlank() }
.associate { token ->
val eq = token.indexOf('=')
if (eq <= 0) return null
token.substring(0, eq).uppercase() to token.substring(eq + 1)
}
if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) { if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) {
return null return null
} }
val freq = when (parts["FREQ"]?.uppercase()) { val freq = RecurrenceFreq.entries.firstOrNull { it.name.equals(parts["FREQ"], ignoreCase = true) }
"DAILY" -> RecurrenceFreq.Daily ?: return null
"WEEKLY" -> RecurrenceFreq.Weekly
"MONTHLY" -> RecurrenceFreq.Monthly
"YEARLY" -> RecurrenceFreq.Yearly
else -> return null
}
val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1 val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1
// BYDAY is simple only as plain weekday picks on a weekly rule; ordinal // BYDAY is simple only as plain weekday picks on a weekly rule; ordinal
@@ -124,20 +116,27 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
when (val e = end) { when (val e = end) {
RecurrenceEnd.Never -> Unit RecurrenceEnd.Never -> Unit
is RecurrenceEnd.Until -> { is RecurrenceEnd.Until -> {
val utc = LocalDateTime(e.date, LocalTime(23, 59, 59)) append(";UNTIL=")
.toInstant(zone) append(LocalDateTime(e.date, LocalTime(23, 59, 59)).toInstant(zone).toUtcBasic())
.toLocalDateTime(TimeZone.UTC)
append(
";UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
utc.year, utc.month.number, utc.day,
utc.hour, utc.minute, utc.second,
),
)
} }
is RecurrenceEnd.Count -> append(";COUNT=${e.times}") is RecurrenceEnd.Count -> append(";COUNT=${e.times}")
} }
} }
/** One `;`-separated part of an RRULE as written; [key] is null when it has no `KEY=`. */
internal class RrulePart(val raw: String) {
private val eq = raw.indexOf('=')
val key: String? = if (eq > 0) raw.substring(0, eq).uppercase() else null
val value: String = if (eq > 0) raw.substring(eq + 1) else ""
}
/**
* [rule]'s non-blank parts, after an optional leading "RRULE:". What to do
* with a malformed one (reject, repair or skip) is the caller's call.
*/
internal fun rruleParts(rule: String): List<RrulePart> =
rule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }.map(::RrulePart)
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf( internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
DayOfWeek.MONDAY to "MO", DayOfWeek.MONDAY to "MO",
DayOfWeek.TUESDAY to "TU", DayOfWeek.TUESDAY to "TU",
@@ -165,11 +164,7 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
val key = part.substringBefore('=').trim().uppercase() val key = part.substringBefore('=').trim().uppercase()
key == "UNTIL" || key == "COUNT" key == "UNTIL" || key == "COUNT"
} }
val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC) val untilPart = "UNTIL=" + Instant.fromEpochMilliseconds(untilUtcMillis).toUtcBasic()
val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
until.year, until.month.number, until.day,
until.hour, until.minute, until.second,
)
return (kept + untilPart).joinToString(";") return (kept + untilPart).joinToString(";")
} }
@@ -178,20 +173,8 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
* forms are UTC (RFC 5545); the date is taken after converting into [zone] so * forms are UTC (RFC 5545); the date is taken after converting into [zone] so
* a [toRRule]-rendered value round-trips to the day the user picked. * a [toRRule]-rendered value round-trips to the day the user picked.
*/ */
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching { private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? {
val date = LocalDate( val date = parseBasicDate(raw) ?: return null
raw.substring(0, 4).toInt(), if (raw.length < 15 || raw[8] != 'T') return date
raw.substring(4, 6).toInt(), return parseBasicDateTime(raw)?.toInstant(TimeZone.UTC)?.toLocalDateTime(zone)?.date
raw.substring(6, 8).toInt(), }
)
if (raw.length >= 15 && raw[8] == 'T') {
val time = LocalTime(
raw.substring(9, 11).toInt(),
raw.substring(11, 13).toInt(),
raw.substring(13, 15).toInt(),
)
LocalDateTime(date, time).toInstant(TimeZone.UTC).toLocalDateTime(zone).date
} else {
date
}
}.getOrNull()
@@ -19,30 +19,30 @@ import kotlinx.datetime.LocalDate
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? { fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
if (oldStart == newStart) return rrule if (oldStart == newStart) return rrule
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else "" val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() } val parts = rruleParts(rrule)
if (parts.isEmpty()) return null if (parts.isEmpty()) return null
var freq = false
var weekly = false var weekly = false
var byDay = false
val rebuilt = parts.map { part -> val rebuilt = parts.map { part ->
val eq = part.indexOf('=') val value = part.value.trim()
if (eq <= 0) return null when (part.key) {
val key = part.substring(0, eq).uppercase()
val value = part.substring(eq + 1).trim()
when (key) {
"FREQ" -> { "FREQ" -> {
freq = true
weekly = value.equals("WEEKLY", ignoreCase = true) weekly = value.equals("WEEKLY", ignoreCase = true)
part part.raw
} }
"BYDAY" -> { "BYDAY" -> {
byDay = true
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
if (!value.equals(old, ignoreCase = true)) return null if (!value.equals(old, ignoreCase = true)) return null
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}" "BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
} }
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part "INTERVAL", "COUNT", "UNTIL", "WKST" -> part.raw
else -> return null else -> return null
} }
} }
// BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence. // BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null if (!freq || (byDay && !weekly)) return null
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
return prefix + rebuilt.joinToString(";") return prefix + rebuilt.joinToString(";")
} }
@@ -157,14 +157,36 @@ fun timeZoneOptionOf(
* the offset isn't stated twice in ICU's spelling and ours. * the offset isn't stated twice in ICU's spelling and ours.
*/ */
fun zoneDescriptor(option: TimeZoneOption): String { fun zoneDescriptor(option: TimeZoneOption): String {
val abbrev = option.shortName val name = zoneAbbreviation(option)
return when { val statesOffset = option.shortName == "UTC" || option.shortName.startsWith("GMT")
abbrev == "UTC" -> "UTC" return if (statesOffset) name else "$name · ${formatGmtOffset(option.offsetMinutes)}"
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
}
} }
/**
* [option]'s short name on its own — "CEST", "UTC" — with an offset-shaped one
* normalised to our GMT form, as [zoneDescriptor] spells it.
*/
fun zoneAbbreviation(option: TimeZoneOption): String = abbreviation(option.shortName, option.offsetMinutes)
/**
* Zone [id]'s abbreviation [at] an instant, as [zoneAbbreviation] spells it,
* without the localized long name a whole [TimeZoneOption] would look up. Null
* for an id the tz database can't parse.
*/
fun zoneAbbreviationAt(
id: String,
locale: Locale,
at: Instant,
regionOf: (String) -> String? = { null },
): String? {
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
val shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf)
return abbreviation(shortName, zone.rules.getOffset(at).totalSeconds / 60)
}
private fun abbreviation(shortName: String, offsetMinutes: Int): String =
if (shortName.startsWith("GMT")) formatGmtOffset(offsetMinutes) else shortName
/** /**
* [options] matching [query], best matches first; a blank query returns the list * [options] matching [query], best matches first; a blank query returns the list
* unchanged. Matching is accent- and case-insensitive and treats underscores as * unchanged. Matching is accent- and case-insensitive and treats underscores as
@@ -0,0 +1,47 @@
package de.jeanlucmakiola.calendula.domain.ics
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.format
import kotlinx.datetime.format.char
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/** RFC 5545 basic format, `yyyyMMddTHHmmss`. */
private val BASIC_DATE_TIME = LocalDateTime.Format {
date(LocalDate.Formats.ISO_BASIC)
char('T')
hour()
minute()
second()
}
/** `yyyyMMdd`. */
internal fun LocalDate.toBasic(): String = format(LocalDate.Formats.ISO_BASIC)
/** `yyyyMMddTHHmmss`, floating. */
internal fun LocalDateTime.toBasic(): String = format(BASIC_DATE_TIME)
/** `yyyyMMddTHHmmssZ`, the instant in UTC. */
internal fun Instant.toUtcBasic(): String = toLocalDateTime(TimeZone.UTC).toBasic() + "Z"
/**
* The date in the first eight characters of a basic-format value; whatever
* follows (a time, a `Z`) is ignored. Null when those aren't a valid date.
*/
internal fun parseBasicDate(s: String): LocalDate? = runCatching {
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
}.getOrNull()
/** `yyyyMMddTHHmm[ss]`; the separator isn't checked and missing seconds read as zero. */
internal fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
val date = parseBasicDate(s) ?: return null
val time = LocalTime(
s.substring(9, 11).toInt(),
s.substring(11, 13).toInt(),
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
)
LocalDateTime(date, time)
}.getOrNull()
@@ -2,19 +2,17 @@ package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.LocalDate import de.jeanlucmakiola.calendula.domain.zoneOrNull
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
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 kotlinx.datetime.toLocalDateTime
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
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 private const val MINUTES_PER_DAY = 1_440
/** /**
@@ -318,13 +316,9 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
dtEnd: IcsDateTime?, dtEnd: IcsDateTime?,
duration: String?, duration: String?,
): Instant { ): Instant {
val explicit = dtEnd?.instant ?: duration?.let { val explicit = dtEnd?.instant ?: duration?.let { start.instant + parseRfc2445DurationMillis(it).milliseconds }
Instant.fromEpochMilliseconds(
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
)
}
if (!start.isAllDay) return explicit ?: start.instant if (!start.isAllDay) return explicit ?: start.instant
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1) return explicit?.takeIf { it > start.instant } ?: (start.instant + 1.days)
} }
/** /**
@@ -343,17 +337,17 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
*/ */
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> { private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
if (lines.isEmpty()) return emptyList() if (lines.isEmpty()) return emptyList()
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone val startZone = zoneOrNull(start.zoneId) ?: deviceZone
val startTime = start.instant.toLocalDateTime(startZone).time val startTime = start.instant.toLocalDateTime(startZone).time
return lines return lines
.flatMap { line -> line.value.split(',').map { line to it.trim() } } .flatMap { line -> line.value.split(',').map { line to it.trim() } }
.mapNotNull { (line, token) -> .mapNotNull { (line, token) ->
when { when {
token.isEmpty() -> null token.isEmpty() -> null
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode) start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.toBasic()
token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp) token.contains('T') -> parseExDateTime(token, line, startZone)?.toUtcBasic()
else -> parseBasicDate(token) else -> parseBasicDate(token)
?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) } ?.let { LocalDateTime(it, startTime).toInstant(startZone).toUtcBasic() }
} }
} }
.distinct() .distinct()
@@ -373,7 +367,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
val zone = when { val zone = when {
token.endsWith("Z") -> TimeZone.UTC token.endsWith("Z") -> TimeZone.UTC
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() } else -> line.params["TZID"]?.let(::zoneOrNull)
?: startZone ?: startZone
} }
return ldt.toInstant(zone) return ldt.toInstant(zone)
@@ -409,7 +403,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC") if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
val tzid = line.params["TZID"] val tzid = line.params["TZID"]
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } } val resolved = tzid?.let { id -> zoneOrNull(id)?.let { id to it } }
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone) if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone) val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId) return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
@@ -421,16 +415,6 @@ 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 * 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 * for the source calendar's name. A multi-item list is a tag list and
@@ -449,59 +433,26 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
.takeIf { it.isNotEmpty() && !it.equals("null", true) } .takeIf { it.isNotEmpty() && !it.equals("null", true) }
} }
/** 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 * 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 * `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
* nested `VALARM` is part of the body and is not a boundary. * nested `VALARM` is part of the body and is not a boundary.
*/ */
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int { fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int =
var i = from (from until end).firstOrNull { i ->
while (i < end) {
val line = parseContentLine(lines[i]) val line = parseContentLine(lines[i])
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) { line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))
return i } ?: end
}
i++
}
return end
}
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int { /** Index of the matching `END:<component>` at/after [from], or list end. */
var i = from fun indexOfEnd(lines: List<String>, from: Int, component: String): Int =
while (i < lines.size) { (from until lines.size).firstOrNull { i ->
val line = parseContentLine(lines[i]) val line = parseContentLine(lines[i])
if (line != null && line.name == "END" && line != null && line.name == "END" && line.value.trim().equals(component, true)
line.value.trim().equals(component, true) } ?: lines.size
) {
return i
}
i++
}
return lines.size
}
fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) { fun mapIcsStatus(value: String): EventStatus =
"TENTATIVE" -> EventStatus.Tentative EventStatus.entries.firstOrNull { it.name.equals(value.trim(), ignoreCase = true) }
"CANCELLED" -> EventStatus.Cancelled ?: EventStatus.Confirmed
else -> EventStatus.Confirmed
}
fun parseBasicDate(s: String): LocalDate? = runCatching {
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
}.getOrNull()
fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
val date = LocalDate(
s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
)
// Format is YYYYMMDD 'T' HHMMSS; seconds optional.
val time = LocalTime(
s.substring(9, 11).toInt(),
s.substring(11, 13).toInt(),
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
)
LocalDateTime(date, time)
}.getOrNull()
} }
} }
@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.domain.ics package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.rruleParts
private val VALID_FREQ = setOf( private val VALID_FREQ = setOf(
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY", "SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
) )
@@ -79,13 +81,12 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
return kept.takeIf { it.isNotEmpty() }?.joinToString(",") return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
} }
for (part in rule.split(';')) {
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost. // A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part // A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
// being dropped, so it falls through and marks the rule repaired. // being dropped, so it falls through and marks the rule repaired.
if (part.isBlank()) continue for (part in rruleParts(raw.orEmpty().trim())) {
val key = part.substringBefore('=', "").trim().uppercase() val key = part.key?.trim().orEmpty()
val value = if ('=' in part) part.substringAfter('=').trim() else "" val value = part.value.trim()
val kept = when { val kept = when {
key.isEmpty() || value.isEmpty() -> null key.isEmpty() || value.isEmpty() -> null
key == "FREQ" -> key == "FREQ" ->
@@ -2,9 +2,8 @@ package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import kotlinx.datetime.LocalDateTime import de.jeanlucmakiola.calendula.domain.zoneOrNull
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
@@ -41,7 +40,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) { private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) {
add("BEGIN:VEVENT") add("BEGIN:VEVENT")
add("UID:${event.uid}") add("UID:${event.uid}")
add("DTSTAMP:${utcStamp(dtStamp)}") add("DTSTAMP:${dtStamp.toUtcBasic()}")
add("SUMMARY:${escapeText(event.summary)}") add("SUMMARY:${escapeText(event.summary)}")
appendTimes(event) appendTimes(event)
event.recurrenceRule?.takeIf { it.isNotBlank() } event.recurrenceRule?.takeIf { it.isNotBlank() }
@@ -63,24 +62,24 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
private fun MutableList<String>.appendTimes(event: IcsEvent) = when { private fun MutableList<String>.appendTimes(event: IcsEvent) = when {
event.isAllDay -> { event.isAllDay -> {
add("DTSTART;VALUE=DATE:${utcDate(event.start)}") add("DTSTART;VALUE=DATE:${event.start.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
add("DTEND;VALUE=DATE:${utcDate(event.end)}") add("DTEND;VALUE=DATE:${event.end.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
} }
// Recurring: anchor to wall-clock in the event's own zone. // Recurring: anchor to wall-clock in the event's own zone.
event.recurrenceRule?.isNotBlank() == true -> { event.recurrenceRule?.isNotBlank() == true -> {
val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull() val zone = zoneOrNull(event.zoneId)
if (zone != null) { if (zone != null) {
add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}") add("DTSTART;TZID=${event.zoneId}:${event.start.toLocalDateTime(zone).toBasic()}")
add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}") add("DTEND;TZID=${event.zoneId}:${event.end.toLocalDateTime(zone).toBasic()}")
} else { } else {
// Unknown zone id → fall back to plain UTC instants. // Unknown zone id → fall back to plain UTC instants.
add("DTSTART:${utcStamp(event.start)}") add("DTSTART:${event.start.toUtcBasic()}")
add("DTEND:${utcStamp(event.end)}") add("DTEND:${event.end.toUtcBasic()}")
} }
} }
else -> { else -> {
add("DTSTART:${utcStamp(event.start)}") add("DTSTART:${event.start.toUtcBasic()}")
add("DTEND:${utcStamp(event.end)}") add("DTEND:${event.end.toUtcBasic()}")
} }
} }
@@ -120,20 +119,5 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
// A lead time of 0 fires at start (PT0M); anything positive is "before". // A lead time of 0 fires at start (PT0M); anything positive is "before".
fun triggerValue(minutes: Int): String = fun triggerValue(minutes: Int): String =
if (minutes <= 0) "PT0M" else "-PT${minutes}M" if (minutes <= 0) "PT0M" else "-PT${minutes}M"
fun utcStamp(instant: Instant): String =
basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z"
fun localStamp(instant: Instant, zone: TimeZone): String =
basic(instant.toLocalDateTime(zone))
fun utcDate(instant: Instant): String {
val dt = instant.toLocalDateTime(TimeZone.UTC)
return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day)
}
fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format(
dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second,
)
} }
} }
@@ -1,9 +1,9 @@
package de.jeanlucmakiola.calendula.domain.ics package de.jeanlucmakiola.calendula.domain.ics
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import kotlinx.datetime.LocalDate import de.jeanlucmakiola.calendula.domain.allDayFormTimes
import kotlinx.datetime.LocalDateTime import de.jeanlucmakiola.calendula.domain.normalizedRrule
import kotlinx.datetime.LocalTime import de.jeanlucmakiola.calendula.domain.toFormTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
@@ -21,12 +21,9 @@ import kotlinx.datetime.toLocalDateTime
*/ */
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm { fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
val (start, end) = if (isAllDay) { val (start, end) = if (isAllDay) {
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date allDayFormTimes(this.start.toLocalDateTime(TimeZone.UTC).date, this.end.toLocalDateTime(TimeZone.UTC).date)
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
} else { } else {
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone) this.start.toFormTime(zone) to this.end.toFormTime(zone)
} }
return EventForm( return EventForm(
calendarId = null, calendarId = null,
@@ -38,6 +35,6 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
description = description.orEmpty(), description = description.orEmpty(),
reminders = semanticReminderMinutes(), reminders = semanticReminderMinutes(),
availability = availability, availability = availability,
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, rrule = recurrenceRule.normalizedRrule(),
) )
} }
@@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.SeekableTransitionState import androidx.compose.animation.core.SeekableTransitionState
@@ -19,6 +20,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@@ -27,8 +29,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.screen import de.jeanlucmakiola.calendula.ui.common.screen
import de.jeanlucmakiola.calendula.ui.common.ViewScreen import de.jeanlucmakiola.calendula.ui.common.ViewScreen
@@ -37,6 +41,7 @@ import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
@@ -55,7 +60,13 @@ import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBackExit import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventActionsViewModel
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
import de.jeanlucmakiola.calendula.ui.common.EventSelectionHost
import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage
import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGateOr
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.LocalManageCalendars
@@ -76,6 +87,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView import de.jeanlucmakiola.calendula.ui.common.selectView
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditViewModel
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.edit.ImportSource import de.jeanlucmakiola.calendula.ui.edit.ImportSource
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
@@ -90,7 +102,7 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock import kotlin.time.Instant
/** /**
* Holds the top-level view back stack (spec M1) and swaps between the calendar * Holds the top-level view back stack (spec M1) and swaps between the calendar
@@ -106,28 +118,18 @@ import kotlin.time.Clock
* view remains. So back from a widget-opened screen returns to that widget's * view remains. So back from a widget-opened screen returns to that widget's
* view, then home; and back through pill switches walks the views in reverse. * view, then home; and back through pill switches walks the views in reverse.
* *
* [requestedDetailKey] is an externally requested occurrence (a tapped * [requests] carries what notifications, widgets and other apps asked to
* reminder notification routed through MainActivity): it opens the detail * open. A tapped reminder's occurrence opens the detail overlay exactly like an
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed] * event tap; each request is cleared once acted on so a later recomposition
* so a later recomposition can't re-open it. (A widget event tap instead arrives * can't re-open it. (A widget event tap instead arrives as
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the * [WidgetNavRequest.OpenEvent], which also roots the back stack in the widget's
* widget's view.) * view.)
*/ */
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun CalendarHost( fun CalendarHost(
requests: ExternalRequests,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
requestedInsertForm: EventForm? = null,
requestedInsertSource: ImportSource = ImportSource.Insert,
onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(), viewModel: CalendarHostViewModel = hiltViewModel(),
) { ) {
// Wait for the persisted default view before seeding the stack, so the app // Wait for the persisted default view before seeding the stack, so the app
@@ -146,14 +148,27 @@ fun CalendarHost(
mutableStateOf(listOf(defaultView)) mutableStateOf(listOf(defaultView))
} }
var navigationVersion by remember { mutableIntStateOf(0) } var navigationVersion by remember { mutableIntStateOf(0) }
// True after backing out of a day opened from the view now shown, so the
// agenda returns to where it was instead of today (#367).
var returnedFromDay by remember { mutableStateOf(false) }
val view = viewStack.last() val view = viewStack.last()
// The date every view opens on and carries along (#184); tapping a day // The date every view opens on and carries along (#184); tapping a day
// anywhere focuses it and opens the day view there. // anywhere focuses it and opens the day view there.
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) } val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(currentDate()) }
// A focus still on yesterday follows the date over, so the next view opened
// doesn't land on the old day (#373); one moved elsewhere stays put.
val today = viewModel.today.collectAsStateWithLifecycle().value
var focusToday by rememberSaveable { mutableStateOf(today.toString()) }
LaunchedEffect(today) {
val old = LocalDate.parse(focusToday)
if (old != today && focus.date == old) focus.date = today
focusToday = today.toString()
}
val switchScope = rememberCoroutineScope() val switchScope = rememberCoroutineScope()
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move -> val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
navigationVersion++ navigationVersion++
returnedFromDay = false
viewStack = move(viewStack) viewStack = move(viewStack)
} }
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } } val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
@@ -170,27 +185,17 @@ fun CalendarHost(
// it overlays whichever calendar view is active. We forward the tapped // it overlays whichever calendar view is active. We forward the tapped
// occurrence's own times (eventId + begin + end, packed as a saveable // occurrence's own times (eventId + begin + end, packed as a saveable
// long[]) so recurring events show the correct date, not the series start. // long[]) so recurring events show the correct date, not the series start.
// [heldKey] keeps the last shown key alive through the slide-out (when // Like every overlay below, it draws its last key through the slide-out
// [detailKey] is cleared); it is set in the tap callback — never a [0,0,0] // ([rememberHeld]) rather than a placeholder.
// placeholder — so the destination never loads a bogus id=0 on first frame.
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) } var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldKey by remember { mutableStateOf<LongArray?>(null) } val onOpenDetail: (EventInstance) -> Unit = { event -> detailKey = event.occurrenceKey() }
val onEventClick: (EventInstance) -> Unit = { event ->
val key = longArrayOf(
event.eventId,
event.start.toEpochMilliseconds(),
event.end.toEpochMilliseconds(),
)
heldKey = key
detailKey = key
}
// A tapped reminder notification asks for a specific occurrence. // A tapped reminder notification asks for a specific occurrence.
val requestedDetailKey = requests.detailKey
LaunchedEffect(requestedDetailKey) { LaunchedEffect(requestedDetailKey) {
if (requestedDetailKey != null) { if (requestedDetailKey != null) {
heldKey = requestedDetailKey
detailKey = requestedDetailKey detailKey = requestedDetailKey
onDetailKeyConsumed() requests.detailKey = null
} }
} }
@@ -214,19 +219,17 @@ fun CalendarHost(
// calendar list rather than by preferences. Reached from both. // calendar list rather than by preferences. Reached from both.
var showBackup by rememberSaveable { mutableStateOf(false) } var showBackup by rememberSaveable { mutableStateOf(false) }
// Event form (v1.2 create) — same held-key pattern as the detail screen: // Event form (v1.2 create), opened on a date, a tapped slot or a drafted block.
// [heldCreateIso] keeps the prefill date alive through the slide-out. var createRequest by rememberSaveable(stateSaver = createRequestSaver) {
// [createStartMinutes] is the tapped slot's start (minutes from midnight) mutableStateOf<CreateRequest?>(null)
// when the form is opened from a day/week grid tap; null from the FAB. }
var createDateIso by rememberSaveable { mutableStateOf<String?>(null) } fun openCreate(dateIso: String, startMinutes: Int? = null, draftMillis: LongArray? = null) {
var heldCreateIso by remember { mutableStateOf<String?>(null) } createRequest = CreateRequest(dateIso, startMinutes, draftMillis)
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) } }
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) } val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> openCreate(date.toString(), startMinutes) }
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> val onCreateDraft: (Instant, Instant) -> Unit = { start, end ->
heldCreateIso = date.toString() val date = start.toLocalDateTime(TimeZone.currentSystemDefault()).date
createDateIso = date.toString() openCreate(date.toString(), draftMillis = longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds()))
heldCreateMinutes = startMinutes
createStartMinutes = startMinutes
} }
// Edit form (v1.3) — reuses the detail screen's occurrence key; for // Edit form (v1.3) — reuses the detail screen's occurrence key; for
@@ -236,7 +239,6 @@ fun CalendarHost(
// falling back to the auto-refreshing calendar is the only honest // falling back to the auto-refreshing calendar is the only honest
// destination. // destination.
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) } var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
// An opened/received .ics file. [ImportScreen] parses it and either opens // An opened/received .ics file. [ImportScreen] parses it and either opens
// the prefilled create form (one event → [importForm]) or its own bulk // the prefilled create form (one event → [importForm]) or its own bulk
@@ -246,6 +248,34 @@ fun CalendarHost(
// Which channel filled [importForm]: an .ics file (prompt to apply the default // Which channel filled [importForm]: an .ics file (prompt to apply the default
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49. // reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
var importFormSource by remember { mutableStateOf(ImportSource.File) } var importFormSource by remember { mutableStateOf(ImportSource.File) }
// Bumped per open, so even an equal prefill starts a fresh form.
var importFormId by remember { mutableIntStateOf(0) }
// Whether [importForm] came from an external create request (#30, #306);
// closing it then hands control back to whoever asked.
var importFromRequest by rememberSaveable { mutableStateOf(false) }
// A second external request that arrived while a prefilled form was open:
// it waits behind that form's unsaved-changes prompt, then opens.
var pendingInsert by remember { mutableStateOf<Pair<EventForm, ImportSource>?>(null) }
val importViewModel: EventEditViewModel = hiltViewModel(key = "import")
fun openImportForm(form: EventForm, source: ImportSource, fromRequest: Boolean = false) {
importFormSource = source
importForm = form
importFormId++
importFromRequest = fromRequest
}
fun closeImportForm() {
val next = pendingInsert
pendingInsert = null
if (next != null) {
openImportForm(next.first, next.second, fromRequest = true)
} else {
importForm = null
if (importFromRequest) {
importFromRequest = false
requests.onExternalFormClosed()
}
}
}
// A restore (in-app "Restore from .ics" button) always runs the full import // A restore (in-app "Restore from .ics" button) always runs the full import
// flow — picker + summary — even for a single-event file, because the intent // flow — picker + summary — even for a single-event file, because the intent
// 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
@@ -261,23 +291,28 @@ fun CalendarHost(
// what tells it a *re-import of the same file* is new work and not the run // 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. // it already finished; a rotation keeps the number and keeps the result.
var importSession by rememberSaveable { mutableStateOf(0) } var importSession by rememberSaveable { mutableStateOf(0) }
val requestedImportUri = requests.importUri
LaunchedEffect(requestedImportUri) { LaunchedEffect(requestedImportUri) {
if (requestedImportUri != null) { if (requestedImportUri != null) {
importUri = requestedImportUri importUri = requestedImportUri
importForceMany = false importForceMany = false
importSession++ importSession++
onImportConsumed() requests.importUri = null
} }
} }
// An external ACTION_INSERT launch (another app/widget creating an event, // An external ACTION_INSERT launch (another app/widget creating an event,
// issue #30) arrives already prefilled — open it in the same create form the // issue #30) arrives already prefilled — open it in the same create form the
// single-event .ics path uses. [importForm] is the topmost overlay, so it // single-event .ics path uses. [importForm] is the topmost overlay, so it
// reveals on top of whatever was open without extra dismissal. // reveals on top of whatever was open without extra dismissal.
LaunchedEffect(requestedInsertForm) { val requestedInsert = requests.insert
if (requestedInsertForm != null) { LaunchedEffect(requestedInsert) {
importFormSource = requestedInsertSource if (requestedInsert != null) {
importForm = requestedInsertForm if (importForm == null) {
onInsertConsumed() openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true)
} else {
pendingInsert = requestedInsert.form to requestedInsert.source
}
requests.insert = null
} }
} }
@@ -292,50 +327,55 @@ fun CalendarHost(
editKey = null editKey = null
importUri = null importUri = null
importForm = null importForm = null
importFromRequest = false
pendingInsert = null
importViewModel.reset()
} }
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or // An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
// widget) opens the occurrence straight in the edit form. Drop any covering // widget) opens the occurrence straight in the edit form. Drop any covering
// overlay first — the edit overlay sits below Settings/import in the Box, so // overlay first — the edit overlay sits below Settings/import in the Box, so
// without this it would open hidden underneath them. Same held-key pattern as // without this it would open hidden underneath them. A saved edit just
// a detail-screen "Edit" tap; a saved edit just returns to the calendar. // returns to the calendar.
val requestedEditKey = requests.editKey
LaunchedEffect(requestedEditKey) { LaunchedEffect(requestedEditKey) {
if (requestedEditKey != null) { if (requestedEditKey != null) {
dismissCoveringOverlays() dismissCoveringOverlays()
heldEditKey = requestedEditKey
editKey = requestedEditKey editKey = requestedEditKey
onEditKeyConsumed() requests.editKey = null
} }
} }
// A home-screen widget launch asks to open a date (→ day view), open an // A home-screen widget launch asks to open a date (→ day view), open an
// event's detail, or start a create. Handled once and cleared, mirroring // event's detail, or start a create. Handled once and cleared, mirroring
// [requestedDetailKey]. Date/event opens root the stack in the widget's own // the detail request. Date/event opens root the stack in the widget's own
// view so backing out returns there (then home), not to the default. // view so backing out returns there (then home), not to the default.
val widgetNavRequest = requests.nav
LaunchedEffect(widgetNavRequest) { LaunchedEffect(widgetNavRequest) {
if (widgetNavRequest != null) navigationVersion++ if (widgetNavRequest != null) {
navigationVersion++
returnedFromDay = false
}
when (val req = widgetNavRequest) { when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> { is WidgetNavRequest.OpenDate -> {
// Drill the day view in over the widget's view: drop any overlay // Drill the day view in over the widget's view: drop any overlay
// that would cover it, so the open doesn't land under Settings/form. // that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays() dismissCoveringOverlays()
createDateIso = null createRequest = null
focus.navigateTo(LocalDate.parse(req.dateIso)) focus.navigateTo(LocalDate.parse(req.dateIso))
// No widget source (an external date tap) roots over the default // No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits. // home view, so backing out of the day returns home then exits.
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay() viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
onWidgetNavConsumed() requests.nav = null
} }
is WidgetNavRequest.OpenEvent -> { is WidgetNavRequest.OpenEvent -> {
// Root the stack in the widget's view, then open the occurrence // Root the stack in the widget's view, then open the occurrence
// detail over it (same key shape as a tapped event / reminder). // detail over it (same key shape as a tapped event / reminder).
dismissCoveringOverlays() dismissCoveringOverlays()
createDateIso = null createRequest = null
viewStack = viewBaseStack(defaultView, req.source) viewStack = viewBaseStack(defaultView, req.source)
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis) detailKey = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
heldKey = key requests.nav = null
detailKey = key
onWidgetNavConsumed()
} }
is WidgetNavRequest.OpenView -> { is WidgetNavRequest.OpenView -> {
// A widget header tap: land on a top-level view with no date // A widget header tap: land on a top-level view with no date
@@ -343,10 +383,10 @@ fun CalendarHost(
// the stack on the target (null → the default home) over the // the stack on the target (null → the default home) over the
// default home — so backing out returns to the default, then exits. // default home — so backing out returns to the default, then exits.
dismissCoveringOverlays() dismissCoveringOverlays()
createDateIso = null createRequest = null
focus.navigateTo(localToday()) focus.navigateTo(currentDate())
viewStack = viewBaseStack(defaultView, req.view ?: defaultView) viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
onWidgetNavConsumed() requests.nav = null
} }
is WidgetNavRequest.Create -> { is WidgetNavRequest.Create -> {
// External "new event" entries (QS tile / launcher shortcut / // External "new event" entries (QS tile / launcher shortcut /
@@ -354,13 +394,8 @@ fun CalendarHost(
// sits below Settings/calendars in the Box, so without this it // sits below Settings/calendars in the Box, so without this it
// would open hidden underneath them. // would open hidden underneath them.
dismissCoveringOverlays() dismissCoveringOverlays()
val iso = req.dateIso ?: Clock.System.now() openCreate(req.dateIso ?: currentDate().toString())
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString() requests.nav = null
heldCreateIso = iso
createDateIso = iso
heldCreateMinutes = null
createStartMinutes = null
onWidgetNavConsumed()
} }
null -> {} null -> {}
} }
@@ -370,14 +405,31 @@ fun CalendarHost(
// survives view switches; the calendar surfaces read it out of LocalEventMove. // survives view switches; the calendar surfaces read it out of LocalEventMove.
val reschedule: RescheduleViewModel = hiltViewModel() val reschedule: RescheduleViewModel = hiltViewModel()
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle() val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
val onEditEvent: (EventInstance) -> Unit = { event -> val eventActions: EventActionsViewModel = hiltViewModel()
val key = longArrayOf( val moveOutcome by reschedule.outcome.collectAsStateWithLifecycle()
event.eventId, val selectionFailure by eventActions.failure.collectAsStateWithLifecycle()
event.start.toEpochMilliseconds(), val onEditEvent: (EventInstance) -> Unit = { event -> editKey = event.occurrenceKey() }
event.end.toEpochMilliseconds(), // A tap opens the edit form instead of the detail screen when the user chose
) // so (#178) — only for events whose calendar takes writes. Both are read at
heldEditKey = key // tap time, so a tap before they've loaded still routes right.
editKey = key val tapScope = rememberCoroutineScope()
val tapContext = LocalContext.current
val requireWriteOr = rememberWriteGateOr()
// A refused write upgrade is asked once; later taps just open the details.
var tapWriteRefused by rememberSaveable { mutableStateOf(false) }
val onEventClick: (EventInstance) -> Unit = { event ->
tapScope.launch {
val toEditor = viewModel.tapOpensEditor() && eventActions.canModify(event.calendarId)
when {
!toEditor -> onOpenDetail(event)
tapContext.canWriteCalendar() -> onEditEvent(event)
tapWriteRefused -> onOpenDetail(event)
else -> requireWriteOr({ onEditEvent(event) }) {
tapWriteRefused = true
onOpenDetail(event)
}
}
}
} }
// Off by preference (#173) switches off the gesture, not the scope: blocks // Off by preference (#173) switches off the gesture, not the scope: blocks
// register no drag, while the edit form and its TalkBack action stay put. // register no drag, while the edit form and its TalkBack action stay put.
@@ -394,15 +446,10 @@ fun CalendarHost(
) )
} }
val slideSpec = rememberCalendarSlideSpec()
// Overlays leave from the predictive-back preview (#184): shrinking on from
// where the gesture let go instead of snapping back and sliding off.
val overlayExit = predictiveBackExit()
// Base-level back: pop the view stack while no overlay covers it (each overlay // Base-level back: pop the view stack while no overlay covers it (each overlay
// owns its own BackHandler and takes precedence). Disabled at the home view, // owns its own BackHandler and takes precedence). Disabled at the home view,
// so back there falls through to the system and exits the app. // so back there falls through to the system and exits the app.
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null || val anyOverlayVisible = showSearch || detailKey != null || createRequest != null ||
editKey != null || showSettings || showCalendars || showBackup || editKey != null || showSettings || showCalendars || showBackup ||
importUri != null || importForm != null importUri != null || importForm != null
// The view switch runs on a seekable transition, so a back gesture can scrub // The view switch runs on a seekable transition, so a back gesture can scrub
@@ -415,11 +462,14 @@ fun CalendarHost(
if (reduceMotion) { if (reduceMotion) {
BackHandler(enabled = viewBackEnabled) { BackHandler(enabled = viewBackEnabled) {
navigationVersion++ navigationVersion++
returnedFromDay = viewStack.last() == CalendarView.Day
viewStack = viewStack.dropLast(1) viewStack = viewStack.dropLast(1)
} }
} else { } else {
PredictiveBackHandler(enabled = viewBackEnabled) { events -> PredictiveBackHandler(enabled = viewBackEnabled) { events ->
val previous = viewStack[viewStack.size - 2] val previous = viewStack[viewStack.size - 2]
// Set before the seek, which already composes the previous view.
returnedFromDay = viewStack.last() == CalendarView.Day
try { try {
events.collect { viewTransition.seekTo(it.progress, targetState = previous) } events.collect { viewTransition.seekTo(it.progress, targetState = previous) }
navigationVersion++ navigationVersion++
@@ -508,7 +558,24 @@ fun CalendarHost(
showTodayButton = todayInToolbar, showTodayButton = todayInToolbar,
) )
}, },
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) }, floatingActionButton = {
// While an event is selected the FAB is its action bar's toggle (#141).
val fabSelection = rememberFabSelection(
host = chromeHost,
viewModel = eventActions,
onEdit = onEditEvent,
onDuplicate = { form ->
// A fresh event, like a duplicate from the detail screen (#52).
openImportForm(form, ImportSource.Insert)
},
chipShown = moveOutcome != null || selectionFailure != null,
onDismissChip = {
reschedule.consumeOutcome()
eventActions.consumeFailure()
},
)
CalendarFab(chromeHost, todayInToolbar, fabSelection)
},
) { innerPadding -> ) { innerPadding ->
SharedTransitionLayout( SharedTransitionLayout(
Modifier Modifier
@@ -539,6 +606,7 @@ fun CalendarHost(
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
onCreateDraft = onCreateDraft,
) )
ViewScreen.Month -> MonthScreen( ViewScreen.Month -> MonthScreen(
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
@@ -551,6 +619,7 @@ fun CalendarHost(
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews, quickSwitchViews = quickSwitchViews,
restorePosition = returnedFromDay,
) )
} }
} }
@@ -562,15 +631,19 @@ fun CalendarHost(
// Scope prompt + confirmation/undo snackbar for a dropped event, declared // Scope prompt + confirmation/undo snackbar for a dropped event, declared
// right after the calendar views so later overlays cover it. // right after the calendar views so later overlays cover it.
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize()) EventMoveHost(
reschedule,
modifier = Modifier.fillMaxSize(),
actionFailure = eventActionFailureMessage(selectionFailure),
onActionFailureShown = eventActions::consumeFailure,
)
// The delete prompt and failure chip of a selected event's actions (#141).
EventSelectionHost(host = chromeHost, viewModel = eventActions)
// Search overlay — below detail/edit in the Box so a tapped result's // Search overlay — below detail/edit in the Box so a tapped result's
// detail screen draws on top, and closing it returns to the results. // detail screen draws on top, and closing it returns to the results.
AnimatedVisibility( SlideOverlay(visible = showSearch) {
visible = showSearch,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
SearchScreen( SearchScreen(
onBack = { showSearch = false }, onBack = { showSearch = false },
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -578,29 +651,20 @@ fun CalendarHost(
) )
} }
// Prefer the live key; fall back to the held one only while sliding out. val shownDetailKey = rememberHeld(detailKey)
val activeKey = detailKey ?: heldKey SlideOverlay(visible = detailKey != null) {
AnimatedVisibility( shownDetailKey?.let { key ->
visible = detailKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
activeKey?.let { key ->
EventDetailScreen( EventDetailScreen(
eventId = key[0], eventId = key[0],
beginMillis = key[1], beginMillis = key[1],
endMillis = key[2], endMillis = key[2],
onBack = { detailKey = null }, onBack = { detailKey = null },
onEdit = { onEdit = { editKey = key },
heldEditKey = key
editKey = key
},
onDuplicate = { form -> onDuplicate = { form ->
// Reuse the prefilled-create overlay: a duplicate is a // Reuse the prefilled-create overlay: a duplicate is a
// fresh event, so it applies the default reminder like an // fresh event, so it applies the default reminder like an
// in-app new event (#52). // in-app new event (#52).
importFormSource = ImportSource.Insert openImportForm(form, ImportSource.Insert)
importForm = form
detailKey = null detailKey = null
}, },
visible = detailKey != null, visible = detailKey != null,
@@ -609,30 +673,26 @@ fun CalendarHost(
} }
// Event form (v1.2) — full-screen destination, slides over the calendar. // Event form (v1.2) — full-screen destination, slides over the calendar.
AnimatedVisibility( val shownCreate = rememberHeld(createRequest)
visible = createDateIso != null, SlideOverlay(visible = createRequest != null) {
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), shownCreate?.let { create ->
exit = overlayExit,
) {
(createDateIso ?: heldCreateIso)?.let { iso ->
EventEditScreen( EventEditScreen(
initialDateIso = iso, initialDateIso = create.dateIso,
initialStartMinutes = createStartMinutes ?: heldCreateMinutes, initialStartMinutes = create.startMinutes,
onClose = { createDateIso = null }, initialDraft = create.draftMillis,
onSaved = { createDateIso = null }, onClose = { createRequest = null },
onSaved = { createRequest = null },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
visible = createDateIso != null, visible = createRequest != null,
viewModel = hiltViewModel(key = "create"),
) )
} }
} }
// Edit form (v1.3) — slides over the detail screen. // Edit form (v1.3) — slides over the detail screen.
AnimatedVisibility( val shownEditKey = rememberHeld(editKey)
visible = editKey != null, SlideOverlay(visible = editKey != null) {
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), shownEditKey?.let { key ->
exit = overlayExit,
) {
(editKey ?: heldEditKey)?.let { key ->
EventEditScreen( EventEditScreen(
initialDateIso = null, initialDateIso = null,
editKey = key, editKey = key,
@@ -642,17 +702,23 @@ fun CalendarHost(
detailKey = null detailKey = null
}, },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
onDuplicate = { form ->
openImportForm(form, ImportSource.Insert)
},
// Opened straight from a tap (#178) there's no detail screen
// below to show why, so open it.
onLoadFailed = {
editKey = null
if (detailKey == null) detailKey = key
},
visible = editKey != null, visible = editKey != null,
viewModel = hiltViewModel(key = "edit"),
) )
} }
} }
// Settings (M4) — full-screen destination, slides over the calendar. // Settings (M4) — full-screen destination, slides over the calendar.
AnimatedVisibility( SlideOverlay(visible = showSettings) {
visible = showSettings,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
SettingsScreen( SettingsScreen(
onBack = { showSettings = false }, onBack = { showSettings = false },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
@@ -688,29 +754,29 @@ fun CalendarHost(
importUri = null importUri = null
calendarsAfterImport = false calendarsAfterImport = false
backupAfterImport = false backupAfterImport = false
importFormSource = ImportSource.File openImportForm(form, ImportSource.File)
importForm = form
}, },
) )
} }
importForm?.let { form -> importForm?.let { form ->
key(importFormId) {
EventEditScreen( EventEditScreen(
initialDateIso = null, initialDateIso = null,
initialForm = form, initialForm = form,
initialFormSource = importFormSource, initialFormSource = importFormSource,
onClose = { importForm = null }, onClose = ::closeImportForm,
onSaved = { importForm = null }, onSaved = ::closeImportForm,
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
leaveRequested = pendingInsert != null,
onLeaveDeclined = { pendingInsert = null },
viewModel = importViewModel,
) )
} }
}
// Declared last so it covers every overlay that can open it: Settings, // Declared last so it covers every overlay that can open it: Settings,
// both event forms, and the .ics import picker (#76). // both event forms, and the .ics import picker (#76).
AnimatedVisibility( SlideOverlay(visible = showCalendars) {
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
CalendarsScreen( CalendarsScreen(
onBack = { showCalendars = false }, onBack = { showCalendars = false },
onOpenBackup = { showBackup = true }, onOpenBackup = { showBackup = true },
@@ -719,11 +785,7 @@ fun CalendarHost(
} }
// Backup & restore — over the manager, since the manager links into it. // Backup & restore — over the manager, since the manager links into it.
AnimatedVisibility( SlideOverlay(visible = showBackup) {
visible = showBackup,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
// Settings can open this screen without the manager underneath, so // Settings can open this screen without the manager underneath, so
// its failure states' "Manage calendars" way out has to open the // its failure states' "Manage calendars" way out has to open the
// manager rather than just pop — popping alone lands on Settings // manager rather than just pop — popping alone lands on Settings
@@ -762,5 +824,56 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } }, restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
) )
private fun localToday(): LocalDate = /**
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date * An overlay's slide-in from the end edge, leaving from the predictive-back
* preview (#184): shrinking on from where the gesture let go instead of
* snapping back and sliding off.
*/
@Composable
private fun SlideOverlay(visible: Boolean, content: @Composable AnimatedVisibilityScope.() -> Unit) {
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally(rememberCalendarSlideSpec()) { it } + fadeIn(),
exit = predictiveBackExit(),
content = content,
)
}
/**
* [live], or after it goes null the last value it had, so an overlay keeps
* drawing what it showed through its slide-out instead of flashing empty. Never
* a placeholder: until [live] is first set this is null, so a destination never
* loads a bogus key on its first frame. Not saved — after a recreation the
* saveable [live] is the value again.
*/
@Composable
private fun <T : Any> rememberHeld(live: T?): T? {
val held = remember { HeldValue<T>() }
if (live != null) held.value = live
return live ?: held.value
}
private class HeldValue<T : Any> {
var value: T? = null
}
/** The detail and edit overlays' key for one occurrence: eventId, begin, end. */
private fun EventInstance.occurrenceKey(): LongArray =
longArrayOf(eventId, start.toEpochMilliseconds(), end.toEpochMilliseconds())
/**
* A create-form open: its date, plus the tapped slot's start (minutes from
* midnight) from a day/week grid tap, or a drafted block's start and end (#153)
* as epoch millis. Instants rather than minutes into a day, so the form opens at
* the times drawn even across a DST change (#387).
*/
private class CreateRequest(
val dateIso: String,
val startMinutes: Int?,
val draftMillis: LongArray?,
)
private val createRequestSaver = Saver<CreateRequest?, List<Any?>>(
save = { request -> request?.let { listOf(it.dateIso, it.startMinutes, it.draftMillis) } },
restore = { CreateRequest(it[0] as String, it[1] as Int?, it[2] as LongArray?) },
)
@@ -4,12 +4,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
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 kotlinx.coroutines.flow.SharingStarted import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -21,36 +24,39 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class CalendarHostViewModel @Inject constructor( class CalendarHostViewModel @Inject constructor(
prefs: SettingsPrefs, prefs: SettingsPrefs,
todaySource: TodaySource,
) : ViewModel() { ) : ViewModel() {
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn( /** Today, kept current across midnight so the shared focus can follow it (#373). */
val today: StateFlow<LocalDate> = todaySource.today.stateWhileSubscribed(
scope = viewModelScope,
initialValue = currentDate(),
)
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null, initialValue = null,
) )
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */ /** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
.map { it.cycle } .map { it.cycle }
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS, initialValue = IMPLEMENTED_VIEWS,
) )
/** Order of the views in the navigation drawer (#24); every view always shown. */ /** Order of the views in the navigation drawer (#24); every view always shown. */
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS, initialValue = IMPLEMENTED_VIEWS,
) )
/** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */ /** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */
val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false, initialValue = false,
) )
@@ -62,9 +68,13 @@ class CalendarHostViewModel @Inject constructor(
* switched off to be rid of. * switched off to be rid of.
*/ */
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false, initialValue = false,
) )
private val tapOpensEditorPref = prefs.tapOpensEditor
/** Whether tapping an event opens the edit form rather than the detail screen (#178). */
suspend fun tapOpensEditor(): Boolean = tapOpensEditorPref.first()
} }
@@ -0,0 +1,42 @@
package de.jeanlucmakiola.calendula.ui
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
/**
* What a notification, widget, shortcut or another app asked the running app to
* open. MainActivity fills it from each intent (singleTop + onNewIntent route a
* new one into the running activity); CalendarHost acts on a request and clears
* it, so a later recomposition can't act on it twice.
*
* [onExternalFormClosed] runs when a form opened for [insert] closes, so the
* activity can hand control back to the app that asked.
*/
@Stable
class ExternalRequests(val onExternalFormClosed: () -> Unit = {}) {
/** A tapped reminder notification's occurrence (eventId, begin, end), for the detail screen. */
var detailKey by mutableStateOf<LongArray?>(null)
/** A home-screen widget, shortcut or tile navigation (open a date, an event, a view, or create). */
var nav by mutableStateOf<WidgetNavRequest?>(null)
/** An .ics file opened or shared into the app (ACTION_VIEW/SEND). */
var importUri by mutableStateOf<Uri?>(null)
/**
* A prefilled new-event form from an external ACTION_INSERT launch (#30) or
* shared text (#306), opened in the create form for review.
*/
var insert by mutableStateOf<InsertRequest?>(null)
/** An external "edit this event" (ACTION_EDIT), in the detail key's shape. */
var editKey by mutableStateOf<LongArray?>(null)
}
/** A prefilled create form from an external launch, with the source it came from. */
data class InsertRequest(val form: EventForm, val source: ImportSource)
@@ -1,7 +1,5 @@
package de.jeanlucmakiola.calendula.ui package de.jeanlucmakiola.calendula.ui
import android.Manifest
import android.content.pm.PackageManager
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -20,11 +18,11 @@ import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.ui.common.canReadCalendar
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
@@ -43,26 +41,12 @@ private sealed interface RootTarget {
@Composable @Composable
fun RootScreen( fun RootScreen(
requests: ExternalRequests,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
var hasPermission by remember { var hasPermission by remember {
mutableStateOf( mutableStateOf(context.canReadCalendar())
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR)
== PackageManager.PERMISSION_GRANTED
)
} }
// A launch scan already covers the app coming up with the permission, so // A launch scan already covers the app coming up with the permission, so
@@ -73,9 +57,7 @@ fun RootScreen(
DisposableEffect(lifecycle) { DisposableEffect(lifecycle) {
val obs = LifecycleEventObserver { _, event -> val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) { if (event == Lifecycle.Event.ON_RESUME) {
hasPermission = ContextCompat.checkSelfPermission( hasPermission = context.canReadCalendar()
context, Manifest.permission.READ_CALENDAR
) == PackageManager.PERMISSION_GRANTED
// Refresh the contact special-dates mirror on foreground (the // Refresh the contact special-dates mirror on foreground (the
// worker is debounced and no-ops when the feature is off). Gated // worker is debounced and no-ops when the feature is off). Gated
// on the permission so users without the opt-in never enqueue it. // on the permission so users without the opt-in never enqueue it.
@@ -145,18 +127,8 @@ fun RootScreen(
when (shown) { when (shown) {
RootTarget.Loading -> Unit RootTarget.Loading -> Unit
RootTarget.App -> CalendarHost( RootTarget.App -> CalendarHost(
requests = requests,
modifier = modifier, modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm,
requestedInsertSource = requestedInsertSource,
onInsertConsumed = onInsertConsumed,
requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed,
) )
// Both halves of a transition read their position out of the live // Both halves of a transition read their position out of the live
// plan; the one the outgoing step was drawn with is already gone. // plan; the one the outgoing step was drawn with is already gone.
@@ -3,8 +3,9 @@ package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import java.time.YearMonth
import java.util.Locale import java.util.Locale
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.yearMonth
/** /**
* How far ahead the agenda (screen or widget) shows events, starting at today. * How far ahead the agenda (screen or widget) shows events, starting at today.
@@ -50,7 +51,7 @@ fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (t
DAYS_PER_WEEK - sinceWeekStart DAYS_PER_WEEK - sinceWeekStart
} }
AgendaRange.ThisMonth -> { AgendaRange.ThisMonth -> {
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth() val daysInMonth = anchor.yearMonth.numberOfDays
daysInMonth - anchor.day + 1 daysInMonth - anchor.day + 1
} }
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS) is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
@@ -105,8 +106,8 @@ fun agendaRangeWindowSummary(
locale: Locale, locale: Locale,
monthAsSpan: Boolean = false, monthAsSpan: Boolean = false,
): String { ): String {
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day) val javaStart = start.toJavaLocalDate()
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day) val javaEnd = end.toJavaLocalDate()
val dayMonth = localizedDateFormatter(locale, "dMMM") val dayMonth = localizedDateFormatter(locale, "dMMM")
val dayMonthYear = localizedDateFormatter(locale, "dMMMy") val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
return when { return when {
@@ -1,8 +1,9 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -10,6 +11,8 @@ 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
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Coffee
@@ -31,22 +34,25 @@ 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.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
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
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toJavaLocalDate
import kotlin.time.Instant
import java.util.Locale import java.util.Locale
// The agenda's row vocabulary, split out of AgendaScreen so the month view's // The agenda's row vocabulary, split out of AgendaScreen so the month view's
@@ -127,7 +133,7 @@ internal fun AgendaEventRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow( GroupedRow(
@@ -177,34 +183,74 @@ internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZ
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day) AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts -> stringResource( is AgendaTimeLabel.Starts -> stringResource(
R.string.agenda_span_starts, R.string.agenda_span_starts,
formatTime(label.start, zone, is24Hour, locale), formatTimeOfDay(label.start, zone, is24Hour, locale),
) )
is AgendaTimeLabel.Ends -> stringResource( is AgendaTimeLabel.Ends -> stringResource(
R.string.agenda_span_ends, R.string.agenda_span_ends,
formatTime(label.end, zone, is24Hour, locale), formatTimeOfDay(label.end, zone, is24Hour, locale),
) )
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " + is AgendaTimeLabel.Range -> "${formatTimeOfDay(label.start, zone, is24Hour, locale)} – " +
formatTime(label.end, zone, is24Hour, locale) formatTimeOfDay(label.end, zone, is24Hour, locale)
} }
val location = event.location?.takeIf { it.isNotBlank() } val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time return if (location != null) "$time · $location" else time
} }
private fun formatTime(
instant: Instant,
zone: TimeZone,
is24Hour: Boolean,
locale: Locale,
): String {
val t = instant.toLocalDateTime(zone).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
}
private fun formatAgendaDate(date: LocalDate): String { private fun formatAgendaDate(date: LocalDate): String {
val locale = Locale.getDefault() val locale = Locale.getDefault()
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) val java = date.toJavaLocalDate()
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026" // Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout. // vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
return localizedDateFormatter(locale, "EEEdMMMy").format(java) return localizedDateFormatter(locale, "EEEdMMMy").format(java)
} }
/**
* The agenda's day-by-day list body: a sticky header per day, its events or a
* placeholder row for an event-less day, then a gap. Shared by the Agenda screen
* and the month view's split pane so both lists read the same.
*/
@OptIn(ExperimentalFoundationApi::class)
internal fun LazyListScope.agendaDays(
days: List<AgendaDay>,
today: LocalDate,
zone: TimeZone,
@StringRes emptyText: Int,
onEmptyClick: (LocalDate) -> Unit,
dimmed: (EventInstance, LocalDate) -> Boolean,
onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) {
days.forEach { day ->
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
}
if (day.events.isEmpty()) {
item(key = "placeholder-${day.date}") {
AgendaEmptyDayRow(
text = stringResource(emptyText),
onClick = { onEmptyClick(day.date) },
)
}
} else {
itemsIndexed(
items = day.events,
// Scope the key by day: a multi-day event appears under every
// day it spans, so its instanceId alone is not unique across
// the list (LazyColumn requires unique keys).
key = { _, event -> "${day.date}-${event.instanceId}" },
) { index, event ->
AgendaEventRow(
event = event,
day = day.date,
zone = zone,
position = positionOf(index, day.events.size),
dimmed = dimmed(event, day.date),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
onClick = { onEventClick(event) },
)
}
}
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
}
}
@@ -5,15 +5,11 @@ import de.jeanlucmakiola.calendula.ui.common.ScreenActions
import de.jeanlucmakiola.calendula.ui.common.ScreenBody import de.jeanlucmakiola.calendula.ui.common.ScreenBody
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -27,7 +23,6 @@ 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.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState 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
@@ -38,7 +33,16 @@ 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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -53,16 +57,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay 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.ui.common.isPastOn
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.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig 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.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.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -82,11 +84,23 @@ fun AgendaScreen(
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS, quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
restorePosition: Boolean = false,
viewModel: AgendaViewModel = hiltViewModel(), viewModel: AgendaViewModel = hiltViewModel(),
) { ) {
// Opens on the focused date and hands back the day scrolled to the top. // Opens on today, not on the day another view was on (#367). Backing out of
// a day opened from here, or a recreated screen, returns to the day last at
// the top. The top day still goes back to the shared focus.
val focus = LocalViewFocus.current val focus = LocalViewFocus.current
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) } val opened = rememberSaveable { mutableStateOf(false) }
val restoreTop = remember(viewModel) {
if (restorePosition || opened.value) {
viewModel.lastTopDate
} else {
viewModel.goToToday()
null
}
}
SideEffect { opened.value = true }
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle() val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle() val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
@@ -106,20 +120,19 @@ fun AgendaScreen(
// list scroll, not an anchor change — "today" has to bring the list back to // 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). // the top as well, and stay offered while it is scrolled away (#305).
val listState = rememberLazyListState() val listState = rememberLazyListState()
// Past days load above the anchor (#367), so the anchor's row counts as the
// start of the list.
val anchorIndex = remember { mutableIntStateOf(0) }
val scrolledAway by remember { val scrolledAway by remember {
derivedStateOf { derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 listState.firstVisibleItemIndex != anchorIndex.intValue ||
listState.firstVisibleItemScrollOffset > 0
} }
} }
// A new window is rendered from its first day, so the list belongs at the
// top. Keyed on the anchor the days arrived with, not the one just
// requested: scrolling before the new rows compose leaves the list keyed to
// a row that reappears further down, and it follows it there.
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
val jumpToToday: () -> Unit = { val jumpToToday: () -> Unit = {
// Off today, moving the anchor is what resets the list (above); on today // Off today, moving the anchor is what repositions the list; on today
// the window doesn't change, so the scroll back is the whole action. // the window doesn't change, so the scroll back is the whole action.
if (isOnToday) scope.launch { listState.animateScrollToItem(0) } if (isOnToday) scope.launch { listState.animateScrollToItem(anchorIndex.intValue) }
viewModel.goToToday() viewModel.goToToday()
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it } (state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
} }
@@ -188,11 +201,17 @@ fun AgendaScreen(
pastDisplay = pastDisplay, pastDisplay = pastDisplay,
showToday = showToday, showToday = showToday,
listState = listState, listState = listState,
anchorIndex = anchorIndex,
initialTopDate = restoreTop,
onLoadEarlier = viewModel::loadEarlier,
onRetry = viewModel::goToToday, onRetry = viewModel::goToToday,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onTopDay = if (successState?.anchor == anchor) { onTopDay = if (successState?.anchor == anchor) {
{ focus?.date = it } {
focus?.date = it
viewModel.rememberTopDay(it)
}
} else null, } else null,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
@@ -294,6 +313,9 @@ internal fun AgendaContent(
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(), listState: LazyListState = rememberLazyListState(),
anchorIndex: MutableIntState = remember { mutableIntStateOf(0) },
initialTopDate: LocalDate? = null,
onLoadEarlier: () -> Unit = {},
onTopDay: ((LocalDate) -> Unit)? = null, onTopDay: ((LocalDate) -> Unit)? = null,
) { ) {
when (state) { when (state) {
@@ -308,28 +330,34 @@ internal fun AgendaContent(
// away (or fade) as they end while the screen stays open. // away (or fade) as they end while the screen stays open.
val filtered = if (pastDisplay == PastEventDisplay.HIDE) { val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
state.days.mapNotNull { day -> state.days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(now) } val remaining = day.events.filterNot { it.isPastOn(day.date, now, state.zone) }
if (remaining.isEmpty()) null else day.copy(events = remaining) if (remaining.isEmpty()) null else day.copy(events = remaining)
} }
} else { } else {
state.days state.days
} }
// Anchor today with a "nothing left today" placeholder — but only when // Anchor today with a "nothing left today" placeholder — but only when
// the window actually starts on today; a jumped-to date has no today in // the agenda actually opens on today; a jumped-to date has no today
// it, so anchoring there would be misleading (#35). // in it, so anchoring there would be misleading (#35).
val days = anchorTodayIfMissing( val days = anchorTodayIfMissing(
days = filtered, days = filtered,
today = state.today, today = state.today,
enabled = showToday && state.anchor == state.today, enabled = showToday && state.anchor == state.today,
) )
if (days.isEmpty()) { // Past days alone don't count as anything coming up.
if (days.none { it.date >= state.anchor }) {
// Nothing to scroll: drop the position a previous list left, so // Nothing to scroll: drop the position a previous list left, so
// the today FAB doesn't linger over an empty screen. // the today FAB doesn't linger over an empty screen.
LaunchedEffect(Unit) { listState.scrollToItem(0) } LaunchedEffect(Unit) { listState.scrollToItem(0) }
SideEffect { anchorIndex.intValue = 0 }
AgendaEmpty(modifier) AgendaEmpty(modifier)
} else { } else {
AgendaList( AgendaList(
days = days, days = days,
anchor = state.anchor,
anchorIndex = anchorIndex,
initialTopDate = initialTopDate,
onLoadEarlier = onLoadEarlier,
today = state.today, today = state.today,
zone = state.zone, zone = state.zone,
listState = listState, listState = listState,
@@ -345,10 +373,13 @@ internal fun AgendaContent(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun AgendaList( private fun AgendaList(
days: List<AgendaDay>, days: List<AgendaDay>,
anchor: LocalDate,
anchorIndex: MutableIntState,
initialTopDate: LocalDate?,
onLoadEarlier: () -> Unit,
today: LocalDate, today: LocalDate,
zone: TimeZone, zone: TimeZone,
listState: LazyListState, listState: LazyListState,
@@ -359,59 +390,70 @@ private fun AgendaList(
onTopDay: ((LocalDate) -> Unit)?, onTopDay: ((LocalDate) -> Unit)?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val itemDates = agendaItemDates(days)
val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0)
SideEffect { anchorIndex.intValue = anchorRow }
// A new anchor opens the list on its row from the first frame, with past
// days above. 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. The first run
// may restore a remembered day instead.
val pendingRestore = remember { arrayOf(initialTopDate) }
DisposableEffect(anchor) {
val target = pendingRestore[0]
pendingRestore[0] = null
val index = target?.let { date -> itemDates.indexOfFirst { it >= date } }
?.takeIf { it >= 0 }
?: anchorRow
listState.requestScrollToItem(index)
onDispose {}
}
// Scrolling up near the first loaded day loads earlier days.
val currentLoadEarlier by rememberUpdatedState(onLoadEarlier)
val loadOnScrollUp = remember(listState) {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (available.y > 0 && listState.firstVisibleItemIndex < LOAD_EARLIER_WITHIN) {
currentLoadEarlier()
}
return Offset.Zero
}
}
}
if (onTopDay != null) { if (onTopDay != null) {
val itemDates by rememberUpdatedState(agendaItemDates(days)) val currentItemDates by rememberUpdatedState(itemDates)
val currentOnTopDay by rememberUpdatedState(onTopDay) val currentOnTopDay by rememberUpdatedState(onTopDay)
// The day the list opened on is where the view already is; only a // The day the list opened on is where the view already is; only a
// scroll moves it on. // scroll moves it on.
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex } snapshotFlow { listState.firstVisibleItemIndex }
.drop(1) .drop(1)
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) } .collect { index -> currentItemDates.getOrNull(index)?.let(currentOnTopDay) }
} }
} }
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier.nestedScroll(loadOnScrollUp),
state = listState, 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),
) { ) {
days.forEach { day -> agendaDays(
stickyHeader(key = "header-${day.date}") { days = days,
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay) today = today,
}
if (day.events.isEmpty()) {
// An anchored, event-less today (#35) — "nothing left today".
item(key = "placeholder-${day.date}") {
AgendaEmptyDayRow(
text = stringResource(R.string.agenda_no_more_today),
onClick = { onOpenDay(day.date) },
)
}
} else {
itemsIndexed(
items = day.events,
// Scope the key by day: a multi-day event appears under every
// day it spans, so its instanceId alone is not unique across
// the list (LazyColumn requires unique keys).
key = { _, event -> "${day.date}-${event.instanceId}" },
) { index, event ->
AgendaEventRow(
event = event,
day = day.date,
zone = zone, zone = zone,
position = positionOf(index, day.events.size), // An anchored, event-less today (#35) — "nothing left today".
dimmed = dimPast && event.hasEnded(now), emptyText = R.string.agenda_no_more_today,
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)), onEmptyClick = onOpenDay,
onClick = { onEventClick(event) }, dimmed = { event, day -> dimPast && event.isPastOn(day, now, zone) },
onOpenDay = onOpenDay,
onEventClick = onEventClick,
) )
} }
}
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
}
}
} }
/** How close to the first loaded row an upward scroll starts loading earlier days. */
private const val LOAD_EARLIER_WITHIN = 10
/** The day of each item [AgendaList] lays out for [days], in its order. */ /** The day of each item [AgendaList] lays out for [days], in its order. */
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> = internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
days.flatMap { day -> days.flatMap { day ->
@@ -93,22 +93,21 @@ fun groupAgendaDays(
} }
/** /**
* Ensure [today] surfaces as the first agenda day even when it carries no * Ensure [today] surfaces in the agenda even when it carries no (remaining)
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget * events, by inserting an empty-event [AgendaDay] in date order that the agenda
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and * renders as a "nothing left today" placeholder. A no-op unless [enabled], and
* when today already has its own day in [days]. Keeps today anchored at the top * when today already has its own day in [days]. Keeps today anchored so a glance
* so a glance tells today's events apart from a future day's (issue #35). * tells today's events apart from a future day's (issue #35).
*/ */
fun anchorTodayIfMissing( fun anchorTodayIfMissing(
days: List<AgendaDay>, days: List<AgendaDay>,
today: LocalDate, today: LocalDate,
enabled: Boolean, enabled: Boolean,
): List<AgendaDay> = ): List<AgendaDay> {
if (enabled && days.none { it.date == today }) { if (!enabled || days.any { it.date == today }) return days
listOf(AgendaDay(today, emptyList())) + days val at = days.indexOfFirst { it.date > today }.let { if (it < 0) days.size else it }
} else { return days.take(at) + AgendaDay(today, emptyList()) + days.drop(at)
days }
}
/** /**
* State for the Agenda view: a flat, forward-looking list of upcoming events * State for the Agenda view: a flat, forward-looking list of upcoming events
@@ -118,8 +117,10 @@ sealed interface AgendaUiState {
data object Loading : AgendaUiState data object Loading : AgendaUiState
data class Failure(val reason: FailureReason) : AgendaUiState data class Failure(val reason: FailureReason) : AgendaUiState
data class Success( data class Success(
/** First day of the loaded window (today, or a jumped-to date). */ /** The day the agenda opens on (today, or a jumped-to date). */
val anchor: LocalDate, val anchor: LocalDate,
/** First loaded day, before [anchor] once past days are loaded (#367). */
val windowStart: LocalDate,
val today: LocalDate, val today: LocalDate,
val days: List<AgendaDay>, val days: List<AgendaDay>,
/** The range currently in effect — the saved default or a session override. */ /** The range currently in effect — the saved default or a session override. */
@@ -1,17 +1,22 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.dayRange
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
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
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.calendarListFailure
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
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
@@ -21,17 +26,14 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
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.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.minus
import kotlinx.datetime.atTime
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
@@ -40,6 +42,7 @@ import javax.inject.Inject
class AgendaViewModel @Inject constructor( class AgendaViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs, settingsPrefs: SettingsPrefs,
todaySource: TodaySource,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -62,9 +65,8 @@ class AgendaViewModel @Inject constructor(
* the screen combines it with a per-minute "now" to fade or drop past rows. * the screen combines it with a per-minute "now" to fade or drop past rows.
*/ */
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = PastEventDisplay.SHOW, initialValue = PastEventDisplay.SHOW,
) )
@@ -75,19 +77,35 @@ class AgendaViewModel @Inject constructor(
* alongside the data state like [pastEventDisplay] rather than re-querying. * alongside the data state like [pastEventDisplay] rather than re-querying.
*/ */
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = true, initialValue = true,
) )
private val zone = TimeZone.currentSystemDefault() private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date get() = currentDate()
private val _anchor = MutableStateFlow(todayDate) // The anchor and how many days before it are loaded, moved together so a
val anchor: StateFlow<LocalDate> = _anchor // jump never queries a window that pairs the new anchor with the old span.
private val _window = MutableStateFlow(AgendaWindow(todayDate, PAST_CHUNK_DAYS))
val anchor: StateFlow<LocalDate> = _window
.map { it.anchor }
.stateIn(viewModelScope, SharingStarted.Eagerly, todayDate)
/** The day last scrolled to the top, so a return to the agenda lands back on it. */
var lastTopDate: LocalDate? = null
private set
/**
* Today, kept current across midnight (#373). An agenda still starting on
* yesterday moves on to the new day; one jumped elsewhere stays put.
*/
private val today: StateFlow<LocalDate> =
todaySource.today.stateFollowingDays(viewModelScope, todayDate) { old, date ->
if (_window.value.anchor == old) _window.value = _window.value.copy(anchor = date)
}
// A transient, in-view override of the saved agenda range. Held in memory // A transient, in-view override of the saved agenda range. Held in memory
// (not persisted), so it survives view switches and rotation within the // (not persisted), so it survives view switches and rotation within the
@@ -95,9 +113,17 @@ class AgendaViewModel @Inject constructor(
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null) private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
val state: StateFlow<AgendaUiState> = val state: StateFlow<AgendaUiState> =
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart -> combine(
_window,
agendaSettings,
_rangeOverride,
weekStart,
settingsPrefs.pastEventDisplay,
) { window, settings, override, weekStart, pastDisplay ->
AgendaParams( AgendaParams(
anchor = anchor, anchor = window.anchor,
// Hidden past events leave nothing to scroll back to (#367).
pastDays = if (pastDisplay == PastEventDisplay.HIDE) 0 else window.pastDays,
range = override ?: settings.range, range = override ?: settings.range,
rangeIsOverride = override != null && override != settings.range, rangeIsOverride = override != null && override != settings.range,
weekStart = weekStart, weekStart = weekStart,
@@ -106,32 +132,50 @@ class AgendaViewModel @Inject constructor(
} }
.flatMapLatest { params -> .flatMapLatest { params ->
val window = agendaRange( val window = agendaRange(
params.anchor, params.windowStart,
params.range.dayCount(params.anchor, params.weekStart) - 1, params.pastDays + params.range.dayCount(params.anchor, params.weekStart) - 1,
zone, zone,
) )
combine( combine(
repository.calendars(), repository.calendars(),
repository.instances(window), repository.instances(window),
) { calendars, instances -> today,
buildState(params, calendars, instances) ) { calendars, instances, today ->
buildState(params, calendars, instances, today)
} }
} }
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AgendaUiState.Loading, initialValue = AgendaUiState.Loading,
) )
fun goToToday() { fun goToToday() {
_anchor.value = todayDate goToDate(todayDate)
} }
/** Jump the agenda window to start on a specific date (drawer jump-to-date). */ /** Jump the agenda window to start on a specific date (drawer jump-to-date). */
fun goToDate(date: LocalDate) { fun goToDate(date: LocalDate) {
_anchor.value = date if (_window.value.anchor == date) return
_window.value = AgendaWindow(date, PAST_CHUNK_DAYS)
lastTopDate = null
}
/**
* Extend the window [PAST_CHUNK_DAYS] further back (#367). Ignored while the
* previous extension is still loading, so a scroll that keeps asking only
* moves one chunk at a time.
*/
fun loadEarlier() {
val loaded = (state.value as? AgendaUiState.Success)?.windowStart ?: return
val window = _window.value
if (loaded != window.start) return
_window.value = window.copy(pastDays = window.pastDays + PAST_CHUNK_DAYS)
}
fun rememberTopDay(date: LocalDate) {
lastTopDate = date
} }
/** Temporarily override the agenda range for this session (the bottom-left pill). */ /** Temporarily override the agenda range for this session (the bottom-left pill). */
@@ -144,18 +188,26 @@ class AgendaViewModel @Inject constructor(
val showBar: Boolean, val showBar: Boolean,
) )
private data class AgendaWindow(val anchor: LocalDate, val pastDays: Int) {
val start: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY)
}
private data class AgendaParams( private data class AgendaParams(
val anchor: LocalDate, val anchor: LocalDate,
val pastDays: Int,
val range: AgendaRange, val range: AgendaRange,
val rangeIsOverride: Boolean, val rangeIsOverride: Boolean,
val weekStart: DayOfWeek, val weekStart: DayOfWeek,
val showRangeBar: Boolean, val showRangeBar: Boolean,
) ) {
val windowStart: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY)
}
private fun buildState( private fun buildState(
params: AgendaParams, params: AgendaParams,
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
today: LocalDate,
): AgendaUiState { ): AgendaUiState {
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) } calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) }
val anchor = params.anchor val anchor = params.anchor
@@ -163,10 +215,11 @@ class AgendaViewModel @Inject constructor(
params.range.dayCount(anchor, params.weekStart) - 1, params.range.dayCount(anchor, params.weekStart) - 1,
DateTimeUnit.DAY, DateTimeUnit.DAY,
) )
val days = groupAgendaDays(anchor, rangeEnd, instances, zone) val days = groupAgendaDays(params.windowStart, rangeEnd, instances, zone)
return AgendaUiState.Success( return AgendaUiState.Success(
anchor = anchor, anchor = anchor,
today = todayDate, windowStart = params.windowStart,
today = today,
days = days, days = days,
range = params.range, range = params.range,
rangeIsOverride = params.rangeIsOverride, rangeIsOverride = params.rangeIsOverride,
@@ -177,9 +230,9 @@ class AgendaViewModel @Inject constructor(
} }
} }
/** How many days each scroll back into the past loads (#367). */
internal const val PAST_CHUNK_DAYS = 30
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */ /** Inclusive instant range from the start of [anchor] through [days] days ahead. */
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> { internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> =
val from = anchor.atStartOfDayIn(zone) dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone)
val to = anchor.plus(days, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
return from..to
}
@@ -6,8 +6,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
import de.jeanlucmakiola.floret.components.ScaledViewPreview
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@@ -51,6 +51,7 @@ private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.S
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY) val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
return AgendaUiState.Success( return AgendaUiState.Success(
anchor = today, anchor = today,
windowStart = today,
today = today, today = today,
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone), days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS), range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.calendars package de.jeanlucmakiola.calendula.ui.calendars
import android.net.Uri import android.net.Uri
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.Box
@@ -54,13 +53,17 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure 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.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
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
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.relativeTimeAgo
import java.time.LocalDate import java.time.LocalDate
// SAF mime filter for the restore picker. Source apps hand `.ics` files out // SAF mime filter for the restore picker. Source apps hand `.ics` files out
@@ -222,8 +225,8 @@ private fun BackupContent(
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore) val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
if (exportable.isEmpty()) { if (exportable.isEmpty()) {
SectionHeader(stringResource(R.string.calendars_restore_header)) GroupedSectionHeader(stringResource(R.string.calendars_restore_header))
HintText(restoreSummary) GroupedHint(restoreSummary, style = MaterialTheme.typography.bodySmall)
GroupedRow( GroupedRow(
title = stringResource(R.string.calendars_restore_action), title = stringResource(R.string.calendars_restore_action),
position = Position.Alone, position = Position.Alone,
@@ -233,7 +236,7 @@ private fun BackupContent(
return return
} }
HintText(stringResource(R.string.calendars_backup_hint)) GroupedHint(stringResource(R.string.calendars_backup_hint), style = MaterialTheme.typography.bodySmall)
GroupedRow( GroupedRow(
title = stringResource(R.string.calendars_backup_action), title = stringResource(R.string.calendars_backup_action),
position = Position.Top, position = Position.Top,
@@ -272,7 +275,7 @@ private fun BackupContent(
position = Position.Bottom, position = Position.Bottom,
onClick = onEditInterval, onClick = onEditInterval,
) )
HintText(backupStatusText(autoBackup.status)) GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
} }
} }
@@ -309,12 +312,13 @@ private fun ExportCalendarPicker(
FullScreenPicker( FullScreenPicker(
title = stringResource(R.string.calendars_export_title), title = stringResource(R.string.calendars_export_title),
onDismiss = onDismiss, onDismiss = onDismiss,
predictiveBack = true,
) { ) {
HintText(stringResource(R.string.calendars_export_hint)) GroupedHint(stringResource(R.string.calendars_export_hint), style = MaterialTheme.typography.bodySmall)
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.orUnnamed(),
summary = calendar.description, summary = calendar.description,
position = positionOf(index, calendars.size), position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) }, leading = { CalendarColorChip(calendar.color) },
@@ -376,9 +380,7 @@ private fun backupIntervalLabel(minutes: Long): String {
@Composable @Composable
private fun backupStatusText(status: BackupStatus): String { private fun backupStatusText(status: BackupStatus): String {
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never) if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
val relative = DateUtils.getRelativeTimeSpanString( val relative = relativeTimeAgo(status.lastRun)
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
).toString()
return if (status.lastSuccess) { return if (status.lastSuccess) {
stringResource(R.string.calendars_auto_backup_status_ok, relative) stringResource(R.string.calendars_auto_backup_status_ok, relative)
} else { } else {
@@ -6,34 +6,27 @@ import android.content.Intent
import android.provider.Settings import android.provider.Settings
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
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
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.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Backup import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
@@ -41,20 +34,14 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.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.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -85,6 +72,7 @@ import de.jeanlucmakiola.calendula.domain.stateLabels
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.AccountKey import de.jeanlucmakiola.calendula.ui.common.AccountKey
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarVisibilitySwitch
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.groupByAccount import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -94,11 +82,14 @@ 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
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
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.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
@@ -224,7 +215,7 @@ private fun CalendarsList(
visible = visible, visible = visible,
) { ) {
// What the per-calendar / per-account switches below actually do. // What the per-calendar / per-account switches below actually do.
HintText(stringResource(R.string.calendars_visibility_hint)) GroupedHint(stringResource(R.string.calendars_visibility_hint), style = MaterialTheme.typography.bodySmall)
// Local (device-only) calendars — one collapsible group. The header's // Local (device-only) calendars — one collapsible group. The header's
// "+" adds a calendar; the switch enables/disables them all at once; // "+" adds a calendar; the switch enables/disables them all at once;
@@ -245,24 +236,13 @@ private fun CalendarsList(
onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) }, onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
) { ) {
if (local.isEmpty()) { if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty)) GroupedHint(stringResource(R.string.calendars_local_empty), style = MaterialTheme.typography.bodySmall)
} else { } else {
local.forEachIndexed { index, calendar -> local.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem CalendarManagerRow(
GroupedRow( calendar = calendar,
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, last = index == local.lastIndex,
summary = calendarRowSummary(calendar), onSetVisible = onSetVisible,
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
EnableSwitch(
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
enabled = !disabled,
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
)
},
onClick = { onEdit(calendar) }, onClick = { onEdit(calendar) },
) )
} }
@@ -291,8 +271,8 @@ private fun CalendarsList(
// Synced calendars — read-only, grouped by account. Each account is a // Synced calendars — read-only, grouped by account. Each account is a
// collapsible group whose header opens the source app (icon) and toggles // collapsible group whose header opens the source app (icon) and toggles
// all of its calendars at once (switch). // all of its calendars at once (switch).
SectionHeader(stringResource(R.string.calendars_synced_header)) GroupedSectionHeader(stringResource(R.string.calendars_synced_header))
HintText(stringResource(R.string.calendars_synced_hint)) GroupedHint(stringResource(R.string.calendars_synced_hint), style = MaterialTheme.typography.bodySmall)
synced synced
.groupByAccount() .groupByAccount()
.forEach { group -> .forEach { group ->
@@ -333,27 +313,10 @@ private fun CalendarsList(
// bottom, dimmed and switchless. // bottom, dimmed and switchless.
val ordered = cals.orderedForManager() val ordered = cals.orderedForManager()
ordered.forEachIndexed { index, calendar -> ordered.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced CalendarManagerRow(
GroupedRow( calendar = calendar,
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, last = index == ordered.lastIndex,
summary = calendarRowSummary(calendar), onSetVisible = onSetVisible,
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = if (calendar.hasVisibilitySwitch) {
{
EnableSwitch(
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
enabled = calendar.isVisibleInSystem,
onToggle = { enabled ->
onSetVisible(calendar.id, enabled)
},
)
}
} else {
null
},
) )
} }
} }
@@ -478,7 +441,7 @@ private fun CalendarNameField(
onNameChange: (String) -> Unit, onNameChange: (String) -> Unit,
onImeAction: () -> Unit, onImeAction: () -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Row( Row(
@@ -522,7 +485,7 @@ private fun CalendarNameField(
*/ */
@Composable @Composable
private fun CalendarColorGrid(selected: Int, onSelect: (Int) -> Unit) { private fun CalendarColorGrid(selected: Int, onSelect: (Int) -> Unit) {
val dark = isSystemInDarkTheme() val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Column( Column(
@@ -692,17 +655,37 @@ private fun calendarRowSummary(calendar: CalendarSource): String? {
* `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and * `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and
* stops its reminders. Carries its own content description. * stops its reminders. Carries its own content description.
*/ */
/**
* One calendar in a group: dimmed while switched off or not syncing, with the
* visibility switch only where it can change anything.
*/
@Composable @Composable
private fun EnableSwitch( private fun CalendarManagerRow(
calendarName: String, calendar: CalendarSource,
enabled: Boolean, last: Boolean,
onToggle: (Boolean) -> Unit, onSetVisible: (Long, Boolean) -> Unit,
onClick: (() -> Unit)? = null,
) { ) {
val label = stringResource(R.string.calendars_visibility_a11y, calendarName) val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
Switch( GroupedRow(
checked = enabled, title = calendar.displayName.orUnnamed(),
onCheckedChange = onToggle, summary = calendarRowSummary(calendar),
modifier = Modifier.semantics { contentDescription = label }, position = if (last) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = if (calendar.hasVisibilitySwitch) {
{
CalendarVisibilitySwitch(
calendarName = calendar.displayName.orUnnamed(),
enabled = calendar.isVisibleInSystem,
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
)
}
} else {
null
},
onClick = onClick,
) )
} }
@@ -841,33 +824,6 @@ private fun CalendarGroupMenu(
} }
@Composable
internal fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
// The cards' own edge, so header and group share a left margin.
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 4.dp,
),
)
}
@Composable
internal fun HintText(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
)
}
/** /**
* Pick the app to open for managing a synced calendar's account. The account's * Pick the app to open for managing a synced calendar's account. The account's
* own authenticator package (resolved from [AccountManager], no permission * own authenticator package (resolved from [AccountManager], no permission
@@ -9,16 +9,16 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter 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.FailureReason
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@@ -26,7 +26,6 @@ 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.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@@ -52,9 +51,8 @@ class CalendarsViewModel @Inject constructor(
repository.calendars() repository.calendars()
.catch { emit(emptyList()) } .catch { emit(emptyList()) }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptyList(), initialValue = emptyList(),
) )
@@ -68,9 +66,8 @@ class CalendarsViewModel @Inject constructor(
.map { backupUiState(it) } .map { backupUiState(it) }
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = BackupUiState.Loading, initialValue = BackupUiState.Loading,
) )
@@ -84,9 +81,8 @@ class CalendarsViewModel @Inject constructor(
AutoBackupUiState(enabled, interval, folder, status) AutoBackupUiState(enabled, interval, folder, status)
} }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AutoBackupUiState(), initialValue = AutoBackupUiState(),
) )
@@ -103,9 +99,8 @@ class CalendarsViewModel @Inject constructor(
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet() if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
} }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(), initialValue = emptySet(),
) )
@@ -57,7 +57,7 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
*/ */
@Composable @Composable
fun accountGroupTitle(group: CalendarAccountGroup): String { fun accountGroupTitle(group: CalendarAccountGroup): String {
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) } val label = group.label.orUnnamed()
return if (!group.ambiguous) { return if (!group.ambiguous) {
label label
} else { } else {
@@ -278,12 +278,23 @@ data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y:
* A pinch-zoom rewrites the hour height every pointer frame, and the gutter and * A pinch-zoom rewrites the hour height every pointer frame, and the gutter and
* grid lines follow it instantly — so the tween stands down for the gesture * grid lines follow it instantly — so the tween stands down for the gesture
* rather than leaving the blocks trailing the ruler they are measured against. * rather than leaving the blocks trailing the ruler they are measured against.
* A [landing] block, under a dropped copy, moves without the spring's bounce.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement { fun animatedBlockPlacement(
xFraction: Float,
widthFraction: Float,
y: Dp,
height: Dp,
landing: Boolean = false,
): BlockPlacement {
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec() val dpSpec: FiniteAnimationSpec<Dp> = when {
still -> snap()
landing -> MaterialTheme.motionScheme.fastEffectsSpec()
else -> MaterialTheme.motionScheme.fastSpatialSpec()
}
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec() val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x") val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width") val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
@@ -1,5 +1,14 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -16,6 +25,9 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
@@ -24,6 +36,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.domain.EventInstance
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
/** /**
@@ -53,12 +66,39 @@ class ScreenActions(
val onCreate: () -> Unit, val onCreate: () -> Unit,
) )
/**
* An event a long press left selected (#141), which the host answers with the
* action bar in place of the FAB. [dismiss] clears it on the screen that owns it.
* [create] is set for a block drafted on an empty slot (#153), which has no
* event yet, so the bar offers to create one.
*/
@Stable
class EventSelection(
val event: EventInstance,
val dismiss: () -> Unit,
val create: (() -> Unit)? = null,
/** The event at the times an ongoing move or resize would give it, else null. */
val preview: () -> EventInstance? = { null },
)
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */ /** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
@Stable @Stable
class ChromeHost { class ChromeHost {
var chrome by mutableStateOf<ScreenChrome?>(null) var chrome by mutableStateOf<ScreenChrome?>(null)
private set private set
var selection by mutableStateOf<EventSelection?>(null)
internal set
/** Whether the selected event is being dragged or resized right now. */
var selectionBusy by mutableStateOf(false)
internal set
/** Whether the bar would cover the selected event, and so draws compact. */
var selectionCompact by mutableStateOf(false)
internal set
var actions: ScreenActions? = null var actions: ScreenActions? = null
private set private set
@@ -80,8 +120,54 @@ fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
if (slot.current) SideEffect { slot.host.publish(chrome, actions) } if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
} }
/** The top bar every calendar screen shares. */ /** Hands the screen's selected [event] to the host, for as long as it stays selected. */
@OptIn(ExperimentalMaterial3Api::class) @Composable
fun PublishSelection(
event: EventInstance?,
busy: Boolean,
compact: Boolean,
onDismiss: () -> Unit,
onCreate: (() -> Unit)? = null,
preview: () -> EventInstance? = { null },
) {
val slot = LocalChromeSlot.current ?: return
val currentDismiss by rememberUpdatedState(onDismiss)
val currentCreate by rememberUpdatedState(onCreate)
val currentPreview by rememberUpdatedState(preview)
val drafted = onCreate != null
val selection = remember(event, drafted) {
event?.let {
EventSelection(
event = it,
dismiss = { currentDismiss() },
create = if (drafted) ({ currentCreate?.invoke() }) else null,
preview = { currentPreview() },
)
}
}
val host = slot.host
val current = slot.current
DisposableEffect(host, selection, current) {
if (current) host.selection = selection
onDispose {
if (host.selection === selection) host.selection = null
// Left mid-drag, nothing else would clear these for the other views.
host.selectionBusy = false
host.selectionCompact = false
}
}
if (current) {
SideEffect {
host.selectionBusy = busy && selection != null
host.selectionCompact = compact && selection != null
}
}
}
/**
* The top bar every calendar screen shares — or, while an event is selected,
* the selection's own bar in its place (#141).
*/
@Composable @Composable
fun CalendarTopBar( fun CalendarTopBar(
host: ChromeHost, host: ChromeHost,
@@ -91,6 +177,43 @@ fun CalendarTopBar(
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
showTodayButton: Boolean, showTodayButton: Boolean,
) {
val effects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
AnimatedContent(
targetState = host.selection,
contentKey = { it != null },
transitionSpec = {
(fadeIn(effects) + slideInVertically { -it / 4 })
.togetherWith(fadeOut(effects) + slideOutVertically { -it / 4 })
},
label = "top-bar",
) { selection ->
if (selection != null) {
SelectionTopBar(selection)
} else {
ViewTopBar(
host = host,
selectedView = selectedView,
quickSwitchViews = quickSwitchViews,
onCycleView = onCycleView,
onOpenDrawer = onOpenDrawer,
onOpenSearch = onOpenSearch,
showTodayButton = showTodayButton,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ViewTopBar(
host: ChromeHost,
selectedView: CalendarView,
quickSwitchViews: List<CalendarView>,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
) { ) {
val chrome = host.chrome val chrome = host.chrome
TopAppBar( TopAppBar(
@@ -136,16 +259,26 @@ fun CalendarTopBar(
) )
} }
/** The FAB column every calendar screen shares. */ /**
* The FAB column every calendar screen shares. While an event is selected the
* FAB is [selection]'s toggle instead, and it steps aside mid-gesture.
*/
@Composable @Composable
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) { fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean, selection: FabSelection? = null) {
val chrome = host.chrome ?: return val chrome = host.chrome ?: return
AnimatedVisibility(
visible = !host.selectionBusy,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(),
) {
CalendarFabColumn( CalendarFabColumn(
todayVisible = chrome.awayFromToday && !todayInToolbar, todayVisible = chrome.awayFromToday && !todayInToolbar,
todayText = chrome.todayText, todayText = chrome.todayText,
onToday = { host.actions?.onToday?.invoke() }, onToday = { host.actions?.onToday?.invoke() },
onCreate = { host.actions?.onCreate?.invoke() }, onCreate = { host.actions?.onCreate?.invoke() },
selection = selection,
) )
}
} }
/** /**
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -14,6 +13,7 @@ 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.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
/** /**
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted * Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
@@ -23,7 +23,7 @@ import androidx.compose.ui.unit.dp
*/ */
@Composable @Composable
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) { fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme() val dark = LocalFloretDarkTheme.current
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
Box( Box(
modifier = modifier modifier = modifier
@@ -1,11 +1,20 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkHorizontally
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.Row
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -15,15 +24,38 @@ 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.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
/** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */
val FabSize = 56.dp
/** What the FAB is while an event is selected (#141): the toggle for its action bar. */
@Stable
class FabSelection(
val expanded: Boolean,
val onToggle: () -> Unit,
val bar: @Composable () -> Unit,
)
/** /**
* The FAB stack shared by the three calendar views: a persistent "+" to * The FAB stack shared by the three calendar views: a persistent "+" to
* create an event, with the jump-to-today pill appearing above it whenever * create an event, with the jump-to-today pill appearing above it whenever
* the view isn't anchored on today. * the view isn't anchored on today. While an event is selected the same FAB,
* in the same spot, opens and folds the selection's action bar, which unrolls
* from it towards the start.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -32,13 +64,14 @@ fun CalendarFabColumn(
todayText: String, todayText: String,
onToday: () -> Unit, onToday: () -> Unit,
onCreate: () -> Unit, onCreate: () -> Unit,
selection: FabSelection? = null,
) { ) {
Column( Column(
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = todayVisible, visible = todayVisible && selection == null,
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()), enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()), exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
) { ) {
@@ -48,11 +81,71 @@ fun CalendarFabColumn(
text = { Text(todayText) }, text = { Text(todayText) },
) )
} }
FloatingActionButton(onClick = onCreate) { // Held past the selection clearing, so the bar has something to fold away.
Icon( val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) }
imageVector = Icons.Default.Add, if (selection != null && heldBar.value !== selection.bar) heldBar.value = selection.bar
contentDescription = stringResource(R.string.event_edit_new_title), // Held at the FAB's height so the taller bar can't shift the FAB.
Row(
modifier = Modifier.height(FabSize),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AnimatedVisibility(
visible = selection?.expanded == true,
enter = expandHorizontally(
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
expandFrom = Alignment.End,
) + fadeIn(),
exit = shrinkHorizontally(
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
shrinkTowards = Alignment.End,
) + fadeOut(),
) {
heldBar.value?.invoke()
}
val label = stringResource(
when {
selection == null -> R.string.event_edit_new_title
selection.expanded -> R.string.event_selection_hide
else -> R.string.event_selection_actions
},
) )
FloatingActionButton(
onClick = selection?.onToggle ?: onCreate,
modifier = Modifier.semantics { contentDescription = label },
) {
FabGlyph(selecting = selection != null, expanded = selection?.expanded == true)
}
} }
} }
} }
/** "+", or with an event selected a chevron that turns to open and fold the bar. */
@Composable
private fun FabGlyph(selecting: Boolean, expanded: Boolean) {
val spatial = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
val swap by animateFloatAsState(if (selecting) 1f else 0f, spatial, label = "fab-swap")
val turn by animateFloatAsState(if (expanded) 0f else 180f, spatial, label = "fab-turn")
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.graphicsLayer {
rotationZ = 90f * swap
scaleX = 1f - 0.5f * swap
scaleY = 1f - 0.5f * swap
alpha = (1f - swap).coerceIn(0f, 1f)
},
)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.graphicsLayer {
rotationZ = turn - 90f * (1f - swap)
scaleX = 0.5f + 0.5f * swap
scaleY = 0.5f + 0.5f * swap
alpha = swap.coerceIn(0f, 1f)
},
)
}
}
@@ -34,7 +34,6 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
/** /**
* The app's single "which calendar" selection list, shared by the event editor * The app's single "which calendar" selection list, shared by the event editor
@@ -114,17 +113,11 @@ private fun CalendarPickerGroup(
leading = leading, leading = leading,
) )
calendars.forEachIndexed { index, calendar -> calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId
GroupedRow( GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = calendar.displayName.orUnnamed(),
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle, position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
selected = isSelected, checked = calendar.id == selectedId,
leading = { CalendarColorChip(calendar.color) }, leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(calendar.id) }, onClick = { onSelect(calendar.id) },
) )
} }
@@ -3,10 +3,12 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.R
/** /**
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in * The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
@@ -55,3 +57,7 @@ fun rememberTrimmedLineHeight(style: TextStyle): Dp {
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() } with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
} }
} }
/** A calendar or account name, or the "unnamed" label when it is blank. */
@Composable
fun String.orUnnamed(): String = ifBlank { stringResource(R.string.calendar_unnamed) }
@@ -0,0 +1,24 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.material3.Switch
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 de.jeanlucmakiola.calendula.R
/** The per-row switch writing the device's own `Calendars.VISIBLE`, as Settings does (#75). */
@Composable
fun CalendarVisibilitySwitch(
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 },
)
}
@@ -2,14 +2,16 @@ package de.jeanlucmakiola.calendula.ui.common
import android.content.ClipData import android.content.ClipData
import android.os.Build import android.os.Build
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
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.ClipEntry import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
@@ -67,9 +69,12 @@ fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
* *
* [onTap] carries a field's existing tap action through, announced as * [onTap] carries a field's existing tap action through, announced as
* [onTapLabel]; a field without one stays unclickable rather than growing a * [onTapLabel]; 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 * ripple that leads nowhere. The long press is announced through semantics,
* node it sits on, so a screen reader lands on the field itself and finds the * merging the node it sits on, so a screen reader lands on the field itself
* action there — a bare container is never focused. * and finds the action there — a bare container is never focused.
*
* The press is caught ahead of the field's children, so a long press on a
* link inside the text copies too instead of leaving that stretch dead.
*/ */
@Composable @Composable
fun Modifier.copyOnLongPress( fun Modifier.copyOnLongPress(
@@ -80,27 +85,45 @@ fun Modifier.copyOnLongPress(
onTapLabel: String? = null, onTapLabel: String? = null,
): Modifier { ): Modifier {
val actionLabel = stringResource(R.string.field_copy_action) val actionLabel = stringResource(R.string.field_copy_action)
return if (onTap != null) {
combinedClickable(
onClick = onTap,
onClickLabel = onTapLabel,
onLongClickLabel = actionLabel,
onLongClick = { copy(label, text) },
)
} else {
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
pointerInput(label, text, copy) { return longPressAheadOfChildren(label, text, copy) {
detectTapGestures(
onLongPress = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) haptics.performHapticFeedback(HapticFeedbackType.LongPress)
copy(label, text) copy(label, text)
}, }
) .then(if (onTap != null) Modifier.clickable(onClickLabel = onTapLabel, onClick = onTap) else Modifier)
}.semantics(mergeDescendants = true) { .semantics(mergeDescendants = true) {
onLongClick(actionLabel) { onLongClick(actionLabel) {
copy(label, text) copy(label, text)
true true
} }
} }
}
} }
/**
* Run [onLongPress] once a press has held still for the long-press timeout,
* watching on the initial pass — before the children see the events — and
* then swallow the rest of that press, so neither a link under the finger nor
* a tap handler fires on release.
*/
private fun Modifier.longPressAheadOfChildren(vararg keys: Any?, onLongPress: () -> Unit): Modifier =
pointerInput(*keys) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
val endedEarly = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
while (true) {
val change = awaitPointerEvent(PointerEventPass.Initial).changes
.firstOrNull { it.id == down.id }
val moved = change != null &&
(change.position - down.position).getDistance() > viewConfiguration.touchSlop
if (change == null || !change.pressed || moved) break
}
true
}
if (endedEarly != null) return@awaitEachGesture
onLongPress()
do {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
} while (event.changes.any { it.pressed })
}
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.calendula.ui.common
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.toInstant
import kotlin.time.Instant
/** Instant range from the start of [first] through the last second of [last] in [zone]. */
internal fun dayRange(first: LocalDate, last: LocalDate, zone: TimeZone): ClosedRange<Instant> =
first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
/** Beginning of the week (at [weekStart]) that contains this date. */
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
return minus(offset, DateTimeUnit.DAY)
}
@@ -0,0 +1,205 @@
package de.jeanlucmakiola.calendula.ui.common
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.components.OptionCard
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock
/**
* Serialise [detail] to a cache `.ics` and return a shareable content Uri for an
* `ACTION_SEND`, or null when the write fails. Exported as a one-off (see
* [toShareIcsEvent]). Blocking — call it off the main thread.
*/
fun IcsExporter.stageEventShare(detail: EventDetail): Uri? = runCatching {
val ics = IcsWriter().writeCalendar(
events = listOf(detail.toShareIcsEvent()),
dtStamp = Clock.System.now(),
)
stageShareFile(shareFileName(detail.instance.title), ics)
}.getOrNull()
/** Open the system chooser for a staged `.ics`; false when nothing could take it. */
fun Context.sendEventShare(uri: Uri, chooserTitle: String): Boolean = runCatching {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(send, chooserTitle))
}.isSuccess
/** Delete an event, or as much of its series as [scope] reaches from the occurrence at [beginMillis]. */
suspend fun CalendarRepository.deleteEvent(
eventId: Long,
beginMillis: Long,
scope: RecurringWriteScope,
) = when (scope) {
RecurringWriteScope.AllEvents -> deleteEvent(eventId)
RecurringWriteScope.ThisEvent -> deleteOccurrence(eventId, beginMillis)
RecurringWriteScope.ThisAndFollowing -> deleteEventFromOccurrence(eventId, beginMillis)
}
/** How a [tryDeleteEvent] ended. */
enum class DeleteOutcome { Deleted, Gone, NeedsPermission, Failed }
/** Whether this install may read the calendar at all. */
fun Context.canReadCalendar(): Boolean =
hasPermission(Manifest.permission.READ_CALENDAR)
/** Whether this install may write to the calendar; v1.0 installs only hold read access. */
fun Context.canWriteCalendar(): Boolean =
hasPermission(Manifest.permission.WRITE_CALENDAR)
/** [deleteEvent], with the provider's failures mapped to a [DeleteOutcome]. */
suspend fun CalendarRepository.tryDeleteEvent(
eventId: Long,
beginMillis: Long,
scope: RecurringWriteScope,
): DeleteOutcome = try {
deleteEvent(eventId, beginMillis, scope)
DeleteOutcome.Deleted
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
DeleteOutcome.NeedsPermission
} catch (e: NoSuchEventException) {
DeleteOutcome.Gone
} catch (e: Exception) {
Log.w("EventActions", "Delete failed (scope=$scope, eventId=$eventId)", e)
DeleteOutcome.Failed
}
/**
* Runs an action that writes to the calendar, asking for `WRITE_CALENDAR` first
* where the install only holds read access (v1.0 installs). A grant continues
* straight into the action that asked for it.
*/
@Composable
fun rememberWriteGate(): (() -> Unit) -> Unit {
val gate = rememberWriteGateOr()
return { action -> gate(action) {} }
}
/** [rememberWriteGate], running the fallback instead when the user refuses. */
@Composable
fun rememberWriteGateOr(): (action: () -> Unit, fallback: () -> Unit) -> Unit {
val context = LocalContext.current
var pending by remember { mutableStateOf<Pair<() -> Unit, () -> Unit>?>(null) }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
pending?.let { (action, fallback) -> if (granted) action() else fallback() }
pending = null
}
return { action, fallback ->
if (context.canWriteCalendar()) {
action()
} else {
pending = action to fallback
launcher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
}
/**
* Delete confirmation. Recurring events choose between cancelling just the
* tapped occurrence (default), truncating the series from it onwards, and
* removing the whole series.
*/
@Composable
fun DeleteEventDialog(
isRecurring: Boolean,
onConfirm: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(
if (isRecurring) R.string.event_delete_recurring_title
else R.string.event_delete_title,
),
)
},
text = {
if (isRecurring) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { scope = RecurringWriteScope.ThisEvent },
selected = scope == RecurringWriteScope.ThisEvent,
)
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
selected = scope == RecurringWriteScope.ThisAndFollowing,
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { scope = RecurringWriteScope.AllEvents },
selected = scope == RecurringWriteScope.AllEvents,
)
}
} else {
Text(stringResource(R.string.event_delete_body))
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
private fun shareFileName(title: String): String {
val base = title.trim()
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
.replace(' ', '_')
.take(40)
.ifBlank { "event" }
return "$base.ics"
}
@@ -0,0 +1,137 @@
package de.jeanlucmakiola.calendula.ui.common
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.atOccurrence
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject
/** A delete waiting on its confirmation dialog. */
data class DeletePrompt(val event: EventInstance, val isRecurring: Boolean)
/** How an action on a selected event ended, when it has something to say. */
enum class EventActionFailure { LoadFailed, ShareFailed, DeleteFailed, WriteDenied, Gone }
/** Duplicate, share and delete for a selected event (#141), loading it on demand. */
@HiltViewModel
class EventActionsViewModel @Inject constructor(
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
/** Every calendar by id, for what a selected event's bar may offer. */
val calendars: StateFlow<Map<Long, CalendarSource>> = repository.calendars()
.map { list -> list.associateBy { it.id } }
.catch { emit(emptyMap()) }
.flowOn(io)
.stateWhileSubscribed(
scope = viewModelScope,
initialValue = emptyMap(),
)
private val _deletePrompt = MutableStateFlow<DeletePrompt?>(null)
val deletePrompt: StateFlow<DeletePrompt?> = _deletePrompt.asStateFlow()
private val _failure = MutableStateFlow<EventActionFailure?>(null)
val failure: StateFlow<EventActionFailure?> = _failure.asStateFlow()
/** A create form copying [event], or null when it can't be read. */
suspend fun duplicateForm(event: EventInstance): EventForm? {
val detail = load(event) ?: return null
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
return detail.toDuplicateForm(canModify, TimeZone.currentSystemDefault())
}
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
suspend fun shareUri(event: EventInstance): Uri? {
val uri = load(event)?.let { withContext(io) { icsExporter.stageEventShare(it) } }
if (uri == null && _failure.value == null) _failure.value = EventActionFailure.ShareFailed
return uri
}
/** Ask to delete [event]; the dialog it needs depends on whether it recurs. */
fun requestDelete(event: EventInstance) {
viewModelScope.launch {
val detail = load(event) ?: return@launch
_deletePrompt.value = DeletePrompt(event, isRecurring = !detail.rrule.isNullOrBlank())
}
}
fun confirmDelete(scope: RecurringWriteScope) {
val prompt = _deletePrompt.value ?: return
_deletePrompt.value = null
viewModelScope.launch {
val event = prompt.event
_failure.value = when (repository.tryDeleteEvent(event.eventId, event.start.toEpochMilliseconds(), scope)) {
DeleteOutcome.Deleted -> null
DeleteOutcome.Gone -> EventActionFailure.Gone
DeleteOutcome.NeedsPermission -> EventActionFailure.WriteDenied
DeleteOutcome.Failed -> EventActionFailure.DeleteFailed
}
}
}
fun cancelDelete() {
_deletePrompt.value = null
}
/** The chooser itself couldn't open — nothing on the device takes an `.ics`. */
fun reportShareFailed() {
_failure.value = EventActionFailure.ShareFailed
}
fun consumeFailure() {
_failure.value = null
}
/** Whether [calendarId] takes writes, asking the provider while [calendars] hasn't loaded yet. */
suspend fun canModify(calendarId: Long): Boolean {
val calendar = calendars.value[calendarId] ?: try {
repository.calendars().first().firstOrNull { it.id == calendarId }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
null
}
return calendar?.canModifyContents == true
}
private suspend fun load(event: EventInstance): EventDetail? = try {
// The row holds the series start; the actions work on this occurrence.
repository.eventDetail(event.eventId)
.atOccurrence(event.start.toEpochMilliseconds(), event.end.toEpochMilliseconds())
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
_failure.value = EventActionFailure.Gone
null
} catch (e: Exception) {
_failure.value = EventActionFailure.LoadFailed
null
}
}
@@ -24,38 +24,57 @@ import androidx.compose.ui.unit.dp
/** How far the finger may wander during the hold — well under touch slop. */ /** How far the finger may wander during the hold — well under touch slop. */
private val PICKUP_TOLERANCE = 6.dp private val PICKUP_TOLERANCE = 6.dp
/** What a long press on a block turned into. */
enum class PickUp {
/** Nothing to pick up; the gesture is left alone. */
None,
/** Lifted and following the finger. */
Drag,
/** Held but not movable — the gesture is swallowed until the finger lifts. */
Hold,
}
/** /**
* Pick an event block up with a long press and drag it, without * Pick an event block up with a long press and drag it, without
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an * `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
* ancestor consumes or the finger leaves the block, which a * ancestor consumes or the finger leaves the block, which a
* `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout * `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
* is deliberately not consumed, so a scroll starting on top of a block survives. * is deliberately not consumed, so a scroll starting on top of a block survives.
*
* [onRelease] is the long press that never moved: the finger lifted within touch
* slop of where it was picked up, or a [PickUp.Hold] ended. Without it, such a
* release is a drop like any other. [immediate] also lifts the block as soon as
* the finger moves past touch slop, with no hold — for a block already selected.
*/ */
@Composable @Composable
fun rememberEventDragSource( fun rememberEventDragSource(
enabled: Boolean, enabled: Boolean,
key: Any?, key: Any?,
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit, onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> PickUp,
onMove: (pointerInRoot: Offset) -> Unit, onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit, onDrop: () -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
): Modifier = rememberDragSurface( onRelease: (() -> Unit)? = null,
immediate: Boolean = false,
): Modifier = rememberPickUpSurface(
enabled = enabled, enabled = enabled,
key = key, key = key,
onPickUp = { _, pointerInRoot, nodeInRoot, size -> immediate = immediate,
onPickUp(pointerInRoot, nodeInRoot, size) onPickUp = { _, pointerInRoot, nodeInRoot, size -> onPickUp(pointerInRoot, nodeInRoot, size) },
true
},
onMove = onMove, onMove = onMove,
onDrop = onDrop, onDrop = onDrop,
onCancel = onCancel, onCancel = onCancel,
onRelease = onRelease,
) )
/** /**
* The same pickup, for a surface that carries many draggable pieces rather than * The same pickup, for a surface that carries many draggable pieces rather than
* being one itself — the month grid, whose chips are covered by a full-bleed tap * being one itself — the month grid, whose chips are covered by a full-bleed tap
* layer. [onPickUp] receives the press position local to this node and answers * layer. [onPickUp] receives the press position local to this node and answers
* whether anything is there; false abandons the gesture. * whether anything is there; false abandons the gesture. [skipClaimedDown] leaves
* a press a descendant already consumed alone.
*/ */
@Composable @Composable
fun rememberDragSurface( fun rememberDragSurface(
@@ -65,6 +84,33 @@ fun rememberDragSurface(
onMove: (pointerInRoot: Offset) -> Unit, onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit, onDrop: () -> Unit,
onCancel: () -> Unit, onCancel: () -> Unit,
onRelease: (() -> Unit)? = null,
skipClaimedDown: Boolean = false,
): Modifier = rememberPickUpSurface(
enabled = enabled,
key = key,
immediate = false,
onPickUp = { local, pointer, node, size ->
if (onPickUp(local, pointer, node, size)) PickUp.Drag else PickUp.None
},
onMove = onMove,
onDrop = onDrop,
onCancel = onCancel,
onRelease = onRelease,
skipClaimedDown = skipClaimedDown,
)
@Composable
private fun rememberPickUpSurface(
enabled: Boolean,
key: Any?,
immediate: Boolean,
onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> PickUp,
onMove: (pointerInRoot: Offset) -> Unit,
onDrop: () -> Unit,
onCancel: () -> Unit,
onRelease: (() -> Unit)?,
skipClaimedDown: Boolean = false,
): Modifier { ): Modifier {
if (!enabled) return Modifier if (!enabled) return Modifier
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
@@ -74,27 +120,44 @@ fun rememberDragSurface(
val currentMove by rememberUpdatedState(onMove) val currentMove by rememberUpdatedState(onMove)
val currentDrop by rememberUpdatedState(onDrop) val currentDrop by rememberUpdatedState(onDrop)
val currentCancel by rememberUpdatedState(onCancel) val currentCancel by rememberUpdatedState(onCancel)
val currentRelease by rememberUpdatedState(onRelease)
// Not a key: the pickup changes it, and a restart would cancel the drag.
val currentImmediate by rememberUpdatedState(immediate)
return Modifier return Modifier
.onGloballyPositioned { coordinates[0] = it } .onGloballyPositioned { coordinates[0] = it }
.pointerInput(key) { .pointerInput(key) {
val slop = viewConfiguration.touchSlop
awaitEachGesture { awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false)
// A child that took the press, such as a resize handle, keeps it.
if (skipClaimedDown && down.isConsumed) return@awaitEachGesture
val origin = down.position val origin = down.position
val heldStill = withTimeoutOrNull(holdMillis) { val immediate = currentImmediate
while (true) { // Null: held still. True: an immediate pickup past slop. False: abandoned.
val outcome = withTimeoutOrNull(holdMillis) {
var result: Boolean? = null
while (result == null) {
val main = awaitPointerEvent() val main = awaitPointerEvent()
val change = main.changes.firstOrNull { it.id == down.id } ?: break val change = main.changes.firstOrNull { it.id == down.id }
if (!change.pressed) break val distance = change?.let { (it.position - origin).getDistance() } ?: 0f
if (main.changes.count { it.pressed } > 1) break result = when {
if ((change.position - origin).getDistance() > tolerance) break change == null || !change.pressed -> false
// An ancestor's claim only becomes visible once the whole main.changes.count { it.pressed } > 1 -> false
// main pass has run, so look again on the final pass. immediate && distance > slop && !change.isConsumed -> {
val final = awaitPointerEvent(PointerEventPass.Final) change.consume()
if (final.changes.any { it.isConsumed }) break true
} }
} == null !immediate && distance > tolerance -> false
if (!heldStill) return@awaitEachGesture // An ancestor's claim only becomes visible once the
// whole main pass has run, so look again on the final pass.
awaitPointerEvent(PointerEventPass.Final).changes.any { it.isConsumed } -> false
else -> null
}
}
result
}
if (outcome == false) return@awaitEachGesture
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
val took = currentPickUp( val took = currentPickUp(
@@ -103,8 +166,11 @@ fun rememberDragSurface(
layout.positionInRoot(), layout.positionInRoot(),
layout.size, layout.size,
) )
if (!took) return@awaitEachGesture if (took == PickUp.None) return@awaitEachGesture
// Moved already, for an immediate pickup: it can't be a release.
var moved = outcome == true
var dropped = false var dropped = false
var released = false
try { try {
// Driven on the initial pass, which runs parent → child: an // Driven on the initial pass, which runs parent → child: an
// ancestor that outranks us (the pinch) has already consumed // ancestor that outranks us (the pinch) has already consumed
@@ -116,28 +182,51 @@ fun rememberDragSurface(
if (change.isConsumed) break if (change.isConsumed) break
if (!change.pressed) { if (!change.pressed) {
change.consume() change.consume()
if (took == PickUp.Hold || (!moved && currentRelease != null)) {
released = true
} else {
dropped = true dropped = true
}
break break
} }
if (event.changes.count { it.pressed } > 1) break if (event.changes.count { it.pressed } > 1) break
change.consume() change.consume()
if (took == PickUp.Hold) continue
if ((change.position - origin).getDistance() > slop) moved = true
coordinates[0]?.takeIf { it.isAttached } coordinates[0]?.takeIf { it.isAttached }
?.let { currentMove(it.localToRoot(change.position)) } ?.let { currentMove(it.localToRoot(change.position)) }
} }
} finally { } finally {
// Also reached when the pointer node is disposed mid-drag — // Also reached when the pointer node is disposed mid-drag —
// a cancel, not a drop, and must never write. // a cancel, not a drop, and must never write.
if (dropped) currentDrop() else currentCancel() when {
released -> {
if (took == PickUp.Drag) currentCancel()
currentRelease?.invoke()
}
dropped -> currentDrop()
took == PickUp.Drag -> currentCancel()
}
} }
} }
} }
} }
/** A lift on pickup, then a tick every time the drop target snaps to a new slot. */ /**
* A lift on pickup, a tick every time the drop target snaps to a new slot, and a
* confirm each time [drops] counts a real drop.
*/
@Composable @Composable
fun DragSnapHaptics(slot: Any?) { fun DragSnapHaptics(slot: Any?, drops: Int) {
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
val previous = remember { arrayOfNulls<Any>(1) } val previous = remember { arrayOfNulls<Any>(1) }
val dropsSeen = remember { intArrayOf(drops) }
LaunchedEffect(drops) {
if (drops != dropsSeen[0]) {
dropsSeen[0] = drops
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
}
}
LaunchedEffect(slot) { LaunchedEffect(slot) {
val had = previous[0] val had = previous[0]
previous[0] = slot previous[0] = slot
@@ -48,7 +48,12 @@ private val FAB_BAND = 88.dp
* a pill on the FAB's band, leaving the calendar visible behind it. * a pill on the FAB's band, leaving the calendar visible behind it.
*/ */
@Composable @Composable
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) { fun EventMoveHost(
viewModel: RescheduleViewModel,
modifier: Modifier = Modifier,
actionFailure: String? = null,
onActionFailureShown: () -> Unit = {},
) {
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle() val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
val outcome by viewModel.outcome.collectAsStateWithLifecycle() val outcome by viewModel.outcome.collectAsStateWithLifecycle()
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle() val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
@@ -58,7 +63,9 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
prompt?.let { pending -> prompt?.let { pending ->
RecurringScopeDialog( RecurringScopeDialog(
title = stringResource(R.string.event_move_recurring_title), title = stringResource(
if (pending.resize) R.string.event_resize_recurring_title else R.string.event_move_recurring_title,
),
onSelect = viewModel::moveWithScope, onSelect = viewModel::moveWithScope,
onDismiss = viewModel::cancelScope, onDismiss = viewModel::cancelScope,
allowSeries = !pending.occurrenceOnly, allowSeries = !pending.occurrenceOnly,
@@ -69,9 +76,17 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
val moved = outcome as? MoveOutcome.Moved val moved = outcome as? MoveOutcome.Moved
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) } val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
val resizedLabel = moved?.endMillis?.let { end ->
"$movedLabel–${formatTime(end, use24Hour, locale)}"
}
// A failed selection action (#141) shares the chip rather than stacking on it.
val message = when (outcome) { val message = when (outcome) {
null -> null null -> actionFailure
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty()) is MoveOutcome.Moved -> if (resizedLabel != null) {
stringResource(R.string.event_resize_done, resizedLabel)
} else {
stringResource(R.string.event_move_done, movedLabel.orEmpty())
}
MoveOutcome.Undone -> stringResource(R.string.event_move_undone) MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied) MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
MoveOutcome.Gone -> stringResource(R.string.event_move_gone) MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
@@ -96,6 +111,12 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS) delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
viewModel.consumeOutcome() viewModel.consumeOutcome()
} }
// A failure waits for the chip to be free, then has its own turn.
LaunchedEffect(actionFailure, outcome == null) {
if (actionFailure == null || outcome != null) return@LaunchedEffect
delay(CHIP_MILLIS)
onActionFailureShown()
}
BoxWithConstraints(modifier = modifier.fillMaxSize()) { BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val chipMaxWidth = maxWidth - FAB_BAND val chipMaxWidth = maxWidth - FAB_BAND
@@ -113,7 +134,7 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
// write anyway, so offering it would be a button that does nothing. // write anyway, so offering it would be a button that does nothing.
val undo = content.undo?.takeIf { !writeInFlight } val undo = content.undo?.takeIf { !writeInFlight }
SnackChip( SnackChip(
visible = outcome != null, visible = outcome != null || actionFailure != null,
message = content.message, message = content.message,
maxWidth = chipMaxWidth, maxWidth = chipMaxWidth,
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null }, actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
@@ -142,3 +163,7 @@ private fun formatMovedTo(
return localizedDateFormatter(locale, skeleton) return localizedDateFormatter(locale, skeleton)
.format(Instant.ofEpochMilli(startMillis).atZone(zone)) .format(Instant.ofEpochMilli(startMillis).atZone(zone))
} }
/** "10:30" — the end of a resized event, read in the device zone like its start. */
private fun formatTime(millis: Long, use24Hour: Boolean, locale: Locale): String =
timeOfDayFormatter(use24Hour, locale).format(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()))
@@ -131,6 +131,13 @@ fun eventDragAllowed(event: EventInstance): Boolean {
return move.dragEnabled && move.allows(event) return move.dragEnabled && move.allows(event)
} }
/**
* Whether a long press may select events at all (#141) — the gesture setting,
* whatever the event's calendar allows. A read-only event is selected but stays put.
*/
@Composable
fun eventSelectAllowed(): Boolean = LocalEventMove.current?.dragEnabled == true
/** /**
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't * A TalkBack action that opens [event] in the edit form, so rescheduling isn't
* pointer-only. Null when this event can't be moved. * pointer-only. Null when this event can't be moved.
@@ -0,0 +1,82 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
/** The selected event's action bar (#141); Edit and Delete only where its calendar allows. */
@Composable
fun EventSelectionBar(
canEdit: Boolean,
canDelete: Boolean,
onEdit: () -> Unit,
onDuplicate: () -> Unit,
onShare: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
// The FAB's height and colours, so the two read as one control.
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
shadowElevation = 6.dp,
modifier = modifier.height(FabSize),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 4.dp),
) {
if (canEdit) BarAction(Icons.Default.Edit, R.string.event_detail_edit, onEdit)
BarAction(Icons.Default.ContentCopy, R.string.event_detail_duplicate, onDuplicate)
BarAction(Icons.Default.Share, R.string.event_detail_share, onShare)
if (canDelete) BarAction(Icons.Default.Delete, R.string.event_detail_delete, onDelete)
}
}
}
/** A drafted block's bar (#153): create the event, or let the draft go. */
@Composable
fun DraftSelectionBar(onCreate: () -> Unit, onDiscard: () -> Unit, modifier: Modifier = Modifier) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
shadowElevation = 6.dp,
modifier = modifier.height(FabSize),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 4.dp),
) {
BarAction(Icons.Default.Close, R.string.event_draft_discard, onDiscard)
BarAction(Icons.Default.Check, R.string.event_draft_create, onCreate)
}
}
}
@Composable
private fun BarAction(icon: ImageVector, label: Int, onClick: () -> Unit) {
IconButton(onClick = onClick) {
Icon(imageVector = icon, contentDescription = stringResource(label))
}
}
@@ -0,0 +1,134 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.floret.components.SnackChipMargin
import kotlinx.coroutines.launch
/** The bottom band the selection bar and FAB cover, margin included. */
val SelectionBarClearance = FabSize + SnackChipMargin
/** The FAB's role while an event is selected (#141): its action bar's toggle. Null otherwise. */
@Composable
fun rememberFabSelection(
host: ChromeHost,
viewModel: EventActionsViewModel,
onEdit: (EventInstance) -> Unit,
onDuplicate: (EventForm) -> Unit,
chipShown: Boolean,
onDismissChip: () -> Unit,
): FabSelection? {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val requireWrite = rememberWriteGate()
val chooserTitle = stringResource(R.string.event_share_chooser_title)
// What the FAB has been told for this selection, over what the coverage says.
var chosen by remember(host.selection?.event) { mutableStateOf<Boolean?>(null) }
// Folded while a chip is up, which then sits beside the FAB as usual.
val expanded = !chipShown && (chosen ?: !host.selectionCompact)
val currentEdit by rememberUpdatedState(onEdit)
val currentDuplicate by rememberUpdatedState(onDuplicate)
val currentDismissChip by rememberUpdatedState(onDismissChip)
val currentWrite by rememberUpdatedState(requireWrite)
val selection = host.selection ?: return null
selection.create?.let { create ->
return remember(selection, expanded, chipShown) {
FabSelection(
expanded = expanded,
onToggle = {
if (chipShown) currentDismissChip()
chosen = !expanded
},
bar = {
DraftSelectionBar(
onCreate = { currentWrite { create() } },
onDiscard = selection.dismiss,
)
},
)
}
}
val event = selection.event
val calendar = calendars[event.calendarId]
val canEdit = calendar?.canModifyContents == true
val canDelete = canEdit && calendar?.isManaged == false
return remember(selection, expanded, chipShown, canEdit, canDelete) {
FabSelection(
expanded = expanded,
onToggle = {
if (chipShown) currentDismissChip()
chosen = !expanded
},
bar = {
EventSelectionBar(
canEdit = canEdit,
canDelete = canDelete,
onEdit = {
currentWrite {
selection.dismiss()
currentEdit(event)
}
},
onDuplicate = {
currentWrite {
scope.launch {
val form = viewModel.duplicateForm(event) ?: return@launch
selection.dismiss()
currentDuplicate(form)
}
}
},
onShare = {
scope.launch {
val uri = viewModel.shareUri(event) ?: return@launch
if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed()
}
},
onDelete = { currentWrite { viewModel.requestDelete(event) } },
)
},
)
}
}
/** What a failed action on a selected event says, for the move host's chip to show (#141). */
@Composable
fun eventActionFailureMessage(failure: EventActionFailure?): String? = when (failure) {
null -> null
EventActionFailure.LoadFailed -> stringResource(R.string.event_action_load_failed)
EventActionFailure.ShareFailed -> stringResource(R.string.event_share_failed)
EventActionFailure.DeleteFailed -> stringResource(R.string.event_delete_failed)
EventActionFailure.WriteDenied -> stringResource(R.string.event_delete_write_denied)
EventActionFailure.Gone -> stringResource(R.string.event_move_gone)
}
/** The delete confirmation a selected event's Delete brings up (#141). */
@Composable
fun EventSelectionHost(host: ChromeHost, viewModel: EventActionsViewModel) {
val deletePrompt by viewModel.deletePrompt.collectAsStateWithLifecycle()
deletePrompt?.let { prompt ->
DeleteEventDialog(
isRecurring = prompt.isRecurring,
onConfirm = { writeScope ->
host.selection?.takeIf { it.event == prompt.event }?.dismiss()
viewModel.confirmDelete(writeScope)
},
onDismiss = viewModel::cancelDelete,
)
}
}
@@ -19,12 +19,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.runtime.mutableStateOf
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.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
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
/** Width of the hour gutter down the start edge of the day and week timelines. */ /** Width of the hour gutter down the start edge of the day and week timelines. */
@@ -67,7 +70,7 @@ fun HourGutter(
val locale = currentLocale() val locale = currentLocale()
// Derived: the drag is rewritten every frame, its snapped start once a slot. // Derived: the drag is rewritten every frame, its snapped start once a slot.
val dragStartMin by remember(dragController) { val dragStartMin by remember(dragController) {
derivedStateOf { dragController.drag?.startMin } derivedStateOf { dragController.drag?.markerMin }
} }
val hourAlpha by animateFloatAsState( val hourAlpha by animateFloatAsState(
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f, targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
@@ -107,12 +110,25 @@ fun HourGutter(
} }
} }
} }
dragStartMin?.let { startMin -> // Held while it fades, so it goes out where it was rather than blinking off.
val badgeMin = remember { mutableStateOf<Int?>(null) }
dragStartMin?.let { badgeMin.value = it }
val badgeIn by animateFloatAsState(
targetValue = if (dragStartMin != null) 1f else 0f,
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
label = "gutterBadge",
)
badgeMin.value?.takeIf { badgeIn > 0f }?.let { startMin ->
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp) val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.offset(y = top) .offset(y = top)
.graphicsLayer {
alpha = badgeIn
scaleX = 0.8f + 0.2f * badgeIn
scaleY = 0.8f + 0.2f * badgeIn
}
.height(BADGE_HEIGHT) .height(BADGE_HEIGHT)
.background(MaterialTheme.colorScheme.primary, CircleShape) .background(MaterialTheme.colorScheme.primary, CircleShape)
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
@@ -121,7 +137,8 @@ fun HourGutter(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = formatGutterTime(startMin, use24Hour, locale), // A resized end can sit on midnight: 00:00, not a clamped 23:59.
text = formatGutterTime(startMin % MINUTES_PER_DAY, use24Hour, locale),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onPrimary,
maxLines = 1, maxLines = 1,
@@ -195,15 +195,20 @@ class OutOfViewEdges internal constructor(
} }
} }
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */ /** Whether a press at [y] (content px) lands on an edge bar (#153). */
fun tap(y: Float): Boolean { fun covers(y: Float): Boolean = edgeAt(y) != null
val edge = tappedEdge(
private fun edgeAt(y: Float) = tappedEdge(
events = events, events = events,
y = y, y = y,
viewportTop = scrollState.value.toFloat(), viewportTop = scrollState.value.toFloat(),
viewportPx = scrollState.viewportSize, viewportPx = scrollState.viewportSize,
reachPx = reachPx, reachPx = reachPx,
) ?: return false )
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
fun tap(y: Float): Boolean {
val edge = edgeAt(y) ?: return false
reveal(edge) reveal(edge)
return true return true
} }
@@ -1,10 +1,10 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
@@ -45,7 +45,7 @@ class PageStateCache<K, S>(
return entries.getOrPut(key) { return entries.getOrPut(key) {
val job = SupervisorJob(scope.coroutineContext[Job]) val job = SupervisorJob(scope.coroutineContext[Job])
Entry( Entry(
state = load(key).stateIn(scope + job, SharingStarted.WhileSubscribed(5_000L), initial), state = load(key).stateWhileSubscribed(scope + job, initial),
job = job, job = job,
) )
}.state }.state
@@ -5,6 +5,11 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -16,6 +21,17 @@ import kotlin.time.Instant
*/ */
val LocalDimCutoff = compositionLocalOf<Instant?> { null } val LocalDimCutoff = compositionLocalOf<Instant?> { null }
/**
* Whether [this] counts as past where it's drawn on [day]: once it has ended,
* or on any day before the one [now] falls in, so a multi-day event still
* running doesn't stay undimmed on the days already behind it.
*/
fun EventInstance.isPastOn(
day: LocalDate,
now: Instant,
zone: TimeZone = TimeZone.currentSystemDefault(),
): Boolean = hasEnded(now) || day < now.toLocalDateTime(zone).date
/** Opacity applied to a completed/past event chip when it is dimmed. */ /** Opacity applied to a completed/past event chip when it is dimmed. */
const val EventDimAlpha = 0.4f const val EventDimAlpha = 0.4f
@@ -1,14 +1,8 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -22,17 +16,16 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.CustomAmountEditor import de.jeanlucmakiola.floret.components.CustomAmountEditor
import de.jeanlucmakiola.floret.components.CustomExpandRow
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderUnit import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
@@ -90,7 +83,7 @@ fun ReminderDefaultPicker(
} }
val inherits = current is ReminderOverride.Inherit val inherits = current is ReminderOverride.Inherit
val isNone = current is ReminderOverride.None val isNone = current is ReminderOverride.None
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty() val selectedMinutes = current.minutesOrEmpty()
// Custom (non-preset) lead times seen this session, so unchecking one keeps // Custom (non-preset) lead times seen this session, so unchecking one keeps
// its row (unchecked) until the picker closes instead of vanishing mid-tap, // its row (unchecked) until the picker closes instead of vanishing mid-tap,
// which would strand a hand-entered value with no way to re-check it. // which would strand a hand-entered value with no way to re-check it.
@@ -121,24 +114,14 @@ fun ReminderDefaultPicker(
GroupedRow( GroupedRow(
title = stringResource(R.string.reminder_use_default), title = stringResource(R.string.reminder_use_default),
position = Position.Top, position = Position.Top,
selected = inherits, checked = inherits,
trailing = if (inherits) {
{ SelectedCheck() }
} else {
null
},
onClick = { apply(ReminderOverride.Inherit) }, onClick = { apply(ReminderOverride.Inherit) },
) )
} }
GroupedRow( GroupedRow(
title = stringResource(R.string.reminder_none), title = stringResource(R.string.reminder_none),
position = if (allowInherit) Position.Bottom else Position.Alone, position = if (allowInherit) Position.Bottom else Position.Alone,
selected = isNone, checked = isNone,
trailing = if (isNone) {
{ SelectedCheck() }
} else {
null
},
onClick = { apply(ReminderOverride.None) }, onClick = { apply(ReminderOverride.None) },
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
@@ -154,17 +137,11 @@ fun ReminderDefaultPicker(
onClick = { toggle(minute) }, onClick = { toggle(minute) },
) )
} }
// When expanded, the Custom row connects downward into the editor card CustomExpandRow(
// so the two read as one grouped container (the per-calendar pattern).
GroupedRow(
title = stringResource(R.string.event_edit_reminder_custom), title = stringResource(R.string.event_edit_reminder_custom),
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount), position = positionOf(rows.size, rowCount),
onClick = { customExpanded = !customExpanded }, expanded = customExpanded,
) onExpandedChange = { customExpanded = it },
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
) { ) {
CustomReminderEditor( CustomReminderEditor(
amountText = amountText, amountText = amountText,
@@ -212,19 +189,6 @@ private fun CustomReminderEditor(
) )
} }
/** A short explanatory paragraph shown under a picker's title, above the rows,
* on the same left margin as the rows themselves. */
@Composable
internal fun PickerDescription(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
)
}
/** /**
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned * Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
* options (today / this week / this month) and the rolling windows (next 7 / 30 * options (today / this week / this month) and the rolling windows (next 7 / 30
@@ -265,17 +229,11 @@ fun AgendaRangePicker(
} }
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position -> val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
val isSelected = option == selected
GroupedRow( GroupedRow(
title = agendaRangeLabel(option), title = agendaRangeLabel(option),
summary = windowSummary(option), summary = windowSummary(option),
position = position, position = position,
selected = isSelected, checked = option == selected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onClick = {
onSelect(option) onSelect(option)
onDismiss() onDismiss()
@@ -284,7 +242,7 @@ fun AgendaRangePicker(
} }
FullScreenPicker(title = title, onDismiss = onDismiss) { FullScreenPicker(title = title, onDismiss = onDismiss) {
PickerDescription(description) GroupedHint(description, verticalPadding = 8.dp)
// Calendar-aligned windows. // Calendar-aligned windows.
calendarAligned.forEachIndexed { index, option -> calendarAligned.forEachIndexed { index, option ->
@@ -297,7 +255,7 @@ fun AgendaRangePicker(
rolling.forEachIndexed { index, option -> rolling.forEachIndexed { index, option ->
rangeRow(option, positionOf(index, rollingRowCount)) rangeRow(option, positionOf(index, rollingRowCount))
} }
GroupedRow( CustomExpandRow(
title = if (customSelected) { title = if (customSelected) {
agendaRangeLabel(selected) agendaRangeLabel(selected)
} else { } else {
@@ -306,19 +264,10 @@ fun AgendaRangePicker(
// An unset custom window has no day count yet, so the row stays // An unset custom window has no day count yet, so the row stays
// single-line until it does. // single-line until it does.
summary = if (customSelected) windowSummary(selected) else null, summary = if (customSelected) windowSummary(selected) else null,
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount), position = positionOf(rolling.size, rollingRowCount),
selected = customSelected, expanded = customExpanded,
trailing = if (customSelected) { onExpandedChange = { customExpanded = it },
{ SelectedCheck() } checked = customSelected,
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
) { ) {
CustomDaysEditor( CustomDaysEditor(
amountText = amountText, amountText = amountText,
@@ -396,17 +345,12 @@ fun DurationPicker(
} }
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) { FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
if (description != null) PickerDescription(description) if (description != null) GroupedHint(description, verticalPadding = 8.dp)
if (inheritLabel != null) { if (inheritLabel != null) {
GroupedRow( GroupedRow(
title = inheritLabel, title = inheritLabel,
position = Position.Alone, position = Position.Alone,
selected = inherits, checked = inherits,
trailing = if (inherits) {
{ SelectedCheck() }
} else {
null
},
onClick = { onClick = {
onSelect(null) onSelect(null)
onDismiss() onDismiss()
@@ -415,39 +359,22 @@ fun DurationPicker(
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
} }
presets.forEachIndexed { index, minute -> presets.forEachIndexed { index, minute ->
val isSelected = minute == selected
GroupedRow( GroupedRow(
title = label(minute), title = label(minute),
position = positionOf(index, rowCount), position = positionOf(index, rowCount),
selected = isSelected, checked = minute == selected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onClick = {
onSelect(minute) onSelect(minute)
onDismiss() onDismiss()
}, },
) )
} }
// The Custom row connects downward into the editor card when expanded, so CustomExpandRow(
// the two read as one grouped container (the shared custom-expand pattern).
GroupedRow(
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom), title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount), position = positionOf(presets.size, rowCount),
selected = custom != null, expanded = customExpanded,
trailing = if (custom != null) { onExpandedChange = { customExpanded = it },
{ SelectedCheck() } checked = custom != null,
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
) { ) {
CustomDurationEditor( CustomDurationEditor(
amountText = amountText, amountText = amountText,
@@ -10,6 +10,8 @@ import androidx.compose.ui.text.font.FontStyle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
import de.jeanlucmakiola.calendula.domain.rruleParts
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
import de.jeanlucmakiola.floret.locale.localizedDateFormatter import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
@@ -32,10 +34,7 @@ import java.util.Locale
*/ */
@Composable @Composable
fun recurrenceText(rrule: String, locale: Locale): AnnotatedString { fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token -> val parts = rruleParts(rrule).mapNotNull { part -> part.key?.let { it to part.value } }.toMap()
val eq = token.indexOf('=')
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
}.toMap()
val freq = parts["FREQ"]?.uppercase() val freq = parts["FREQ"]?.uppercase()
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1 val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
@@ -171,12 +170,5 @@ internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()):
.atOffset(ZoneOffset.UTC) .atOffset(ZoneOffset.UTC)
.atZoneSameInstant(zone) .atZoneSameInstant(zone)
.toLocalDate() .toLocalDate()
}.recoverCatching { }.getOrNull() ?: parseBasicDate(value.takeWhile { it.isDigit() })?.toJavaLocalDate()
val digits = value.takeWhile { it.isDigit() }
LocalDate.of(
digits.substring(0, 4).toInt(),
digits.substring(4, 6).toInt(),
digits.substring(6, 8).toInt(),
)
}.getOrNull()
} }
@@ -3,9 +3,9 @@ package de.jeanlucmakiola.calendula.ui.common
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
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.EventFormProblem
@@ -15,26 +15,28 @@ import de.jeanlucmakiola.calendula.domain.allowsEventMove
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.problems import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.realignRecurrence import de.jeanlucmakiola.calendula.domain.realignRecurrence
import de.jeanlucmakiola.calendula.domain.resizedTo
import de.jeanlucmakiola.calendula.domain.endInstant
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.resolvedZone import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.shiftedByDays import de.jeanlucmakiola.calendula.domain.shiftedByDays
import de.jeanlucmakiola.calendula.domain.shiftedTo import de.jeanlucmakiola.calendula.domain.shiftedTo
import de.jeanlucmakiola.calendula.domain.toEditForm import de.jeanlucmakiola.calendula.domain.toEditForm
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant import kotlin.time.Instant
@@ -54,6 +56,9 @@ sealed interface MoveTarget {
* disagree by a day over which zone the event's first day is resolved in. * disagree by a day over which zone the event's first day is resolved in.
*/ */
data class ByDays(val days: Int) : MoveTarget data class ByDays(val days: Int) : MoveTarget
/** New start and end instants — a timeline resize, which changes the length. */
data class Span(val start: Instant, val end: Instant) : MoveTarget
} }
/** /**
@@ -74,7 +79,7 @@ data class MoveRequest(
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor * re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
* across the same midnights the occurrence crossed. * across the same midnights the occurrence crossed.
*/ */
data class MoveScopePrompt(val occurrenceOnly: Boolean) data class MoveScopePrompt(val occurrenceOnly: Boolean, val resize: Boolean = false)
/** The inverse of a completed move, for the undo action. */ /** The inverse of a completed move, for the undo action. */
data class MoveUndo( data class MoveUndo(
@@ -88,12 +93,14 @@ data class MoveUndo(
sealed interface MoveOutcome { sealed interface MoveOutcome {
/** /**
* Written. [startMillis] is where the dragged occurrence now begins, for the * Written. [startMillis] is where the dragged occurrence now begins, for the
* confirmation message; [undo] is null when the write has no clean inverse. * confirmation message, and [endMillis] where it ends — set only for a resize,
* whose message names both; [undo] is null when the write has no clean inverse.
*/ */
data class Moved( data class Moved(
val startMillis: Long, val startMillis: Long,
val isAllDay: Boolean, val isAllDay: Boolean,
val undo: MoveUndo?, val undo: MoveUndo?,
val endMillis: Long? = null,
) : MoveOutcome ) : MoveOutcome
data object Undone : MoveOutcome data object Undone : MoveOutcome
@@ -122,6 +129,7 @@ sealed interface MoveOutcome {
class RescheduleViewModel @Inject constructor( class RescheduleViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
) : ViewModel() { ) : ViewModel() {
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null) private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
@@ -179,9 +187,8 @@ class RescheduleViewModel @Inject constructor(
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() } .map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
.catch { emit(emptySet<Long>()) } .catch { emit(emptySet<Long>()) }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(), initialValue = emptySet(),
) )
@@ -226,7 +233,10 @@ class RescheduleViewModel @Inject constructor(
} else { } else {
// Still busy: the scope dialog is now the thing in flight. // Still busy: the scope dialog is now the thing in flight.
pending = prepared pending = prepared
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign) _scopePrompt.value = MoveScopePrompt(
occurrenceOnly = !prepared.canRealign,
resize = request.target is MoveTarget.Span,
)
} }
} }
return true return true
@@ -308,18 +318,19 @@ class RescheduleViewModel @Inject constructor(
return null return null
} }
val zone = TimeZone.currentSystemDefault() val zone = zoneSource.current()
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone) val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
val shifted = when (val target = request.target) { val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone) is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone) is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
is MoveTarget.Span -> original.resizedTo(target.start, target.end, zone)
} }
// A zero-distance drop is not a write. // A zero-distance drop is not a write.
if (shifted == original) return null if (shifted == original) return null
// The UNTIL check is deferred to the write, which knows how far the move // The UNTIL check is deferred to the write, which knows how far the move
// reaches and so which date has to clear it. // reaches and so which date has to clear it.
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) { if ((shifted.problems(zone) - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
_outcome.value = MoveOutcome.Failed _outcome.value = MoveOutcome.Failed
return null return null
} }
@@ -396,6 +407,7 @@ class RescheduleViewModel @Inject constructor(
startMillis = movedStartMillis(prepared), startMillis = movedStartMillis(prepared),
isAllDay = prepared.updated.isAllDay, isAllDay = prepared.updated.isAllDay,
undo = undoFor(prepared, scope), undo = undoFor(prepared, scope),
endMillis = resizedEndMillis(prepared),
) )
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -480,6 +492,13 @@ class RescheduleViewModel @Inject constructor(
null null
} }
/** Where a resized occurrence now ends; null for a move, which keeps its length. */
private fun resizedEndMillis(prepared: PreparedMove): Long? {
if (prepared.request.target !is MoveTarget.Span) return null
val form = prepared.updated
return form.endInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds()
}
/** /**
* Where the dragged occurrence now begins, in the same anchoring the views * Where the dragged occurrence now begins, in the same anchoring the views
* read dates back in: UTC midnight for an all-day event. * read dates back in: UTC midnight for an all-day event.
@@ -489,8 +508,7 @@ class RescheduleViewModel @Inject constructor(
return if (form.isAllDay) { return if (form.isAllDay) {
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
} else { } else {
form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault())) form.startInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds()
.toEpochMilliseconds()
} }
} }
@@ -0,0 +1,102 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp
/** The handle's touch target, centred on the edge it moves. */
val ResizeHandleTarget = 48.dp
private val HANDLE_DOT = 16.dp
private val HANDLE_RING = 2.dp
/**
* A grip on one edge of a selected event (#141), dragged without a hold. No
* semantics: TalkBack resizes through the event's edit action.
*/
@Composable
fun ResizeHandle(
color: Color,
enabled: Boolean,
onStart: (pointerInRoot: Offset) -> Unit,
onMove: (pointerInRoot: Offset) -> Unit,
onEnd: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val currentStart by rememberUpdatedState(onStart)
val currentMove by rememberUpdatedState(onMove)
val currentEnd by rememberUpdatedState(onEnd)
val currentCancel by rememberUpdatedState(onCancel)
// Not a key: turning it off mid-resize must not cancel that resize.
val currentEnabled by rememberUpdatedState(enabled)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.size(ResizeHandleTarget)
.clearAndSetSemantics { }
.onGloballyPositioned { coordinates[0] = it }
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
if (!currentEnabled) return@awaitEachGesture
down.consume()
var started = false
var ended = false
try {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
// Claimed by an ancestor that outranks us — the pinch.
if (change.isConsumed) break
if (event.changes.count { it.pressed } > 1) break
change.consume()
if (!change.pressed) {
ended = started
break
}
val root = coordinates[0]?.takeIf { it.isAttached }
?.localToRoot(change.position) ?: break
if (started) {
currentMove(root)
} else {
started = true
currentStart(root)
}
}
} finally {
when {
ended -> currentEnd()
started -> currentCancel()
}
}
}
},
) {
Box(
Modifier
.size(HANDLE_DOT)
.background(color, CircleShape)
.border(HANDLE_RING, MaterialTheme.colorScheme.surface, CircleShape),
)
}
}
@@ -0,0 +1,96 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import java.time.ZoneId
import java.util.Locale
import kotlin.time.toJavaInstant
/**
* The top bar while an event is selected (#141): says which, and when, with a
* way out — the selection otherwise only shows on the grid and in the FAB.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectionTopBar(selection: EventSelection) {
val event = selection.event
// Follows a move or resize, so the times stay readable under the finger.
val preview by remember(selection) { derivedStateOf { selection.preview() } }
val shown = preview ?: event
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val whenText = remember(shown.start, shown.end, use24Hour, locale) {
formatSelectionWhen(shown, use24Hour, locale)
}
TopAppBar(
navigationIcon = {
IconButton(onClick = selection.dismiss) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(
if (selection.create != null) R.string.event_draft_discard else R.string.event_selection_close,
),
)
}
},
title = {
Column {
Text(
text = event.title.ifBlank { stringResource(R.string.event_untitled) },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = whenText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
// The header's own surface, as the view's bar has (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
}
/**
* "Fri, 9 Oct · 09:00–10:00", or with both days named for an event that runs
* into another: "Fri, 9 Oct, 22:00 – Sat, 10 Oct, 02:00".
*/
private fun formatSelectionWhen(event: EventInstance, use24Hour: Boolean, locale: Locale): String {
val zone = ZoneId.systemDefault()
val start = event.start.toJavaInstant().atZone(zone)
val end = event.end.toJavaInstant().atZone(zone)
val day = localizedDateFormatter(locale, "EEEdMMM")
val time = timeOfDayFormatter(use24Hour, locale)
val endsSameDay = end.toLocalDate() == start.toLocalDate() ||
(end.toLocalTime().toSecondOfDay() == 0 && end.toLocalDate() == start.toLocalDate().plusDays(1))
return if (endsSameDay) {
"${day.format(start)} · ${time.format(start)}–${time.format(end)}"
} else {
"${day.format(start)}, ${time.format(start)} – ${day.format(end)}, ${time.format(end)}"
}
}
@@ -1,9 +1,15 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import de.jeanlucmakiola.calendula.domain.inRepeatedHour
import de.jeanlucmakiola.calendula.domain.zoneAbbreviationAt
import kotlinx.datetime.TimeZone
import java.time.LocalTime import java.time.LocalTime
import kotlin.time.Instant
import kotlin.time.toJavaInstant
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import kotlinx.datetime.toLocalDateTime
/** /**
* The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"), * The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"),
@@ -32,6 +38,12 @@ fun formatTimeOfDay(hour: Int, minute: Int, is24Hour: Boolean, locale: Locale):
LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59)) LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59))
.format(timeOfDayFormatter(is24Hour, locale)) .format(timeOfDayFormatter(is24Hour, locale))
/** [instant]'s wall-clock time in [zone], as [formatTimeOfDay] renders it. */
fun formatTimeOfDay(instant: Instant, zone: TimeZone, is24Hour: Boolean, locale: Locale): String {
val time = instant.toLocalDateTime(zone).time
return formatTimeOfDay(time.hour, time.minute, is24Hour, locale)
}
/** /**
* Format minutes-from-midnight (0..1440) as a time label. The end-of-day value * Format minutes-from-midnight (0..1440) as a time label. The end-of-day value
* 1440 renders as "24:00" in 24h (its established reading) and as midnight * 1440 renders as "24:00" in 24h (its established reading) and as midnight
@@ -68,3 +80,15 @@ fun formatHourLabel(hour: Int, is24Hour: Boolean, locale: Locale): String =
} }
private const val MINUTES_PER_DAY = 1_440 private const val MINUTES_PER_DAY = 1_440
/**
* [formatted] — [instant]'s time in [zone] — with the zone's abbreviation after
* it ("2:30 AM CEST") when the fall-back hour repeats that time, the one case
* where the time alone doesn't say which instant it is (#387).
*/
fun withRepeatedHourZone(formatted: String, instant: Instant, zone: TimeZone, locale: Locale): String {
if (!instant.inRepeatedHour(zone)) return formatted
val abbreviation = zoneAbbreviationAt(zone.id, locale, instant.toJavaInstant(), ::icuTimeZoneRegion)
?: return formatted
return "$formatted $abbreviation"
}
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -39,11 +38,10 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.timeZoneOptions import de.jeanlucmakiola.calendula.domain.timeZoneOptions
import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -118,19 +116,14 @@ fun TimeZonePickerDialog(
title = stringResource(R.string.event_edit_timezone_device), title = stringResource(R.string.event_edit_timezone_device),
summary = deviceSummary, summary = deviceSummary,
position = Position.Alone, position = Position.Alone,
selected = selected == null, checked = selected == null,
leading = { Icon(Icons.Default.Public, contentDescription = null) }, leading = { Icon(Icons.Default.Public, contentDescription = null) },
trailing = if (selected == null) {
{ SelectedCheck() }
} else {
null
},
onClick = { choose(null) }, onClick = { choose(null) },
) )
} }
if (recentZones.isNotEmpty()) { if (recentZones.isNotEmpty()) {
item(key = "recent_header") { item(key = "recent_header") {
SectionHeader(stringResource(R.string.event_edit_timezone_recent)) GroupedSectionHeader(stringResource(R.string.event_edit_timezone_recent))
} }
itemsIndexed( itemsIndexed(
items = recentZones, items = recentZones,
@@ -145,7 +138,7 @@ fun TimeZonePickerDialog(
} }
} }
item(key = "all_header") { item(key = "all_header") {
SectionHeader(stringResource(R.string.event_edit_timezone_all)) GroupedSectionHeader(stringResource(R.string.event_edit_timezone_all))
} }
} }
@@ -234,23 +227,6 @@ private fun SearchField(
} }
} }
/** A small primary-coloured group label, matching the settings screens. */
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(
start = GroupedListInset,
end = GroupedListInset,
top = 16.dp,
bottom = 4.dp,
),
)
}
@Composable @Composable
private fun ZoneRow( private fun ZoneRow(
zone: TimeZoneOption, zone: TimeZoneOption,
@@ -262,12 +238,7 @@ private fun ZoneRow(
title = zone.label, title = zone.label,
summary = zoneDescriptor(zone), summary = zoneDescriptor(zone),
position = position, position = position,
selected = selected, checked = selected,
trailing = if (selected) {
{ SelectedCheck() }
} else {
null
},
onClick = onClick, onClick = onClick,
) )
} }
@@ -1,11 +1,10 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -19,7 +18,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -27,6 +29,7 @@ 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.draw.clipToBounds
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin 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
@@ -35,14 +38,25 @@ import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.layout.onGloballyPositioned 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.platform.LocalViewConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
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
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.instantIn
import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt
import de.jeanlucmakiola.calendula.domain.repicked
import de.jeanlucmakiola.calendula.domain.toFormTime
import de.jeanlucmakiola.calendula.domain.resolveIn
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.TimedBlock import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.calendula.ui.week.minutesPast
import de.jeanlucmakiola.calendula.ui.week.wallClockEndMinutes
import de.jeanlucmakiola.calendula.ui.week.wallClockMinutes
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
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
@@ -50,13 +64,15 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.UtcOffset
import kotlinx.datetime.plus 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
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant import kotlin.time.Instant
/** Start granularity a dragged block snaps to, matching the big calendar apps. */ /** Start granularity a dragged block snaps to, matching the big calendar apps. */
@@ -97,11 +113,28 @@ data class TimelineDrag(
* drop lands, so it is dropped once the gesture ends ([finish]). * drop lands, so it is dropped once the gesture ends ([finish]).
*/ */
val edgeOnly: Boolean = false, val edgeOnly: Boolean = false,
/**
* The minute of its day the gutter badge names — the top edge, or for a
* bottom-edge resize the end being dragged (#141).
*/
val markerMin: Int = startMin,
) { ) {
/** What the snap haptics key off: one tick per changed slot, not per frame. */ /**
val slot: Pair<LocalDate, Int> get() = date to startMin * What the snap haptics key off: one tick per changed slot, not per frame.
* Carries the length, which is all a bottom-edge resize changes.
*/
val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin)
} }
/** The event a long press left selected (#141). */
data class TimelineSelection(
val event: EventInstance,
/** Set from a move or resize of it until the grid shows where it landed. */
val moved: Boolean = false,
/** Selected by letting go of a lifted block, which then keeps that lift as it is. */
val fromLift: Boolean = false,
)
/** /**
* One day column's share of a dragged event, ready to draw. Both halves of an * One day column's share of a dragged event, ready to draw. Both halves of an
* event crossing midnight are previewed, whichever half the finger picked up: * event crossing midnight are previewed, whichever half the finger picked up:
@@ -248,6 +281,142 @@ data class TimelineDrop(
val clipOffsetMin: Int = 0, val clipOffsetMin: Int = 0,
) )
/** The id a drafted block's stand-in event carries, which no provider row has (#153). */
private const val DRAFT_ID = Long.MIN_VALUE
/** Whether this is the stand-in for a block drafted on an empty slot, not a real event. */
val EventInstance.isDraft: Boolean get() = instanceId == DRAFT_ID
/** A drafted block's stand-in event, [minutes] long from [start]. */
fun draftEvent(title: String, color: Int, start: Instant, minutes: Int): EventInstance = EventInstance(
instanceId = DRAFT_ID,
eventId = DRAFT_ID,
calendarId = DRAFT_ID,
title = title,
start = start,
end = start + minutes.minutes,
isAllDay = false,
color = color,
location = null,
)
/** Where a long press [minutes] down the column starts its draft, snapped down to the slot. */
internal fun draftStartMin(minutes: Float): Int =
(minutes / DRAG_SNAP_MINUTES).toInt().times(DRAG_SNAP_MINUTES)
.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
/** The part of [draft] on [date] as a full-width block, or null if none of it falls there. */
internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): TimedBlock? {
// Clipped to the day the way the grid lays out events, so it's drawn the same across a DST change (#387).
// The end is exclusive, so a draft ending at midnight belongs to the day before.
val start = maxOf(draft.start, date.atStartOfDayIn(zone))
val end = minOf(draft.end, date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone))
if (end <= start) return null
val startMin = wallClockMinutes(start, date, zone)
val endMin = wallClockEndMinutes(startMin, start, end, date, zone).coerceAtMost(MINUTES_PER_DAY)
return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1)
}
/** Which end of an event a resize handle moves (#141). */
enum class ResizeEdge { Top, Bottom }
/** Where a finished resize asks its event to begin and end. */
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
/**
* [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s
* midnight. Only the held edge moves. The other keeps its own instant rather than
* a round trip through the grid, where an end in the fall-back hour's repeat is
* drawn to that hour's end, not at its own time (#387). In a repeated hour, the
* held edge takes the pass that matches the direction it was dragged from where
* it was drawn (see [repicked]). An end takes a pass the grid draws at the drop
* point, so the block settles under the finger.
*/
internal fun resizedDrop(
event: EventInstance,
edge: ResizeEdge?,
date: LocalDate,
startMin: Int,
spanMin: Int,
zone: TimeZone,
): TimelineResizeDrop {
val drawn = wallClockSpan(event, zone)
return TimelineResizeDrop(
event = event,
start = if (edge == ResizeEdge.Top) {
event.start.toFormTime(zone).repicked(
wallClockTime(date, startMin),
zone,
shownAt = wallClockTime(drawn.day, drawn.startMin),
notAfter = event.end,
).instantIn(zone)
} else {
event.start
},
end = if (edge == ResizeEdge.Bottom) {
val picked = wallClockTime(date, startMin + spanMin)
val pickedMin = (picked.date.toEpochDays() - drawn.day.toEpochDays()).toInt() * MINUTES_PER_DAY +
picked.time.toSecondOfDay() / 60
event.end.toFormTime(zone).repicked(
picked,
zone,
shownAt = wallClockTime(drawn.day, drawn.endMin),
notBefore = event.start,
drawable = { wallClockEndMinutes(drawn.startMin, event.start, it, drawn.day, zone) == pickedMin },
).instantIn(zone)
} else {
event.end
},
)
}
/**
* The span after dragging [edge] to [edgeMin] in day [edgeDay], in minutes from the
* start day's midnight. Snapped, and at least one snap step long.
*/
internal fun resizedSpan(
edge: ResizeEdge,
startMin: Int,
endMin: Int,
edgeMin: Float,
edgeDay: Int? = null,
): Pair<Int, Int> {
// An edge dragged into a day past the other end changes nothing.
if (edgeDay != null) {
val endDay = (endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val wrongWay = when (edge) {
ResizeEdge.Top -> edgeDay > endDay
ResizeEdge.Bottom -> edgeDay < startMin.floorDiv(MINUTES_PER_DAY)
}
if (wrongWay) return startMin to endMin
}
val snapped = snapToSlot(edgeMin)
return when (edge) {
ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin
ResizeEdge.Bottom -> startMin to maxOf(snapped, startMin + DRAG_SNAP_MINUTES)
}
}
/** [minutes] rounded to the nearest [DRAG_SNAP_MINUTES] slot. */
internal fun snapToSlot(minutes: Float): Int = (minutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
/** [minutes] snapped, kept at [held] until three quarters of a step away. */
internal fun stickySnap(minutes: Float, held: Int?): Int {
if (held != null && kotlin.math.abs(minutes - held) < DRAG_SNAP_MINUTES * 0.75f) return held
return snapToSlot(minutes)
}
/** How far past its column's side the finger has to go before a resize changes day. */
private const val COLUMN_HYSTERESIS = 0.25f
/** The column under [position], kept at [held] until [COLUMN_HYSTERESIS] into another. */
internal fun stickyColumn(position: Float, held: Int?, lastIndex: Int): Int {
val under = position.toInt().coerceIn(0, lastIndex)
if (held == null) return under
val stays = position >= held - COLUMN_HYSTERESIS && position < held + 1 + COLUMN_HYSTERESIS
return if (stays) held else under
}
/** /**
* The timeline's live geometry, republished on every layout. Plain fields rather * The timeline's live geometry, republished on every layout. Plain fields rather
* than snapshot state: it changes on every scroll frame, and the drag loop reads * than snapshot state: it changes on every scroll frame, and the drag loop reads
@@ -302,6 +471,10 @@ class TimelineDragController {
var settling: TimelineDrag? by mutableStateOf(null) var settling: TimelineDrag? by mutableStateOf(null)
private set private set
/** How many real drops this controller has handed back. */
var drops by mutableIntStateOf(0)
private set
/** /**
* Which block is lifted, and whether anything is. Separate snapshot state * Which block is lifted, and whether anything is. Separate snapshot state
* from [drag], which changes every frame, so blocks that only need "am I the * from [drag], which changes every frame, so blocks that only need "am I the
@@ -320,10 +493,92 @@ class TimelineDragController {
*/ */
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null) private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
/** How long a block drafted on an empty slot starts out, in minutes (#153). */
var draftMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION
/** The selected event, if any — see [TimelineSelection]. */
var selection: TimelineSelection? by mutableStateOf(null)
private set
fun select(event: EventInstance, moved: Boolean = false, fromLift: Boolean = false) {
if (selection?.event?.instanceId != event.instanceId) selectionCovered = false
selection = TimelineSelection(event, moved, fromLift)
}
fun deselect() {
if (selection?.event?.isDraft == true && settling?.event?.isDraft == true) release()
selection = null
selectionCovered = false
}
/** The events of the blocks on screen that show the selection; none for a while ends it. */
val selectedCandidates = mutableStateListOf<EventInstance>()
fun noteSelectedBlock(event: EventInstance, shown: Boolean) {
if (shown) selectedCandidates.add(event) else selectedCandidates.remove(event)
}
/**
* Once a move of the selected event has settled, point the selection at the
* row it landed as. A new event id wins: a single moved occurrence is written
* as an exception, and another occurrence of its series may share its time.
*/
fun settleMovedSelection() {
val current = selection?.takeIf { it.moved } ?: return
if (isDragging || settling != null) return
val landed = selectedCandidates.filter { it.instanceId != current.event.instanceId }
// Only the original row left: the move was abandoned, and it still stands.
val chosen = landed.firstOrNull { it.eventId != current.event.eventId }
?: landed.firstOrNull() ?: selectedCandidates.firstOrNull() ?: return
selection = current.copy(event = chosen, moved = false)
}
/** Whether the selected block's bottom sits where the action bar would cover it. */
var selectionCovered: Boolean by mutableStateOf(false)
private set
/**
* Whether [block] is the selected event: by instance, by series and start, and
* just after a move by title and times, since a moved occurrence gets new ids.
*/
fun isSelected(block: TimedBlock): Boolean {
val current = selection ?: return false
val selected = current.event
val event = block.event
return event.instanceId == selected.instanceId ||
(event.eventId == selected.eventId && event.start == selected.start) ||
(
current.moved &&
event.calendarId == selected.calendarId &&
event.title.isNotBlank() &&
event.title == selected.title &&
event.start == selected.start &&
event.end == selected.end
)
}
/** Point the selection at [block]'s event, so the bar acts on the row now there. */
fun adoptSelection(block: TimedBlock) {
val current = selection?.takeIf { !it.moved } ?: return
if (current.event != block.event) selection = current.copy(event = block.event)
}
/** 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)
private set private set
/** Whether the finger has moved since the pickup; only then may the view auto-scroll. */
var pointerMoved: Boolean by mutableStateOf(false)
private set
/** Touch slop, past which the finger counts as having moved. */
var slopPx: Float = 0f
private var pickupPointer = Offset.Zero
/** The end a settling resize asked for, which the grid has to show before it settles. */
private var settledEnd: Instant? = null
/** /**
* Whether the grid itself now draws the settled drop at its landing slot. * Whether the grid itself now draws the settled drop at its landing slot.
* The copy may only be handed back once this is true, or the source ghost * The copy may only be handed back once this is true, or the source ghost
@@ -332,6 +587,10 @@ class TimelineDragController {
var settledOnGrid: Boolean by mutableStateOf(false) var settledOnGrid: Boolean by mutableStateOf(false)
private set private set
/** Whether the settling drop is the selected event, whose block lands still lifted. */
var settlingSelected: Boolean = false
private set
/** /**
* The abandoned-move counter, read at the drop so the mark can't be a * The abandoned-move counter, read at the drop so the mark can't be a
* composition behind the tick it is compared against. * composition behind the tick it is compared against.
@@ -353,6 +612,10 @@ class TimelineDragController {
* finger is on; this is what turns where it lands back into an event start. * finger is on; this is what turns where it lands back into an event start.
*/ */
private var clipOffsetMin = 0 private var clipOffsetMin = 0
private var dragZone: TimeZone? = null
private var dragDuration = Duration.ZERO
/** The dragged event's pass of a repeated hour, worked out once per drag (#387). */
private var dragKept: UtcOffset? = null
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */ /** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
private var titleLines = 0 private var titleLines = 0
@@ -361,44 +624,73 @@ class TimelineDragController {
* 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: Triple<LocalDate, Int, Int>? = null
/** The resize in progress, or null for a plain drag (#141). */
private var resize: ResizeSession? = null
fun begin( fun begin(
block: TimedBlock, block: TimedBlock,
clipOffsetMin: Int, clipOffsetMin: Int,
zone: TimeZone,
titleLines: Int, titleLines: Int,
pointerInRoot: Offset, pointerInRoot: Offset,
blockInRoot: Offset, blockInRoot: Offset,
) { ) {
// The selected event keeps its selection through a drag; any other ends it.
if (!isSelected(block)) deselect()
source = block source = block
this.clipOffsetMin = clipOffsetMin this.clipOffsetMin = clipOffsetMin
dragZone = zone
dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO)
dragKept = zone.repeatedHourOffsetAt(block.event.start)
this.titleLines = titleLines this.titleLines = titleLines
settling = null settling = null
isDragging = true isDragging = true
liftedInstanceId = block.event.instanceId liftedInstanceId = block.event.instanceId
grab = pointerInRoot - blockInRoot grab = pointerInRoot - blockInRoot
pointer = pointerInRoot pointer = pointerInRoot
pickupPointer = pointerInRoot
pointerMoved = false
originSlot = null originSlot = null
recompute() recompute()
originSlot = drag?.slot originSlot = drag?.slot
} }
/**
* Draft a block on an empty slot and lift it at once (#153). It is selected
* from the start, so it keeps its handles and bar once the finger lets go.
*/
fun beginDraft(block: TimedBlock, zone: TimeZone, pointerInRoot: Offset, blockInRoot: Offset) {
deselect()
select(block.event)
begin(block, clipOffsetMin = 0, zone, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot)
}
fun move(pointerInRoot: Offset) { fun move(pointerInRoot: Offset) {
pointer = pointerInRoot pointer = pointerInRoot
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
recompute() recompute()
} }
fun cancel() { fun cancel() {
resize = null
source = null source = null
clipOffsetMin = 0 clipOffsetMin = 0
dragZone = null
dragDuration = Duration.ZERO
dragKept = null
titleLines = 0 titleLines = 0
isDragging = false isDragging = false
pointerMoved = false
liftedInstanceId = null liftedInstanceId = null
originSlot = null originSlot = null
drag = null drag = null
settling = null settling = null
settledOnGrid = false settledOnGrid = false
settledOrigin = null settledOrigin = null
settledEnd = null
settlingSelected = false
} }
/** /**
@@ -429,8 +721,11 @@ class TimelineDragController {
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt() val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset) val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
.firstOrNull() ?: return .firstOrNull() ?: return
val end = settledEnd
settledOnGrid = blocks.any { block -> settledOnGrid = blocks.any { block ->
block.startMin == slice.startMin && block.startMin == slice.startMin &&
// A resize may keep its start, so its end has to have landed too.
(end == null || block.event.end == end) &&
( (
block.event.eventId == landed.event.eventId || block.event.eventId == landed.event.eventId ||
( (
@@ -452,6 +747,15 @@ class TimelineDragController {
val clipOffset = clipOffsetMin val clipOffset = clipOffsetMin
cancel() cancel()
if (landed == null || landed.slot == origin) return null if (landed == null || landed.slot == origin) return null
// A draft dropped on a day off screen would vanish, so it stays where it was (#153).
if (landed.edgeOnly && landed.event.isDraft) return null
settle(landed)
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
}
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
private fun settle(landed: TimelineDrag, end: Instant? = null) {
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
// An edge piece is a hint for the finger, not a preview of the landing // An edge piece is a hint for the finger, not a preview of the landing
// slot: the event belongs to a day this timeline has no column for, so // 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 // [noteGrid] can never seat it and it would sit at the top of today for
@@ -460,7 +764,139 @@ class TimelineDragController {
liftedInstanceId = landed.event.instanceId liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0 settledAbandonedMark = abandonedTicks?.value ?: 0
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset) settledEnd = end
drops++
}
/**
* Grab one edge of [block] to resize it (#141). The edge follows the finger
* across days as a drag does; the other stays where it is.
*/
fun beginResize(
block: TimedBlock,
edge: ResizeEdge,
zone: TimeZone,
titleLines: Int,
pointerInRoot: Offset,
) {
val span = wallClockSpan(block.event, zone)
val session = ResizeSession(edge, span.day, span.startMin, span.endMin)
source = block
resize = session
clipOffsetMin = 0
this.titleLines = titleLines
settling = null
isDragging = true
pointerMoved = true
liftedInstanceId = block.event.instanceId
pointer = pointerInRoot
// Measured from the edge's own column: the handle overhangs into the next.
val edgeDay = if (edge == ResizeEdge.Top) 0 else (session.endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
session.column = edgeIndex.takeIf { it >= 0 }
?.let { column(it) }
session.grabMin = (pointerResizeMinutes(session) ?: 0f) -
if (edge == ResizeEdge.Top) session.startMin else session.endMin
originSlot = null
recompute()
originSlot = drag?.slot
}
/** The slot the gesture would land on, changing once per snap rather than per frame. */
private val landingSlot by derivedStateOf { drag?.slot }
/**
* The dragged event at the times letting go now would give it, worked out as
* [finish] and [finishResize] do. Null while it is still on its own slot (#141).
*/
fun preview(zone: TimeZone): EventInstance? {
val (date, startMin, spanMin) = landingSlot?.takeIf { it != originSlot } ?: return null
val event = source?.event ?: return null
val edge = resize?.edge
if (edge != null) {
val drop = resizedDrop(event, edge, date, startMin, spanMin, zone)
return event.copy(start = drop.start, end = drop.end)
}
val start = TimelineDrop(event, date, startMin, clipOffsetMin).startInstant(zone)
return event.copy(start = start, end = start + dragDuration)
}
/**
* End a resize, handing back the event's new span — null when it never
* resolved or came back to the length it started at. Settles as [finish] does.
*/
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
val landed = drag
val origin = originSlot
val edge = resize?.edge
cancel()
if (landed == null || landed.slot == origin) return null
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
settle(landed, end = drop.end)
return drop
}
/**
* Where the finger is, in minutes from the resized event's start day —
* past 1440 in a later column, negative in an earlier one.
*/
private fun pointerResizeMinutes(session: ResizeSession): Float? {
val grid = geometry.grid?.takeIf { it.isAttached } ?: return null
val hourPx = geometry.hourPx
val columnPx = geometry.columnWidthPx
val days = geometry.days
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return null
val origin = grid.positionInRoot()
val held = stickyColumn(
position = (pointer.x - origin.x) / columnPx,
held = session.column,
lastIndex = days.lastIndex,
)
session.column = held
val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt()
session.edgeDay = dayOffset
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
}
private fun recomputeResize(session: ResizeSession) {
val block = source ?: return
val edge = session.edge
val day = session.day
val raw = pointerResizeMinutes(session) ?: return
val slot = stickySnap(raw - session.grabMin, session.slot)
session.slot = slot
val (start, end) = resizedSpan(edge, session.startMin, session.endMin, slot.toFloat(), session.edgeDay)
val days = geometry.days
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
// Which column the start day sits in, possibly off either end of the view.
val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt()
val visible = -startIndex..days.lastIndex - startIndex
val slices = dragSlices(start, end - start, within = visible)
drag = TimelineDrag(
event = block.event,
date = day,
startMin = start,
eventStartMin = start,
eventSpanMin = end - start,
titleLines = titleLines,
pieces = pieces(slices, startIndex, origin),
edgeOnly = slices.isEmpty(),
markerMin = if (edge == ResizeEdge.Top) {
start.mod(MINUTES_PER_DAY)
} else {
end - (end - 1).floorDiv(MINUTES_PER_DAY) * MINUTES_PER_DAY
},
)
}
/** Note whether the action bar would cover the selected block's bottom handle (#141). */
fun noteSelectedBottom(block: LayoutCoordinates, extraPx: Float, clearancePx: Float) {
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return
if (!block.isAttached) return
val port = viewport.boundsInRoot()
val bottom = block.positionInRoot().y + block.size.height + extraPx
val covered = bottom > port.bottom - clearancePx
if (covered != selectionCovered) selectionCovered = covered
} }
/** /**
@@ -477,6 +913,8 @@ class TimelineDragController {
fun release() { fun release() {
settling = null settling = null
settledOnGrid = false settledOnGrid = false
settledEnd = null
settlingSelected = false
handOver() handOver()
} }
@@ -486,6 +924,10 @@ class TimelineDragController {
* no move events while auto-scroll walks the content underneath it. * no move events while auto-scroll walks the content underneath it.
*/ */
fun recompute() { fun recompute() {
resize?.let {
recomputeResize(it)
return
}
val block = source ?: return val block = source ?: return
val grid = geometry.grid?.takeIf { it.isAttached } ?: return val grid = geometry.grid?.takeIf { it.isAttached } ?: return
val hourPx = geometry.hourPx val hourPx = geometry.hourPx
@@ -495,14 +937,22 @@ 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 = snapToSlot(rawMinutes)
val eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt() val zone = dragZone ?: return
.coerceAtLeast(0) val dayIndex = column(((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex))
val floor = dragFloorMin(clipOffsetMin, eventSpan) val target = days[dayIndex]
val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) val keep = dragKept
val eventStartMin = startMin - clipOffsetMin // Measured where it would land: the drop keeps the real duration, which in
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) // wall clock gains or loses the hour of a DST change it crosses (#387).
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column val floor = dragFloorMin(
clipOffsetMin,
wallClockSpanMinutes(target, snapped - clipOffsetMin, dragDuration, zone, keep),
)
val held = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) - clipOffsetMin
// A start in the spring-forward gap lands an hour on; show it there.
val eventStartMin = wallClockInstant(target, held, zone, keep).minutesPast(target, zone)
val startMin = (eventStartMin + clipOffsetMin).coerceAtMost(MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
val eventSpan = wallClockSpanMinutes(target, eventStartMin, dragDuration, zone, keep)
val visible = -dayIndex..days.lastIndex - dayIndex val visible = -dayIndex..days.lastIndex - dayIndex
val onGrid = dragSlices(eventStartMin, eventSpan, within = visible) val onGrid = dragSlices(eventStartMin, eventSpan, within = visible)
val slices = onGrid.ifEmpty { val slices = onGrid.ifEmpty {
@@ -517,14 +967,19 @@ class TimelineDragController {
titleLines = titleLines, titleLines = titleLines,
// Bounded to the columns this timeline actually shows: a day it // Bounded to the columns this timeline actually shows: a day it
// doesn't has no piece to draw. The write is unaffected. // doesn't has no piece to draw. The write is unaffected.
pieces = slices pieces = pieces(slices, dayIndex, origin),
.map { slice -> edgeOnly = onGrid.isEmpty(),
val day = dayIndex + slice.dayOffset )
val col = if (geometry.isRtl) days.lastIndex - day else day }
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
/** [slices] as floating pieces, the first slice's day being [dayIndex] in the shown days. */
private fun pieces(slices: List<DragSlice>, dayIndex: Int, origin: Offset): List<TimelineDragPiece> {
val columnPx = geometry.columnWidthPx
return slices.map { slice ->
val bounds = dragPieceBounds(slice, geometry.hourPx, geometry.blockInsetPx)
TimelineDragPiece( TimelineDragPiece(
topLeftInRoot = Offset( topLeftInRoot = Offset(
x = origin.x + col * columnPx, x = origin.x + column(dayIndex + slice.dayOffset) * columnPx,
y = origin.y + bounds.top, y = origin.y + bounds.top,
), ),
sizePx = IntSize( sizePx = IntSize(
@@ -534,10 +989,11 @@ class TimelineDragController {
continuesBefore = slice.continuesBefore, continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter, continuesAfter = slice.continuesAfter,
) )
},
edgeOnly = onGrid.isEmpty(),
)
} }
}
/** The on-screen column of a day index, and back: right-to-left lays the days out mirrored. */
private fun column(index: Int): Int = if (geometry.isRtl) geometry.days.lastIndex - index else index
/** /**
* Hold the scroll for the whole drag and nudge it once per frame while the * Hold the scroll for the whole drag and nudge it once per frame while the
@@ -571,12 +1027,34 @@ class TimelineDragController {
} }
} }
/** One resize gesture (#141): the edge it drags, and the edges as minutes from [day]'s midnight. */
private class ResizeSession(
val edge: ResizeEdge,
/** The day the resized event starts on. */
val day: LocalDate,
val startMin: Int,
val endMin: Int,
) {
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
var grabMin = 0f
/** The column the resized edge is in, held until the finger is well into another. */
var column: Int? = null
/** The slot the resized edge sits on, held against a finger trembling between two. */
var slot: Int? = null
/** The day, counted from the event's start day, the finger is resizing in. */
var edgeDay = 0
}
@Composable @Composable
fun rememberTimelineDragController(): TimelineDragController { fun rememberTimelineDragController(): TimelineDragController {
val controller = remember { TimelineDragController() } val controller = remember { 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.slopPx = LocalViewConfiguration.current.touchSlop
controller.abandonedTicks = LocalEventMove.current?.abandoned controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller return controller
} }
@@ -589,10 +1067,7 @@ fun rememberTimelineDragController(): TimelineDragController {
* so the offset has to come back off at the drop (#253). * so the offset has to come back off at the drop (#253).
*/ */
fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int { fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
val start = event.start.toLocalDateTime(zone) return (startMin - event.start.minutesPast(day, zone)).coerceAtLeast(0)
val days = (day.toEpochDays() - start.date.toEpochDays()).toInt()
val minutes = days * MINUTES_PER_DAY + startMin - start.time.toSecondOfDay() / 60
return minutes.coerceAtLeast(0)
} }
/** /**
@@ -613,15 +1088,58 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
* of its own; `toInstant` resolves it forward by the missing hour, and the block * of its own; `toInstant` resolves it forward by the missing hour, and the block
* visibly settles there. * visibly settles there.
*/ */
fun TimelineDrop.startInstant(zone: TimeZone): Instant { fun TimelineDrop.startInstant(zone: TimeZone): Instant =
// Wall clock throughout, and past this day at either end: a block's top edge wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
// 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. * The wall-clock minutes an event lasting [duration] covers when it starts
val fromMidnight = startMin - clipOffsetMin * [startMin] past [date]'s midnight in [zone] — its real length, give or take a
val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY) * DST change in between (#387).
val minute = fromMidnight.mod(MINUTES_PER_DAY) */
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone) internal fun wallClockSpanMinutes(
date: LocalDate,
startMin: Int,
duration: Duration,
zone: TimeZone,
kept: UtcOffset? = null,
): Int {
val start = wallClockInstant(date, startMin, zone, kept)
return wallClockEndMinutes(startMin, start, start + duration, date, zone) - startMin
}
/**
* [minutes] past [date]'s midnight, either way past it, as wall clock in [zone]
* (DST-safe). A time the fall-back hour repeats lands on its earlier pass, unless
* [kept] is its later one: an event there stays on it (#387). Pass
* [repeatedHourOffsetAt] of the event's time, which is null outside the hour —
* such an instant says nothing about which pass the grid slot means, so a drop
* never depends on the season the event came from.
*/
fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone, kept: UtcOffset? = null): Instant =
wallClockTime(date, minutes).resolveIn(zone, kept)
/** The wall-clock time [minutes] past [date]'s midnight, on a later day past 1440. */
fun wallClockTime(date: LocalDate, minutes: Int): LocalDateTime {
val day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
val minute = minutes.mod(MINUTES_PER_DAY)
return LocalDateTime(day, LocalTime(minute / 60, minute % 60))
}
/** An event's span as wall-clock minutes from its start day's midnight. */
data class WallClockSpan(val day: LocalDate, val startMin: Int, val endMin: Int)
/**
* [event]'s [WallClockSpan], its end past 1440 when it ends on a later day
* (DST-safe). One ending in the fall-back hour's repeat ends where that hour does.
*/
fun wallClockSpan(event: EventInstance, zone: TimeZone): WallClockSpan {
val start = event.start.toLocalDateTime(zone)
val startMin = start.time.toSecondOfDay() / 60
return WallClockSpan(
start.date,
startMin,
wallClockEndMinutes(startMin, event.start, event.end, start.date, zone),
)
} }
/** /**
@@ -651,7 +1169,7 @@ const val SETTLE_FADE_MILLIS: Int = 250
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 density = LocalDensity.current
val dark = isSystemInDarkTheme() val dark = LocalFloretDarkTheme.current
val use24Hour = LocalUse24HourFormat.current val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale() val locale = currentLocale()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
@@ -659,9 +1177,11 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// Both live here rather than beside the controller: they read [drag], which // Both live here rather than beside the controller: they read [drag], which
// changes every frame, and this composable is the one that is meant to. // changes every frame, and this composable is the one that is meant to.
DragSnapHaptics(controller.drag?.slot) DragSnapHaptics(controller.drag?.slot, controller.drops)
LaunchedEffect(controller.isDragging) { // Only once the finger has moved: a long press held near an edge must not
if (controller.isDragging) controller.autoScroll() // scroll the drop target out from under a finger that only means to select.
LaunchedEffect(controller.isDragging && controller.pointerMoved) {
if (controller.isDragging && controller.pointerMoved) controller.autoScroll()
} }
// Hold the landed copy until the write is done — including the time a // Hold the landed copy until the write is done — including the time a
// recurring drop's scope dialog is up — and then until the grid draws the // recurring drop's scope dialog is up — and then until the grid draws the
@@ -693,7 +1213,8 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// Landed: the copy sinks back to the grid's own plane while the write runs. // Landed: the copy sinks back to the grid's own plane while the write runs.
val landed = controller.drag == null val landed = controller.drag == null
val lift by animateFloatAsState( val lift by animateFloatAsState(
targetValue = if (landed || reduceMotion) 0f else 1f, // A selected event's block is lifted too: sinking first made it shrink, then grow back.
targetValue = if (reduceMotion || (landed && !controller.settlingSelected)) 0f else 1f,
label = "drag-lift", label = "drag-lift",
) )
val copyAlpha by animateFloatAsState( val copyAlpha by animateFloatAsState(
@@ -870,7 +1391,7 @@ private fun DragCopy(
Text( Text(
text = label, text = label,
// As the block it lifted off sets its own time (#219). // As the block it lifted off sets its own time (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(), style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime()),
maxLines = timeMaxLines, maxLines = timeMaxLines,
overflow = overflow.overflow, overflow = overflow.overflow,
softWrap = overflow.softWrap, softWrap = overflow.softWrap,
@@ -880,3 +1401,30 @@ private fun DragCopy(
} }
} }
} }
/** How far a selected block is lifted, 0 to 1 (#141); [snap] takes it over at once. */
@Composable
fun selectionLift(selected: Boolean, snap: Boolean = false): Float {
val reduceMotion = rememberReduceMotion()
val spec = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
val raise = remember { Animatable(if (selected) 1f else 0f) }
LaunchedEffect(selected) {
val target = if (selected) 1f else 0f
if (reduceMotion || (selected && snap)) raise.snapTo(target) else raise.animateTo(target, spec)
}
// The effect runs a frame late, and that frame would show the block unlifted.
return if (selected && snap) 1f else raise.value
}
/**
* A selected block raised off the grid, the way a dragged copy is: scaled from
* its top edge so the text doesn't creep, with a shadow in the block's shape.
*/
fun Modifier.selectionLift(raise: Float, shape: Shape): Modifier = graphicsLayer {
transformOrigin = TransformOrigin(0.5f, 0f)
scaleX = 1f + 0.02f * raise
scaleY = 1f + 0.02f * raise
shadowElevation = 6.dp.toPx() * raise
this.shape = shape
clip = false
}
@@ -16,8 +16,6 @@ data class TimelineHours(
val startHour: Int = DEFAULT_START_HOUR, val startHour: Int = DEFAULT_START_HOUR,
val endHour: Int = DEFAULT_END_HOUR, val endHour: Int = DEFAULT_END_HOUR,
) { ) {
val span: Int get() = endHour - startHour
companion object { companion object {
const val DEFAULT_START_HOUR = 7 const val DEFAULT_START_HOUR = 7
const val DEFAULT_END_HOUR = 22 const val DEFAULT_END_HOUR = 22
@@ -1,145 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlin.math.roundToInt
/**
* The viewport a view preview pretends to be: a phone's width by the height a
* calendar view gets under the top bar. Scaling every preview from one fixed
* height keeps them comparable — each shows the same slice of screen.
*/
internal val PREVIEW_VIEWPORT_HEIGHT = 440.dp
/**
* Renders [content] at a full phone viewport and shrinks the finished layout
* into a box [height] tall, for the settings and onboarding choosers. Callers
* pass the *real* view composables, so a preview cannot drift from what it
* depicts.
*
* The [layout] below ignores the incoming constraints so the view measures at a
* phone viewport, and a [graphicsLayer] scale shrinks the result.
* `Modifier.requiredSize` is the obvious alternative but *centres* content that
* overflows its constraints, leaving only the bottom-right corner in the clip.
*/
@Composable
internal fun ScaledViewPreview(
height: Dp,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val scale = height.value / PREVIEW_VIEWPORT_HEIGHT.value
Box(
modifier = modifier
.clipToBounds()
.background(MaterialTheme.colorScheme.surface)
// A preview is a picture, not a control: swallow touches before the
// view's own clickables see them, and give screen readers one label
// instead of six weeks of day cells.
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial).changes
.forEach { it.consume() }
}
}
}
.clearAndSetSemantics { }
// Measure at a full phone viewport but report the scaled size, so the
// node occupies exactly what it draws.
.layout { measurable, _ ->
val fullWidth = screenWidth.roundToPx()
val fullHeight = PREVIEW_VIEWPORT_HEIGHT.roundToPx()
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
placeable.place(0, 0)
}
}
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0f, 0f)
},
) {
Column(Modifier.fillMaxSize()) { content() }
}
}
/**
* The stage every preview picker sets its preview on, cross-fading as [selected]
* changes. A preview draws the app's own surface, so a tonal plate gives it the
* edge it would otherwise lack on a chooser screen; the plate takes the width
* and outer radius of the grouped rows underneath so the two read as one stack.
*
* Only vertical spacing is the caller's to pass — the horizontal inset is the
* rows' own, and the alignment is the point.
*/
@Composable
internal fun <T> ViewPreviewFrame(
selected: T,
label: String,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit,
) {
val reduceMotion = rememberReduceMotion()
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset)
.clip(FRAME_SHAPE)
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(FRAME_INSET),
contentAlignment = Alignment.Center,
) {
Crossfade(
targetState = selected,
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
label = label,
) { shown ->
// Each preview renders the real view at phone size and scales it
// down, so its own corners are square — the plate rounds them.
Box(
modifier = Modifier
.clip(PREVIEW_SHAPE)
.background(MaterialTheme.colorScheme.surface)
.clipToBounds(),
) {
content(shown)
}
}
}
}
private val FRAME_INSET = 8.dp
/** The grouped rows' own outer radius, so the stack shares one silhouette. */
private val FRAME_SHAPE = RoundedCornerShape(22.dp)
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
@@ -1,22 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
/** Pages the day pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
internal const val DAY_PAGE_COUNT: Int = 140_000
private const val EPOCH_PAGE = DAY_PAGE_COUNT / 2
/** The day at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
private val PAGE_EPOCH = LocalDate(2000, 1, 1)
/** The day shown on pager [page]. */
internal fun dayForPage(page: Int): LocalDate =
PAGE_EPOCH.plus(page - EPOCH_PAGE, DateTimeUnit.DAY)
/** The pager page showing [date]. */
internal fun dayPageFor(date: LocalDate): Int =
(EPOCH_PAGE + PAGE_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
@@ -1,19 +1,13 @@
package de.jeanlucmakiola.calendula.ui.detail package de.jeanlucmakiola.calendula.ui.detail
import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -22,7 +16,6 @@ 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
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
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -41,7 +34,6 @@ import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -51,7 +43,6 @@ 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
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -65,19 +56,21 @@ 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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
@@ -90,24 +83,30 @@ import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.InlineStyle
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.RichText
import de.jeanlucmakiola.calendula.domain.TimeZoneOption import de.jeanlucmakiola.calendula.domain.TimeZoneOption
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor import de.jeanlucmakiola.calendula.domain.zoneDescriptor
import de.jeanlucmakiola.calendula.domain.zoneOrNull
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.IconInfoCard
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
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.DeleteEventDialog
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
import de.jeanlucmakiola.calendula.ui.common.FieldCopier import de.jeanlucmakiola.calendula.ui.common.FieldCopier
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier 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
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.OptionCard
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.timeOfDayFormatter import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
import de.jeanlucmakiola.calendula.ui.common.recurrenceText import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -162,43 +161,15 @@ fun EventDetailScreen(
val onShareClick = { val onShareClick = {
scope.launch { scope.launch {
val uri = viewModel.shareUri() val uri = viewModel.shareUri()
val sent = uri != null && runCatching { val sent = uri != null && context.sendEventShare(uri, shareChooserTitle)
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/calendar"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(send, shareChooserTitle))
}.isSuccess
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage) if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
} }
Unit Unit
} }
// v1.0 installs only hold READ_CALENDAR; the first write asks for the // v1.0 installs only hold READ_CALENDAR; the first write asks for the
// upgrade in place. Granting continues straight into the tapped action — // upgrade in place and continues straight into the tapped action.
// edit, delete, or duplicate — held here until the result comes back. val requireWrite = rememberWriteGate()
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
val writePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) pendingWrite?.invoke()
pendingWrite = null
}
val hasWritePermission = {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
}
val requireWrite: (() -> Unit) -> Unit = { action ->
if (hasWritePermission()) {
action()
} else {
pendingWrite = action
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
val onDeleteClick = { requireWrite { showDeleteDialog = true } } val onDeleteClick = { requireWrite { showDeleteDialog = true } }
val onEditClick = { requireWrite(onEdit) } val onEditClick = { requireWrite(onEdit) }
// Duplicate reads the loaded detail into a create form and hands it up; the // Duplicate reads the loaded detail into a create form and hands it up; the
@@ -322,70 +293,6 @@ fun EventDetailScreen(
} }
} }
/**
* Delete confirmation. Recurring events choose between cancelling just the
* tapped occurrence (default), truncating the series from it onwards, and
* removing the whole series.
*/
@Composable
private fun DeleteEventDialog(
isRecurring: Boolean,
onConfirm: (RecurringWriteScope) -> Unit,
onDismiss: () -> Unit,
) {
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(
if (isRecurring) R.string.event_delete_recurring_title
else R.string.event_delete_title,
),
)
},
text = {
if (isRecurring) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OptionCard(
label = stringResource(R.string.event_delete_option_occurrence),
onClick = { scope = RecurringWriteScope.ThisEvent },
selected = scope == RecurringWriteScope.ThisEvent,
)
OptionCard(
label = stringResource(R.string.event_delete_option_following),
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
selected = scope == RecurringWriteScope.ThisAndFollowing,
)
OptionCard(
label = stringResource(R.string.event_delete_option_series),
onClick = { scope = RecurringWriteScope.AllEvents },
selected = scope == RecurringWriteScope.AllEvents,
)
}
} else {
Text(stringResource(R.string.event_delete_body))
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
) {
Text(
text = stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
@Composable @Composable
private fun EventDetailContent( private fun EventDetailContent(
state: EventDetailUiState.Success, state: EventDetailUiState.Success,
@@ -394,8 +301,8 @@ private fun EventDetailContent(
) { ) {
val detail = state.detail val detail = state.detail
val instance = detail.instance val instance = detail.instance
val dark = isSystemInDarkTheme() val dark = LocalFloretDarkTheme.current
val locale = currentDetailLocale() val locale = currentLocale()
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current) val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
Column( Column(
@@ -409,15 +316,26 @@ private fun EventDetailContent(
// 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) val titleLabel = stringResource(R.string.event_detail_title)
Text( val titleDecoration = if (detail.status == EventStatus.Cancelled) {
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
textDecoration = if (detail.status == EventStatus.Cancelled) {
TextDecoration.LineThrough TextDecoration.LineThrough
} else { } else {
null null
}
Text(
// A phone number in the title ("Call Anna 0151 …") dials too.
text = if (instance.title.isBlank()) {
AnnotatedString(stringResource(R.string.event_untitled))
} else {
linkify(
instance.title,
state.phoneLinks.title,
MaterialTheme.colorScheme.primary,
titleDecoration,
)
}, },
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
textDecoration = titleDecoration,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.then( .then(
@@ -464,8 +382,8 @@ private fun EventDetailContent(
val gap = 12.dp val gap = 12.dp
// "When" — date/all-day plus the time range. // "When" — date/all-day plus the time range.
val (whenPrimary, whenSecondary) = formatWhen(instance, TimeZone.currentSystemDefault(), locale) val (whenPrimary, whenSecondary) = rememberWhen(instance, TimeZone.currentSystemDefault(), locale)
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) { IconInfoCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium) Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
if (whenSecondary != null) { if (whenSecondary != null) {
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
@@ -490,7 +408,7 @@ private fun EventDetailContent(
} }
foreignZone?.let { zoneOption -> foreignZone?.let { zoneOption ->
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( IconInfoCard(
icon = Icons.Default.Public, icon = Icons.Default.Public,
iconContentDescription = stringResource(R.string.event_detail_timezone), iconContentDescription = stringResource(R.string.event_detail_timezone),
) { ) {
@@ -503,8 +421,8 @@ private fun EventDetailContent(
// — which a cross-zone event easily does — carries the whole span // — which a cross-zone event easily does — carries the whole span
// in the primary instead, so fall back to it. If the zone can't be // in the primary instead, so fall back to it. If the zone can't be
// read back at all, drop to just the abbreviation + offset. // read back at all, drop to just the abbreviation + offset.
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull() val originalTime = zoneOrNull(zoneOption.id)
?.let { formatWhen(instance, it, locale) } ?.let { rememberWhen(instance, it, locale) }
?.let { (primary, secondary) -> secondary ?: primary } ?.let { (primary, secondary) -> secondary ?: primary }
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
Text( Text(
@@ -522,29 +440,30 @@ private fun EventDetailContent(
// Calendar — icon tinted in the calendar colour conveys identity, so no // Calendar — icon tinted in the calendar colour conveys identity, so no
// separate colour dot is needed. // separate colour dot is needed.
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( IconInfoCard(
icon = Icons.Default.CalendarMonth, icon = Icons.Default.CalendarMonth,
iconTint = accent, iconTint = accent,
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?.orUnnamed()
?: stringResource(R.string.event_detail_calendar_unknown), ?: stringResource(R.string.event_detail_calendar_unknown),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
} }
// Location (conditional). URLs open in the browser, the rest in maps. // Location (conditional). URLs open in the browser, phone numbers in the
// dialer, the rest in 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) val locationLabel = stringResource(R.string.event_detail_location)
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( IconInfoCard(
icon = Icons.Default.Place, icon = Icons.Default.Place,
iconContentDescription = locationLabel, iconContentDescription = locationLabel,
) { ) {
Text( Text(
text = linkifyUrls(location, MaterialTheme.colorScheme.primary), text = linkify(location, state.phoneLinks.location, MaterialTheme.colorScheme.primary),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier modifier = Modifier
@@ -561,20 +480,21 @@ private fun EventDetailContent(
} }
} }
// Description (conditional). URLs are auto-linked. // Description (conditional). HTML is rendered (#271); URLs and phone
detail.description?.takeIf { it.isNotBlank() }?.let { description -> // numbers are auto-linked.
state.description?.let { description ->
val descriptionLabel = stringResource(R.string.event_detail_description) val descriptionLabel = stringResource(R.string.event_detail_description)
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( IconInfoCard(
icon = Icons.AutoMirrored.Filled.Notes, icon = Icons.AutoMirrored.Filled.Notes,
iconContentDescription = descriptionLabel, iconContentDescription = descriptionLabel,
// The gesture sits on the card so the icon and padding answer // The gesture sits on the card so the icon and padding answer
// it too: every linkified URL owns the pointer over its own // it too: every linkified URL owns the pointer over its own
// glyphs, which leaves the text itself a patchy target. // glyphs, which leaves the text itself a patchy target.
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField), modifier = Modifier.copyOnLongPress(descriptionLabel, description.copyText(), copyField),
) { ) {
Text( Text(
text = linkifyUrls(description, MaterialTheme.colorScheme.primary), text = linkify(description, state.phoneLinks.description, MaterialTheme.colorScheme.primary),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
} }
@@ -584,7 +504,7 @@ private fun EventDetailContent(
// each attendee with their role and reply. // each attendee with their role and reply.
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees -> detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( IconInfoCard(
icon = Icons.Default.People, icon = Icons.Default.People,
iconContentDescription = stringResource(R.string.event_detail_attendees), iconContentDescription = stringResource(R.string.event_detail_attendees),
) { ) {
@@ -606,7 +526,7 @@ private fun EventDetailContent(
// Reminders (conditional) — list each lead time before the event. // Reminders (conditional) — list each lead time before the event.
detail.reminders.takeIf { it.isNotEmpty() }?.let { reminders -> detail.reminders.takeIf { it.isNotEmpty() }?.let { reminders ->
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( IconInfoCard(
icon = Icons.Default.Notifications, icon = Icons.Default.Notifications,
iconContentDescription = stringResource(R.string.event_detail_reminders), iconContentDescription = stringResource(R.string.event_detail_reminders),
) { ) {
@@ -615,7 +535,7 @@ private fun EventDetailContent(
.sortedBy { it.minutes } .sortedBy { it.minutes }
.forEach { reminder -> .forEach { reminder ->
Text( Text(
text = reminderLeadText(reminder), text = reminderLeadTimeLabel(reminder.minutes),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(vertical = 2.dp), modifier = Modifier.padding(vertical = 2.dp),
) )
@@ -626,7 +546,7 @@ private fun EventDetailContent(
// Recurrence (conditional) — humanised from the RRULE. // Recurrence (conditional) — humanised from the RRULE.
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule -> detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( IconInfoCard(
icon = Icons.Default.Repeat, icon = Icons.Default.Repeat,
iconContentDescription = stringResource(R.string.event_detail_recurrence), iconContentDescription = stringResource(R.string.event_detail_recurrence),
) { ) {
@@ -639,36 +559,6 @@ private fun EventDetailContent(
} }
} }
/** One info card: tonal container, leading icon in the gutter, value to the right. */
@Composable
private fun DetailCard(
icon: ImageVector,
iconContentDescription: String?,
modifier: Modifier = Modifier,
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = iconTint,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f), content = content)
}
}
}
@Composable @Composable
private fun AttendeeRow(attendee: Attendee) { private fun AttendeeRow(attendee: Attendee) {
Row( Row(
@@ -792,11 +682,6 @@ private fun SkeletonBar(widthFraction: Float, height: Dp) {
// --- helpers ------------------------------------------------------------- // --- helpers -------------------------------------------------------------
// Observable locale read (shared helper) — avoids NonObservableLocale /
// LocalContextConfigurationRead lint by going through LocalConfiguration.
@Composable
private fun currentDetailLocale(): Locale = currentLocale()
private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) { private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
AttendeeStatus.Accepted -> R.string.event_attendee_accepted AttendeeStatus.Accepted -> R.string.event_attendee_accepted
AttendeeStatus.Declined -> R.string.event_attendee_declined AttendeeStatus.Declined -> R.string.event_attendee_declined
@@ -816,10 +701,6 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
else -> null else -> null
} }
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
@Composable
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
/** /**
* [tz] resolved as a [TimeZoneOption], but only when the event is timed and * [tz] resolved as a [TimeZoneOption], but only when the event is timed and
* pinned to a zone different from the device's — the cases where showing it * pinned to a zone different from the device's — the cases where showing it
@@ -847,45 +728,114 @@ private fun foreignTimeZone(
) )
} }
/** Wrap http(s) URLs in [text] as links tinted [linkColor] that open in the browser. */ /**
* Wrap http(s) URLs in [text] as links that open in the browser, and its
* [phoneLinks] as links that open the dialer, both tinted [linkColor]. A
* link's underline joins [textDecoration] rather than replacing it, so a
* cancelled title stays struck through across its links.
*/
@Composable @Composable
private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString { private fun linkify(
text: String,
phoneLinks: List<PhoneLink>,
linkColor: Color,
textDecoration: TextDecoration? = null,
): AnnotatedString = linkify(RichText(text), phoneLinks, linkColor, textDecoration)
/**
* [linkify] for text rendered from HTML: its own formatting and `<a href>`
* links come along, and a URL or number already inside one isn't linked again.
*/
@Composable
private fun linkify(
text: RichText,
phoneLinks: List<PhoneLink>,
linkColor: Color,
textDecoration: TextDecoration? = null,
): AnnotatedString {
val context = LocalContext.current val context = LocalContext.current
return remember(text, linkColor, context) { return remember(text, phoneLinks, linkColor, textDecoration, context) {
val styles = TextLinkStyles( // Decorations go on separately so overlapping ones add up: a span's
style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline), // textDecoration replaces whatever sits under it.
) val styles = TextLinkStyles(style = SpanStyle(color = linkColor))
val decorations = mutableListOf<Triple<Int, Int, TextDecoration>>()
textDecoration?.let { decorations += Triple(0, text.text.length, it) }
for (range in text.styles) {
when (range.style) {
InlineStyle.Underline -> decorations += Triple(range.start, range.end, TextDecoration.Underline)
InlineStyle.Strikethrough -> decorations += Triple(range.start, range.end, TextDecoration.LineThrough)
else -> Unit
}
}
fun linked(start: Int, end: Int) = text.links.any { start < it.end && it.start < end }
buildAnnotatedString { buildAnnotatedString {
append(text) append(text.text)
for (link in findWebLinks(text)) { for (range in text.styles) range.style.toSpanStyle()?.let { addStyle(it, range.start, range.end) }
addLink( fun link(url: String, start: Int, end: Int, onClick: () -> Unit) {
LinkAnnotation.Url(link.url, styles) { openWebLink(context, link.url) }, addLink(LinkAnnotation.Url(url, styles) { onClick() }, start, end)
link.start, decorations += Triple(start, end, TextDecoration.Underline)
link.end, }
) for (link in text.links) {
if (link.url.startsWith("tel:")) {
link(link.url, link.start, link.end) { dial(context, Uri.decode(link.url.substring(4))) }
} else {
link(link.url, link.start, link.end) { openWebLink(context, link.url) }
}
}
for (link in findWebLinks(text.text)) {
if (linked(link.start, link.end)) continue
link(link.url, link.start, link.end) { openWebLink(context, link.url) }
}
for (link in phoneLinks) {
if (linked(link.start, link.end)) continue
link("tel:${link.number}", link.start, link.end) { dial(context, link.number) }
}
val bounds = decorations.flatMap { listOf(it.first, it.second) }.distinct().sorted()
for ((start, end) in bounds.zipWithNext()) {
val covering = decorations.filter { it.first <= start && end <= it.second }.map { it.third }.distinct()
if (covering.isNotEmpty()) addStyle(SpanStyle(textDecoration = TextDecoration.combine(covering)), start, end)
} }
} }
} }
} }
private fun InlineStyle.toSpanStyle(): SpanStyle? = when (this) {
InlineStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold)
InlineStyle.Italic -> SpanStyle(fontStyle = FontStyle.Italic)
InlineStyle.Superscript -> SpanStyle(baselineShift = BaselineShift.Superscript, fontSize = 0.75.em)
InlineStyle.Subscript -> SpanStyle(baselineShift = BaselineShift.Subscript, fontSize = 0.75.em)
InlineStyle.Monospace -> SpanStyle(fontFamily = FontFamily.Monospace)
InlineStyle.Underline, InlineStyle.Strikethrough -> null
}
/** /**
* Format an event's time into a primary line (date, or "All day") and an * Format an event's time into a primary line (date, or "All day") and an
* optional secondary line (time range). Multi-day timed events collapse into a * optional secondary line (time range). Multi-day timed events collapse into a
* single primary line spanning both ends. * single primary line spanning both ends.
*/ */
@Composable @Composable
private fun rememberWhen(instance: EventInstance, zone: TimeZone, locale: Locale): Pair<String, String?> {
val use24Hour = LocalUse24HourFormat.current
val allDayLabel = stringResource(R.string.event_detail_all_day)
// Remembered: a time in the repeated hour looks up its zone abbreviation (#387).
return remember(instance, zone, locale, use24Hour, allDayLabel) {
formatWhen(instance, zone, locale, use24Hour, allDayLabel)
}
}
private fun formatWhen( private fun formatWhen(
instance: EventInstance, instance: EventInstance,
zone: TimeZone, zone: TimeZone,
locale: Locale, locale: Locale,
use24Hour: Boolean,
allDayLabel: String,
): Pair<String, String?> { ): Pair<String, String?> {
val zid = ZoneId.of(zone.id) val zid = ZoneId.of(zone.id)
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale) val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
val timeShort = timeOfDayFormatter(LocalUse24HourFormat.current, locale) val timeShort = timeOfDayFormatter(use24Hour, locale)
val startLdt = instance.start.toJavaLocalDateTime(zid) val startLdt = instance.start.toJavaLocalDateTime(zid)
val allDayLabel = stringResource(R.string.event_detail_all_day)
if (instance.isAllDay) { if (instance.isAllDay) {
// All-day events live at UTC midnights with an exclusive end. Resolve // All-day events live at UTC midnights with an exclusive end. Resolve
@@ -905,12 +855,13 @@ private fun formatWhen(
} }
val endLdt = instance.end.toJavaLocalDateTime(zid) val endLdt = instance.end.toJavaLocalDateTime(zid)
val startTime = withRepeatedHourZone(timeShort.format(startLdt), instance.start, zone, locale)
val endTime = withRepeatedHourZone(timeShort.format(endLdt), instance.end, zone, locale)
return if (startLdt.toLocalDate() == endLdt.toLocalDate()) { return if (startLdt.toLocalDate() == endLdt.toLocalDate()) {
dateFull.format(startLdt.toLocalDate()) to dateFull.format(startLdt.toLocalDate()) to "$startTime – $endTime"
"${timeShort.format(startLdt)} – ${timeShort.format(endLdt)}"
} else { } else {
val start = "${dateMedium.format(startLdt.toLocalDate())} ${timeShort.format(startLdt)}" val start = "${dateMedium.format(startLdt.toLocalDate())} $startTime"
val end = "${dateMedium.format(endLdt.toLocalDate())} ${timeShort.format(endLdt)}" val end = "${dateMedium.format(endLdt.toLocalDate())} $endTime"
"$start – $end" to null "$start – $end" to null
} }
} }
@@ -920,7 +871,15 @@ private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime =
/** Open [url] in a browsable app, or say so when none can take it. */ /** Open [url] in a browsable app, or say so when none can take it. */
private fun openWebLink(context: Context, url: String) { private fun openWebLink(context: Context, url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE) startOrSayNoApp(context, Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE))
}
/** Open the dialer with [number] filled in, or say so when none can take it. */
private fun dial(context: Context, number: String) {
startOrSayNoApp(context, Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", number, null)))
}
private fun startOrSayNoApp(context: Context, intent: Intent) {
if (runCatching { context.startActivity(intent) }.isFailure) { if (runCatching { context.startActivity(intent) }.isFailure) {
Toast.makeText(context, R.string.link_open_failed, Toast.LENGTH_SHORT).show() Toast.makeText(context, R.string.link_open_failed, Toast.LENGTH_SHORT).show()
} }
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.detail
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RichText
/** /**
* UI state for the event-detail screen (spec S4). * UI state for the event-detail screen (spec S4).
@@ -21,6 +22,10 @@ sealed interface EventDetailUiState {
* the sync would just resurrect it. Contact dates are removed at the source. * the sync would just resurrect it. Contact dates are removed at the source.
*/ */
val isManaged: Boolean = false, val isManaged: Boolean = false,
/** Phone numbers to link in the title, location and description. */
val phoneLinks: EventPhoneLinks = EventPhoneLinks(),
/** The description as drawn — HTML rendered (#271); null when there is none. */
val description: RichText? = null,
) : EventDetailUiState ) : EventDetailUiState
} }
@@ -6,20 +6,22 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.atOccurrence
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent import de.jeanlucmakiola.calendula.domain.descriptionRichText
import de.jeanlucmakiola.calendula.domain.toEditForm import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.time.Clock
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -28,11 +30,9 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -75,9 +75,8 @@ class EventDetailViewModel @Inject constructor(
} }
} }
.flowOn(io) .flowOn(io)
.stateIn( .stateWhileSubscribed(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = EventDetailUiState.Loading, initialValue = EventDetailUiState.Loading,
) )
@@ -112,22 +111,11 @@ class EventDetailViewModel @Inject constructor(
if (_deleteState.value == DeleteUiState.Deleting) return if (_deleteState.value == DeleteUiState.Deleting) return
viewModelScope.launch { viewModelScope.launch {
_deleteState.value = DeleteUiState.Deleting _deleteState.value = DeleteUiState.Deleting
_deleteState.value = try { _deleteState.value = when (repository.tryDeleteEvent(target.eventId, target.beginMillis, scope)) {
when (scope) { // Already gone is what a delete wants anyway.
RecurringWriteScope.AllEvents -> DeleteOutcome.Deleted, DeleteOutcome.Gone -> DeleteUiState.Deleted
repository.deleteEvent(target.eventId) DeleteOutcome.NeedsPermission -> DeleteUiState.NeedsPermission
RecurringWriteScope.ThisEvent -> DeleteOutcome.Failed -> DeleteUiState.Failed
repository.deleteOccurrence(target.eventId, target.beginMillis)
RecurringWriteScope.ThisAndFollowing ->
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
}
DeleteUiState.Deleted
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
DeleteUiState.NeedsPermission
} catch (e: Exception) {
DeleteUiState.Failed
} }
} }
} }
@@ -144,66 +132,46 @@ class EventDetailViewModel @Inject constructor(
*/ */
suspend fun shareUri(): Uri? { suspend fun shareUri(): Uri? {
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
return runCatching { return withContext(io) { icsExporter.stageEventShare(detail) }
withContext(io) {
val ics = IcsWriter().writeCalendar(
events = listOf(detail.toShareIcsEvent()),
dtStamp = Clock.System.now(),
)
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
}
}.getOrNull()
} }
/** /**
* Build a create form seeded from the open event so the user can save a * A create form seeded from the open event, to save a copy as a new event
* copy as a new, independent event (#52). Returns null when nothing is * (#52) — see [toDuplicateForm]. Null when nothing is loaded.
* loaded.
*
* The recurrence is dropped — a duplicate is a single event; anyone who
* wants a series can re-add one in the edit form that opens. The source
* calendar is kept only when it's writable, otherwise the id is cleared so
* the create resolves to the last-used/first-writable calendar — which lets
* a read-only event (a WebCal subscription, a birthday) be copied into an
* editable calendar. The occurrence's own times carry over unchanged.
*/ */
fun duplicateForm(): EventForm? { fun duplicateForm(): EventForm? {
val loaded = state.value as? EventDetailUiState.Success ?: return null val loaded = state.value as? EventDetailUiState.Success ?: return null
val detail = loaded.detail return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault())
return detail.toEditForm(
beginMillis = detail.instance.start.toEpochMilliseconds(),
endMillis = detail.instance.end.toEpochMilliseconds(),
zone = TimeZone.currentSystemDefault(),
).copy(
rrule = null,
calendarId = detail.instance.calendarId.takeIf { loaded.canModify },
)
} }
private suspend fun loadDetail(target: Target): EventDetailUiState = try { private suspend fun loadDetail(target: Target): EventDetailUiState = try {
val detail = repository.eventDetail(target.eventId) val detail = repository.eventDetail(target.eventId)
// The Events row holds the series start; replace it with this // An external "open event" that names no occurrence ([NO_OCCURRENCE_TIME] —
// occurrence's time so recurring events render correctly. An external // e.g. a bare content://.../events/<id> VIEW intent, issue #48) keeps the
// "open event" that names no occurrence ([NO_OCCURRENCE_TIME] — e.g. a // row's own DTSTART/DTEND instead of overriding it to the epoch.
// bare content://.../events/<id> VIEW intent, issue #48) keeps the row's
// own DTSTART/DTEND instead of overriding it to the epoch.
val corrected = if (target.beginMillis == NO_OCCURRENCE_TIME) { val corrected = if (target.beginMillis == NO_OCCURRENCE_TIME) {
detail detail
} else { } else {
detail.copy( detail.atOccurrence(target.beginMillis, target.endMillis)
instance = detail.instance.copy(
start = Instant.fromEpochMilliseconds(target.beginMillis),
end = Instant.fromEpochMilliseconds(target.endMillis),
),
)
} }
val calendar = repository.calendars().first() val calendar = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId } .firstOrNull { it.id == corrected.instance.calendarId }
val description = corrected.description
?.let(::descriptionRichText)
?.takeIf { it.text.isNotBlank() }
EventDetailUiState.Success( EventDetailUiState.Success(
detail = corrected, detail = corrected,
calendarName = calendar?.displayName, calendarName = calendar?.displayName,
canModify = calendar?.canModifyContents == true, canModify = calendar?.canModifyContents == true,
isManaged = calendar?.isManaged == true, isManaged = calendar?.isManaged == true,
// Found here, off the main thread and once per load: the platform
// matcher loads its metadata on first use.
phoneLinks = findEventPhoneLinks(
title = corrected.instance.title,
location = corrected.instance.location,
description = description?.text,
),
description = description,
) )
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
@@ -228,13 +196,3 @@ class EventDetailViewModel @Inject constructor(
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
} }
} }
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
private fun shareFileName(title: String): String {
val base = title.trim()
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
.replace(' ', '_')
.take(40)
.ifBlank { "event" }
return "$base.ics"
}
@@ -0,0 +1,202 @@
package de.jeanlucmakiola.calendula.ui.detail
import android.text.SpannableString
import android.text.style.URLSpan
import android.text.util.Linkify
/**
* A phone number found in free text, spanning [start] until [end] (exclusive).
* [number] is what the dialer gets: digits, a leading `+`, and any `,`/`#`
* dial-in suffix — the separators that only made it readable are gone.
*/
data class PhoneLink(val start: Int, val end: Int, val number: String)
/** The phone numbers in an event's free-text fields, each against its own text. */
data class EventPhoneLinks(
val title: List<PhoneLink> = emptyList(),
val location: List<PhoneLink> = emptyList(),
val description: List<PhoneLink> = emptyList(),
)
/** Where the platform matcher saw a phone number: [start] until [end] (exclusive). */
internal data class PhoneMatch(val start: Int, val end: Int)
// A conference dial-in's pauses and meeting id right after the number:
// "+16465588656,,85012345678#" or Zoom's ",,850…#,,,,*123456#". It opens on a
// double comma or closes on "#", so "030 1234567,0151 2345678" stays a list.
private val dialSuffixRegex = Regex("""^(?:,,+[\d*]+#?|,[\d*]+#)(?:,+[\d*]+#?)*""")
// Whitespace as invites write it: Java's \s misses the no-break space and the
// invisible direction marks Meet and Outlook wrap around numbers.
private const val GAP = """\s\u00A0\u200E\u200F\u202A-\u202E\u2066-\u2069"""
// A number right after one of these labels is an id to type in, not one to
// call. English and German cover the Zoom/Teams/Meet/Webex invites seen so far.
// "Conference number" is left out: invites use it for the dial-in itself.
private val notAPhoneLabelRegex = Regex(
"""(?:(?:meeting|webinar|besprechungs)[ -]?(?:id|number|nummer)|(?:conference|konferenz)[ -]?id|""" +
"""\bpin|passcode|password|passwort|kenncode|access code|zugangscode)[$GAP:#.()]*$""",
RegexOption.IGNORE_CASE,
)
// A day.month.year (or year-month-day, or US month/day/year) date: exactly
// three groups, so a French "01.23.45.67.89" chain doesn't read as one.
private val dateRegex = Regex("""(?<![\d./-])(\d{1,4})([./-])(\d{1,2})\2(\d{4}|\d{2})(?![\d]|[./-]\d)""")
// Thousands groups. A short lead ("1.250.000", "12 500 000") is an amount; a
// three-digit lead is one only beside a currency — "612 345 678" and
// "612.345.678" are Spanish mobiles, "125.000.000 €" a budget.
private val shortLeadAmountRegex = Regex("""[1-9]\d?(?:([. ])\d{3})(?:\1\d{3})*""")
private val amountRegex = Regex("""[1-9]\d{0,2}(?:([. ])\d{3})(?:\1\d{3})*""")
private val currencyBeforeRegex = Regex("""(?:[€$£]|\b(?:eur|euro|usd|chf|gbp))\s*$""", RegexOption.IGNORE_CASE)
private val currencyAfterRegex = Regex("""^\s*(?:[€$£]|(?:eur|euro|usd|chf|gbp)\b)""", RegexOption.IGNORE_CASE)
// A short group set off by a space after a long one, with a word straight
// after — "… 2345678 2 times" — is the prose's count, not the number's tail.
// Without the word ("030 123456 0") or after a short group ("0221 1234 56")
// it is the number's own.
private val strayTailRegex = Regex("""(?<=\d{6})\s+\d{1,2}$""")
private val wordAfterRegex = Regex("""[ \t\u00A0]+\p{L}""")
// "9.30 – 12.00", "10.00-11.30": times of day written the German way.
private val dottedTimesRegex = Regex("""(\d{1,2})\.(\d{2})(?:\s*[-–]\s*(\d{1,2})\.(\d{2}))?""")
// "ext. 567", "x567", "Durchwahl 567": the dialer reaches an extension after a pause.
private val extensionRegex = Regex("""\s*(?:ext\.?|extension|x|durchwahl|dw\.?)\s*(?=\d)""", RegexOption.IGNORE_CASE)
private val trunkPrefixRegex = Regex("""\(\s*0\s*\)""")
private val strandedCountryCodeRegex = Regex("""\+\d{1,3}\s*\(\s*0\s*\)\s*$""")
// Short local numbers exist, but in event text a 5- or 6-digit run is far more
// often a postcode, house number or booking reference.
private const val MIN_DIGITS = 7
/**
* Find the phone numbers in [text]. The platform's libphonenumber-backed
* matcher does the finding — it knows the device's region and real number
* lengths; [refinePhoneMatches] then fits what it found to an event's text.
* Call off the main thread: the matcher loads its metadata on first use.
* No match at all when the platform matcher fails.
*/
internal fun findPhoneLinks(text: String): List<PhoneLink> =
refinePhoneMatches(text, platformPhoneMatches(text), findWebLinks(text))
/** [findPhoneLinks] for each of an event's free-text fields. */
internal fun findEventPhoneLinks(title: String, location: String?, description: String?) = EventPhoneLinks(
title = findPhoneLinks(title),
location = location?.let(::findPhoneLinks).orEmpty(),
description = description?.let(::findPhoneLinks).orEmpty(),
)
// The matcher reads the region off the SIM, which throws on a device without
// telephony; a field then just goes without phone links.
private fun platformPhoneMatches(text: String): List<PhoneMatch> = runCatching {
val spannable = SpannableString(text)
if (!Linkify.addLinks(spannable, Linkify.PHONE_NUMBERS)) return emptyList()
spannable.getSpans(0, spannable.length, URLSpan::class.java)
.map { PhoneMatch(spannable.getSpanStart(it), spannable.getSpanEnd(it)) }
}.getOrDefault(emptyList())
/**
* Turn the platform's [matches] in [text] into dialable links. The matcher is
* lenient, so a match is dropped when it sits inside one of the [webLinks],
* follows a meeting id or PIN label, holds a date, reads as an amount or is
* too short. What's kept loses a stray count off its end, regains an
* international "+49 (0)" prefix the matcher left behind, and carries a
* conference dial-in suffix along.
*/
internal fun refinePhoneMatches(
text: String,
matches: List<PhoneMatch>,
webLinks: List<WebLink>,
): List<PhoneLink> {
val links = mutableListOf<PhoneLink>()
for (match in matches.sortedBy { it.start }) {
var start = match.start
var end = match.end
val matched = text.substring(start, end)
if (webLinks.any { start < it.end && it.start < end }) continue
if (gluedIntoAWord(text, start, end)) continue
val lineBefore = text.window(lineStart(text, start).coerceAtLeast(start - LOOK_BEHIND), start)
if (notAPhoneLabelRegex.containsMatchIn(lineBefore)) continue
if (dateRegex.findAll(matched).any { it.isDate() }) continue
if (isDottedTimes(matched)) continue
if (isAmount(text, start, end)) continue
strayTailRegex.find(matched)?.let { tail ->
if (wordAfterRegex.matchesAt(text, end)) end = start + tail.range.first
}
// "+49 (0)151 …": a matcher that stops at the "(0)" leaves the domestic
// tail, which would dial without the country code.
strandedCountryCodeRegex.find(text.window(start - LOOK_BEHIND, start))
?.let { start -= it.value.length }
val number = dialable(text.substring(start, end))
if (number.count { it.isDigit() } < MIN_DIGITS) continue
val suffix = dialSuffixRegex.find(text.window(end, end + LOOK_AHEAD))?.value.orEmpty()
// A suffix swallowed what the matcher saw as the next number.
if (links.lastOrNull()?.let { start < it.end } == true) continue
links += PhoneLink(start, end + suffix.length, number + dialable(suffix))
}
return links
}
// How far around a match the label, prefix, currency and suffix checks look.
// Enough for any of them, and it keeps a long invite from being re-read whole
// for every number in it.
private const val LOOK_BEHIND = 40
private const val LOOK_AHEAD = 60
private fun String.window(from: Int, to: Int): String =
substring(from.coerceAtLeast(0), to.coerceAtMost(length))
/**
* Digits run straight into a letter or a URL path — "zoom.us/j/850…" without
* its https:// — which no web link covered. A phone number stands apart.
*/
private fun gluedIntoAWord(text: String, start: Int, end: Int): Boolean {
val before = text.getOrNull(start - 1)
val after = text.getOrNull(end)
return (before != null && (before.isLetter() || before in "/=?&")) ||
(after != null && (after.isLetter() || after == '/'))
}
private fun isDottedTimes(matched: String): Boolean {
val times = dottedTimesRegex.matchEntire(matched) ?: return false
return times.groupValues.drop(1).chunked(2).all { (hour, minute) ->
hour.isEmpty() || (hour.toInt() in 0..24 && minute.toInt() in 0..59)
}
}
private fun isAmount(text: String, start: Int, end: Int): Boolean {
val matched = text.substring(start, end)
if (shortLeadAmountRegex.matches(matched)) return true
return amountRegex.matches(matched) &&
(currencyBeforeRegex.containsMatchIn(text.window(start - LOOK_BEHIND, start)) ||
currencyAfterRegex.containsMatchIn(text.window(end, end + LOOK_AHEAD)))
}
private fun MatchResult.isDate(): Boolean {
val (a, _, b, c) = destructured
val first = a.toInt()
val second = b.toInt()
val last = c.toInt()
return when {
a.length == 4 -> second in 1..12 && last in 1..31 && c.length <= 2
a.length > 2 -> false
// Day first (most of the world) or month first (US).
else -> (first in 1..31 && second in 1..12) || (first in 1..12 && second in 1..31)
}
}
private fun lineStart(text: String, index: Int): Int = text.lastIndexOf('\n', index - 1) + 1
/**
* Strip [raw] down to what a dialer takes. An international number's "(0)"
* trunk prefix — "+49 (0) 151 …" — is a domestic-only hint and would break the
* call, so it goes too; an extension becomes a pause.
*/
private fun dialable(raw: String): String {
val international = raw.trimStart('(').startsWith("+")
val withoutTrunk = if (international) raw.replace(trunkPrefixRegex, "") else raw
return withoutTrunk.replace(extensionRegex, ",")
.filter { it.isDigit() || it == '+' || it == ',' || it == '#' || it == '*' }
}
File diff suppressed because it is too large Load Diff

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