Compare commits

..
Author SHA1 Message Date
makiolaj 49cb11fe09 Share the host overlays' slide, held key and create request 2026-10-16 14:07:39 +02:00
makiolaj 32668623a6 Hold the editor's open picker in one slot and split up the form 2026-10-16 14:07:39 +02:00
makiolaj 6e673a1cec Share the drag controller's piece building, settling, column flip and slot snap 2026-10-16 14:07:39 +02:00
makiolaj 11c1b71c85 Hand external requests to the host as one holder 2026-10-16 14:07:39 +02:00
makiolaj 28c4e68de4 Bundle the timeline's shared scroll, drag and callbacks into TimelineActions 2026-10-16 14:07:39 +02:00
makiolaj 7a43ee0595 Share one body for the editor's picker cards 2026-10-16 14:07:39 +02:00
makiolaj 3ed12f1fee One rollover-following today flow for the view models 2026-10-16 14:07:39 +02:00
makiolaj 16fe2687e8 Drop the editor's pass-through wrappers 2026-10-16 14:07:39 +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
179 changed files with 2228 additions and 9672 deletions
+1 -44
View File
@@ -8,23 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Six new home-screen widgets.** Quick add (1×1) opens a new event on today with the same defaults as the app's + button ([#435]). Date (1×1) shows today's weekday and day number on a rounded shape and opens today ([#376]). Week strip (4×1) shows this week's days with a dot in each event's colour and today filled ([#436]). Upcoming (2×2) shows today's date over your next events, as many as fit ([#437]). Today lists today's events; once they're over it says "Nothing left today" and shows tomorrow's first one or two ([#438]). Countdown counts down to your next timed event, or to an event or series you pick, like a holiday in 12 days ([#439]).
- **A month and day widget.** A compact month grid sits beside the events of one day. Tap a day in the grid to see its events, and tap the list to open that day in the app ([#441]).
- **Each widget has its own settings.** Placing a widget, or long-pressing it and choosing Edit, opens a settings screen with a live preview. Pick a solid, tinted or transparent background, a light or dark theme or the app's, and which calendars it shows. Widgets that list events also have their time range, text size and how past events look. Settings → Widgets lists the widgets on your home screen so you can change them from there. These options used to be shared by all widgets; widgets you already placed keep what you had set ([#292]).
### Changed
- **The agenda and month widgets have a new look.** The agenda puts each day in its own card, with a round date pill beside the first event and today's pill filled. The month widget has rounded day cells, weekday pills, shaded days outside the month and a + for a new event. The agenda's refresh button is gone, since widgets now update on their own ([#71], [#90]).
- **Tapping a day in the month widget opens that day,** wherever in its cell you tap ([#386]).
- **The widget picker shows what each widget looks like.** On Android 15 and later the previews are the real widgets with sample events, in your language and colours; on older versions they are redrawn to match.
### Fixed
- **Widgets show your changes straight away.** After you added, edited or deleted an event, a widget could keep showing the old events until something else redrew it ([#420], [#229]).
- **The month widget handles busy months.** It could show "Can't show content", or only one or two events per day. Each day now shows as many events as its height fits, and the rest fold into +N ([#372]).
## [2.23.0] — 2026-10-21
### 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. The bar shows the new times while you move or resize. Read-only events can be selected but not moved or resized ([#141]).
- **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]).
@@ -32,23 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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]).
- **Donate through Liberapay.** Settings → Support development now offers Liberapay for recurring donations next to Ko-fi for one-off tips.
- **More haptic feedback.** Swiping between days in the split month pane ticks, and dropping a moved or resized event gives a short buzz ([#116]).
### Changed
- **The editor says Create for a new event.** New events, duplicates, shared text, imported .ics files and long-press drafts show Create instead of Save, so a duplicate no longer looks like you're editing the original ([#141]).
### 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]).
- **Moving a recurring event to another calendar keeps the occurrences you changed.** Deleted occurrences used to come back after the move, and edited ones showed up twice. They now stay deleted or edited, and keep their colour.
- **Sharing an .ics file to Calendula opens the import,** even when the email app adds a subject line as text.
- **Rotating the phone no longer throws away an open import, duplicate or picker.**
- **The repeat-until date picker opens on today.** Near midnight it used to open on yesterday or tomorrow.
- **Tap ripples on week numbers, split-view day cells and the split-view handle stay inside their rounded shape** ([#413]).
- **The export calendar picker previews the back gesture** like the other full-screen pickers.
## [2.22.5] — 2026-10-07
@@ -1901,19 +1874,3 @@ automatically, with zero telemetry and no internet permission.
[#181]: https://codeberg.org/jlmakiola/calendula/issues/181
[#306]: https://codeberg.org/jlmakiola/calendula/issues/306
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178
[#116]: https://codeberg.org/jlmakiola/calendula/issues/116
[#413]: https://codeberg.org/jlmakiola/calendula/issues/413
[#71]: https://codeberg.org/jlmakiola/calendula/issues/71
[#90]: https://codeberg.org/jlmakiola/calendula/issues/90
[#229]: https://codeberg.org/jlmakiola/calendula/issues/229
[#292]: https://codeberg.org/jlmakiola/calendula/issues/292
[#372]: https://codeberg.org/jlmakiola/calendula/issues/372
[#376]: https://codeberg.org/jlmakiola/calendula/issues/376
[#386]: https://codeberg.org/jlmakiola/calendula/issues/386
[#420]: https://codeberg.org/jlmakiola/calendula/issues/420
[#435]: https://codeberg.org/jlmakiola/calendula/issues/435
[#436]: https://codeberg.org/jlmakiola/calendula/issues/436
[#437]: https://codeberg.org/jlmakiola/calendula/issues/437
[#438]: https://codeberg.org/jlmakiola/calendula/issues/438
[#439]: https://codeberg.org/jlmakiola/calendula/issues/439
[#441]: https://codeberg.org/jlmakiola/calendula/issues/441
-9
View File
@@ -23,8 +23,6 @@ It works on top of the system calendar and has no network access.</p>
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
&nbsp;
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
&nbsp;
<a href="https://liberapay.com/Jean-Luc_Makiola/donate"><img src="https://liberapay.com/assets/widgets/donate.svg" alt="Donate using Liberapay" height="56"></a>
</p>
<p>
@@ -205,13 +203,6 @@ anywhere. Your events stay in Android's own calendar store.
[Privacy policy](https://jeanlucmakiola.de/calendula/privacy), written in
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
## Support
Calendula is free and has no ads. If you'd like to support its development,
you can tip once on [Ko-fi](https://ko-fi.com/jeanlucmakiola) or donate
regularly on [Liberapay](https://liberapay.com/Jean-Luc_Makiola/donate). Both
are linked in the app under *Settings → Support development*.
## License
[MIT](LICENSE), Jean-Luc Makiola, 2026
+2 -3
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,
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 2240001
versionName = "2.24.0-beta.1"
versionCode = 2230004
versionName = "2.23.0-beta.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -181,7 +181,6 @@ dependencies {
implementation("de.jeanlucmakiola.floret:core-android")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
implementation("de.jeanlucmakiola.floret:glance")
implementation(libs.kotlinx.coroutines.core)
debugImplementation(libs.androidx.ui.tooling)
@@ -1,140 +0,0 @@
package de.jeanlucmakiola.calendula.widget.month
import android.Manifest
import android.content.ContentUris
import android.content.ContentValues
import android.net.Uri
import android.os.Parcel
import android.provider.CalendarContract
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.glance.ExperimentalGlanceApi
import androidx.glance.appwidget.compose
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 kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import de.jeanlucmakiola.calendula.R
import java.time.LocalDate
import java.time.ZoneId
/**
* Renders the month widget for a packed month through Glance's own translator
* (#372) and checks what one size costs on the wire: the host's binder buffer is
* 1 MB for every widget update at once, and Exact sends one copy per size (#214).
*/
@OptIn(ExperimentalGlanceApi::class)
@RunWith(AndroidJUnit4::class)
class MonthWidgetPayloadTest {
@get:Rule
val permissionRule: GrantPermissionRule =
GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context = instrumentation.targetContext
private var calendarId = -1L
private fun Uri.asSyncAdapter(): Uri = buildUpon()
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT)
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
.build()
@Before
fun packTheMonth() {
val calendar = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.NAME, ACCOUNT)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, ACCOUNT)
put(CalendarContract.Calendars.CALENDAR_COLOR, 0xFF3F51B5.toInt())
put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER)
put(CalendarContract.Calendars.OWNER_ACCOUNT, ACCOUNT)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
}
val uri = context.contentResolver.insert(CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(), calendar)
calendarId = ContentUris.parseId(requireNotNull(uri))
val zone = ZoneId.systemDefault()
val first = LocalDate.now(zone).withDayOfMonth(1).minusDays(7)
val events = (0 until 49).flatMap { offset ->
val day = first.plusDays(offset.toLong())
(0 until EVENTS_PER_DAY).map { i ->
val start = day.atTime(8 + i, 0).atZone(zone).toInstant().toEpochMilli()
ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.TITLE, "Busy $i")
put(CalendarContract.Events.DTSTART, start)
put(CalendarContract.Events.DTEND, start + 30 * 60_000L)
put(CalendarContract.Events.EVENT_TIMEZONE, zone.id)
}
}
}
context.contentResolver.bulkInsert(CalendarContract.Events.CONTENT_URI.asSyncAdapter(), events.toTypedArray())
}
@After
fun removeCalendar() {
if (calendarId < 0) return
context.contentResolver.delete(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId).asSyncAdapter(),
null,
null,
)
}
@Test
fun aPackedMonthRendersWithinItsPayload() = runBlocking {
for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp), DpSize(600.dp, 1000.dp))) {
val views = MonthWidget().compose(context, size = size)
val bytes = Parcel.obtain().let { parcel ->
try {
views.writeToParcel(parcel, 0)
parcel.dataSize()
} finally {
parcel.recycle()
}
}
var dayNumbers = 0
var bars = 0
instrumentation.runOnMainSync {
val root = views.apply(context, FrameLayout(context))
fun walk(view: View) {
if (view.id in DAY_IDS && view is TextView && view.text.isNotEmpty()) dayNumbers += 1
if (view.id == R.id.bar_title) bars += 1
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
}
walk(root)
}
Log.i(TAG, "size=$size bytes=$bytes days=$dayNumbers bars=$bars")
// The grid itself rendered, not Glance's error layout: a number in every day of every week.
assertThat(dayNumbers % 7).isEqualTo(0)
assertThat(dayNumbers).isAtLeast(28)
assertThat(bytes).isAtMost(MAX_BYTES_PER_SIZE)
}
}
private companion object {
const val TAG = "MonthWidgetPayload"
const val ACCOUNT = "calendula-widget-payload-test"
const val EVENTS_PER_DAY = 6
val DAY_IDS = setOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6)
/** A quarter of the host's buffer, leaving room for a second size and every other widget. */
const val MAX_BYTES_PER_SIZE = 256 * 1024
}
}
+7 -108
View File
@@ -152,7 +152,7 @@
</intent-filter>
<!-- Text shared or selected in another app opens a prefilled new
event (MainActivity.sharedTextRequestOrNull, issue #306). -->
event (MainActivity.sharedTextExtrasOrNull, issue #306). -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@@ -318,16 +318,6 @@
android:exported="false" />
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
<activity
android:name=".widget.config.WidgetConfigActivity"
android:exported="true"
android:excludeFromRecents="true"
android:taskAffinity=""
android:theme="@style/Theme.Calendula.WidgetConfig">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<receiver
android:name=".widget.agenda.AgendaWidgetReceiver"
android:label="@string/widget_agenda_label"
@@ -352,103 +342,13 @@
android:resource="@xml/appwidget_info_month" />
</receiver>
<receiver
android:name=".widget.quickadd.QuickAddWidgetReceiver"
android:label="@string/widget_quick_add_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_quick_add" />
</receiver>
<receiver
android:name=".widget.date.DateWidgetReceiver"
android:label="@string/widget_date_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_date" />
</receiver>
<receiver
android:name=".widget.week.WeekStripWidgetReceiver"
android:label="@string/widget_week_strip_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_week_strip" />
</receiver>
<receiver
android:name=".widget.upcoming.UpcomingWidgetReceiver"
android:label="@string/widget_upcoming_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_upcoming" />
</receiver>
<receiver
android:name=".widget.today.TodayWidgetReceiver"
android:label="@string/widget_today_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_today" />
</receiver>
<receiver
android:name=".widget.countdown.CountdownWidgetReceiver"
android:label="@string/widget_countdown_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_countdown" />
</receiver>
<!-- A countdown widget's redraw for when its label changes (#439). -->
<receiver
android:name=".widget.countdown.CountdownTickReceiver"
android:exported="false" />
<receiver
android:name=".widget.split.MonthDayWidgetReceiver"
android:label="@string/widget_month_day_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/appwidget_info_month_day" />
</receiver>
<!-- Keeps both widgets fresh: the calendar provider broadcasts
PROVIDER_CHANGED on any data change (external sync; the app's own
writes redraw directly). The day boundary arrives as the app's own
ROLLOVER alarm (#228), by explicit PendingIntent, so it needs no
filter here; DATE_CHANGED is a free extra only, since Android 8+
withholds it from manifest receivers. TIME_SET / TIMEZONE_CHANGED
move the boundary, boot / package-replace wipe the alarm, and
LOCALE_CHANGED redraws the labels in the new language.
PROVIDER_CHANGED on any data change (our writes and external sync).
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
a free extra only, since Android 8+ withholds it from manifest
receivers. The four below re-arm that alarm: TIME_SET /
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
Exported: the system broadcasts arrive from outside the app. -->
<receiver
android:name=".widget.WidgetUpdateReceiver"
@@ -465,7 +365,6 @@
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.LOCALE_CHANGED" />
</intent-filter>
</receiver>
@@ -10,9 +10,7 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.calendula.widget.publishCalendulaWidgetPreviews
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope
@@ -45,8 +43,6 @@ class CalendulaApp : Application() {
chooseIssueUrl = getString(R.string.report_issue_choose_url),
),
)
// Before anything that writes, so the widgets see every change (#420).
widgetEntryPoint().widgetRefreshCoordinator().start()
reconcileAutoBackup()
reconcileSpecialDates()
reconcileCalendarVisibility()
@@ -59,12 +55,10 @@ class CalendulaApp : Application() {
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
* dropped by a force-stop is armed again the next time the app is opened.
* Off the main thread: a handful of binder calls on every process start.
* The picker previews are republished here too, once per update.
*/
private fun reconcileWidgetRollover() {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
syncWidgetRollover(this@CalendulaApp)
publishCalendulaWidgetPreviews(this@CalendulaApp)
WidgetRolloverScheduler.sync(this@CalendulaApp)
}
}
@@ -6,7 +6,6 @@ import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.provider.CalendarContract
import android.provider.OpenableColumns
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -62,8 +61,6 @@ 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.resolveFontFamily
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
@@ -127,17 +124,10 @@ class MainActivity : AppCompatActivity() {
cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) }
systemBarsDark = (cachedTheme ?: ThemeMode.SYSTEM).isDark(systemDark)
applyEdgeToEdge()
val appearance = MutableStateFlow(
Appearance(
themeMode = cachedTheme ?: ThemeMode.SYSTEM,
dynamicColor = WindowThemeCache.readDynamicColor(this),
),
)
lifecycleScope.launch {
combine(settingsPrefs.themeMode, settingsPrefs.dynamicColor, ::Appearance).collect { stored ->
WindowThemeCache.write(this@MainActivity, stored.themeMode, stored.dynamicColor ?: true)
window.setBackgroundDrawableResource(stored.themeMode.windowBackgroundRes() ?: R.color.window_background)
appearance.value = stored
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
@@ -153,7 +143,6 @@ class MainActivity : AppCompatActivity() {
// is what carries one through a recreation — the create form only while
// it's still open.
val replaying = savedInstanceState != null || intent.isRelaunch()
requests.replayed = savedInstanceState != null
if (!replaying) {
requests.detailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requests.editKey = intent.editEventKeyOrNull()
@@ -172,11 +161,7 @@ class MainActivity : AppCompatActivity() {
// and the Settings screen, so a theme change applies app-wide at once.
val settingsViewModel: SettingsViewModel = hiltViewModel()
val settings by settingsViewModel.state.collectAsStateWithLifecycle()
// Seeded from the cache, so the first frame doesn't draw the default
// theme and palette before the stored choices arrive.
val shownAppearance by appearance.collectAsStateWithLifecycle()
val themeMode = shownAppearance.themeMode
val darkTheme = themeMode.isDark(isSystemInDarkTheme())
val darkTheme = settings.themeMode.isDark(isSystemInDarkTheme())
// onCreate can only see the night resource qualifier, not the in-app
// override — re-apply from the resolved theme so the bar icons follow
// the app's light/dark choice.
@@ -214,8 +199,8 @@ class MainActivity : AppCompatActivity() {
}
CalendulaTheme(
darkTheme = darkTheme,
dynamicColor = shownAppearance.dynamicColor ?: settings.dynamicColor,
pureBlack = themeMode.isPureBlack,
dynamicColor = settings.dynamicColor,
pureBlack = settings.themeMode.isPureBlack,
typography = typography,
) {
Box(modifier = Modifier.fillMaxSize()) {
@@ -288,7 +273,6 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
requests.replayed = false
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it }
intent.navRequestOrNull()?.let { requests.nav = it }
intent.importUriOrNull()?.let { requests.importUri = it }
@@ -297,13 +281,6 @@ class MainActivity : AppCompatActivity() {
insertFormOpen = true
}
intent.editEventKeyOrNull()?.let { requests.editKey = it }
// The user moved on into the app, so closing the create form no longer
// hands control back to the app that started it.
if (requests.detailKey != null || requests.nav != null || requests.importUri != null ||
requests.editKey != null
) {
launchedForInsert = false
}
}
/**
@@ -324,10 +301,9 @@ class MainActivity : AppCompatActivity() {
private fun Intent.importUriOrNull(): Uri? {
val uri = when (action) {
Intent.ACTION_VIEW -> data
// Shared text is a create (#306); a text/plain file is imported only
// when it is calendar data, e.g. an .ics attachment labelled text/plain.
Intent.ACTION_SEND -> sharedStreamOrNull()
?.takeUnless { type == SHARED_TEXT_TYPE && !it.isCalendarFile() }
// Plain text is a create (#306), never a file to import.
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
.takeUnless { type == SHARED_TEXT_TYPE }
else -> null
} ?: return null
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
@@ -381,7 +357,7 @@ class MainActivity : AppCompatActivity() {
*/
private fun Intent.sharedTextRequestOrNull(): InsertRequest? {
val shared = when (action) {
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE && sharedStreamOrNull()?.isCalendarFile() != true) {
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE) {
sharedTextOrNull(
subject = getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString(),
text = getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString(),
@@ -402,23 +378,6 @@ class MainActivity : AppCompatActivity() {
return InsertRequest(form, ImportSource.InsertOpenEnded)
}
private fun Intent.sharedStreamOrNull(): Uri? =
IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
/** Whether a shared file is iCalendar/vCalendar data, by its resolved type or its name. */
private fun Uri.isCalendarFile(): Boolean {
val resolvedType = runCatching { contentResolver.getType(this) }.getOrNull()
if (resolvedType in CALENDAR_TYPES) return true
val name = when (scheme) {
"content" -> runCatching {
contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
}.getOrNull()
else -> lastPathSegment
} ?: return false
return CALENDAR_EXTENSIONS.any { name.endsWith(it, ignoreCase = true) }
}
/** A Long extra's value, or null when the extra is absent. */
private fun Intent.longExtraOrNull(key: String): Long? =
if (hasExtra(key)) getLongExtra(key, 0L) else null
@@ -545,8 +504,6 @@ class MainActivity : AppCompatActivity() {
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
private const val SHARED_TEXT_TYPE = "text/plain"
private val CALENDAR_TYPES = setOf("text/calendar", "text/x-vcalendar", "application/ics")
private val CALENDAR_EXTENSIONS = listOf(".ics", ".vcs")
private const val KEY_INSERT_FORM_OPEN = "insert_form_open"
private const val KEY_LAUNCHED_FOR_INSERT = "launched_for_insert"
@@ -639,6 +596,3 @@ class MainActivity : AppCompatActivity() {
}
}
}
/** The theme choices the window needs before the settings screen's state loads. */
private data class Appearance(val themeMode: ThemeMode, val dynamicColor: Boolean?)
@@ -56,10 +56,6 @@ import javax.inject.Singleton
interface CalendarDataSource {
fun calendars(): List<CalendarSource>
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
/** [instances] of event [eventId] alone, soonest first. */
fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
instances(beginMillis, endMillis).filter { it.eventId == eventId }
/**
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
* decode their stored offsets back to whole-day lead times (see
@@ -567,33 +563,21 @@ class AndroidCalendarDataSource @Inject constructor(
}
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
queryInstances(beginMillis, endMillis, null, null)
override fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
queryInstances(beginMillis, endMillis, "${CalendarContract.Instances.EVENT_ID} = ?", arrayOf(eventId.toString()))
private fun queryInstances(
beginMillis: Long,
endMillis: Long,
extraSelection: String?,
args: Array<String>?,
): List<EventInstance> {
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
ensureObserversRegistered()
// Hide cancelled occurrences: "delete only this event" writes a
// cancelled exception for the one instance (#47). A NULL status is a
// normal, un-cancelled event, so it must survive the filter — a bare
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
// DELETED = 0: a provider that soft-deletes may keep the row's
// instances until a purge (#364).
val visible = "(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
"${CalendarContract.Events.DELETED} = 0"
return resolver.query(
instancesUri(beginMillis, endMillis),
InstanceProjection.COLUMNS,
if (extraSelection == null) visible else "$visible AND $extraSelection",
args,
// Hide cancelled occurrences: "delete only this event" writes a
// cancelled exception for the one instance (#47). A NULL status is a
// normal, un-cancelled event, so it must survive the filter — a bare
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
// DELETED = 0: a provider that soft-deletes may keep the row's
// instances until a purge (#364).
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
"${CalendarContract.Events.DELETED} = 0",
null,
CalendarContract.Instances.BEGIN + " ASC",
)?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
}
@@ -741,7 +725,7 @@ class AndroidCalendarDataSource @Inject constructor(
),
null,
)?.use { c ->
c.mapRows { EventColorOption(key = getString(0).orEmpty(), argb = getInt(1)) }
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
}
?.filter { it.key.isNotEmpty() }
?: emptyList()
@@ -1132,7 +1116,7 @@ class AndroidCalendarDataSource @Inject constructor(
?: throw WriteFailedException("read modified occurrence id=${ex.exceptionEventId}")
val endMillis = ex.dtEndMillis ?: (ex.dtStartMillis + parseRfc2445DurationMillis(ex.duration))
val form = detail.toEditForm(ex.dtStartMillis, endMillis, zone)
.copy(calendarId = targetCalendarId, colorKey = null)
.copy(calendarId = targetCalendarId)
.toDetachedOccurrence()
detached += insertEvent(form, allDayReminderTimeMinutes)
}
@@ -1707,6 +1691,11 @@ class AndroidCalendarDataSource @Inject constructor(
null,
)?.use { c -> c.mapRows { toReminder() } } ?: emptyList()
/** Iterate every row and map; skips nothing. */
private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
while (moveToNext()) add(mapper(this@mapAll))
}
/** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */
private fun mintUid(): String = "${UUID.randomUUID()}@calendula"
@@ -16,15 +16,8 @@ interface CalendarRepository {
fun calendars(): Flow<List<CalendarSource>>
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
/** One read of event [eventId]'s occurrences overlapping [range], soonest first, whatever calendar it is in. */
suspend fun eventOccurrences(eventId: Long, range: ClosedRange<Instant>): List<EventInstance>
/** Re-query every open [calendars] / [instances] flow. */
fun refresh()
/** Emits on the app's own writes and visibility changes; not on provider notifications or [refresh]. */
val localChanges: Flow<Unit>
suspend fun eventDetail(eventId: Long): EventDetail
/**
@@ -78,28 +78,15 @@ class CalendarRepositoryImpl @Inject constructor(
ticks.tryEmit(Unit)
}
private val writes = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Run a provider write on [io], then re-query whether or not it threw. */
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
try {
block()
} finally {
refresh()
writes.tryEmit(Unit)
}
}
override val localChanges: Flow<Unit> = merge(
writes,
prefs.pendingDisabledCalendarIds.distinctUntilChanged().drop(1).map {},
prefs.hiddenCalendarIds.distinctUntilChanged().drop(1).map {},
)
/**
* Re-query signal for everything filtered by visibility: the provider's own
* notifications, plus every change to the pending switch-off set.
@@ -194,11 +181,6 @@ class CalendarRepositoryImpl @Inject constructor(
cachedCalendars
}
override suspend fun eventOccurrences(eventId: Long, range: ClosedRange<Instant>): List<EventInstance> =
withContext(io) {
dataSource.eventInstances(eventId, range.start.toEpochMillis(), range.endInclusive.toEpochMillis())
}
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
?: throw NoSuchEventException(eventId)
@@ -1,8 +0,0 @@
package de.jeanlucmakiola.calendula.data.di
import javax.inject.Qualifier
/** The process-wide `CoroutineScope` that app-lifetime singletons share their flows in. */
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ApplicationScope
@@ -24,9 +24,6 @@ import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
@@ -88,9 +85,4 @@ object DataProvideModule {
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.calendulaDataStore
@Provides
@Singleton
@ApplicationScope
fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
@@ -154,6 +154,8 @@ class SettingsPrefs @Inject constructor(
val dragToReschedule: Flow<Boolean> = prefs.flow(SettingsKeys.dragToReschedule)
val tapOpensEditor: Flow<Boolean> = prefs.flow(SettingsKeys.tapOpensEditor)
val agendaScreenRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaScreenRange)
val agendaWidgetRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaWidgetRange)
val widgetSize: Flow<WidgetSize> = prefs.flow(SettingsKeys.widgetSize)
val agendaShowRangeBar: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowRangeBar)
val agendaShowToday: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowToday)
val defaultView: Flow<CalendarView> = prefs.flow(SettingsKeys.defaultView)
@@ -229,6 +231,8 @@ class SettingsPrefs @Inject constructor(
suspend fun setDragToReschedule(enabled: Boolean) = prefs.set(SettingsKeys.dragToReschedule, enabled)
suspend fun setTapOpensEditor(enabled: Boolean) = prefs.set(SettingsKeys.tapOpensEditor, enabled)
suspend fun setAgendaScreenRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaScreenRange, range)
suspend fun setAgendaWidgetRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaWidgetRange, range)
suspend fun setWidgetSize(size: WidgetSize) = prefs.set(SettingsKeys.widgetSize, size)
suspend fun setAgendaShowRangeBar(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowRangeBar, enabled)
suspend fun setAgendaShowToday(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowToday, enabled)
suspend fun setDefaultView(view: CalendarView) = prefs.set(SettingsKeys.defaultView, view)
@@ -493,16 +497,13 @@ object SettingsKeys {
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: Pref<AgendaRange> = agendaRangePref("agenda_screen_range")
/**
* How far ahead the agenda **widget** shows events (v2.11). Read-only since
* widgets keep their own options (#292): the default for a widget that has
* none yet.
*/
/** How far ahead the agenda **widget** shows events (v2.11). */
val agendaWidgetRange: Pref<AgendaRange> = agendaRangePref("agenda_widget_range")
/**
* The size step the agenda widget draws its text at (#51). [WidgetSize.SMALL]
* reproduces its original metrics. Read-only like [agendaWidgetRange] (#292).
* reproduces its original metrics. The month widget takes no size setting —
* it divides the width it is given by seven (#103).
*/
val widgetSize: Pref<WidgetSize> = enumPref("widget_size", WidgetSize.SMALL)
@@ -510,8 +511,8 @@ object SettingsKeys {
val agendaShowRangeBar: Pref<Boolean> = booleanPref("agenda_show_range_bar", true)
/**
* Whether the agenda screen always anchors today at the top with a "nothing
* left today" placeholder (issue #35), and the default for a widget (#292).
* Whether the agenda (screen and widget) always anchors today at the top with
* a "nothing left today" placeholder (issue #35).
*/
val agendaShowToday: Pref<Boolean> = booleanPref("agenda_show_today", true)
@@ -3,15 +3,12 @@ package de.jeanlucmakiola.calendula.data.time
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
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.SharingStarted
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onEach
@@ -30,12 +27,10 @@ interface TodaySource {
/**
* 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.
* One receiver and tick loop is shared by every collector.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AndroidTodaySource @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope scope: CoroutineScope,
) : TodaySource {
override val today: Flow<LocalDate> = context
@@ -47,7 +42,6 @@ class AndroidTodaySource @Inject constructor(
}
}
.distinctUntilChanged()
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
}
private const val MINUTE_MILLIS = 60_000L
@@ -3,14 +3,10 @@ package de.jeanlucmakiola.calendula.data.time
import android.content.Context
import android.content.Intent
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
import de.jeanlucmakiola.floret.android.broadcastTicks
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.datetime.TimeZone
import javax.inject.Inject
@@ -27,16 +23,14 @@ interface ZoneSource {
fun current(): TimeZone
}
/** Follows the system's zone broadcast, one receiver shared by every collector. */
/** Follows the system's zone broadcast. */
class AndroidZoneSource @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope scope: CoroutineScope,
) : ZoneSource {
override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
.map { current() }
.distinctUntilChanged()
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
override fun current(): TimeZone = TimeZone.currentSystemDefault()
}
@@ -131,10 +131,6 @@ private class HtmlRenderer(private val html: String) {
text("<")
return lt + 1
}
if (!closing && isAutolink(html.substring(nameStart, end))) {
text(html.substring(lt, end + 1))
return end + 1
}
var nameEnd = nameStart
while (nameEnd < end && (html[nameEnd].isLetterOrDigit() || html[nameEnd] == ':')) nameEnd++
val name = html.substring(nameStart, nameEnd).lowercase()
@@ -153,11 +149,6 @@ private class HtmlRenderer(private val html: String) {
return end + 1
}
/** An angle-bracketed URL or address (`<https://…>`, `<a@b.c>`), kept as text. */
private fun isAutolink(inner: String): Boolean =
LINK_SCHEMES.any { inner.startsWith(it, ignoreCase = true) } ||
('@' in inner && inner.none { it.isWhitespace() || it == '=' })
/** The `>` closing the tag at [lt], skipping any inside quoted attributes. */
private fun tagEnd(lt: Int): Int {
var quote: Char? = null
@@ -66,9 +66,7 @@ data class SanitizedRrule(val rule: String?, val repaired: Boolean)
* rule at all — gives up entirely.
*/
fun sanitizeRrule(raw: String?): SanitizedRrule {
val rule = raw?.trim()
?.let { if (it.startsWith("RRULE:", ignoreCase = true)) it.drop("RRULE:".length) else it }
?.trim().orEmpty()
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
var freqSeen = false
@@ -86,8 +84,8 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
// being dropped, so it falls through and marks the rule repaired.
for (part in rruleParts(rule)) {
val key = part.key?.trim()?.uppercase().orEmpty()
for (part in rruleParts(raw.orEmpty().trim())) {
val key = part.key?.trim().orEmpty()
val value = part.value.trim()
val kept = when {
key.isEmpty() || value.isEmpty() -> null
@@ -7,7 +7,6 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.SeekableTransitionState
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.fadeIn
@@ -35,7 +34,6 @@ import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.rememberHeld
import de.jeanlucmakiola.calendula.ui.common.screen
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
import androidx.compose.ui.layout.onGloballyPositioned
@@ -68,7 +66,7 @@ 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.rememberWriteAction
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGateOr
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
@@ -245,29 +243,20 @@ fun CalendarHost(
// An opened/received .ics file. [ImportScreen] parses it and either opens
// the prefilled create form (one event → [importForm]) or its own bulk
// picker (many). A plain conditional overlay (no slide) — it's transient.
var importUri by rememberSaveable { mutableStateOf<android.net.Uri?>(null) }
val importViewModel: EventEditViewModel = hiltViewModel(key = "import")
// Saved as "open"; a recreation takes the form back from the ViewModel, which
// still holds the user's edits.
var importForm by rememberSaveable(
stateSaver = Saver<EventForm?, Boolean>(
save = { it != null },
restore = { open -> if (open) importViewModel.currentForm() else null },
),
) { mutableStateOf<EventForm?>(null) }
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
var importForm by remember { mutableStateOf<EventForm?>(null) }
// Which channel filled [importForm]: an .ics file (prompt to apply the default
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
var importFormSource by rememberSaveable { 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 rememberSaveable { mutableIntStateOf(0) }
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 createViewModel: EventEditViewModel = hiltViewModel(key = "create")
val editViewModel: EventEditViewModel = hiltViewModel(key = "edit")
val importViewModel: EventEditViewModel = hiltViewModel(key = "import")
fun openImportForm(form: EventForm, source: ImportSource, fromRequest: Boolean = false) {
importFormSource = source
importForm = form
@@ -291,7 +280,7 @@ fun CalendarHost(
// 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
// keeps routing a single event straight into the prefilled create form.
var importForceMany by rememberSaveable { mutableStateOf(false) }
var importForceMany by remember { mutableStateOf(false) }
// Where a restore came from, so closing the import puts it back: the import
// overlays are declared under Backup & restore and the manager, so starting
// one has to close them — without this, finishing a restore drops you on the
@@ -302,20 +291,9 @@ fun CalendarHost(
// what tells it a *re-import of the same file* is new work and not the run
// it already finished; a rotation keeps the number and keeps the result.
var importSession by rememberSaveable { mutableStateOf(0) }
// A recreation re-reads the launch intent; when the import it carries came
// back with the saved state, that replay is the same work, not a new one.
fun isReplayOfRestoredImport(): Boolean {
val replay = requests.replayed && (importUri != null || importForm != null)
requests.replayed = false
return replay
}
val requestedImportUri = requests.importUri
LaunchedEffect(requestedImportUri) {
if (requestedImportUri != null) {
if (isReplayOfRestoredImport()) {
requests.importUri = null
return@LaunchedEffect
}
importUri = requestedImportUri
importForceMany = false
importSession++
@@ -329,10 +307,6 @@ fun CalendarHost(
val requestedInsert = requests.insert
LaunchedEffect(requestedInsert) {
if (requestedInsert != null) {
if (isReplayOfRestoredImport()) {
requests.insert = null
return@LaunchedEffect
}
if (importForm == null) {
openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true)
} else {
@@ -344,22 +318,16 @@ fun CalendarHost(
// Close every overlay that can sit over the calendar, so an externally
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
// top instead of underneath whatever the user had open. The create form,
// declared under the edit form, can stay for an edit to open over it.
fun dismissCoveringOverlays(keepCreate: Boolean = false) {
// top instead of underneath whatever the user had open.
fun dismissCoveringOverlays() {
showSettings = false
showSearch = false
showCalendars = false
showBackup = false
detailKey = null
editKey = null
if (!keepCreate) createRequest = null
importUri = null
importForm = null
if (importFromRequest) {
importFromRequest = false
requests.onExternalFormClosed()
}
importFromRequest = false
pendingInsert = null
importViewModel.reset()
}
@@ -372,10 +340,7 @@ fun CalendarHost(
val requestedEditKey = requests.editKey
LaunchedEffect(requestedEditKey) {
if (requestedEditKey != null) {
// An open edit form stays on screen, so it starts over here rather
// than once it has slid away.
if (editKey != null) editViewModel.reset()
dismissCoveringOverlays(keepCreate = true)
dismissCoveringOverlays()
editKey = requestedEditKey
requests.editKey = null
}
@@ -396,6 +361,7 @@ fun CalendarHost(
// Drill the day view in over the widget's view: drop any overlay
// that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays()
createRequest = null
focus.navigateTo(LocalDate.parse(req.dateIso))
// No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits.
@@ -406,6 +372,7 @@ fun CalendarHost(
// Root the stack in the widget's view, then open the occurrence
// detail over it (same key shape as a tapped event / reminder).
dismissCoveringOverlays()
createRequest = null
viewStack = viewBaseStack(defaultView, req.source)
detailKey = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
requests.nav = null
@@ -416,6 +383,7 @@ fun CalendarHost(
// the stack on the target (null → the default home) over the
// default home — so backing out returns to the default, then exits.
dismissCoveringOverlays()
createRequest = null
focus.navigateTo(currentDate())
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
requests.nav = null
@@ -425,7 +393,6 @@ fun CalendarHost(
// widget) must land on top of whatever is open — the form overlay
// sits below Settings/calendars in the Box, so without this it
// would open hidden underneath them.
if (createRequest != null) createViewModel.reset()
dismissCoveringOverlays()
openCreate(req.dateIso ?: currentDate().toString())
requests.nav = null
@@ -447,22 +414,9 @@ fun CalendarHost(
// tap time, so a tap before they've loaded still routes right.
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) }
// The tap waiting on the WRITE_CALENDAR prompt, saved so a rotation keeps it.
var tapAwaitingWrite by rememberSaveable { mutableStateOf<LongArray?>(null) }
val editAwaitingTap = rememberWriteAction(
onDenied = {
tapAwaitingWrite?.let { key ->
tapWriteRefused = true
detailKey = key
}
tapAwaitingWrite = null
},
) {
tapAwaitingWrite?.let { editKey = it }
tapAwaitingWrite = null
}
val onEventClick: (EventInstance) -> Unit = { event ->
tapScope.launch {
val toEditor = viewModel.tapOpensEditor() && eventActions.canModify(event.calendarId)
@@ -470,9 +424,9 @@ fun CalendarHost(
!toEditor -> onOpenDetail(event)
tapContext.canWriteCalendar() -> onEditEvent(event)
tapWriteRefused -> onOpenDetail(event)
else -> {
tapAwaitingWrite = event.occurrenceKey()
editAwaitingTap()
else -> requireWriteOr({ onEditEvent(event) }) {
tapWriteRefused = true
onOpenDetail(event)
}
}
}
@@ -720,26 +674,24 @@ fun CalendarHost(
// Event form (v1.2) — full-screen destination, slides over the calendar.
val shownCreate = rememberHeld(createRequest)
SlideOverlay(visible = createRequest != null, onHidden = createViewModel::reset) {
SlideOverlay(visible = createRequest != null) {
shownCreate?.let { create ->
key(create) {
EventEditScreen(
initialDateIso = create.dateIso,
initialStartMinutes = create.startMinutes,
initialDraft = create.draftMillis,
onClose = { createRequest = null },
onSaved = { createRequest = null },
onManageCalendars = { showCalendars = true },
visible = createRequest != null,
viewModel = createViewModel,
)
}
EventEditScreen(
initialDateIso = create.dateIso,
initialStartMinutes = create.startMinutes,
initialDraft = create.draftMillis,
onClose = { createRequest = null },
onSaved = { createRequest = null },
onManageCalendars = { showCalendars = true },
visible = createRequest != null,
viewModel = hiltViewModel(key = "create"),
)
}
}
// Edit form (v1.3) — slides over the detail screen.
val shownEditKey = rememberHeld(editKey)
SlideOverlay(visible = editKey != null, onHidden = editViewModel::reset) {
SlideOverlay(visible = editKey != null) {
shownEditKey?.let { key ->
EventEditScreen(
initialDateIso = null,
@@ -760,7 +712,7 @@ fun CalendarHost(
if (detailKey == null) detailKey = key
},
visible = editKey != null,
viewModel = editViewModel,
viewModel = hiltViewModel(key = "edit"),
)
}
}
@@ -878,25 +830,33 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
* snapping back and sliding off.
*/
@Composable
private fun SlideOverlay(
visible: Boolean,
onHidden: () -> Unit = {},
content: @Composable AnimatedVisibilityScope.() -> Unit,
) {
val state = remember { MutableTransitionState(visible) }
state.targetState = visible
val hidden = state.isIdle && !state.currentState
LaunchedEffect(hidden) {
if (hidden) onHidden()
}
private fun SlideOverlay(visible: Boolean, content: @Composable AnimatedVisibilityScope.() -> Unit) {
AnimatedVisibility(
visibleState = state,
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())
@@ -36,9 +36,6 @@ class ExternalRequests(val onExternalFormClosed: () -> Unit = {}) {
/** An external "edit this event" (ACTION_EDIT), in the detail key's shape. */
var editKey by mutableStateOf<LongArray?>(null)
/** Whether these came from re-reading the launch intent on a recreation. */
var replayed = false
}
/** A prefilled create form from an external launch, with the source it came from. */
@@ -393,8 +393,11 @@ private fun AgendaList(
val itemDates = agendaItemDates(days)
val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0)
SideEffect { anchorIndex.intValue = anchorRow }
// A new anchor opens on its row from the first frame; keyed on the anchor
// the days arrived with, not the one requested. The first run may restore.
// 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]
@@ -163,17 +163,15 @@ class AgendaViewModel @Inject constructor(
}
/**
* Extend the window further back (#367), doubling what's loaded (at least
* [PAST_CHUNK_DAYS], at most [MAX_PAST_STEP_DAYS]) so a long scroll back
* re-queries only a few times. Ignored while the previous extension is
* still loading.
* 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
val step = window.pastDays.coerceIn(PAST_CHUNK_DAYS, MAX_PAST_STEP_DAYS)
_window.value = window.copy(pastDays = window.pastDays + step)
_window.value = window.copy(pastDays = window.pastDays + PAST_CHUNK_DAYS)
}
fun rememberTopDay(date: LocalDate) {
@@ -235,9 +233,6 @@ class AgendaViewModel @Inject constructor(
/** How many days each scroll back into the past loads (#367). */
internal const val PAST_CHUNK_DAYS = 30
/** The most one scroll back adds, so an empty past grows the window by years, not exponentially. */
internal const val MAX_PAST_STEP_DAYS = 365
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> =
dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone)
@@ -26,6 +26,8 @@ import androidx.compose.material3.Text
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.Modifier
import androidx.compose.ui.graphics.graphicsLayer
@@ -80,7 +82,8 @@ fun CalendarFabColumn(
)
}
// Held past the selection clearing, so the bar has something to fold away.
val heldBar = rememberHeld(selection?.bar)
val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) }
if (selection != null && heldBar.value !== selection.bar) heldBar.value = selection.bar
// Held at the FAB's height so the taller bar can't shift the FAB.
Row(
modifier = Modifier.height(FabSize),
@@ -98,7 +101,7 @@ fun CalendarFabColumn(
shrinkTowards = Alignment.End,
) + fadeOut(),
) {
heldBar?.invoke()
heldBar.value?.invoke()
}
val label = stringResource(
when {
@@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
@@ -101,28 +100,33 @@ suspend fun CalendarRepository.tryDeleteEvent(
}
/**
* [action], which writes to the calendar, as a handler that asks for
* `WRITE_CALENDAR` first where the install only holds read access (v1.0
* installs). A grant continues into [action], also when it returns after a
* rotation; a refusal runs [onDenied].
* 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 rememberWriteAction(onDenied: () -> Unit = {}, action: () -> Unit): () -> Unit {
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
val currentAction by rememberUpdatedState(action)
val currentDenied by rememberUpdatedState(onDenied)
var pending by remember { mutableStateOf<Pair<() -> Unit, () -> Unit>?>(null) }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) currentAction() else currentDenied()
pending?.let { (action, fallback) -> if (granted) action() else fallback() }
pending = null
}
return remember(context, launcher) {
{
if (context.canWriteCalendar()) {
currentAction()
} else {
launcher.launch(Manifest.permission.WRITE_CALENDAR)
}
return { action, fallback ->
if (context.canWriteCalendar()) {
action()
} else {
pending = action to fallback
launcher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
}
@@ -7,7 +7,6 @@ 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.data.time.ZoneSource
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
@@ -27,6 +26,7 @@ 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
@@ -42,7 +42,6 @@ class EventActionsViewModel @Inject constructor(
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
) : ViewModel() {
/** Every calendar by id, for what a selected event's bar may offer. */
@@ -65,7 +64,7 @@ class EventActionsViewModel @Inject constructor(
suspend fun duplicateForm(event: EventInstance): EventForm? {
val detail = load(event) ?: return null
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
return detail.toDuplicateForm(canModify, zoneSource.current())
return detail.toDuplicateForm(canModify, TimeZone.currentSystemDefault())
}
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
@@ -32,6 +32,7 @@ fun rememberFabSelection(
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.
@@ -42,28 +43,10 @@ fun rememberFabSelection(
val currentEdit by rememberUpdatedState(onEdit)
val currentDuplicate by rememberUpdatedState(onDuplicate)
val currentDismissChip by rememberUpdatedState(onDismissChip)
// Each reads the selection at grant time, which may come after a rotation.
val onCreate = rememberWriteAction { host.selection?.create?.invoke() }
val onEditSelected = rememberWriteAction {
host.selection?.takeIf { it.create == null }?.let { selected ->
selected.dismiss()
currentEdit(selected.event)
}
}
val onDuplicateSelected = rememberWriteAction {
val selected = host.selection?.takeIf { it.create == null } ?: return@rememberWriteAction
scope.launch {
val form = viewModel.duplicateForm(selected.event) ?: return@launch
selected.dismiss()
currentDuplicate(form)
}
}
val onDeleteSelected = rememberWriteAction {
host.selection?.takeIf { it.create == null }?.let { viewModel.requestDelete(it.event) }
}
val currentWrite by rememberUpdatedState(requireWrite)
val selection = host.selection ?: return null
if (selection.create != null) {
selection.create?.let { create ->
return remember(selection, expanded, chipShown) {
FabSelection(
expanded = expanded,
@@ -73,7 +56,7 @@ fun rememberFabSelection(
},
bar = {
DraftSelectionBar(
onCreate = onCreate,
onCreate = { currentWrite { create() } },
onDiscard = selection.dismiss,
)
},
@@ -95,15 +78,28 @@ fun rememberFabSelection(
EventSelectionBar(
canEdit = canEdit,
canDelete = canDelete,
onEdit = onEditSelected,
onDuplicate = onDuplicateSelected,
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 = onDeleteSelected,
onDelete = { currentWrite { viewModel.requestDelete(event) } },
)
},
)
@@ -20,6 +20,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
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.Modifier
import androidx.compose.ui.platform.LocalDensity
@@ -110,13 +111,14 @@ fun HourGutter(
}
}
// Held while it fades, so it goes out where it was rather than blinking off.
val badgeMin = rememberHeld(dragStartMin)
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?.takeIf { badgeIn > 0f }?.let { startMin ->
badgeMin.value?.takeIf { badgeIn > 0f }?.let { startMin ->
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
Box(
modifier = Modifier
@@ -1,22 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
/**
* [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
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
}
@@ -101,10 +101,9 @@ fun TimeZonePickerDialog(
onDismiss = onDismiss,
scrollable = false,
) {
PickerSearchField(
SearchField(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.event_edit_timezone_search),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
@@ -182,10 +181,9 @@ fun TimeZonePickerDialog(
* which has a top bar to put it in) because the picker's bar is the title.
*/
@Composable
internal fun PickerSearchField(
private fun SearchField(
query: String,
onQueryChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
) {
val keyboard = LocalSoftwareKeyboardController.current
@@ -208,7 +206,7 @@ internal fun PickerSearchField(
InlineTextField(
value = query,
onValueChange = onQueryChange,
placeholder = placeholder,
placeholder = stringResource(R.string.event_edit_timezone_search),
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Search,
onImeAction = { keyboard?.hide() },
@@ -626,8 +626,25 @@ class TimelineDragController {
*/
private var originSlot: Triple<LocalDate, Int, Int>? = null
/** The resize in progress, or null for a plain drag (#141). */
private var resize: ResizeSession? = null
/** The edge a resize is dragging, or null for a plain drag (#141). */
private var resizeEdge: ResizeEdge? = null
/** The day the resized event starts on; its edges are minutes from that midnight. */
private var resizeDay: LocalDate? = null
private var resizeStartMin = 0
private var resizeEndMin = 0
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
private var resizeGrabMin = 0f
/** The column the resized edge is in, held until the finger is well into another. */
private var resizeColumn: Int? = null
/** The slot the resized edge sits on, held against a finger trembling between two. */
private var resizeSlot: Int? = null
/** The day, counted from the event's start day, the finger is resizing in. */
private var resizeEdgeDay = 0
fun begin(
block: TimedBlock,
@@ -674,7 +691,10 @@ class TimelineDragController {
}
fun cancel() {
resize = null
resizeEdge = null
resizeDay = null
resizeColumn = null
resizeSlot = null
source = null
clipOffsetMin = 0
dragZone = null
@@ -780,9 +800,11 @@ class TimelineDragController {
pointerInRoot: Offset,
) {
val span = wallClockSpan(block.event, zone)
val session = ResizeSession(edge, span.day, span.startMin, span.endMin)
source = block
resize = session
resizeEdge = edge
resizeDay = span.day
resizeStartMin = span.startMin
resizeEndMin = span.endMin
clipOffsetMin = 0
this.titleLines = titleLines
settling = null
@@ -791,12 +813,12 @@ class TimelineDragController {
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 edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
session.column = edgeIndex.takeIf { it >= 0 }
resizeColumn = edgeIndex.takeIf { it >= 0 }
?.let { column(it) }
session.grabMin = (pointerResizeMinutes(session) ?: 0f) -
if (edge == ResizeEdge.Top) session.startMin else session.endMin
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
originSlot = null
recompute()
originSlot = drag?.slot
@@ -812,7 +834,7 @@ class TimelineDragController {
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
val edge = resizeEdge
if (edge != null) {
val drop = resizedDrop(event, edge, date, startMin, spanMin, zone)
return event.copy(start = drop.start, end = drop.end)
@@ -828,7 +850,7 @@ class TimelineDragController {
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
val landed = drag
val origin = originSlot
val edge = resize?.edge
val edge = resizeEdge
cancel()
if (landed == null || landed.slot == origin) return null
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
@@ -840,8 +862,9 @@ class TimelineDragController {
* 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? {
private fun pointerResizeMinutes(): Float? {
val grid = geometry.grid?.takeIf { it.isAttached } ?: return null
val day = resizeDay ?: return null
val hourPx = geometry.hourPx
val columnPx = geometry.columnWidthPx
val days = geometry.days
@@ -849,23 +872,22 @@ class TimelineDragController {
val origin = grid.positionInRoot()
val held = stickyColumn(
position = (pointer.x - origin.x) / columnPx,
held = session.column,
held = resizeColumn,
lastIndex = days.lastIndex,
)
session.column = held
val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt()
session.edgeDay = dayOffset
resizeColumn = held
val dayOffset = (days[column(held)].toEpochDays() - day.toEpochDays()).toInt()
resizeEdgeDay = dayOffset
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
}
private fun recomputeResize(session: ResizeSession) {
private fun recomputeResize(edge: ResizeEdge) {
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 day = resizeDay ?: return
val raw = pointerResizeMinutes() ?: return
val slot = stickySnap(raw - resizeGrabMin, resizeSlot)
resizeSlot = slot
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay)
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.
@@ -924,7 +946,7 @@ class TimelineDragController {
* no move events while auto-scroll walks the content underneath it.
*/
fun recompute() {
resize?.let {
resizeEdge?.let {
recomputeResize(it)
return
}
@@ -1027,27 +1049,6 @@ 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
fun rememberTimelineDragController(): TimelineDragController {
val controller = remember { TimelineDragController() }
@@ -95,7 +95,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
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.copyOnLongPress
@@ -169,11 +169,14 @@ fun EventDetailScreen(
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
// upgrade in place and continues straight into the tapped action.
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
val onEditClick = rememberWriteAction(action = onEdit)
val requireWrite = rememberWriteGate()
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
val onEditClick = { requireWrite(onEdit) }
// Duplicate reads the loaded detail into a create form and hands it up; the
// create still needs WRITE_CALENDAR even when the source is read-only.
val onDuplicateClick = rememberWriteAction { viewModel.duplicateForm()?.let(onDuplicate) }
val onDuplicateClick = {
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
}
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
val writeDeniedMessage = stringResource(R.string.event_delete_write_denied)
@@ -7,7 +7,6 @@ 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.data.time.ZoneSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
@@ -32,6 +31,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlin.coroutines.cancellation.CancellationException
import javax.inject.Inject
@@ -45,7 +45,6 @@ class EventDetailViewModel @Inject constructor(
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
) : ViewModel() {
private val _target = MutableStateFlow<Target?>(null)
@@ -142,7 +141,7 @@ class EventDetailViewModel @Inject constructor(
*/
fun duplicateForm(): EventForm? {
val loaded = state.value as? EventDetailUiState.Success ?: return null
return loaded.detail.toDuplicateForm(loaded.canModify, zoneSource.current())
return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault())
}
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
@@ -41,7 +41,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import kotlinx.coroutines.launch
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.material.icons.filled.Share
@@ -168,7 +168,6 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
@@ -289,6 +288,7 @@ fun EventEditScreen(
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val requireWrite = rememberWriteGate()
// Save keeps its own launcher: its result calls straight into the ViewModel,
// so a save waiting on the permission dialog survives a rotation.
val savePermissionLauncher = rememberLauncherForActivityResult(
@@ -313,13 +313,14 @@ fun EventEditScreen(
}
}
// The copy opens over this form; a form with nothing to lose steps aside.
val onDuplicateClick = rememberWriteAction {
viewModel.duplicateForm()?.let { form ->
onDuplicate?.invoke(form)
if (!hasUnsavedChanges) close()
val onDuplicateClick: () -> Unit = {
requireWrite {
viewModel.duplicateForm()?.let { form ->
onDuplicate?.invoke(form)
if (!hasUnsavedChanges) close()
}
}
}
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
val saveFailedMessage = stringResource(R.string.event_edit_save_failed)
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
@@ -385,7 +386,7 @@ fun EventEditScreen(
canDuplicate = onDuplicate != null,
onDuplicate = onDuplicateClick,
onShare = onShareClick,
onDelete = onDeleteClick,
onDelete = { requireWrite { showDeleteDialog = true } },
)
}
Button(
@@ -1127,7 +1128,13 @@ private fun WhenCard(
color = MaterialTheme.colorScheme.error,
)
}
// A pinned event's local equivalent, shown only when pinned elsewhere.
// The rows above edit a pinned event in *its own* zone, which is the
// time it was set at — but that says nothing about when it lands for
// whoever is reading it. Spell the local equivalent out rather than
// leaving the user to do the offset arithmetic. Absent unless the
// event is pinned somewhere other than here.
// Keyed rather than recomputed: the form recomposes on every title
// keystroke, and this would otherwise re-parse the zone each time.
val pinnedAway = remember(form.timezone, form.isAllDay, deviceZone) {
form.isPinnedAwayFrom(deviceZone)
}
@@ -2414,8 +2421,9 @@ private fun accessLevelSummary(level: AccessLevel): Int = when (level) {
private fun formatTimeRange(start: Instant, end: Instant, zone: TimeZone, locale: Locale): String {
val use24Hour = LocalUse24HourFormat.current
return remember(start, end, zone, locale, use24Hour) {
val timeFormat = timeOfDayFormatter(use24Hour, locale)
fun format(at: Instant) = withRepeatedHourZone(
formatTimeOfDay(at, zone, use24Hour, locale),
timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()),
at,
zone,
locale,
@@ -590,9 +590,6 @@ class EventEditViewModel @Inject constructor(
}
}
/** The form as the user has it now, or null when none is open. */
fun currentForm(): EventForm? = _form.value
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
fun reset() {
session++
@@ -90,10 +90,8 @@ fun ImportScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
// A single event isn't shown here — it opens the create form for review.
// Read live: the state composed with may be the previous import's result,
// which the load above has already replaced.
LaunchedEffect(state) {
(viewModel.state.value as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
}
// Hoisted target calendar so the always-visible top-bar Import action can
@@ -78,7 +78,6 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.InputChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ripple
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -842,7 +841,7 @@ internal fun monthBandHeight(rowHeight: Dp): Dp =
/** Whether any day in this week holds more than [lanes] lanes can seat. */
private fun MonthWeek.overflowsAt(lanes: Int): Boolean =
days.indices.any { col -> overflowEvents(col, lanes).isNotEmpty() }
days.withIndex().any { (col, day) -> overflowEvents(col, day, lanes).isNotEmpty() }
/**
* Row height in the continuous grid. The paged grid divides the viewport between
@@ -1530,30 +1529,18 @@ private fun SplitExpandHandle(
modifier = modifier
.fillMaxWidth()
.height(SPLIT_HANDLE_ROW_HEIGHT)
.clickable(onClick = onToggle)
.semantics { contentDescription = label },
contentAlignment = Alignment.Center,
) {
Box(
Modifier
.size(width = SPLIT_HANDLE_RIPPLE_WIDTH, height = SPLIT_HANDLE_ROW_HEIGHT)
.clip(RoundedCornerShape(percent = 50))
.clickable(
interactionSource = null,
indication = ripple(),
onClick = onToggle,
)
.clearAndSetSemantics { },
contentAlignment = Alignment.Center,
) {
Box(
Modifier
.size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT)
// Tagged too, so it slides from the pane's seam down to the foot
// of the grid rather than blinking out at one and in at the other.
.morphElement(MonthMorphKey.Handle)
.background(MaterialTheme.colorScheme.outlineVariant, CircleShape),
)
}
.size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT)
// Tagged too, so it slides from the pane's seam down to the foot
// of the grid rather than blinking out at one and in at the other.
.morphElement(MonthMorphKey.Handle)
.background(MaterialTheme.colorScheme.outlineVariant, CircleShape),
)
}
}
@@ -1586,7 +1573,6 @@ private val SPLIT_DOT_SIZE = 5.dp
private val SPLIT_HANDLE_WIDTH = 32.dp
private val SPLIT_HANDLE_HEIGHT = 4.dp
private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
private val SPLIT_HANDLE_RIPPLE_WIDTH = SPLIT_HANDLE_WIDTH + 24.dp
/** The pane below the split grid fades in once the grid's morph has mostly landed. */
private const val SPLIT_PANE_FADE_IN_DELAY_MILLIS = 300
@@ -1641,11 +1627,11 @@ internal fun SplitMonthGrid(
val inMonth = day.month == month.month && day.year == month.year
// Seated by lane rather than gathered by colour, so each dot
// is the event the expanded grid draws in that same lane.
val seated = week.laneEvents(col, SPLIT_DOT_LANES)
val seated = week.laneEvents(col, day, SPLIT_DOT_LANES)
SplitDayCell(
date = day,
events = seated,
hidden = week.overflowEvents(col, SPLIT_DOT_LANES),
hidden = week.overflowEvents(col, day, SPLIT_DOT_LANES),
isToday = day == state.today,
// A page marks only the days its own month owns. Paging
// moves the selection before this month's replacement
@@ -1730,7 +1716,6 @@ private fun SplitDayCell(
modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.todayRipple(relativeToToday, rippleProgress)
.clip(CELL_SHAPE)
.selectable(selected = isSelected, onClick = onClick),
contentAlignment = Alignment.TopCenter,
) {
@@ -2113,8 +2098,8 @@ private fun MonthWeekRow(
val ripple = rememberTodayRipple(active = todayCol != null)
val overflowRow = rememberOverflowRowHeight()
val laneCap = week.laneCapFor(rowHeight, overflowRow)
// Once per row rather than per frame: a drag recomposes every frame.
val seatings = remember(week, laneCap) { week.days.indices.map { week.seating(it, laneCap) } }
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
val shownLanes = laneCount.coerceAtMost(laneCap)
val morphing = morphInFlight()
// Every chip's start time for this row at once, and only when the row's own
// inputs change: formatting is a parsed pattern per call, and the dim cutoff
@@ -2298,7 +2283,7 @@ private fun MonthWeekRow(
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),
) {
// Spanning bars on their shared lanes.
week.spans.filter { it.lane < laneCap }.forEach { span ->
week.spans.filter { it.lane < shownLanes }.forEach { span ->
val cols = span.endCol - span.startCol + 1
MonthBar(
event = span.event,
@@ -2370,13 +2355,19 @@ private fun MonthWeekRow(
)
}
}
// Single-day timed pills + overflow, per column, in the lanes
// [seating] gave them.
// Single-day timed pills + overflow, per column. Pills fill the
// lane slots no bar occupies on THIS day (top-most first), so a
// bar-free day isn't pushed down by a multi-day event that only
// sits on other days of the week.
week.days.forEachIndexed { col, d ->
val seating = seatings[col]
seating.seated.forEach { seat ->
if (seat.bar) return@forEach
val ev = seat.event
val timed = week.timedByDay[d].orEmpty()
val occupied = week.spans
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
.map { it.lane }
.toSet()
val freeSlots = (0 until laneCap).filter { it !in occupied }
val pillsShown = timed.take(freeSlots.size)
pillsShown.forEachIndexed { i, ev ->
MonthBar(
event = ev,
dark = dark,
@@ -2388,7 +2379,7 @@ private fun MonthWeekRow(
modifier = Modifier
.offset(
x = colW * col,
y = EVENT_ROW_HEIGHT * seat.lane,
y = EVENT_ROW_HEIGHT * freeSlots[i],
)
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
.then(
@@ -2404,10 +2395,18 @@ private fun MonthWeekRow(
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
)
}
if (seating.hidden.isNotEmpty()) {
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
if (hidden > 0) {
val hiddenEvents = buildList {
week.spans
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
.forEach { add(it.event) }
addAll(timed.drop(pillsShown.size))
}
OverflowDots(
events = seating.hidden,
events = hiddenEvents,
day = d,
total = hidden,
dark = dark,
rowHeight = overflowRow,
modifier = Modifier
@@ -2631,12 +2630,11 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier
val onOpenWeek = LocalOpenWeek.current
Box(
modifier = modifier
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.clip(CELL_SHAPE)
.background(MaterialTheme.colorScheme.secondaryContainer)
.then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) {
onOpenWeek(weekStart)
} else Modifier)
.padding(horizontal = CELL_GAP, vertical = 1.dp)
.background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE)
.semantics { contentDescription = "$label $weekNumber" },
contentAlignment = Alignment.Center,
) {
@@ -2788,6 +2786,7 @@ private fun MonthBar(
private fun OverflowDots(
events: List<EventInstance>,
day: LocalDate,
total: Int,
dark: Boolean,
rowHeight: Dp,
modifier: Modifier = Modifier,
@@ -2809,7 +2808,7 @@ private fun OverflowDots(
.background(eventAccent(argb, dark, soften), CircleShape),
)
}
val extra = events.size - dots.size
val extra = total - dots.size
if (extra > 0) {
Text(
text = "+$extra",
@@ -39,55 +39,33 @@ data class MonthWeek(
val countByDay: Map<LocalDate, Int>,
)
/** One event seated in [lane] of a day; [bar] is a spanning bar rather than that day's own pill. */
data class Seat(val lane: Int, val event: EventInstance, val bar: Boolean)
/**
* How one day of a week row is seated with [laneCap] lanes: what sits in each
* lane, top-most first with empty lanes left out, and what had no lane left.
*/
data class DaySeating(val seated: List<Seat>, val hidden: List<EventInstance>)
/**
* The seating of column [col] with [laneCap] lanes — the one every reader of a
* row builds on: the bars and pills the paged grid draws, the split style's dots
* ([laneEvents]), the hit test ([chipAt]) and the overflow ([overflowEvents]).
* The events occupying each lane of [day] — column [col] of this week — in lane
* order, capped at [laneCap] lanes.
*
* Bars keep the lane the row layout gave them, and the day's timed events fill
* whatever slots are left, top-most first, so a bar-free day isn't pushed down by
* a multi-day event that only sits on other days of the week. Overflow is the
* exact complement, bars before pills.
*/
fun MonthWeek.seating(col: Int, laneCap: Int): DaySeating {
val covering = spans.filter { col in it.startCol..it.endCol }
val byLane = arrayOfNulls<Seat>(laneCap.coerceAtLeast(0))
covering.forEach { span ->
if (span.lane < laneCap) byLane[span.lane] = Seat(span.lane, span.event, bar = true)
}
val timed = timedByDay[days[col]].orEmpty()
var pills = 0
for (lane in byLane.indices) {
if (byLane[lane] == null && pills < timed.size) byLane[lane] = Seat(lane, timed[pills++], bar = false)
}
return DaySeating(
seated = byLane.filterNotNull(),
hidden = covering.filter { it.lane >= laneCap }.map { it.event } + timed.drop(pills),
)
}
/**
* The events occupying each lane of column [col], in lane order, capped at
* [laneCap] lanes — the same [seating] the paged grid draws a week with. Reading
* it here means the split style's dots and the paged style's bars describe a day
* in the *same order*, so dot _i_ and lane _i_ are the same event and one can
* morph into the other (#53).
* This is the same seating [spans]/[timedByDay] get when the paged grid draws a
* week: bars keep the lane the row layout gave them, and the day's timed events
* fill whatever slots are left, top-most first. Reading it here means the split
* style's dots and the paged style's bars describe a day in the *same order*, so
* dot _i_ and lane _i_ are the same event and one can morph into the other (#53).
*
* Deriving the dots independently — by distinct colour, as they first were —
* left dot _i_ standing for no particular event, and quietly merged two events
* that shared a calendar into a single dot.
*/
fun MonthWeek.laneEvents(col: Int, laneCap: Int): List<EventInstance> =
seating(col, laneCap).seated.map { it.event }
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
val byLane = arrayOfNulls<EventInstance>(laneCap)
spans.forEach { span ->
if (span.lane < laneCap && col in span.startCol..span.endCol) {
byLane[span.lane] = span.event
}
}
val free = (0 until laneCap).filter { byLane[it] == null }
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
byLane[free[i]] = event
}
return byLane.filterNotNull()
}
/**
* The event drawn in lane [lane] of column [col], or null for an empty slot.
@@ -96,7 +74,14 @@ fun MonthWeek.laneEvents(col: Int, laneCap: Int): List<EventInstance> =
*/
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
if (col !in days.indices || lane !in 0 until laneCap) return null
return seating(col, laneCap).seated.firstOrNull { it.lane == lane }?.event
spanAt(col, lane)?.let { return it.event }
val occupied = spans
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
.map { it.lane }
.toSet()
val free = (0 until laneCap).filter { it !in occupied }
val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
}
/**
@@ -113,11 +98,19 @@ fun MonthWeek.spanAt(col: Int, lane: Int): MonthSpan? =
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
/**
* The events in column [col] that [laneEvents] had no lane left for — its exact
* The events on [day] that [laneEvents] had no lane left for — its exact
* complement, in the same bars-then-pills order. Returned as events rather than
* a count because dimming is a per-event question (#79).
*/
fun MonthWeek.overflowEvents(col: Int, laneCap: Int): List<EventInstance> = seating(col, laneCap).hidden
fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol }
return buildList {
spans.forEach { span ->
if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event)
}
addAll(timedByDay[day].orEmpty().drop(laneCap - seatedLanes))
}
}
/**
* State for the continuous style (#38): a vertical stream of *self-contained*
@@ -1,6 +1,9 @@
package de.jeanlucmakiola.calendula.ui.search
import android.Manifest
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
@@ -56,6 +59,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@@ -76,11 +80,11 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.calendula.domain.SearchMonth
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField
@@ -129,6 +133,7 @@ fun SearchScreen(
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
val focusRequester = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
val context = LocalContext.current
val inSelection = selection.isNotEmpty()
var showDeleteDialog by remember { mutableStateOf(false) }
@@ -150,8 +155,24 @@ fun SearchScreen(
keyboard?.show()
}
// A v1.0 install holds only READ_CALENDAR; granting continues into the held delete.
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
// Same in-place WRITE_CALENDAR upgrade the detail screen does: a v1.0 install
// holds only READ_CALENDAR, and granting continues into the held delete.
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
val writePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) pendingWrite?.invoke()
pendingWrite = null
}
val requireWrite: (() -> Unit) -> Unit = { action ->
val granted = context.hasPermission(Manifest.permission.WRITE_CALENDAR)
if (granted) {
action()
} else {
pendingWrite = action
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
}
}
// Back leaves selection mode before it leaves the screen, and without the
// predictive-back scale-out — that would read as search closing and snapping
@@ -172,7 +193,7 @@ fun SearchScreen(
onBack = onBack,
onCloseSelection = viewModel::clearSelection,
onSelectAll = viewModel::selectAll,
onDelete = onDeleteClick,
onDelete = { requireWrite { showDeleteDialog = true } },
)
},
) { padding ->
@@ -7,7 +7,6 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventSearch
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.ui.common.deleteEvent
import de.jeanlucmakiola.calendula.domain.SearchResults
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
@@ -197,11 +196,15 @@ class SearchViewModel @Inject constructor(
}
private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) {
repository.deleteEvent(
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
scope = if (event.isRecurring) scope else RecurringWriteScope.AllEvents,
)
val begin = event.start.toEpochMilliseconds()
when {
!event.isRecurring -> repository.deleteEvent(event.eventId)
scope == RecurringWriteScope.ThisEvent ->
repository.deleteOccurrence(event.eventId, begin)
scope == RecurringWriteScope.ThisAndFollowing ->
repository.deleteEventFromOccurrence(event.eventId, begin)
else -> repository.deleteEvent(event.eventId)
}
}
private suspend fun readOnlyCalendarIds(): Set<Long> =
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Autorenew
import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Cake
@@ -24,7 +23,6 @@ import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.LocalCafe
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PrivacyTip
@@ -58,7 +56,6 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.licences.LicencesScreen
import de.jeanlucmakiola.floret.components.AboutCard
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
@@ -155,7 +152,7 @@ fun SettingsScreen(
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit,
) {
WidgetsScreen(onBack = { section = null }, visible = section == SettingsSection.Widgets)
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets)
}
AnimatedVisibility(
visible = section == SettingsSection.Licences,
@@ -363,46 +360,17 @@ private fun AboutCard() {
)
}
/**
* "Support development", as the closing row of the About card's group. Opens a
* picker of the donation platforms.
*/
/** "Support development", as the closing row of the About card's group. */
@Composable
private fun SupportRow() {
val context = LocalContext.current
var showPicker by rememberSaveable { mutableStateOf(false) }
val url = stringResource(R.string.about_support_url)
GroupedRow(
title = stringResource(R.string.settings_about_support),
position = Position.Bottom,
leading = { CategoryIcon(Icons.Default.Favorite, ChipAccent.Primary) },
onClick = { showPicker = true },
onClick = { openUrl(context, url) },
)
if (showPicker) {
val kofiUrl = stringResource(R.string.about_support_kofi_url)
val liberapayUrl = stringResource(R.string.about_support_liberapay_url)
FullScreenPicker(
title = stringResource(R.string.settings_about_support),
onDismiss = { showPicker = false },
predictiveBack = true,
) {
GroupedRow(
title = stringResource(R.string.settings_support_kofi),
summary = stringResource(R.string.settings_support_kofi_hint),
position = Position.Top,
leading = { CategoryIcon(Icons.Default.LocalCafe, ChipAccent.Primary) },
onClick = { openUrl(context, kofiUrl) },
)
GroupedRow(
title = stringResource(R.string.settings_support_liberapay),
summary = stringResource(R.string.settings_support_liberapay_hint),
position = Position.Bottom,
leading = { CategoryIcon(Icons.Default.Autorenew, ChipAccent.Primary) },
onClick = { openUrl(context, liberapayUrl) },
)
}
}
}
/**
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.widget.WidgetSize
/**
* Settings screen state (M4). Persisted preferences are instant to read, so
@@ -50,10 +51,14 @@ data class SettingsUiState(
val tapOpensEditor: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** Whether the agenda screen always anchors today at the top (#35); widgets have their own (#292). */
/** How far ahead the agenda widget shows events (v2.11). */
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
/** Whether the agenda (screen + widget) always anchors today at the top (#35). */
val agendaShowToday: Boolean = true,
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
val agendaShowRangeBar: Boolean = true,
/** The size step the agenda widget draws its text at (#51). */
val widgetSize: WidgetSize = WidgetSize.SMALL,
/** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week,
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
@@ -3,6 +3,9 @@ package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import android.net.Uri
import android.os.Build
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.appwidget.updateAll
import androidx.datastore.preferences.core.Preferences
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -39,12 +42,20 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SIZE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -56,6 +67,8 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
@@ -105,8 +118,10 @@ class SettingsViewModel @Inject constructor(
dragToReschedule = this[SettingsKeys.dragToReschedule],
tapOpensEditor = this[SettingsKeys.tapOpensEditor],
agendaScreenRange = this[SettingsKeys.agendaScreenRange],
agendaWidgetRange = this[SettingsKeys.agendaWidgetRange],
agendaShowToday = this[SettingsKeys.agendaShowToday],
agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar],
widgetSize = this[SettingsKeys.widgetSize],
defaultView = this[SettingsKeys.defaultView],
quickSwitchConfig = this[SettingsKeys.quickSwitchConfig],
monthViewStyle = this[SettingsKeys.monthViewStyle],
@@ -166,6 +181,10 @@ class SettingsViewModel @Inject constructor(
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
// Serialises widget redraws: concurrent updateAll calls coalesce around a
// stale read and can leave the widget on an intermediate value.
private val widgetRefreshMutex = Mutex()
/** Contact special-dates sub-page (issue #15). */
val specialDatesState: StateFlow<SpecialDatesUiState> = prefs.data.map { stored ->
val templates = stored.specialDatesTitleTemplates
@@ -248,7 +267,16 @@ class SettingsViewModel @Inject constructor(
fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) }
fun setSoftenColors(enabled: Boolean) = save { setSoftenCalendarColors(enabled) }
fun setSoftenColors(enabled: Boolean) {
viewModelScope.launch {
prefs.setSoftenCalendarColors(enabled)
// Both widgets paint event colours through the same softener.
widgetRefreshMutex.withLock {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
}
}
}
fun setFont(role: FontRole, token: String) = save {
when (role) {
@@ -276,13 +304,68 @@ class SettingsViewModel @Inject constructor(
}
}
fun setWeekStart(pref: WeekStartPref) = save { setWeekStart(pref) }
fun setWeekStart(pref: WeekStartPref) {
viewModelScope.launch {
prefs.setWeekStart(pref)
// Both widgets depend on the week start (month weekday header; the
// agenda widget's "this week" range).
widgetRefreshMutex.withLock {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
}
}
}
fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) }
fun setAgendaWidgetRange(range: AgendaRange) {
viewModelScope.launch {
prefs.setAgendaWidgetRange(range)
widgetRefreshMutex.withLock {
// Push into each instance's Glance state and recompose:
// updateAll alone won't re-run provideGlance's data preamble.
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) }
fun setAgendaShowToday(enabled: Boolean) = save { setAgendaShowToday(enabled) }
/**
* Set the size the agenda widget draws its text at (#51), via the same
* Glance-state path as [setAgendaWidgetRange]. Agenda-only: the month widget
* sizes itself from the space it is given (#103).
*/
fun setWidgetSize(size: WidgetSize) {
viewModelScope.launch {
prefs.setWidgetSize(size)
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_SIZE_KEY] = size.name }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setAgendaShowToday(enabled: Boolean) {
viewModelScope.launch {
prefs.setAgendaShowToday(enabled)
// Same Glance-state path as setPastEventDisplay.
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) }
@@ -313,7 +396,19 @@ class SettingsViewModel @Inject constructor(
}
}
fun setPastEventDisplay(mode: PastEventDisplay) = save { setPastEventDisplay(mode) }
fun setPastEventDisplay(mode: PastEventDisplay) {
viewModelScope.launch {
prefs.setPastEventDisplay(mode)
// Same Glance-state path as setAgendaWidgetRange.
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(enabled) }
@@ -10,9 +10,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -20,46 +18,56 @@ 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.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.widget.config.PlacedWidget
import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
import de.jeanlucmakiola.calendula.widget.WidgetKind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import de.jeanlucmakiola.calendula.widget.config.backgroundLabel
import de.jeanlucmakiola.calendula.widget.config.placedWidgets
import de.jeanlucmakiola.calendula.widget.config.themeLabel
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlin.math.roundToInt
/**
* Widgets & tiles (#69): the placed home-screen widgets, each opening its own
* config screen (#292), and the Quick Settings tile shortcut.
* Widgets & tiles (#69): the home-screen widgets' settings and the Quick
* Settings tile shortcut. The agenda widget keeps its own range and text size,
* separate from the Agenda screen's range in [ViewsScreen].
*/
@Composable
internal fun WidgetsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
visible: Boolean = true,
) {
var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showWidgetSize by remember { mutableStateOf(false) }
CollapsingScaffold(
title = stringResource(R.string.settings_section_widgets),
onBack = onBack,
predictiveBack = true,
visible = visible,
) {
GroupedHint(
stringResource(
// The launcher's "Edit widget" action only exists from Android 12.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.string.settings_widgets_hint
else R.string.settings_widgets_hint_legacy,
),
GroupedHint(stringResource(R.string.settings_widgets_hint))
Spacer(Modifier.height(8.dp))
GroupedRow(
title = stringResource(R.string.settings_agenda_widget_range),
summary = agendaRangeLabel(state.agendaWidgetRange),
position = Position.Top,
onClick = { showAgendaWidgetRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_widget_size),
summary = widgetSizeLabel(state.widgetSize),
position = Position.Bottom,
onClick = { showWidgetSize = true },
)
GroupedSectionHeader(stringResource(R.string.settings_widgets_placed))
PlacedWidgetRows()
// The add-tile prompt is API 33+; below that the tile is still
// addable from the QS editor, so the row just doesn't appear.
@@ -68,44 +76,30 @@ internal fun WidgetsScreen(
QuickSettingsTileRow()
}
}
}
@Composable
private fun PlacedWidgetRows() {
val context = LocalContext.current
// Read on every resume, the first included, so a widget added, removed or
// edited meanwhile shows up.
var refresh by remember { mutableIntStateOf(0) }
LifecycleResumeEffect(Unit) {
refresh++
onPauseOrDispose {}
}
val widgets by produceState<List<PlacedWidget>?>(initialValue = null, refresh) {
if (refresh > 0) value = withContext(Dispatchers.IO) { context.placedWidgets() }
}
val placed = widgets ?: return
if (placed.isEmpty()) {
GroupedRow(
title = stringResource(R.string.settings_widgets_none),
position = Position.Alone,
dimmed = true,
if (showAgendaWidgetRange) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_widget_range),
description = stringResource(R.string.settings_agenda_widget_range_hint),
selected = state.agendaWidgetRange,
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
onSelect = viewModel::setAgendaWidgetRange,
onDismiss = { showAgendaWidgetRange = false },
)
return
}
val kindCounts = placed.groupingBy { it.kind }.eachCount()
val kindSeen = mutableMapOf<WidgetKind, Int>()
placed.forEachIndexed { index, widget ->
val number = kindSeen.merge(widget.kind, 1, Int::plus)!!
val label = stringResource(widget.kind.label)
GroupedRow(
title = if (kindCounts.getValue(widget.kind) > 1) stringResource(R.string.widget_numbered, label, number) else label,
summary = stringResource(
R.string.widget_appearance_summary,
backgroundLabel(widget.appearance.background),
themeLabel(widget.appearance.theme),
),
position = positionOf(index, placed.size),
onClick = { context.startActivity(WidgetConfigActivity.intent(context, widget.appWidgetId)) },
if (showWidgetSize) {
OptionPicker(
title = stringResource(R.string.settings_widget_size),
header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
predictiveBack = true,
options = WidgetSize.entries,
selected = state.widgetSize,
label = { widgetSizeLabel(it) },
// No live preview: the widget is Glance/RemoteViews, so a Compose
// mock-up would be a second implementation free to drift.
summary = { widgetSizeSummary(it) },
onSelect = viewModel::setWidgetSize,
onDismiss = { showWidgetSize = false },
)
}
}
@@ -137,3 +131,24 @@ private fun requestAddQsTile(context: Context) {
context.mainExecutor,
) { /* result code unused — the system surfaces its own feedback */ }
}
/**
* What a size step does, as a percentage of the smallest one's event text.
* Derived from the widget's own [metricsFor] table rather than hardcoded.
*/
@Composable
private fun widgetSizeSummary(size: WidgetSize): String {
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
return stringResource(R.string.settings_widget_size_summary, percent)
}
@Composable
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
when (size) {
WidgetSize.SMALL -> R.string.settings_widget_size_small
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
WidgetSize.LARGE -> R.string.settings_widget_size_large
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
},
)
@@ -7,8 +7,8 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.prefs.ThemeMode
/**
* A synchronous copy of the theme and dynamic-colour choices, for the window
* backdrop and the first Compose frame. The real setting lives in DataStore, which only
* A synchronous copy of the theme choice, for the window backdrop drawn before
* the first Compose frame. The real setting lives in DataStore, which only
* answers asynchronously — too late for onCreate, so a Light, Dark or Black
* override flashed the night-qualifier backdrop on a cold start or a
* recreation (#181). MainActivity mirrors every stored value here and reads it
@@ -18,7 +18,6 @@ internal object WindowThemeCache {
private const val FILE = "window_theme"
private const val KEY_THEME_MODE = "theme_mode"
private const val KEY_DYNAMIC_COLOR = "dynamic_color"
/** The last mirrored theme choice, or null before the first mirror. */
fun read(context: Context): ThemeMode? =
@@ -26,23 +25,10 @@ internal object WindowThemeCache {
.getString(KEY_THEME_MODE, null)
?.let { name -> ThemeMode.entries.firstOrNull { it.name == name } }
/** The last mirrored dynamic-colour choice, or null before the first mirror. */
fun readDynamicColor(context: Context): Boolean? {
fun write(context: Context, mode: ThemeMode) {
val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
return if (prefs.contains(KEY_DYNAMIC_COLOR)) prefs.getBoolean(KEY_DYNAMIC_COLOR, true) else null
}
fun write(context: Context, mode: ThemeMode, dynamicColor: Boolean) {
val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
if (prefs.getString(KEY_THEME_MODE, null) == mode.name &&
prefs.contains(KEY_DYNAMIC_COLOR) && prefs.getBoolean(KEY_DYNAMIC_COLOR, true) == dynamicColor
) {
return
}
prefs.edit {
putString(KEY_THEME_MODE, mode.name)
putBoolean(KEY_DYNAMIC_COLOR, dynamicColor)
}
if (prefs.getString(KEY_THEME_MODE, null) == mode.name) return
prefs.edit { putString(KEY_THEME_MODE, mode.name) }
}
}
@@ -7,63 +7,100 @@ import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
/** Pages the week pager spans — a little under four centuries. */
/** Pages the week pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
internal const val WEEK_PAGE_COUNT: Int = 20_000
private const val EPOCH_PAGE = WEEK_PAGE_COUNT / 2
/** The week at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
private val PAGE_EPOCH = LocalDate(2000, 1, 3)
/** First day of the week shown on pager [page], with weeks starting on [firstDay]. */
internal fun weekStartForPage(page: Int, firstDay: DayOfWeek): LocalDate =
PAGE_EPOCH.startOfWeek(firstDay).plus((page - EPOCH_PAGE) * 7, DateTimeUnit.DAY)
/** The pager page whose week contains [date], with weeks starting on [firstDay]. */
internal fun weekPageFor(date: LocalDate, firstDay: DayOfWeek): Int {
val days = PAGE_EPOCH.startOfWeek(firstDay).daysUntil(date.startOfWeek(firstDay))
return (EPOCH_PAGE + days / 7).coerceIn(0, WEEK_PAGE_COUNT - 1)
}
/** How a timeline pager cuts the calendar into pages of [dayCount] days. */
sealed interface PageGrid {
val dayCount: Int
val pageCount: Int
/** First day shown on pager [page]. */
fun startForPage(page: Int): LocalDate
/** The pager page showing [date]. */
fun pageFor(date: LocalDate): Int
}
/** Whole weeks, starting on [firstDay]. */
data class WeekGrid(val firstDay: DayOfWeek) : PageGrid {
override val dayCount: Int get() = 7
override val pageCount: Int get() = WEEK_PAGE_COUNT
override fun startForPage(page: Int): LocalDate = weekStartForPage(page, firstDay)
override fun pageFor(date: LocalDate): Int = weekPageFor(date, firstDay)
}
/** Pages the multi-day pager spans — over three centuries at the widest page. */
internal const val DAYS_PAGE_COUNT: Int = 40_000
/** Pages the day pager spans — a little under four centuries. */
internal const val DAY_PAGE_COUNT: Int = 140_000
private const val DAYS_EPOCH_PAGE = DAYS_PAGE_COUNT / 2
/** A fixed day every grid's pages are counted from, at its middle page. */
/** A fixed day the multi-day pages are counted from. */
private val DAYS_EPOCH = LocalDate(2000, 1, 1)
/** The week the week pager's middle page shows; any date works, it only has to stay fixed. */
private val WEEK_EPOCH = LocalDate(2000, 1, 3)
/**
* How a timeline pager cuts the calendar: runs of [dayCount] days, offset
* [phase] days from a fixed epoch, over [pageCount] pages centred on it. The
* multi-day view's phase (#193) stays put while paging and only changes when a
* jump moves another date to the first column.
* Runs of [dayCount] days (#193), offset [phase] days from a fixed epoch so any
* date can start a page. The phase stays put while paging and only changes when
* a jump moves another date to the first column.
*/
data class DaysGrid(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS_PAGE_COUNT) {
data class DaysGrid(override val dayCount: Int, val phase: Int) : PageGrid {
override val pageCount: Int get() = DAYS_PAGE_COUNT
private val epochPage: Int get() = pageCount / 2
override fun startForPage(page: Int): LocalDate =
DAYS_EPOCH.plus(phase + (page - DAYS_EPOCH_PAGE) * dayCount, DateTimeUnit.DAY)
/** First day shown on pager [page]. */
fun startForPage(page: Int): LocalDate =
DAYS_EPOCH.plus(phase + (page - epochPage) * dayCount, DateTimeUnit.DAY)
/** The pager page showing [date]. */
fun pageFor(date: LocalDate): Int =
(epochPage + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount))
.coerceIn(0, pageCount - 1)
override fun pageFor(date: LocalDate): Int =
(DAYS_EPOCH_PAGE + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount))
.coerceIn(0, DAYS_PAGE_COUNT - 1)
companion object {
/** Single days: every date starts a page of its own. */
val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0, pageCount = DAY_PAGE_COUNT)
/** Whole weeks, starting on [firstDay]. */
fun weeks(firstDay: DayOfWeek): DaysGrid = DaysGrid(
dayCount = 7,
phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.startOfWeek(firstDay)),
pageCount = WEEK_PAGE_COUNT,
)
/** The grid of [dayCount]-day pages on which [date] is a page's first day. */
fun startingAt(date: LocalDate, dayCount: Int): DaysGrid =
DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount))
}
}
/** Pages the day pager spans — a little under four centuries, centred on [DAY_EPOCH]. */
internal const val DAY_PAGE_COUNT: Int = 140_000
private const val DAY_EPOCH_PAGE = DAY_PAGE_COUNT / 2
/** The day at [DAY_EPOCH_PAGE]; any date works, it only has to stay fixed. */
private val DAY_EPOCH = LocalDate(2000, 1, 1)
/** Single days: every date starts a page of its own. */
data object DayGrid : PageGrid {
override val dayCount: Int get() = 1
override val pageCount: Int get() = DAY_PAGE_COUNT
override fun startForPage(page: Int): LocalDate =
DAY_EPOCH.plus(page - DAY_EPOCH_PAGE, DateTimeUnit.DAY)
override fun pageFor(date: LocalDate): Int =
(DAY_EPOCH_PAGE + DAY_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
}
/**
* Where the focused date goes when the pager settles on [page] (#184): kept if
* the page still shows it, otherwise moved by the pages travelled, so it keeps
* its place in the page — the same weekday a week on.
*/
internal fun DaysGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate {
internal fun PageGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate {
val start = startForPage(page)
val end = start.plus(dayCount - 1, DateTimeUnit.DAY)
if (focus in start..end) return focus
@@ -70,7 +70,7 @@ import de.jeanlucmakiola.calendula.ui.common.draftEvent
import de.jeanlucmakiola.calendula.ui.common.draftStartMin
import de.jeanlucmakiola.calendula.ui.common.isDraft
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdges
import de.jeanlucmakiola.calendula.ui.common.wallClockInstant
import de.jeanlucmakiola.calendula.ui.common.PickUp
@@ -464,12 +464,11 @@ private fun PagedTimelineScreen(
// Straight to the pager: a tap mid-fling must still land, and the anchor
// hasn't caught up with the fling yet to tell the difference. A date that
// would open mid-page on this grid, in a view that frames its pages from the
// date (multi-day), re-frames the pages around it instead.
// would open mid-page on this grid re-frames the pages around it instead.
val jumpToDate: (LocalDate) -> Unit = { target ->
focus?.date = target
val page = grid.pageFor(target)
if (grid.startForPage(page) != target && viewModel.rule.value(target) != grid) {
if (grid is DaysGrid && grid.startForPage(page) != target) {
viewModel.jumpTo(target)
} else {
scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) }
@@ -532,7 +531,7 @@ private fun WeekContent(
state: WeekUiState,
today: LocalDate,
pagerState: PagerState,
grid: DaysGrid,
grid: PageGrid,
kind: TimelineKind,
morph: TimelineMorph?,
handoffStrip: Dp?,
@@ -638,15 +637,10 @@ private fun WeekContent(
delay(SELECTION_ORPHAN_GRACE_MILLIS)
dragController.deselect()
}
var pendingDraft by remember { mutableStateOf<EventInstance?>(null) }
val createPendingDraft = rememberWriteAction {
pendingDraft?.let(createDraft)
pendingDraft = null
}
val requireWrite = rememberWriteGate()
val openEvent: (EventInstance) -> Unit = { event ->
if (event.isDraft) {
pendingDraft = event
createPendingDraft()
requireWrite { createDraft(event) }
} else {
dragController.deselect()
onEventClick(event)
@@ -702,7 +696,7 @@ private fun WeekContent(
@Composable
private fun WeekPager(
pagerState: PagerState,
grid: DaysGrid,
grid: PageGrid,
kind: TimelineKind,
span: (DaySpan) -> StateFlow<WeekUiState>,
today: LocalDate,
@@ -62,10 +62,10 @@ private const val SPAN_CACHE_DAYS = 28
data class DaySpan(val start: LocalDate, val count: Int)
/** How the pager cuts the calendar, and the page the anchor sits on. */
data class PagerPosition(val grid: DaysGrid, val page: Int)
data class PagerPosition(val grid: PageGrid, val page: Int)
/** Where [anchor] sits under [rule]. */
fun positionOf(anchor: LocalDate, rule: (LocalDate) -> DaysGrid): PagerPosition =
fun positionOf(anchor: LocalDate, rule: (LocalDate) -> PageGrid): PagerPosition =
rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) }
/**
@@ -84,8 +84,8 @@ abstract class PagedTimelineViewModel(
todaySource: TodaySource,
zoneSource: ZoneSource,
private val io: CoroutineDispatcher,
gridRule: Flow<(LocalDate) -> DaysGrid>,
initialRule: (LocalDate) -> DaysGrid,
gridRule: Flow<(LocalDate) -> PageGrid>,
initialRule: (LocalDate) -> PageGrid,
) : ViewModel() {
/**
@@ -121,7 +121,7 @@ abstract class PagedTimelineViewModel(
* The grid for a given anchor, held eagerly so a screen can place its pager
* on its very first frame rather than a frame after the anchor moved.
*/
val rule: StateFlow<(LocalDate) -> DaysGrid> =
val rule: StateFlow<(LocalDate) -> PageGrid> =
gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule)
/** Today, kept current across midnight so a page left open doesn't mark yesterday (#373). */
@@ -233,11 +233,11 @@ class WeekViewModel @Inject constructor(
zoneSource = zoneSource,
io = io,
gridRule = settingsPrefs.weekStart.map { pref ->
val grid = DaysGrid.weeks(pref.resolveFirstDay(Locale.getDefault()))
val rule: (LocalDate) -> DaysGrid = { grid }
val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault()))
val rule: (LocalDate) -> PageGrid = { grid }
rule
},
initialRule = { DaysGrid.weeks(DayOfWeek.MONDAY) },
initialRule = { WeekGrid(DayOfWeek.MONDAY) },
)
/**
@@ -278,8 +278,8 @@ class DayViewModel @Inject constructor(
todaySource = todaySource,
zoneSource = zoneSource,
io = io,
gridRule = flowOf { _: LocalDate -> DaysGrid.days },
initialRule = { DaysGrid.days },
gridRule = flowOf { _: LocalDate -> DayGrid },
initialRule = { DayGrid },
)
/**
@@ -1,44 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
import de.jeanlucmakiola.floret.glance.restoreState
/**
* What every widget receiver shares: the midnight rollover follows whether any
* widget is placed (#228), a restore keeps each widget's settings (#292), and
* the system's `updatePeriodMillis` wake-up reloads the data and re-arms the
* alarm — anything that drops a pending alarm without a broadcast (force-stop,
* battery restriction, an OEM freeze) is repaired there rather than on the
* next app open.
*/
abstract class CalendulaWidgetReceiver : GlanceAppWidgetReceiver() {
override fun onEnabled(context: Context) {
super.onEnabled(context)
syncWidgetRollover(context)
}
override fun onDisabled(context: Context) {
super.onDisabled(context)
syncWidgetRollover(context)
}
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
restoreState(context, oldWidgetIds, newWidgetIds)
}
@SuppressLint("MissingSuperCall")
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
// In place of super, which only recomposes and would take the goAsync handle.
reloadOnUpdate(context, appWidgetIds)
syncWidgetRollover(context)
}
}
@@ -1,105 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import android.content.Context
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.glance.GlanceId
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.toEnum
/** A widget's own light/dark choice (#292); [FOLLOW_APP] tracks the app's theme setting. */
enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK }
/** How a widget looks, set per placed widget in its config screen (#292). */
data class WidgetAppearance(
val background: WidgetBackground = WidgetBackground.TINTED,
val theme: WidgetTheme = WidgetTheme.FOLLOW_APP,
)
/** What the agenda widget lists, per placed widget. */
data class AgendaWidgetOptions(
val range: AgendaRange,
val textSize: WidgetSize,
val showToday: Boolean,
val pastDisplay: PastEventDisplay,
)
internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) {
WidgetTheme.LIGHT -> WidgetThemeMode.LIGHT
WidgetTheme.DARK -> WidgetThemeMode.DARK
WidgetTheme.FOLLOW_APP -> when (app) {
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
}
}
private val BackgroundKey = stringPreferencesKey("cfg_background")
private val ThemeKey = stringPreferencesKey("cfg_theme")
private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range")
private val TextSizeKey = stringPreferencesKey("cfg_text_size")
private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
private val PastDisplayKey = stringPreferencesKey("cfg_past_display")
private val HiddenCalendarsKey = stringSetPreferencesKey("cfg_hidden_calendars")
internal val Preferences.widgetAppearance: WidgetAppearance
get() = WidgetAppearance(
background = this[BackgroundKey].toEnum(WidgetBackground.TINTED),
theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP),
)
/**
* This widget's agenda options. A widget placed before per-widget options
* existed has none stored, and keeps following the app-wide values in
* [defaults] until it is configured.
*/
internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = AgendaWidgetOptions(
range = parseAgendaRange(this[AgendaRangeKey], defaults.range),
textSize = this[TextSizeKey].toEnum(defaults.textSize),
showToday = this[ShowTodayKey] ?: defaults.showToday,
pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay),
)
/** Whether this widget has agenda options of its own, rather than following the app's. */
internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey] != null
internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) {
this[BackgroundKey] = appearance.background.name
this[ThemeKey] = appearance.theme.name
}
internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
this[AgendaRangeKey] = options.range.storageValue()
this[TextSizeKey] = options.textSize.name
this[ShowTodayKey] = options.showToday
this[PastDisplayKey] = options.pastDisplay.name
}
/**
* Calendars this widget leaves out, on top of the ones hidden app-wide. Stored
* as the excluded set, so a calendar added later shows up by default.
*/
internal val Preferences.hiddenCalendars: Set<Long>
get() = this[HiddenCalendarsKey]?.mapNotNull { it.toLongOrNull() }?.toSet().orEmpty()
internal fun MutablePreferences.putHiddenCalendars(ids: Set<Long>) {
this[HiddenCalendarsKey] = ids.map(Long::toString).toSet()
}
internal fun List<EventInstance>.withoutCalendars(hidden: Set<Long>): List<EventInstance> =
if (hidden.isEmpty()) this else filterNot { it.calendarId in hidden }
internal suspend fun Context.widgetState(id: GlanceId): Preferences =
getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
@@ -4,12 +4,7 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import androidx.glance.GlanceId
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.EventInstance
@@ -18,12 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.get
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -35,7 +25,6 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Clock
import kotlin.time.Instant
@@ -45,63 +34,10 @@ import kotlin.time.Instant
* the whole window, and on slow devices a wide one held the widget on "loading"
* (#363).
*/
internal const val MONTH_WIDGET_PAD_MONTHS = 3
/**
* Every app setting the widgets draw with: what the loads read, and what
* [WidgetRefreshCoordinator] watches. The agenda fields are only the defaults
* for a widget that has no options of its own yet.
*/
internal data class WidgetSettings(
val soften: Boolean,
val weekStart: WeekStartPref,
val timeFormat: TimeFormatPref,
val themeMode: ThemeMode,
val pastDisplay: PastEventDisplay,
val agendaRange: AgendaRange,
val widgetSize: WidgetSize,
val showToday: Boolean,
) {
/** What every widget draws with; the agenda defaults aside. */
val shared: SharedWidgetSettings get() = SharedWidgetSettings(soften, weekStart, timeFormat, themeMode)
val agendaDefaults: AgendaWidgetOptions
get() = AgendaWidgetOptions(agendaRange, widgetSize, showToday, pastDisplay)
}
/** The [WidgetSettings] a configured widget still takes from the app. */
internal data class SharedWidgetSettings(
val soften: Boolean,
val weekStart: WeekStartPref,
val timeFormat: TimeFormatPref,
val themeMode: ThemeMode,
)
/** A widget's loaded [content], with the app theme a [WidgetTheme.FOLLOW_APP] widget resolves against. */
internal data class Themed<out T>(val content: T, val appTheme: ThemeMode)
internal val SettingsPrefs.widgetSettings: Flow<WidgetSettings>
get() = data.map {
WidgetSettings(
soften = it[SettingsKeys.softenCalendarColors],
weekStart = it[SettingsKeys.weekStart],
timeFormat = it[SettingsKeys.timeFormat],
themeMode = it[SettingsKeys.themeMode],
pastDisplay = it[SettingsKeys.pastEventDisplay],
agendaRange = it[SettingsKeys.agendaWidgetRange],
widgetSize = it[SettingsKeys.widgetSize],
showToday = it[SettingsKeys.agendaShowToday],
)
}.distinctUntilChanged()
private const val MONTH_WIDGET_PAD_MONTHS = 3
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
/**
* The locale widget text is written in. Glance has no LocalConfiguration, and a
* widget is redrawn on a locale change anyway (LOCALE_CHANGED), so a plain read is enough.
*/
internal fun widgetLocale(): Locale = Locale.getDefault()
internal fun today(zone: TimeZone): LocalDate =
Clock.System.now().toLocalDateTime(zone).date
@@ -115,74 +51,112 @@ sealed interface AgendaWidgetData {
data object NeedsPermission : AgendaWidgetData
data class Ready(
val today: LocalDate,
/** Days with events across the configured range, unfiltered; hiding finished ones happens in the composition. */
/**
* Days with events across the **widest** selectable window. The displayed
* range is sliced from this in the composition (read reactively from
* Glance state), so a range change is pure recomposition rather than a
* flaky widget session restart — the same approach the month widget uses.
* Kept unfiltered: hiding/dimming finished events also happens reactively
* in the composition, against [now], for the same reliable-update reason.
*/
val days: List<AgendaDay>,
/** Resolved clock convention for event time labels (the time-format pref). */
val is24Hour: Boolean,
/** Whether event colours are softened to pastels, or shown raw (#36). */
val soften: Boolean,
val pastDisplay: PastEventDisplay,
/** "Always show today" (#35): anchor today at the top even with no events left. */
val showToday: Boolean,
val textSize: WidgetSize,
/** First day of the week, for the calendar-aligned "this week" range. */
val weekStart: DayOfWeek,
/** Saved range pref — the fallback when an instance has no Glance state yet. */
val savedRange: AgendaRange,
/** Saved past-event display pref — the fallback before Glance state is set. */
val savedPastDisplay: PastEventDisplay,
/**
* Saved "always show today" pref (#35) — anchors today at the top with a
* placeholder even when it has no events left. The fallback read reactively
* from Glance state before an instance has its own state set.
*/
val savedShowToday: Boolean,
/** Saved widget size step (#103) — the fallback before Glance state is set. */
val savedWidgetSize: WidgetSize,
/** Last day [days] covers; a wider range picked later needs a reload. */
val loadedThrough: LocalDate,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant,
) : AgendaWidgetData
}
/**
* Source data for the month widget: a window of instances plus the week-start
* preference and today. The widget computes each displayed month's grid from
* this in-memory list (via `layoutMonthWeeks`) as the user pages, so paging
* inside the loaded window is pure recomposition.
* Source data for the month widget: a wide window of instances plus the
* week-start preference and today. The widget computes each displayed month's
* grid from this in-memory list (via `layoutMonthWeeks`) as the user pages,
* so month navigation is pure recomposition — no reload, no flaky widget
* session restart.
*/
sealed interface MonthWidgetSource {
data object NeedsPermission : MonthWidgetSource
data class Ready(
val today: LocalDate,
val weekStart: DayOfWeek,
val soften: Boolean,
val instances: List<EventInstance>,
/** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */
val months: IntRange,
) : MonthWidgetSource
}
/** [loadAgendaWidgetData] with agenda widget [id]'s own options. */
internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed<AgendaWidgetData> {
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
val state = widgetState(id)
val options = state.agendaWidgetOptions(settings.agendaDefaults)
return Themed(loadAgendaWidgetData(settings, options, state.hiddenCalendars), settings.themeMode)
/**
* Process-lived cache of the month window. Month navigation can re-run
* `provideGlance`, and re-querying on every arrow tap is what made paging feel
* sluggish — so we load once and reuse the same snapshot for every nearby month. Invalidated by
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
* receiver), and automatically when the day rolls over (the `today` guard).
*/
internal object MonthWidgetCache {
@Volatile
var data: MonthWidgetSource.Ready? = null
}
internal fun invalidateMonthWidgetCache() {
MonthWidgetCache.data = null
}
/**
* One-shot read of the upcoming agenda for the widget. Reuses the app's
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
* [first]-emitted snapshot already has hidden calendars filtered out.
*
* Loads [range] (the widget's own, or the saved pref when null) rather than the
* widest selectable window (#363).
*/
internal suspend fun Context.loadAgendaWidgetData(
settings: WidgetSettings,
options: AgendaWidgetOptions,
hiddenCalendars: Set<Long>,
): AgendaWidgetData {
internal suspend fun Context.loadAgendaWidgetData(range: AgendaRange? = null): AgendaWidgetData {
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
val zone = systemZone()
val anchor = today(zone)
val ep = widgetEntryPoint()
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
val dayCount = options.range.dayCount(anchor, weekStart)
val prefs = ep.settingsPrefs()
val savedRange = prefs.agendaWidgetRange.first()
val savedPastDisplay = prefs.pastEventDisplay.first()
val showToday = prefs.agendaShowToday.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
// The displayed range is still sliced in the composition from Glance state;
// a wider one picked later reloads there (see loadedThrough).
val dayCount = (range ?: savedRange).dayCount(anchor, weekStart)
val window = agendaRange(anchor, dayCount - 1, zone)
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
val instances = ep.calendarRepository().instances(window).first().withoutCalendars(hiddenCalendars)
val instances = ep.calendarRepository().instances(window).first()
val is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
val soften = prefs.softenCalendarColors.first()
return AgendaWidgetData.Ready(
today = anchor,
days = groupAgendaDays(anchor, windowEnd, instances, zone),
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
soften = settings.soften,
pastDisplay = options.pastDisplay,
showToday = options.showToday,
textSize = options.textSize,
is24Hour = is24Hour,
soften = soften,
weekStart = weekStart,
savedRange = savedRange,
savedPastDisplay = savedPastDisplay,
savedShowToday = showToday,
savedWidgetSize = prefs.widgetSize.first(),
loadedThrough = windowEnd,
now = Clock.System.now(),
)
}
@@ -191,55 +165,17 @@ internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.or
private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1)
/**
* The month the widget loads its window around while [index] is shown. Snapped
* to steps of three, so paging only reloads every third month, and the window
* always reaches at least two months past [index] either way.
*/
internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 + 1
/**
* The last window each month widget loaded, under its refresh stamp, so a new
* session for the same widget (a tap after the last one timed out) draws
* without a query. Every redraw moves the stamp, so an entry is never served
* stale.
*/
private object MonthSourceCache {
private class Entry(val center: Int, val stamp: Long, val source: Themed<MonthWidgetSource.Ready>)
private val entries = ConcurrentHashMap<GlanceId, Entry>()
fun get(id: GlanceId, center: Int, stamp: Long): Themed<MonthWidgetSource>? =
entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) }
?.source
fun put(id: GlanceId, center: Int, stamp: Long, source: Themed<MonthWidgetSource>) {
val ready = source.content as? MonthWidgetSource.Ready ?: return
entries[id] = Entry(center, stamp, Themed(ready, source.appTheme))
}
}
/** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */
internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed<MonthWidgetSource> =
MonthSourceCache.get(id, centerIndex, stamp)
?: loadThemedMonthSource(centerIndex, widgetState(id).hiddenCalendars)
.also { MonthSourceCache.put(id, centerIndex, stamp, it) }
/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
internal suspend fun Context.loadThemedMonthSource(centerIndex: Int, hiddenCalendars: Set<Long>): Themed<MonthWidgetSource> {
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
return Themed(queryMonthSource(centerIndex, settings, hiddenCalendars), settings.themeMode)
}
private suspend fun Context.queryMonthSource(
centerIndex: Int,
settings: WidgetSettings,
hiddenCalendars: Set<Long>,
): MonthWidgetSource {
/** Read backing the month widget's grid for [centerIndex] and the months around it. */
internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): MonthWidgetSource {
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
val zone = systemZone()
val anchor = today(zone)
val center = centerIndex ?: monthIndexOf(anchor)
// Reuse the cached window unless the day changed (then it's stale for "today").
MonthWidgetCache.data?.let { if (it.today == anchor && center in it.months) return it }
val ep = widgetEntryPoint()
val months = (centerIndex - MONTH_WIDGET_PAD_MONTHS)..(centerIndex + MONTH_WIDGET_PAD_MONTHS)
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS)
// Padded by the grid's spill into the neighbouring months.
val from = firstDayOfMonthIndex(months.first)
.minus(1, DateTimeUnit.WEEK)
@@ -248,79 +184,11 @@ private suspend fun Context.queryMonthSource(
.plus(2, DateTimeUnit.WEEK)
.atTime(23, 59, 59)
.toInstant(zone)
val instances = ep.calendarRepository().instances(from..to).first()
return MonthWidgetSource.Ready(
today = today(zone),
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
soften = settings.soften,
instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars),
today = anchor,
weekStart = weekStart,
instances = instances,
months = months,
)
}
/** Today and the app theme, for a widget that draws no events. */
internal suspend fun Context.loadToday(): Themed<LocalDate> =
Themed(today(systemZone()), widgetEntryPoint().settingsPrefs().widgetSettings.first().themeMode)
/** What a widget drawing a run of days loads: those days' events, grouped like the agenda. */
sealed interface DaysWidgetData {
data object NeedsPermission : DaysWidgetData
data class Ready(
val today: LocalDate,
val firstDay: LocalDate,
val lastDay: LocalDate,
/** Only the days with events, in order. */
val days: List<AgendaDay>,
val weekStart: DayOfWeek,
val is24Hour: Boolean,
val soften: Boolean,
val options: AgendaWidgetOptions,
val now: Instant,
) : DaysWidgetData {
fun eventsOn(date: LocalDate): List<EventInstance> = days.firstOrNull { it.date == date }?.events.orEmpty()
}
}
/** Widget [id]'s stored options and calendars, with the app's values for what it has none of. */
internal class WidgetLoadContext(val settings: WidgetSettings, val options: AgendaWidgetOptions, val hiddenCalendars: Set<Long>)
internal suspend fun Context.widgetLoadContext(id: GlanceId): WidgetLoadContext {
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
val state = widgetState(id)
return WidgetLoadContext(settings, state.agendaWidgetOptions(settings.agendaDefaults), state.hiddenCalendars)
}
/** The events on the days [days] picks from today and the week start, for [load]'s widget. */
internal suspend fun Context.loadDays(
load: WidgetLoadContext,
days: (today: LocalDate, weekStart: DayOfWeek) -> ClosedRange<LocalDate>,
): Themed<DaysWidgetData> {
val settings = load.settings
if (!hasCalendarPermission()) return Themed(DaysWidgetData.NeedsPermission, settings.themeMode)
val zone = systemZone()
val anchor = today(zone)
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
val range = days(anchor, weekStart)
val window = range.start.atStartOfDayIn(zone)..range.endInclusive.atTime(23, 59, 59).toInstant(zone)
val instances = widgetEntryPoint().calendarRepository().instances(window).first().withoutCalendars(load.hiddenCalendars)
return Themed(
DaysWidgetData.Ready(
today = anchor,
firstDay = range.start,
lastDay = range.endInclusive,
days = groupAgendaDays(range.start, range.endInclusive, instances, zone),
weekStart = weekStart,
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
soften = settings.soften,
options = load.options,
now = Clock.System.now(),
),
settings.themeMode,
)
}
/** The week [today] falls in, from [weekStart]. */
internal fun weekOf(today: LocalDate, weekStart: DayOfWeek): ClosedRange<LocalDate> {
val back = (today.dayOfWeek.ordinal - weekStart.ordinal + 7) % 7
val first = today.minus(back, DateTimeUnit.DAY)
return first..first.plus(6, DateTimeUnit.DAY)
).also { MonthWidgetCache.data = it }
}
@@ -21,7 +21,6 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
interface WidgetEntryPoint {
fun calendarRepository(): CalendarRepository
fun settingsPrefs(): SettingsPrefs
fun widgetRefreshCoordinator(): WidgetRefreshCoordinator
}
internal fun Context.widgetEntryPoint(): WidgetEntryPoint =
@@ -1,114 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.action.clickable
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
import de.jeanlucmakiola.floret.glance.themedColor
/** Text and row sizes of a compact event list, from a widget's [WidgetSize]. */
internal data class EventRowMetrics(val title: TextUnit, val time: TextUnit, val rowHeight: Dp, val message: TextUnit)
/** The agenda widget's sizes with tighter rows, resolved against the system font scale. */
internal fun compactRowMetrics(size: WidgetSize, fontScale: Float): EventRowMetrics {
val agenda = metricsFor(size)
return EventRowMetrics(
title = agenda.eventTitle,
time = agenda.eventTime,
rowHeight = agenda.contentH * fontScale + agenda.rowVPad,
message = agenda.message,
)
}
/**
* One event as a two-line row in its calendar's fill, part of a grouped card
* at [position]; opens the occurrence. [quiet] draws it in the card colour
* instead, for events shown only as a look ahead.
*/
@Composable
internal fun WidgetEventRow(
event: EventInstance,
secondary: String,
soften: Boolean,
metrics: EventRowMetrics,
position: Position,
source: CalendarView,
dimmed: Boolean = false,
quiet: Boolean = false,
) {
val context = LocalContext.current
fun tone(dark: Boolean): Color = eventFill(event.color, dark, soften).let { if (dimmed) it.copy(alpha = EventDimAlpha) else it }
val day = tone(dark = false)
val night = tone(dark = true)
// Ink is picked against the opaque fill, then carries the dim along with it.
fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha)
val card = widgetCardColor()
WidgetGroupedRow(position = position, color = if (quiet) card else themedColor(day = day, night = night)) {
Column(
modifier = GlanceModifier
.fillMaxWidth()
.height(metrics.rowHeight)
.padding(horizontal = 12.dp)
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = source,
),
),
),
verticalAlignment = Alignment.CenterVertically,
) {
val titleInk = if (quiet) {
GlanceTheme.colors.onSurface
} else {
themedColor(day = ink(day, 1f), night = ink(night, 1f))
}
val timeInk = if (quiet) {
GlanceTheme.colors.onSurfaceVariant
} else {
themedColor(day = ink(day, SECONDARY_INK_ALPHA), night = ink(night, SECONDARY_INK_ALPHA))
}
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(
color = titleInk,
fontSize = metrics.title,
fontWeight = FontWeight.Medium,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
)
Text(text = secondary, maxLines = 1, style = TextStyle(color = timeInk, fontSize = metrics.time))
}
}
}
@@ -1,120 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.glance.appwidget.GlanceAppWidget
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidget
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver
import de.jeanlucmakiola.calendula.widget.date.DateWidget
import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidget
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidget
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver
import de.jeanlucmakiola.calendula.widget.today.TodayWidget
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidget
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidget
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
/** A content option a widget's config screen offers, besides background and theme. */
enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
/**
* Every widget the app ships, told apart by their receiver: what redraws,
* the rollover and the config screen go through.
*/
enum class WidgetKind(
val receiver: Class<*>,
val label: Int,
val options: Set<WidgetOption>,
/** The size a config preview draws at before the launcher reports one. */
val defaultSize: DpSize,
) {
AGENDA(
AgendaWidgetReceiver::class.java,
R.string.widget_agenda_label,
setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.SHOW_TODAY, WidgetOption.CALENDARS),
DpSize(260.dp, 280.dp),
),
MONTH(
MonthWidgetReceiver::class.java,
R.string.widget_month_label,
setOf(WidgetOption.CALENDARS),
DpSize(320.dp, 340.dp),
),
QUICK_ADD(
QuickAddWidgetReceiver::class.java,
R.string.widget_quick_add_label,
emptySet(),
DpSize(76.dp, 76.dp),
),
DATE(
DateWidgetReceiver::class.java,
R.string.widget_date_label,
emptySet(),
DpSize(76.dp, 76.dp),
),
WEEK_STRIP(
WeekStripWidgetReceiver::class.java,
R.string.widget_week_strip_label,
setOf(WidgetOption.CALENDARS),
DpSize(300.dp, 90.dp),
),
UPCOMING(
UpcomingWidgetReceiver::class.java,
R.string.widget_upcoming_label,
setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.CALENDARS),
DpSize(180.dp, 180.dp),
),
TODAY(
TodayWidgetReceiver::class.java,
R.string.widget_today_label,
setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS),
DpSize(300.dp, 100.dp),
),
COUNTDOWN(
CountdownWidgetReceiver::class.java,
R.string.widget_countdown_label,
setOf(WidgetOption.CALENDARS),
DpSize(200.dp, 100.dp),
),
MONTH_DAY(
MonthDayWidgetReceiver::class.java,
R.string.widget_month_day_label,
setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS),
DpSize(320.dp, 200.dp),
),
;
fun widget(): GlanceAppWidget = when (this) {
AGENDA -> AgendaWidget()
MONTH -> MonthWidget()
QUICK_ADD -> QuickAddWidget()
DATE -> DateWidget()
WEEK_STRIP -> WeekStripWidget()
UPCOMING -> UpcomingWidget()
TODAY -> TodayWidget()
COUNTDOWN -> CountdownWidget()
MONTH_DAY -> MonthDayWidget()
}
/** Whether this kind stores [AgendaWidgetOptions] of its own. */
val hasContentOptions: Boolean
get() = options.any { it != WidgetOption.CALENDARS }
companion object {
fun of(context: Context, appWidgetId: Int): WidgetKind? {
val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null
return entries.firstOrNull { it.receiver.name == provider.className }
}
}
}
@@ -1,55 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.glance.GlanceTheme
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
import kotlin.time.Instant
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
@Composable
internal fun widgetCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
}
/** An event's time on [day]: "All day", "Starts 14:00", "Ends 9:00" or "14:00 – 15:30". */
internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalDate, is24Hour: Boolean): String =
when (val label = agendaTimeLabel(event, day, systemZone())) {
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts -> context.getString(R.string.agenda_span_starts, formatWidgetTime(label.start, is24Hour))
is AgendaTimeLabel.Ends -> context.getString(R.string.agenda_span_ends, formatWidgetTime(label.end, is24Hour))
is AgendaTimeLabel.Range -> "${formatWidgetTime(label.start, is24Hour)} – ${formatWidgetTime(label.end, is24Hour)}"
}
internal fun formatWidgetTime(instant: Instant, is24Hour: Boolean): String {
val t = instant.toLocalDateTime(systemZone()).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
}
/** "Today", "Tomorrow", or the short weekday within the coming week, else the short date. */
internal fun relativeDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
val locale = Locale.getDefault()
return when {
date == today -> context.getString(R.string.agenda_header_today)
date == today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
date < today.plus(7, DateTimeUnit.DAY) && date > today ->
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, locale)
else -> localizedDateFormatter(locale, "dMMM").format(date.toJavaLocalDate())
}
}
@@ -1,177 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.provideContent
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver
import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent
import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver
import de.jeanlucmakiola.calendula.widget.split.MonthDayData
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver
import de.jeanlucmakiola.floret.glance.publishWidgetPreviews
import kotlinx.coroutines.flow.first
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import java.util.Locale
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
/** Republishes the generated picker previews (Android 15+) after an app update or a language change. */
internal suspend fun publishCalendulaWidgetPreviews(context: Context) {
publishWidgetPreviews(
context,
version = "${context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime}-${Locale.getDefault().toLanguageTag()}",
receivers = listOf(
AgendaWidgetReceiver::class,
MonthWidgetReceiver::class,
QuickAddWidgetReceiver::class,
DateWidgetReceiver::class,
WeekStripWidgetReceiver::class,
UpcomingWidgetReceiver::class,
TodayWidgetReceiver::class,
CountdownWidgetReceiver::class,
MonthDayWidgetReceiver::class,
),
)
}
/** The size [kind]'s picker preview is laid out at. */
internal fun previewSizeOf(kind: WidgetKind) = SizeMode.Responsive(setOf(kind.defaultSize))
/** [kind]'s picker preview: the real widget, drawn with sample events around today's morning. */
internal suspend fun GlanceAppWidget.provideSamplePreview(context: Context, kind: WidgetKind) {
val settings = context.widgetEntryPoint().settingsPrefs().widgetSettings.first()
val zone = systemZone()
val today = today(zone)
val now = today.atTime(8, 0).toInstant(zone)
val instances = context.sampleInstances(today, zone)
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
val options = settings.agendaDefaults
val appearance = WidgetAppearance()
val index = monthIndexOf(today)
val month = MonthWidgetSource.Ready(today, weekStart, settings.soften, instances, index..index)
fun days(range: ClosedRange<LocalDate>) = DaysWidgetData.Ready(
today = today,
firstDay = range.start,
lastDay = range.endInclusive,
days = groupAgendaDays(range.start, range.endInclusive, instances, zone),
weekStart = weekStart,
is24Hour = is24Hour,
soften = settings.soften,
options = options,
now = now,
)
val theme = settings.themeMode
provideContent {
when (kind) {
WidgetKind.AGENDA -> AgendaWidgetContent(
AgendaWidgetData.Ready(
today = today,
days = groupAgendaDays(today, today.plus(6, DateTimeUnit.DAY), instances, zone),
is24Hour = is24Hour,
soften = settings.soften,
pastDisplay = options.pastDisplay,
showToday = options.showToday,
textSize = options.textSize,
now = now,
),
appearance,
theme,
preview = true,
)
WidgetKind.MONTH -> MonthWidgetContent(month, index, appearance, theme)
WidgetKind.MONTH_DAY -> MonthDayWidgetContent(
MonthDayData.Ready(month, options, is24Hour, now),
index,
selected = null,
appearance = appearance,
appTheme = theme,
preview = true,
)
WidgetKind.QUICK_ADD -> QuickAddWidgetContent(today, appearance, theme)
WidgetKind.DATE -> DateWidgetContent(today, appearance, theme)
WidgetKind.WEEK_STRIP -> WeekStripWidgetContent(days(weekOf(today, weekStart)), appearance, theme)
WidgetKind.UPCOMING -> UpcomingWidgetContent(days(today..today.plus(6, DateTimeUnit.DAY)), appearance, theme)
WidgetKind.TODAY -> TodayWidgetContent(days(today..today.plus(1, DateTimeUnit.DAY)), appearance, theme, preview = true)
WidgetKind.COUNTDOWN -> CountdownWidgetContent(
CountdownData.Ready(
options = CountdownOptions(),
event = instances.first { !it.isAllDay && it.start > now },
now = now,
today = today,
is24Hour = is24Hour,
soften = settings.soften,
appWidgetId = null,
),
appearance,
theme,
)
}
}
}
/** A plausible few weeks: a busy today, a trip tomorrow, and a scatter of days with something on. */
private fun Context.sampleInstances(today: LocalDate, zone: TimeZone): List<EventInstance> {
var id = 0L
fun timed(day: Int, at: LocalTime, length: kotlin.time.Duration, title: Int, color: Int): EventInstance {
val start = today.plus(day, DateTimeUnit.DAY).atTime(at).toInstant(zone)
id++
return EventInstance(id, id, 1, getString(title), start, start + length, false, color, null)
}
fun allDay(day: Int, title: Int, color: Int): EventInstance {
val date = today.plus(day, DateTimeUnit.DAY)
id++
return EventInstance(
id, id, 1, getString(title),
date.atStartOfDayIn(TimeZone.UTC), date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(TimeZone.UTC),
true, color, null,
)
}
return listOf(
timed(0, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
timed(0, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green),
timed(0, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
allDay(1, R.string.widget_preview_sample_trip, Orange),
timed(2, LocalTime(10, 0), 1.hours, R.string.widget_preview_sample_dentist, Red),
timed(-6, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
timed(-2, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
timed(5, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
timed(8, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
timed(12, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green),
).sortedBy { it.start }
}
private const val Blue = 0xFF039BE5.toInt()
private const val Green = 0xFF33B679.toInt()
private const val Purple = 0xFF8E24AA.toInt()
private const val Orange = 0xFFF4511E.toInt()
private const val Red = 0xFFD50000.toInt()
@@ -1,124 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.SystemClock
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.glance.WidgetRefresher
import de.jeanlucmakiola.floret.glance.WidgetRollover
import de.jeanlucmakiola.floret.glance.updatePlaced
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import androidx.glance.appwidget.GlanceAppWidgetManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
/** Every widget receiver; the set [WidgetRollover] checks for placed instances. */
internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = WidgetKind.entries.map { it.receiver }
/** The app's own midnight wake-up (#228). */
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
context,
ROLLOVER_REQUEST_CODE,
Intent(context, WidgetUpdateReceiver::class.java).setAction(ACTION_WIDGET_ROLLOVER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** Arm the next midnight rollover, or cancel it when no widget is placed. Idempotent. */
internal fun syncWidgetRollover(context: Context) =
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
/**
* Reloads and redraws every placed widget, a running session included: the
* refresh stamp moves, and every widget keys its data load on it (#420).
*/
internal suspend fun redrawWidgets(context: Context) {
WidgetKind.entries.forEach { redrawWidgets(context, it) }
}
internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) =
kind.widget().updatePlaced(context, kind.receiver, bumpRefreshStamp = true)
/** Redraws only the widgets with no content options of their own, which still follow the app's. */
private suspend fun redrawUnconfiguredWidgets(context: Context) {
val appContext = context.applicationContext
val manager = AppWidgetManager.getInstance(appContext) ?: return
val glanceManager = GlanceAppWidgetManager(appContext)
WidgetKind.entries.filter { it.hasContentOptions }.forEach { kind ->
manager.getAppWidgetIds(ComponentName(appContext, kind.receiver)).forEach { appWidgetId ->
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId)
}
}
}
/** When the app last saw a calendar change of its own, for skipping the `PROVIDER_CHANGED` that echoes it. */
private val lastLocalChange = AtomicLong(0L)
private const val PROVIDER_ECHO_WINDOW_MILLIS = 5_000L
internal fun isProviderEcho(): Boolean {
val last = lastLocalChange.get()
return last != 0L && SystemClock.elapsedRealtime() - last < PROVIDER_ECHO_WINDOW_MILLIS
}
/**
* Redraws the widgets on the app's own calendar writes, visibility changes and
* the settings they draw with, so those don't wait for `PROVIDER_CHANGED`,
* which arrives seconds late or not at all (#420, #229). Changes from outside
* the app are [WidgetUpdateReceiver]'s. The app's agenda options only redraw
* widgets that have no content options of their own (#292). Started once from
* `CalendulaApp`.
*/
@Singleton
class WidgetRefreshCoordinator @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: CalendarRepository,
private val prefs: SettingsPrefs,
@ApplicationScope private val scope: CoroutineScope,
) {
private val started = AtomicBoolean(false)
/** Subscribes before returning, so a change made right after is never taken for the starting value. */
fun start() {
if (!started.compareAndSet(false, true)) return
val all = WidgetKind.entries.map { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) }
watch(repository.localChanges) {
lastLocalChange.set(SystemClock.elapsedRealtime())
all.forEach { it.request() }
}
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
all.forEach { it.request() }
}
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
unconfigured.request()
}
}
private fun watch(changes: Flow<*>, onChange: () -> Unit) {
scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
changes.collect { onChange() }
}
}
}
@@ -0,0 +1,115 @@
package de.jeanlucmakiola.calendula.widget
import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.core.content.getSystemService
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* Holds the app's own wake-up for the next local midnight, so the home-screen
* widgets roll "today" over on the day boundary (#228).
*
* The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
* implicit broadcast — a manifest-declared receiver has not been given it since
* Android 8, leaving only the throttled `updatePeriodMillis`.
*
* Exactly one alarm exists at a time and every firing re-arms the next, the same
* shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
* Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
* survives doze (plain `set` does not), a rollover a few minutes late is
* invisible on a sleeping screen, and exact alarms stay reserved for snooze.
*/
object WidgetRolloverScheduler {
/**
* Fire just *after* midnight: an alarm delivered a few milliseconds early
* would still read the old date and re-arm for an instant later.
*/
internal val ROLLOVER_SLACK = 5.seconds
/**
* Arm the next rollover, or cancel a pending one when no widget is placed.
* Idempotent, so every trigger (boot, app start, widget added/removed, the
* rollover itself, a clock or timezone change) can just call it.
*/
fun sync(context: Context) {
val appContext = context.applicationContext
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
val pendingIntent = rolloverPendingIntent(appContext)
if (!hasPlacedWidgets(appContext)) {
alarmManager.cancel(pendingIntent)
return
}
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
)
}
/**
* The instant just after the next local midnight following [now] in [zone].
*
* The *actual* start of day, not 00:00, so it holds where a DST jump means
* midnight never happens (Havana) and where a date-line move skips a whole
* local date (Apia, December 2011) — the loop walks on to the next real day.
*
* The mirror case — a zone rewinding *across* midnight, so the day starts
* twice — resolves to the earlier start and runs an hour ahead of the clock.
* No live tz entry does that (Brazil dropped DST in 2019) and
* `updatePeriodMillis` covers it, so it isn't worth state to detect.
*/
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
val date = now.toLocalDateTime(zone).date
var days = 1
while (days <= MAX_LOOKAHEAD_DAYS) {
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
if (candidate > now) return candidate
days++
}
// Unreachable for any zone in the tz database. Deliberately an hour and
// not the slack: a 5-second retry would just hit this branch again and
// wake the device in a loop.
return now + 1.hours
}
private fun hasPlacedWidgets(context: Context): Boolean {
val manager = AppWidgetManager.getInstance(context) ?: return false
return PROVIDERS.any {
manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
}
}
private fun rolloverPendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
ROLLOVER_REQUEST_CODE,
Intent(context, WidgetUpdateReceiver::class.java)
.setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private val PROVIDERS = listOf(
MonthWidgetReceiver::class.java,
AgendaWidgetReceiver::class.java,
)
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
/** A gap of more than a couple of days does not exist in any tz database entry. */
private const val MAX_LOOKAHEAD_DAYS = 3
}
@@ -1,36 +1,39 @@
package de.jeanlucmakiola.calendula.widget
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.glance.GlanceTheme
import androidx.glance.material3.ColorProviders
import androidx.glance.text.TextDecoration
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
import de.jeanlucmakiola.floret.glance.WidgetSurface
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.prefs.ThemeMode
/**
* Glance equivalent of `CalendulaTheme`: Material You on API 31+, the app's
* hand-tuned fallback schemes below that, in the light/dark [mode] the widget
* was configured with (#292).
* Brand fallback for devices without Material You dynamic colour (API < 31).
* Reuses the exact same hand-tuned schemes as the in-app theme
* ([CalendulaLightFallback] / [CalendulaDarkFallback]) so a widget on an older
* device matches the app surface-for-surface.
*/
private val CalendulaGlanceColors = ColorProviders(
light = CalendulaLightFallback,
dark = CalendulaDarkFallback,
)
/**
* Glance equivalent of `CalendulaTheme`. On API 31+ it follows the system's
* Material You palette (so the widget matches the home screen / the app's
* dynamic colour); below that it falls back to the brand scheme. Either way the
* widget draws only from M3 colour-role tokens (`GlanceTheme.colors.*`) — never
* a hardcoded colour — so it tracks light/dark automatically.
*/
@Composable
fun CalendulaGlanceTheme(mode: WidgetThemeMode, content: @Composable () -> Unit) {
FloretGlanceTheme(
lightScheme = CalendulaLightFallback,
darkScheme = CalendulaDarkFallback,
dynamicColor = true,
mode = mode,
content = content,
)
}
/** A widget's root: its configured theme and background around [content]. */
@Composable
internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMode, content: @Composable () -> Unit) {
CalendulaGlanceTheme(appearance.theme.resolve(appTheme)) {
WidgetSurface(background = appearance.background, content = content)
fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
GlanceTheme.colors
} else {
CalendulaGlanceColors
}
GlanceTheme(colors = colors, content = content)
}
/**
@@ -1,29 +1,120 @@
package de.jeanlucmakiola.calendula.widget
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
import kotlin.time.Duration.Companion.minutes
import android.os.SystemClock
import androidx.glance.appwidget.updateAll
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicLong
/**
* Redraws both widgets when their data, day or language goes stale, and keeps
* the midnight rollover armed. `PROVIDER_CHANGED` covers changes made outside
* the app; the app's own writes go through [WidgetRefreshCoordinator]. A data
* change also re-arms, at most every 15 minutes: the inexact alarm can be
* dropped in a restricted standby bucket, and a calendar change is the one
* signal that keeps arriving. A `PROVIDER_CHANGED` right after one of the
* app's own changes only echoes it, so it doesn't redraw a second time.
* Redraws both home-screen widgets when their data goes stale, and keeps the
* midnight rollover alarm armed. Triggered by:
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
* so it covers both the app's own writes and external sync.
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
* the day boundary, so "today" highlighting and the agenda's past-event
* dimming move on (#228).
* - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and*
* re-arm.
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
* latter is also what arms installs upgrading into the fix.
*
* `DATE_CHANGED` rides along as a free extra the system may or may not send —
* see [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
* provider XML and the month widget's refresh button.
*
* Every action that dropped, consumed or invalidated the alarm re-arms it, and
* `PROVIDER_CHANGED` re-arms at most once every [DATA_REARM_INTERVAL_MILLIS]
* ([dataRearmDue]). The alarm is inexact, so the system may drop it outright in
* a restricted standby bucket; nothing in ordinary daily use would then notice,
* and the widget sits on yesterday's date. A calendar change is the one signal
* that keeps arriving — but it arrives on *every* write (each drag, each event
* of a thousand-event import), which is what the interval is for.
*
* Exported for the system broadcasts; an extra redraw from another app is
* harmless.
*/
class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
override val rolloverAction = ACTION_WIDGET_ROLLOVER
override val widgetReceiverClasses = WIDGET_RECEIVER_CLASSES
override val extraHandledActions = setOf(Intent.ACTION_PROVIDER_CHANGED)
override val extraRearmInterval = 15.minutes
class WidgetUpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// Exported, so anything can reach it with an explicit intent. Nothing
// here crosses a trust boundary, but narrowing to the actions we asked
// for keeps a stray broadcast from costing two wide provider reads.
if (intent.action !in HANDLED_ACTIONS) return
val appContext = context.applicationContext
val rearm = intent.action in REARM_ACTIONS ||
(intent.action == Intent.ACTION_PROVIDER_CHANGED && dataRearmDue())
// The host sends APPWIDGET_UPDATE after the re-arm-only ones anyway, so
// redrawing there would only repeat the work in a cold process, at the
// moment the device is most contended.
val redraw = intent.action !in REARM_ONLY_ACTIONS
val pending = goAsync()
// Calendar data may have changed (sync / our own write) — drop the cached
// month window so the widgets reload fresh. Month paging does NOT call
// this, so arrow taps stay instant.
if (redraw) invalidateMonthWidgetCache()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
// Re-armed first, so the next day boundary is covered whatever
// the redraw does — and off the receiving thread, since it is a
// handful of binder calls (`CalendulaApp` moves it for the same
// reason).
if (rearm) WidgetRolloverScheduler.sync(appContext)
if (redraw) {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
}
} finally {
pending.finish()
}
}
}
override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
companion object {
/** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
override fun skipRedraw(action: String) = action == Intent.ACTION_PROVIDER_CHANGED && isProviderEcho()
/** Both wipe pending alarms, and the host redraws the widgets itself after them. */
private val REARM_ONLY_ACTIONS = setOf(
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
)
override suspend fun redraw(context: Context) = redrawWidgets(context)
/**
* The actions that leave no alarm standing: the two above wipe it, the
* rollover consumed itself, and a clock, zone or day change moved the
* boundary the pending one was set for.
*/
internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf(
ACTION_ROLLOVER,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
Intent.ACTION_DATE_CHANGED,
)
internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_PROVIDER_CHANGED
/** How rarely a calendar change may re-arm; see the class doc. */
private const val DATA_REARM_INTERVAL_MILLIS = 15 * 60_000L
/** Uptime of the last data-change re-arm, 0 for none in this process. */
private val lastDataRearmMillis = AtomicLong(0L)
/**
* Whether a calendar change may re-arm now — the backstop for an alarm
* the system dropped, throttled so a burst of writes costs one pass.
*/
private fun dataRearmDue(): Boolean {
val now = SystemClock.elapsedRealtime()
val last = lastDataRearmMillis.get()
val due = last == 0L || now - last >= DATA_REARM_INTERVAL_MILLIS
return due && lastDataRearmMillis.compareAndSet(last, now)
}
}
}
@@ -6,11 +6,17 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.calendula.widget.WidgetSize
/** Horizontal inset of a row's text inside its card; doesn't scale, so it never eats title width. */
internal val ROW_H_PAD = 12.dp
/**
* Horizontal layout constants for an agenda event row. These don't scale with the
* size step, since a wider stripe or gap would eat title width, but [TEXT_INDENT]
* is derived from them so non-event text can't drift out of alignment.
*/
internal val ROW_H_PAD = 4.dp
internal val STRIPE_WIDTH = 5.dp
internal val STRIPE_GAP = 10.dp
/** Space between the date-pill gutter and a day's card. */
internal val GUTTER_GAP = 6.dp
/** Left indent that lines non-event text up with the event title column. */
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
/**
* Every size the agenda widget varies by [WidgetSize]. Values that genuinely
@@ -18,91 +24,90 @@ internal val GUTTER_GAP = 6.dp
* constants rather than routing through here.
*/
internal data class AgendaMetrics(
val pillWeekday: TextUnit, // date pill weekday ("Thu")
val pillDay: TextUnit, // date pill day number
val title: TextUnit, // header "Upcoming"
val dayHeader: TextUnit, // day label ("Today · …")
val eventTitle: TextUnit, // event title line
val eventTime: TextUnit, // time/location line
val placeholder: TextUnit, // "no more events today" (#35)
val message: TextUnit, // empty/permission centred message
val contentH: Dp, // an event row's two text lines
val pillWidth: Dp, // date pill / gutter width
val stripeH: Dp, // coloured event stripe height
val iconImage: Dp, // header action icon glyph
val iconBox: Dp, // header action touch target
val rowVPad: Dp, // event row vertical padding
val dayGap: Dp, // space between two days' cards
val monthLabel: TextUnit, // month name above a new month's first day
val dayHeaderTopPad: Dp, // space above a day header
) {
/** One event row's height, shared by the date pill beside it and the placeholder row. */
val rowHeight: Dp get() = contentH + rowVPad * 2
/**
* Resolves the row content height and pill width against the user's system font
* scale: both are [Dp] while the text in and beside them is `sp`, so without
* this the text outgrows them at large font settings.
* Resolves the stripe height against the user's system font scale: the stripe
* is a [Dp] while the lines beside it are `sp`, so without this the text
* outgrows it at large font settings.
*/
fun scaledForFont(fontScale: Float): AgendaMetrics =
if (fontScale == 1f) this else copy(contentH = contentH * fontScale, pillWidth = pillWidth * fontScale)
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
}
/*
* Type sizes are anchored to the Material 3 type scale, with one deviation:
* Type sizes are anchored to the Material 3 type scale, with two deviations:
*
* ‡ SMALL's 13sp day header is off-scale, held there so SMALL reproduces the
* widget's original constants verbatim (#51).
* † Above Title Medium the M3 scale jumps 16 → 22 → 24, too coarse for four
* steps; interpolated values are used where a role would force a ≥1.4x jump.
*/
private val SMALL_METRICS = AgendaMetrics(
pillWeekday = 11.sp,
pillDay = 16.sp,
title = 16.sp, // M3 Title Medium
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
eventTitle = 14.sp, // M3 Body Medium
eventTime = 12.sp, // M3 Body Small
placeholder = 14.sp, // M3 Body Medium
message = 14.sp, // M3 Body Medium
contentH = 36.dp,
pillWidth = 40.dp,
stripeH = 36.dp,
iconImage = 22.dp,
iconBox = 40.dp,
rowVPad = 4.dp,
dayGap = 8.dp,
monthLabel = 12.sp,
dayHeaderTopPad = 10.dp,
)
private val MEDIUM_METRICS = AgendaMetrics(
pillWeekday = 12.sp,
pillDay = 18.sp,
title = 18.sp, // †
dayHeader = 14.sp, // M3 Title Small
eventTitle = 16.sp, // M3 Body Large
eventTime = 14.sp, // M3 Body Medium
placeholder = 16.sp, // M3 Body Large
message = 16.sp, // M3 Body Large
contentH = 40.dp,
pillWidth = 44.dp,
stripeH = 40.dp,
iconImage = 24.dp,
iconBox = 44.dp,
rowVPad = 5.dp,
dayGap = 9.dp,
monthLabel = 14.sp,
dayHeaderTopPad = 11.dp,
)
private val LARGE_METRICS = AgendaMetrics(
pillWeekday = 12.sp,
pillDay = 20.sp,
title = 20.sp, // †
dayHeader = 16.sp, // M3 Title Medium
eventTitle = 18.sp, // †
eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly
placeholder = 18.sp, // † on purpose, so the title keeps its
message = 18.sp, // † lead and the hierarchy survives.
contentH = 46.dp,
pillWidth = 48.dp,
stripeH = 46.dp,
iconImage = 26.dp,
iconBox = 48.dp,
rowVPad = 6.dp,
dayGap = 10.dp,
monthLabel = 14.sp,
dayHeaderTopPad = 12.dp,
)
private val EXTRA_LARGE_METRICS = AgendaMetrics(
pillWeekday = 14.sp,
pillDay = 22.sp,
title = 22.sp, // M3 Title Large
dayHeader = 18.sp, // †
eventTitle = 20.sp, // †
eventTime = 16.sp, // M3 Body Large
placeholder = 20.sp, // †
message = 20.sp, // †
contentH = 52.dp,
pillWidth = 52.dp,
stripeH = 52.dp,
iconImage = 28.dp,
iconBox = 52.dp,
rowVPad = 8.dp,
dayGap = 12.dp,
monthLabel = 16.sp,
dayHeaderTopPad = 14.dp,
)
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
@@ -1,27 +1,40 @@
package de.jeanlucmakiola.calendula.widget.agenda
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.action.ActionParameters
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
@@ -34,58 +47,78 @@ import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.semantics.contentDescription
import androidx.glance.semantics.semantics
import androidx.glance.text.TextAlign
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.floret.glance.WidgetHeader
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetCardColor
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
import de.jeanlucmakiola.calendula.widget.loadAgendaWidget
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
import de.jeanlucmakiola.floret.glance.themedColor
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.prefs.ThemeMode
import androidx.datastore.preferences.core.Preferences
import androidx.glance.currentState
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
import java.util.Locale
/**
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
* The range is read reactively in the composition ([currentState]) so a settings
* change is reflected by plain recomposition — `updateAll` does NOT reliably
* re-run the `provideGlance` preamble for a live widget session, so loading the
* range there and slicing it would silently keep the stale value (mirrors the
* month widget's MONTH_INDEX_KEY approach).
*/
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
/**
* Per-instance Glance state key holding the past-event display mode (as
* [PastEventDisplay.name]). Read reactively in the composition for the same
* reason as [AGENDA_RANGE_KEY] — so toggling the setting reflects on the live
* widget without depending on the `provideGlance` preamble re-running.
*/
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
/**
* Per-instance Glance state key holding the "always show today" toggle (#35).
* Read reactively in the composition for the same reason as [AGENDA_RANGE_KEY] —
* so toggling the setting reflects on the live widget without depending on the
* `provideGlance` preamble re-running.
*/
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
/**
* Per-instance Glance state key holding the chosen [WidgetSize]. Read reactively
* in the composition for the same reason as [AGENDA_RANGE_KEY] — so changing the
* setting reflects on the live widget without depending on the `provideGlance`
* preamble re-running.
*/
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
/**
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
* of events, each day one card of grouped rows with its date pill beside it.
* of events grouped under day headers (the Google "Schedule" widget model).
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
*/
class AgendaWidget : GlanceAppWidget() {
@@ -98,47 +131,32 @@ class AgendaWidget : GlanceAppWidget() {
// the RemoteViews past the binder transaction limit.
override val sizeMode = SizeMode.Single
override val previewSizeMode = previewSizeOf(WidgetKind.AGENDA)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.AGENDA)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val stamp = refreshStamp(context, id)
val widgetData = WidgetData(context.loadAgendaWidget(id), stamp)
val stored = getAppWidgetState<Preferences>(context, id)[AGENDA_RANGE_KEY]
val data = context.loadAgendaWidgetData(stored?.let { parseAgendaRange(it, AgendaRange.Month) })
val dark = (context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
provideContent {
// A running session never re-runs this preamble, so every redraw
// moves the stamp and the data reloads here instead (#420).
val loaded = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidget(id) }
AgendaWidgetContent(
data = loaded.content,
appearance = currentState<Preferences>().widgetAppearance,
appTheme = loaded.appTheme,
)
CalendulaGlanceTheme {
AgendaWidgetBody(data = data, dark = dark)
}
}
}
}
/**
* The whole agenda widget, shared with its config screen's preview (#292). The
* [preview] draws its rows as a plain column, which RemoteViews can show
* outside a widget host on every Android version.
* Redraws the widget (header refresh button). Targets the tapped widget's own id
* rather than `updateAll`, whose provider-name lookup is empty in a process a tap
* woke from cold — see `ShiftMonthAction` (#18). A cold process re-reads the
* calendar in the `provideGlance` preamble; a live session only recomposes from
* the snapshot it already has (see [AGENDA_RANGE_KEY]).
*/
@Composable
internal fun AgendaWidgetContent(
data: AgendaWidgetData,
appearance: WidgetAppearance,
appTheme: ThemeMode,
preview: Boolean = false,
) {
CalendulaWidgetRoot(appearance, appTheme) {
AgendaWidgetBody(data = data, preview = preview)
class RefreshAgendaAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
AgendaWidget().update(context.applicationContext, glanceId)
}
}
/** Rows a preview draws: Glance's cap on a Column's children, still more than a placed widget shows. */
private const val PREVIEW_ROWS = 10
/**
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
* stays well inside the binder transaction limit regardless of range and calendar
@@ -148,32 +166,57 @@ private const val PREVIEW_ROWS = 10
*/
private const val MAX_AGENDA_ROWS = 100
/** Flat row model: one [LazyColumn] item per event or placeholder, a day's first one carrying its date pill. */
/** Flat row model so the [LazyColumn] can mix day headers and events. */
private sealed interface AgendaRow {
val date: LocalDate
data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow
/** "Nothing left today" line for an anchored, event-less today (#35). */
data class Placeholder(override val date: LocalDate) : AgendaRow
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow
/** "Nothing left today" line under an anchored, event-less today (#35). */
data class Placeholder(val date: LocalDate) : AgendaRow
}
@Composable
private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
// The row height is re-resolved against the system font scale so it tracks the
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
// Read reactively from per-instance Glance state, falling back to the saved
// pref for a freshly placed widget (#103, #51).
// The stripe is then re-resolved against the system font scale so it tracks the
// sp-sized text beside it instead of drifting at large accessibility settings.
val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL
val savedSize = (data as? AgendaWidgetData.Ready)?.savedWidgetSize ?: WidgetSize.SMALL
val size = currentState(AGENDA_SIZE_KEY)
?.let { stored -> WidgetSize.entries.firstOrNull { it.name == stored } }
?: savedSize
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
val metrics = metricsFor(size).scaledForFont(fontScale)
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.surface)
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
AgendaHeader()
Spacer(GlanceModifier.height(6.dp))
AgendaHeader(metrics)
Spacer(GlanceModifier.height(4.dp))
when (data) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview)
is AgendaWidgetData.Ready -> {
// Range read reactively from per-instance Glance state (falls back
// to the saved pref for a freshly placed widget), then the loaded
// window is sliced to it — pure recomposition.
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
val context = androidx.glance.LocalContext.current
var loaded by remember(data) { mutableStateOf<AgendaWidgetData.Ready>(data) }
// A range wider than what was loaded reloads in a live session,
// where provideGlance doesn't run again (#363).
LaunchedEffect(range) {
val end = loaded.today.plus(
range.dayCount(loaded.today, loaded.weekStart) - 1,
DateTimeUnit.DAY,
)
if (end > loaded.loadedThrough) {
(context.loadAgendaWidgetData(range) as? AgendaWidgetData.Ready)
?.let { loaded = it }
}
}
AgendaRows(data = loaded, range = range, dark = dark, metrics = metrics)
}
}
}
}
@@ -181,16 +224,24 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
@Composable
private fun AgendaRows(
data: AgendaWidgetData.Ready,
range: AgendaRange,
dark: Boolean,
metrics: AgendaMetrics,
preview: Boolean,
) {
val pastDisplay = data.pastDisplay
// Drop finished events (and any day they leave empty) when the mode is
// Hide — so "Upcoming" really is. Then re-anchor today (as an empty
// placeholder day) when the "always show today" pref is on and today has
// no events left (#35).
val pastDisplay =
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
val rangeEnd = data.today.plus(
range.dayCount(data.today, data.weekStart) - 1,
DateTimeUnit.DAY,
)
// Slice to the chosen range, then drop finished events (and any day
// they leave empty) when the mode is Hide — so "Upcoming" really is.
// Finally re-anchor today (as an empty placeholder day) when the
// "always show today" pref is on and today has no events left (#35).
val visibleDays = anchorTodayIfMissing(
days = data.days
.filter { it.date <= rangeEnd }
.let { days ->
if (pastDisplay == PastEventDisplay.HIDE) {
days.mapNotNull { day ->
@@ -202,223 +253,183 @@ private fun AgendaRows(
}
},
today = data.today,
enabled = data.showToday,
enabled = showToday,
)
if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title, metrics)
} else {
val rows = buildList {
visibleDays.forEach { day ->
add(AgendaRow.Header(day.date, data.today))
if (day.events.isEmpty()) {
add(AgendaRow.Placeholder(day.date))
} else {
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
}
}
}.take(MAX_AGENDA_ROWS)
// Each day is one card of grouped rows (#71), with its date pill
// beside the first row.
val positions = dayPositions(rows)
val cardColor = widgetCardColor()
val item: @Composable (Int) -> Unit = { index ->
val row = rows[index]
val position = positions[index]
val first = position == Position.Top || position == Position.Alone
val dayRow: @Composable (GlanceModifier) -> Unit = { modifier ->
Row(modifier = modifier.fillMaxWidth()) {
Box(modifier = GlanceModifier.width(metrics.pillWidth)) {
if (first) DatePill(row.date, data.today, cardColor, metrics)
}
Spacer(GlanceModifier.width(GUTTER_GAP))
Box(modifier = GlanceModifier.defaultWeight()) {
val rowColor = when (row) {
is AgendaRow.Event -> row.fill(data).let { themedColor(day = it.day, night = it.night) }
is AgendaRow.Placeholder -> cardColor
}
WidgetGroupedRow(position = position, color = rowColor) {
AgendaRowContent(row, data, metrics)
}
}
}
// Bound the payload, then drop a day header the cut left
// stranded with nothing under it.
.take(MAX_AGENDA_ROWS)
.dropLastWhile { it is AgendaRow.Header }
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(rows.size) { index ->
when (val row = rows[index]) {
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
is AgendaRow.Event -> EventRow(
event = row.event,
day = row.date,
dark = dark,
soften = data.soften,
is24Hour = data.is24Hour,
dimmed = pastDisplay == PastEventDisplay.DIM &&
row.event.hasEnded(data.now),
metrics = metrics,
)
}
}
val gap = GlanceModifier.padding(top = if (first && index > 0) metrics.dayGap else 0.dp)
// The pills carry no month, so a new month gets its name above its first day.
if (first && index > 0 && row.date.month != rows[index - 1].date.month) {
Column(modifier = gap.fillMaxWidth()) {
MonthLabel(row.date, data.today, metrics)
dayRow(GlanceModifier)
}
} else {
dayRow(gap)
}
}
if (preview) {
Column(modifier = GlanceModifier.fillMaxSize()) {
repeat(minOf(rows.size, PREVIEW_ROWS)) { item(it) }
}
} else {
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(rows.size) { item(it) }
}
}
}
}
/** Where each row sits among the rows of its day, for the grouped-card shapes. */
private fun dayPositions(rows: List<AgendaRow>): List<Position> = buildList {
var start = 0
for (end in 1..rows.size) {
if (end == rows.size || rows[end].date != rows[start].date) {
for (i in start until end) add(positionOf(i - start, end - start))
start = end
}
}
}
@Composable
private fun AgendaRowContent(row: AgendaRow, data: AgendaWidgetData.Ready, metrics: AgendaMetrics) {
when (row) {
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
is AgendaRow.Event -> EventRow(
event = row.event,
day = row.date,
fill = row.fill(data),
is24Hour = data.is24Hour,
private fun AgendaHeader(metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
Row(
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Tap the "Upcoming" title to open the app on the user's default view
// (issue #20) — a null target resolves to the default home in the host.
Text(
text = context.getString(R.string.widget_agenda_title),
style = TextStyle(
color = GlanceTheme.colors.primary,
fontSize = metrics.title,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier
.defaultWeight()
.clickable(
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
),
)
IconButton(
resId = R.drawable.ic_widget_refresh,
contentDescription = context.getString(R.string.widget_refresh),
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
metrics = metrics,
)
IconButton(
resId = R.drawable.ic_widget_add,
contentDescription = context.getString(R.string.widget_new_event),
onClick = GlanceModifier.clickable(
actionStartActivity(
MainActivity.openCreateIntent(context, today(systemZone())),
),
),
metrics = metrics,
)
}
}
@Composable
private fun AgendaHeader() {
val context = androidx.glance.LocalContext.current
// Tap the "Upcoming" title to open the app on the user's default view
// (issue #20) — a null target resolves to the default home in the host.
WidgetHeader(
title = context.getString(R.string.widget_agenda_title),
titleAction = actionStartActivity(MainActivity.openViewIntent(context, view = null)),
titleInset = 4.dp,
private fun IconButton(
resId: Int,
contentDescription: String,
onClick: GlanceModifier,
metrics: AgendaMetrics,
) {
Box(
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
contentAlignment = Alignment.Center,
) {
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_add),
contentDescription = context.getString(R.string.widget_new_event),
action = actionStartActivity(MainActivity.openCreateIntent(context, today(systemZone()))),
Image(
provider = ImageProvider(resId),
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
modifier = GlanceModifier.size(metrics.iconImage),
)
}
}
/** The month name ("November", with its year outside the current one) above a new month's first day. */
@Composable
private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
Text(
text = monthName(date, today),
maxLines = 1,
text = agendaDayLabel(context, date, today),
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = metrics.monthLabel,
color = if (date == today) GlanceTheme.colors.primary
else GlanceTheme.colors.onSurfaceVariant,
fontSize = metrics.dayHeader,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier
.padding(bottom = 4.dp)
.wallpaperBacking(radius = 8.dp)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
/** A day's weekday and number in a rounded pill, filled for today; opens that day in the app. */
@Composable
private fun DatePill(date: LocalDate, today: LocalDate, cardColor: ColorProvider, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
val isToday = date == today
val container = if (isToday) GlanceTheme.colors.primary else cardColor
val content = if (isToday) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurface
val label = agendaDayLabel(context, date, today)
Column(
modifier = GlanceModifier
.width(metrics.pillWidth)
.height(metrics.rowHeight)
.cornerRadius(metrics.pillWidth / 2)
.background(container)
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda)))
.semantics { contentDescription = label },
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = weekdayShort(date),
maxLines = 1,
style = TextStyle(color = content, fontSize = metrics.pillWeekday, textAlign = TextAlign.Center),
)
Text(
text = date.day.toString(),
maxLines = 1,
style = TextStyle(
color = content,
fontSize = metrics.pillDay,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
),
)
}
}
/** "No more events today" in an anchored, event-less today's card (#35), as tall as the pill beside it. */
@Composable
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
Box(
modifier = GlanceModifier
.fillMaxWidth()
.height(metrics.rowHeight)
.padding(horizontal = ROW_H_PAD)
.padding(
start = 8.dp,
end = 8.dp,
top = metrics.dayHeaderTopPad,
bottom = metrics.rowVPad,
)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = context.getString(R.string.agenda_no_more_today),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
)
}
)
}
/** An event row's light and dark fill: its calendar's container tone, faded when the row is dimmed. */
private data class EventFill(val day: Color, val night: Color)
private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill {
val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now)
fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it
}
return EventFill(day = tone(dark = false), night = tone(dark = true))
/**
* "No more events today" under an anchored, event-less today (#35). Kept plain —
* a muted line indented to the event titles — since the widget's event rows are
* plain too (a stripe + text, not cards), so a card here would look out of place.
*/
@Composable
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
val context = androidx.glance.LocalContext.current
Text(
text = context.getString(R.string.agenda_no_more_today),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
modifier = GlanceModifier
.fillMaxWidth()
.padding(
start = TEXT_INDENT,
end = 8.dp,
top = 2.dp,
bottom = metrics.rowVPad + 2.dp,
)
.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
)
}
@Composable
private fun EventRow(
event: EventInstance,
day: LocalDate,
fill: EventFill,
dark: Boolean,
soften: Boolean,
is24Hour: Boolean,
dimmed: Boolean,
metrics: AgendaMetrics,
) {
val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// Ink is picked against the opaque fill, then carries the dim along with it.
fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha)
val titleInk = themedColor(day = ink(fill.day, 1f), night = ink(fill.night, 1f))
val timeInk = themedColor(
day = ink(fill.day, SECONDARY_INK_ALPHA),
night = ink(fill.night, SECONDARY_INK_ALPHA),
)
Column(
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
// dropping both text lines to the lower-emphasis on-surface-variant tone.
val stripeColor = eventAccent(event.color, dark, soften).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it
}
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
Row(
modifier = GlanceModifier
.fillMaxWidth()
.height(metrics.rowHeight)
.padding(horizontal = ROW_H_PAD)
.padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad)
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
@@ -432,21 +443,30 @@ private fun EventRow(
),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
maxLines = 1,
style = TextStyle(
color = titleInk,
fontSize = metrics.eventTitle,
fontWeight = FontWeight.Medium,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
)
Text(
text = eventTimeSummary(context, event, day, is24Hour),
maxLines = 1,
style = TextStyle(color = timeInk, fontSize = metrics.eventTime),
)
Box(
modifier = GlanceModifier
.width(STRIPE_WIDTH)
.height(metrics.stripeH)
.cornerRadius(3.dp)
.background(stripeColor),
) {}
Spacer(GlanceModifier.width(STRIPE_GAP))
Column(modifier = GlanceModifier.defaultWeight()) {
Text(
text = title,
maxLines = 1,
style = TextStyle(
color = titleColor,
fontSize = metrics.eventTitle,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
)
Text(
text = eventTimeSummary(context, event, day, is24Hour),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
)
}
}
}
@@ -460,11 +480,12 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
Text(
text = context.getString(resId),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message),
modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
private fun zone(): TimeZone = systemZone()
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
val relative = when (date) {
@@ -480,23 +501,26 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
return if (relative != null) "$relative · $formatted" else formatted
}
private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1),
locale = Locale.getDefault(),
currentYear = today.year,
skeleton = "LLLL",
)
private fun weekdayShort(date: LocalDate): String =
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault())
private fun eventTimeSummary(
context: Context,
event: EventInstance,
day: LocalDate,
is24Hour: Boolean,
): String {
val time = widgetTimeLabel(context, event, day, is24Hour)
val time = when (val label = agendaTimeLabel(event, day, zone())) {
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
is AgendaTimeLabel.Starts ->
context.getString(R.string.agenda_span_starts, formatTime(label.start, is24Hour))
is AgendaTimeLabel.Ends ->
context.getString(R.string.agenda_span_ends, formatTime(label.end, is24Hour))
is AgendaTimeLabel.Range ->
"${formatTime(label.start, is24Hour)} – ${formatTime(label.end, is24Hour)}"
}
val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time
}
private fun formatTime(instant: Instant, is24Hour: Boolean): String {
val t = instant.toLocalDateTime(zone()).time
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
}
@@ -1,9 +1,44 @@
package de.jeanlucmakiola.calendula.widget.agenda
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
/** Host-facing receiver for the agenda widget; rendering is [AgendaWidget]'s. */
class AgendaWidgetReceiver : CalendulaWidgetReceiver() {
/**
* Host-facing receiver for the agenda widget. Declared in the manifest with the
* `appwidget_info_agenda` provider metadata; delegates all rendering to
* [AgendaWidget].
*/
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if
* no month widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
*/
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context)
}
}
@@ -1,178 +0,0 @@
package de.jeanlucmakiola.calendula.widget.config
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.PickerSearchField
import de.jeanlucmakiola.calendula.widget.countdown.CountdownMode
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toLocalDateTime
/** What the countdown widget counts to (#439): the next event, or one picked from a searchable list. */
@Composable
internal fun CountdownOptionsSection(
options: CountdownOptions,
candidates: CountdownCandidates,
onChange: (CountdownOptions) -> Unit,
onLoadCandidates: () -> Unit,
) {
var picking by rememberSaveable { mutableStateOf(false) }
GroupedSectionHeader(stringResource(R.string.widget_config_countdown))
GroupedChoiceRow(
title = stringResource(R.string.widget_config_countdown),
options = CountdownMode.entries,
selected = options.mode,
label = {
stringResource(
when (it) {
CountdownMode.NEXT -> R.string.widget_config_countdown_next
CountdownMode.PINNED -> R.string.widget_config_countdown_pinned
},
)
},
onSelect = { mode ->
onChange(options.copy(mode = mode))
if (mode == CountdownMode.PINNED && options.eventId == null) picking = true
},
position = if (options.mode == CountdownMode.PINNED) Position.Top else Position.Alone,
)
when (options.mode) {
CountdownMode.NEXT -> GroupedHint(stringResource(R.string.widget_config_countdown_next_hint), verticalPadding = 8.dp)
CountdownMode.PINNED -> GroupedRow(
title = stringResource(R.string.widget_config_countdown_event),
summary = options.title ?: stringResource(R.string.widget_config_countdown_event_none),
position = Position.Bottom,
onClick = { picking = true },
)
}
if (picking) {
LaunchedEffect(Unit) { onLoadCandidates() }
CountdownEventPicker(
candidates = candidates,
selectedEventId = options.eventId,
onRetry = onLoadCandidates,
onSelect = { candidate ->
onChange(options.copy(mode = CountdownMode.PINNED, eventId = candidate.event.eventId, title = candidate.event.title))
picking = false
},
onDismiss = { picking = false },
)
}
}
@Composable
private fun CountdownEventPicker(
candidates: CountdownCandidates,
selectedEventId: Long?,
onRetry: () -> Unit,
onSelect: (CountdownCandidate) -> Unit,
onDismiss: () -> Unit,
) {
var query by rememberSaveable { mutableStateOf("") }
FullScreenPicker(
title = stringResource(R.string.widget_config_countdown_event),
onDismiss = onDismiss,
predictiveBack = true,
scrollable = false,
) {
PickerSearchField(
query = query,
onQueryChange = { query = it },
placeholder = stringResource(R.string.widget_config_countdown_search),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
when (candidates) {
CountdownCandidates.Idle, CountdownCandidates.Loading -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
CountdownCandidates.Failed -> Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
PickerMessage(stringResource(R.string.widget_config_countdown_load_failed))
FilledTonalButton(onClick = onRetry, modifier = Modifier.padding(top = 16.dp)) {
Text(stringResource(R.string.state_retry))
}
}
is CountdownCandidates.Ready -> {
val locale = currentLocale()
val matches = remember(candidates, query) {
val needle = query.trim()
if (needle.isEmpty()) candidates.events
else candidates.events.filter { it.event.title.contains(needle, ignoreCase = true) }
}
if (matches.isEmpty()) {
PickerMessage(
stringResource(R.string.widget_config_countdown_empty),
Modifier.fillMaxWidth().padding(24.dp),
)
} else {
val zone = remember { TimeZone.currentSystemDefault() }
val formatter = remember(locale) { localizedDateFormatter(locale, "EEEdMMMy") }
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
itemsIndexed(matches, key = { _, it -> it.event.eventId }) { index, candidate ->
val date = formatter.format(candidate.event.start.toLocalDateTime(zone).date.toJavaLocalDate())
GroupedRow(
title = candidate.event.title.ifBlank { stringResource(R.string.event_untitled) },
summary = if (candidate.repeats) stringResource(R.string.widget_config_countdown_repeats, date) else date,
position = positionOf(index, matches.size),
selected = candidate.event.eventId == selectedEventId,
minHeight = 56.dp,
leading = { CalendarColorChip(candidate.event.color) },
onClick = { onSelect(candidate) },
)
}
}
}
}
}
}
}
@Composable
private fun PickerMessage(text: String, modifier: Modifier = Modifier) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = modifier,
)
}
@@ -1,32 +0,0 @@
package de.jeanlucmakiola.calendula.widget.config
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetManager
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetState
/** One of the app's widgets on the home screen, for the settings list. */
data class PlacedWidget(val appWidgetId: Int, val kind: WidgetKind, val appearance: WidgetAppearance)
/**
* Every placed widget, agenda ones first, each in placement order. An id the
* host bound but never laid out (a placement it abandoned without freeing the
* id) has no size yet and is left out, as is one removed while this reads.
*/
suspend fun Context.placedWidgets(): List<PlacedWidget> {
val manager = AppWidgetManager.getInstance(this) ?: return emptyList()
val glanceManager = GlanceAppWidgetManager(this)
return WidgetKind.entries.flatMap { kind ->
manager.getAppWidgetIds(ComponentName(this, kind.receiver)).sorted().mapNotNull { id ->
if (manager.getAppWidgetOptions(id).getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) <= 0) {
return@mapNotNull null
}
val glanceId = runCatching { glanceManager.getGlanceIdBy(id) }.getOrNull() ?: return@mapNotNull null
PlacedWidget(id, kind, widgetState(glanceId).widgetAppearance)
}
}
}
@@ -1,60 +0,0 @@
package de.jeanlucmakiola.calendula.widget.config
import androidx.compose.material3.Checkbox
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.positionOf
/** Which of the app's calendars one widget shows, grouped by account like the drawer's filter. */
@Composable
internal fun WidgetCalendarPicker(
calendars: List<CalendarSource>,
hidden: Set<Long>,
onSetShown: (Long, Boolean) -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(
title = stringResource(R.string.widget_config_calendars),
onDismiss = onDismiss,
predictiveBack = true,
) {
GroupedHint(stringResource(R.string.widget_config_calendars_hint), verticalPadding = 8.dp)
calendars.groupByAccount().forEach { group ->
GroupedSectionHeader(accountGroupTitle(group))
group.calendars.forEachIndexed { index, calendar ->
val shown = calendar.id !in hidden
GroupedRow(
title = calendar.displayName.orUnnamed(),
position = positionOf(index, group.calendars.size),
minHeight = 56.dp,
leading = { CalendarColorChip(calendar.color) },
trailing = { Checkbox(checked = shown, onCheckedChange = { onSetShown(calendar.id, it) }) },
onClick = { onSetShown(calendar.id, !shown) },
)
}
}
}
}
/** "All calendars", or how many of them the widget shows. */
@Composable
internal fun calendarsSummary(calendars: List<CalendarSource>, hidden: Set<Long>): String {
val shown = calendars.count { it.id !in hidden }
return if (shown == calendars.size) {
stringResource(R.string.widget_config_calendars_all)
} else {
pluralStringResource(R.plurals.widget_config_calendars_some, calendars.size, shown, calendars.size)
}
}
@@ -1,133 +0,0 @@
package de.jeanlucmakiola.calendula.widget.config
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.get
import de.jeanlucmakiola.floret.prefs.isDark
import de.jeanlucmakiola.floret.prefs.isPureBlack
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* A widget's config screen (#292): opened by the launcher when the widget is
* placed, from its "Edit widget" action on Android 12+, and from the app's
* widget settings. Shows over the wallpaper, so a transparent widget previews
* against the real home screen.
*/
@AndroidEntryPoint
class WidgetConfigActivity : ComponentActivity() {
@Inject lateinit var settingsPrefs: SettingsPrefs
private val viewModel: WidgetConfigViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appWidgetId = viewModel.appWidgetId
// Backing out of a fresh placement leaves no unconfigured widget behind.
setResult(RESULT_CANCELED, result(appWidgetId))
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
finish()
return
}
enableEdgeToEdge()
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.events.collect { event ->
when (event) {
WidgetConfigEvent.SAVED -> {
setResult(RESULT_OK, result(appWidgetId))
finish()
}
WidgetConfigEvent.SAVE_FAILED ->
Toast.makeText(this@WidgetConfigActivity, R.string.widget_config_save_failed, Toast.LENGTH_SHORT).show()
}
}
}
}
val fontSettings = settingsPrefs.data.map {
AppFontSettings(
brand = it[SettingsKeys.brandFont],
plain = it[SettingsKeys.plainFont],
brandStamp = it[SettingsKeys.brandFontStamp],
plainStamp = it[SettingsKeys.plainFontStamp],
)
}
// Seeded from MainActivity's mirrored copy, so an app theme override
// shows from the first frame.
val cachedTheme = WindowThemeCache.read(this) ?: ThemeMode.SYSTEM
val cachedDynamicColor = WindowThemeCache.readDynamicColor(this) ?: true
setContent {
val themeMode by settingsPrefs.themeMode.collectAsStateWithLifecycle(cachedTheme)
val dynamicColor by settingsPrefs.dynamicColor.collectAsStateWithLifecycle(cachedDynamicColor)
val fonts by fontSettings.collectAsStateWithLifecycle(initialValue = null)
val context = LocalContext.current
// Nothing until the font choice is known, rather than a frame in the default one.
val typography = remember(fonts, context) {
fonts?.let {
calendulaTypography(
brand = resolveFontFamily(it.brand, FontRole.BRAND, context),
plain = resolveFontFamily(it.plain, FontRole.PLAIN, context),
)
}
} ?: return@setContent
CalendulaTheme(
darkTheme = themeMode.isDark(isSystemInDarkTheme()),
dynamicColor = dynamicColor,
pureBlack = themeMode.isPureBlack,
typography = typography,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
if (state is WidgetConfigUiState.Missing) LaunchedEffect(Unit) { finish() }
WidgetConfigScreen(
state = state,
onClose = ::finish,
onSave = viewModel::save,
onRetry = viewModel::retry,
onAppearance = viewModel::setAppearance,
onContent = viewModel::setContent,
onCalendarShown = viewModel::setCalendarShown,
onCountdown = viewModel::setCountdown,
onLoadCountdownCandidates = viewModel::loadCountdownCandidates,
)
}
}
}
private fun result(appWidgetId: Int) = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
companion object {
/** Opens placed widget [appWidgetId]'s config screen from inside the app. */
fun intent(context: Context, appWidgetId: Int): Intent =
Intent(context, WidgetConfigActivity::class.java)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
}
@@ -1,396 +0,0 @@
package de.jeanlucmakiola.calendula.widget.config
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.settings.pastEventDisplayLabelRes
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetOption
import de.jeanlucmakiola.calendula.widget.WidgetSize
import de.jeanlucmakiola.calendula.widget.WidgetTheme
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent
import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview
import de.jeanlucmakiola.floret.glance.WallpaperWindow
import de.jeanlucmakiola.floret.glance.WidgetBackground
import kotlin.math.roundToInt
private val PreviewMaxHeight = 280.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun WidgetConfigScreen(
state: WidgetConfigUiState,
onClose: () -> Unit,
onSave: () -> Unit,
onRetry: () -> Unit,
onAppearance: (WidgetAppearance) -> Unit,
onContent: (AgendaWidgetOptions) -> Unit,
onCalendarShown: (Long, Boolean) -> Unit,
onCountdown: (CountdownOptions) -> Unit,
onLoadCountdownCandidates: () -> Unit,
) {
val ready = state as? WidgetConfigUiState.Ready
val surface = MaterialTheme.colorScheme.surface
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(
title = {
Text(
text = ready?.let { stringResource(it.kind.label) }.orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
}
},
actions = {
Button(
onClick = onSave,
enabled = ready != null && !ready.saving,
modifier = Modifier.padding(end = 8.dp),
) {
Text(stringResource(R.string.dialog_save))
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = surface),
)
when (state) {
WidgetConfigUiState.Loading, WidgetConfigUiState.Missing -> Box(
modifier = Modifier.fillMaxSize().background(surface),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
WidgetConfigUiState.Failed -> Column(
modifier = Modifier.fillMaxSize().background(surface).padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.widget_config_load_failed),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
FilledTonalButton(onClick = onRetry) { Text(stringResource(R.string.state_retry)) }
}
is WidgetConfigUiState.Ready -> {
WallpaperWindow(color = surface) {
val maxSize = DpSize(maxWidth, PreviewMaxHeight)
when (val preview = state.preview) {
null -> Box(
modifier = Modifier.padding(vertical = 16.dp).height(maxSize.height / 2),
contentAlignment = Alignment.Center,
) {
if (state.previewFailed) PreviewFailed() else CircularProgressIndicator()
}
else -> GlanceWidgetPreview(
size = state.placedSize,
maxSize = maxSize,
preview,
state.appearance,
state.appTheme,
modifier = Modifier.padding(vertical = 16.dp),
placeholder = { CircularProgressIndicator() },
failure = { PreviewFailed() },
) {
when (preview) {
is WidgetPreviewData.Agenda ->
AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
is WidgetPreviewData.Month ->
MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme)
is WidgetPreviewData.MonthDay -> MonthDayWidgetContent(
preview.data,
preview.index,
selected = null,
appearance = state.appearance,
appTheme = state.appTheme,
preview = true,
)
is WidgetPreviewData.Countdown ->
CountdownWidgetContent(preview.data, state.appearance, state.appTheme)
is WidgetPreviewData.Days -> when (state.kind) {
WidgetKind.TODAY ->
TodayWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
WidgetKind.UPCOMING -> UpcomingWidgetContent(preview.data, state.appearance, state.appTheme)
else -> WeekStripWidgetContent(preview.data, state.appearance, state.appTheme)
}
is WidgetPreviewData.Today -> if (state.kind == WidgetKind.DATE) {
DateWidgetContent(preview.today, state.appearance, state.appTheme)
} else {
QuickAddWidgetContent(preview.today, state.appearance, state.appTheme)
}
}
}
}
}
ConfigOptions(
state = state,
onAppearance = onAppearance,
onContent = onContent,
onCalendarShown = onCalendarShown,
onCountdown = onCountdown,
onLoadCountdownCandidates = onLoadCountdownCandidates,
modifier = Modifier
.weight(1f)
.background(surface),
)
}
}
}
}
@Composable
private fun PreviewFailed() {
Text(
text = stringResource(R.string.widget_config_preview_failed),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier
.background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.large)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
@Composable
private fun ConfigOptions(
state: WidgetConfigUiState.Ready,
onAppearance: (WidgetAppearance) -> Unit,
onContent: (AgendaWidgetOptions) -> Unit,
onCalendarShown: (Long, Boolean) -> Unit,
onCountdown: (CountdownOptions) -> Unit,
onLoadCountdownCandidates: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.navigationBarsPadding()
.padding(bottom = 16.dp),
) {
GroupedSectionHeader(stringResource(R.string.settings_section_appearance))
GroupedChoiceRow(
title = stringResource(R.string.widget_config_background),
options = WidgetBackground.entries,
selected = state.appearance.background,
label = { backgroundLabel(it) },
onSelect = { onAppearance(state.appearance.copy(background = it)) },
position = Position.Top,
)
GroupedChoiceRow(
title = stringResource(R.string.settings_theme),
options = WidgetTheme.entries,
selected = state.appearance.theme,
label = { themeLabel(it) },
onSelect = { onAppearance(state.appearance.copy(theme = it)) },
position = Position.Bottom,
)
if (state.kind == WidgetKind.COUNTDOWN) {
CountdownOptionsSection(
options = state.draft.countdown,
candidates = state.candidates,
onChange = onCountdown,
onLoadCandidates = onLoadCountdownCandidates,
)
}
if (state.kind.options.isNotEmpty()) {
ContentOptions(state = state, onChange = onContent, onCalendarShown = onCalendarShown)
}
}
}
@Composable
private fun ContentOptions(
state: WidgetConfigUiState.Ready,
onChange: (AgendaWidgetOptions) -> Unit,
onCalendarShown: (Long, Boolean) -> Unit,
) {
val options = state.draft.content
var showRange by rememberSaveable { mutableStateOf(false) }
var showSize by rememberSaveable { mutableStateOf(false) }
var showPast by rememberSaveable { mutableStateOf(false) }
var showCalendars by rememberSaveable { mutableStateOf(false) }
// Rows in WidgetOption order, each shaped by where it lands among the kind's own.
val rows = WidgetOption.entries.filter { it in state.kind.options }
fun position(option: WidgetOption) = positionOf(rows.indexOf(option), rows.size)
GroupedSectionHeader(stringResource(R.string.widget_config_content))
rows.forEach { option ->
when (option) {
WidgetOption.RANGE -> GroupedRow(
title = stringResource(R.string.widget_config_range),
summary = agendaRangeLabel(options.range),
position = position(option),
onClick = { showRange = true },
)
WidgetOption.TEXT_SIZE -> GroupedRow(
title = stringResource(R.string.widget_config_text_size),
summary = widgetSizeLabel(options.textSize),
position = position(option),
onClick = { showSize = true },
)
WidgetOption.PAST_DISPLAY -> GroupedRow(
title = stringResource(R.string.settings_past_events),
summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)),
position = position(option),
onClick = { showPast = true },
)
WidgetOption.SHOW_TODAY -> GroupedSwitchRow(
title = stringResource(R.string.settings_agenda_show_today),
checked = options.showToday,
onCheckedChange = { onChange(options.copy(showToday = it)) },
position = position(option),
)
WidgetOption.CALENDARS -> GroupedRow(
title = stringResource(R.string.widget_config_calendars),
summary = calendarsSummary(state.calendars, state.draft.hiddenCalendars),
position = position(option),
onClick = { showCalendars = true },
)
}
}
if (showCalendars) {
WidgetCalendarPicker(
calendars = state.calendars,
hidden = state.draft.hiddenCalendars,
onSetShown = onCalendarShown,
onDismiss = { showCalendars = false },
)
}
if (showRange) {
AgendaRangePicker(
title = stringResource(R.string.widget_config_range),
description = stringResource(R.string.settings_agenda_widget_range_hint),
selected = options.range,
weekStart = state.weekStart,
onSelect = { onChange(options.copy(range = it)) },
onDismiss = { showRange = false },
)
}
if (showSize) {
OptionPicker(
title = stringResource(R.string.widget_config_text_size),
header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
predictiveBack = true,
options = WidgetSize.entries,
selected = options.textSize,
label = { widgetSizeLabel(it) },
summary = { widgetSizeSummary(it) },
onSelect = { onChange(options.copy(textSize = it)) },
onDismiss = { showSize = false },
)
}
if (showPast) {
OptionPicker(
title = stringResource(R.string.settings_past_events),
header = { GroupedHint(stringResource(R.string.settings_past_events_hint), verticalPadding = 8.dp) },
predictiveBack = true,
options = PastEventDisplay.entries,
selected = options.pastDisplay,
label = { stringResource(pastEventDisplayLabelRes(it)) },
onSelect = { onChange(options.copy(pastDisplay = it)) },
onDismiss = { showPast = false },
)
}
}
@Composable
internal fun backgroundLabel(background: WidgetBackground): String = stringResource(
when (background) {
WidgetBackground.SOLID -> R.string.widget_config_background_solid
WidgetBackground.TINTED -> R.string.widget_config_background_tinted
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent
},
)
@Composable
internal fun themeLabel(theme: WidgetTheme): String = stringResource(
when (theme) {
WidgetTheme.FOLLOW_APP -> R.string.widget_config_theme_follow_app
WidgetTheme.LIGHT -> R.string.settings_theme_light
WidgetTheme.DARK -> R.string.settings_theme_dark
},
)
/**
* What a size step does, as a percentage of the smallest one's event text.
* Derived from the widget's own [metricsFor] table rather than hardcoded.
*/
@Composable
private fun widgetSizeSummary(size: WidgetSize): String {
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
return stringResource(R.string.settings_widget_size_summary, percent)
}
@Composable
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
when (size) {
WidgetSize.SMALL -> R.string.settings_widget_size_small
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
WidgetSize.LARGE -> R.string.settings_widget_size_large
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
},
)
@@ -1,335 +0,0 @@
package de.jeanlucmakiola.calendula.widget.config
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
import de.jeanlucmakiola.calendula.widget.WidgetSettings
import de.jeanlucmakiola.calendula.widget.loadDays
import de.jeanlucmakiola.calendula.widget.weekOf
import de.jeanlucmakiola.calendula.widget.upcoming.loadUpcoming
import de.jeanlucmakiola.calendula.widget.today.loadTodayList
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.countdownOptions
import de.jeanlucmakiola.calendula.widget.countdown.loadCountdown
import de.jeanlucmakiola.calendula.widget.countdown.putCountdownOptions
import de.jeanlucmakiola.calendula.widget.split.MonthDayData
import de.jeanlucmakiola.calendula.widget.split.loadMonthDay
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import de.jeanlucmakiola.calendula.widget.WidgetOption
import de.jeanlucmakiola.calendula.widget.hiddenCalendars
import de.jeanlucmakiola.calendula.widget.putHiddenCalendars
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource
import de.jeanlucmakiola.calendula.widget.monthIndexOf
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.putAgendaOptions
import de.jeanlucmakiola.calendula.widget.putAppearance
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetSettings
import de.jeanlucmakiola.calendula.widget.widgetState
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import de.jeanlucmakiola.floret.prefs.ThemeMode
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import java.util.Locale
/** What the preview draws, loaded with the draft's options. */
sealed interface WidgetPreviewData {
data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData
data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData
data class Today(val today: LocalDate) : WidgetPreviewData
data class Days(val data: DaysWidgetData) : WidgetPreviewData
data class Countdown(val data: CountdownData) : WidgetPreviewData
data class MonthDay(val data: MonthDayData, val index: Int) : WidgetPreviewData
}
/** The options being edited, applied to the preview as they change and written on save. */
data class WidgetDraft(
val appearance: WidgetAppearance,
/** The content options; stored only for a kind that [WidgetKind.hasContentOptions]. */
val content: AgendaWidgetOptions,
val hiddenCalendars: Set<Long>,
val countdown: CountdownOptions = CountdownOptions(),
)
/** One event or series the countdown can be pinned to, at its next occurrence. */
data class CountdownCandidate(val event: EventInstance, val repeats: Boolean)
/** The countdown's event list, loaded when its picker first opens. */
sealed interface CountdownCandidates {
data object Idle : CountdownCandidates
data object Loading : CountdownCandidates
data object Failed : CountdownCandidates
data class Ready(val events: List<CountdownCandidate>) : CountdownCandidates
}
private fun WidgetDraft.loadContext(settings: WidgetSettings) = WidgetLoadContext(settings, content, hiddenCalendars)
sealed interface WidgetConfigUiState {
data object Loading : WidgetConfigUiState
/** The widget is gone, or isn't one of ours; the screen closes. */
data object Missing : WidgetConfigUiState
/** Its stored options couldn't be read. */
data object Failed : WidgetConfigUiState
data class Ready(
val kind: WidgetKind,
val draft: WidgetDraft,
/** The calendars the app shows, for the widget's own calendar choice. */
val calendars: List<CalendarSource>,
val appTheme: ThemeMode,
val weekStart: DayOfWeek,
/** The widget's size on the home screen, which the preview is laid out at. */
val placedSize: DpSize,
/** Null until the first preview load lands. */
val preview: WidgetPreviewData?,
val candidates: CountdownCandidates = CountdownCandidates.Idle,
/** The first preview load failed, so there's nothing to show. */
val previewFailed: Boolean = false,
val saving: Boolean = false,
) : WidgetConfigUiState {
val appearance: WidgetAppearance get() = draft.appearance
}
}
/** One-time outcomes of a save, for the screen that's showing when they land. */
enum class WidgetConfigEvent { SAVED, SAVE_FAILED }
/**
* Backs [WidgetConfigActivity] (#292): the placed widget's stored options as an
* editable draft, a preview loaded with that draft, and the save that writes it
* into the widget's Glance state and redraws it.
*/
@HiltViewModel
class WidgetConfigViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val prefs: SettingsPrefs,
private val calendarPrefs: CalendarPrefs,
private val repository: CalendarRepository,
savedState: SavedStateHandle,
) : ViewModel() {
val appWidgetId: Int = savedState[AppWidgetManager.EXTRA_APPWIDGET_ID] ?: AppWidgetManager.INVALID_APPWIDGET_ID
private val _state = MutableStateFlow<WidgetConfigUiState>(WidgetConfigUiState.Loading)
val state: StateFlow<WidgetConfigUiState> = _state.asStateFlow()
private val _events = Channel<WidgetConfigEvent>(Channel.BUFFERED)
val events: Flow<WidgetConfigEvent> = _events.receiveAsFlow()
private var previewJob: Job? = null
init {
viewModelScope.launch { load() }
}
fun retry() {
if (_state.value != WidgetConfigUiState.Failed) return
_state.value = WidgetConfigUiState.Loading
viewModelScope.launch { load() }
}
private suspend fun load() {
val kind = WidgetKind.of(context, appWidgetId)
if (kind == null) {
_state.value = WidgetConfigUiState.Missing
return
}
_state.value = try {
val stored = context.widgetState(GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId))
val settings = prefs.widgetSettings.first()
WidgetConfigUiState.Ready(
kind = kind,
draft = WidgetDraft(
appearance = stored.widgetAppearance,
content = stored.agendaWidgetOptions(settings.agendaDefaults),
hiddenCalendars = stored.hiddenCalendars,
countdown = stored.countdownOptions,
),
calendars = if (WidgetOption.CALENDARS in kind.options) shownCalendars() else emptyList(),
appTheme = settings.themeMode,
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
placedSize = placedSize(kind),
preview = null,
)
} catch (e: CancellationException) {
throw e
} catch (_: IllegalArgumentException) {
// getGlanceIdBy: the widget was removed meanwhile.
WidgetConfigUiState.Missing
} catch (_: Exception) {
WidgetConfigUiState.Failed
}
reloadPreview()
}
/** The size the launcher placed the widget at (portrait), or a default before it has reported one. */
private fun placedSize(kind: WidgetKind): DpSize {
val options = AppWidgetManager.getInstance(context)?.getAppWidgetOptions(appWidgetId)
val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0
val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0
if (width > 0 && height > 0) return DpSize(width.dp, height.dp)
return kind.defaultSize
}
/** The calendars the app itself shows; the ones hidden there never reach a widget anyway. */
private suspend fun shownCalendars(): List<CalendarSource> {
val hidden = calendarPrefs.hiddenCalendarIds.first()
return repository.calendars().first().filter { it.isVisibleInSystem && it.id !in hidden }
}
fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(draft = it.draft.copy(appearance = appearance)) }
fun setContent(options: AgendaWidgetOptions) {
edit { it.copy(draft = it.draft.copy(content = options)) }
reloadPreview()
}
fun setCountdown(options: CountdownOptions) {
edit { it.copy(draft = it.draft.copy(countdown = options)) }
reloadPreview()
}
/** Loads the events the countdown can be pinned to, unless they're loaded or loading. */
fun loadCountdownCandidates() {
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
if (ready.candidates is CountdownCandidates.Loading || ready.candidates is CountdownCandidates.Ready) return
edit { it.copy(candidates = CountdownCandidates.Loading) }
viewModelScope.launch {
val candidates = try {
val now = Clock.System.now()
val occurrences = repository.instances(now..now + 365.days).first().filter { it.end > now }
val perEvent = occurrences.groupBy { it.eventId }
CountdownCandidates.Ready(
perEvent.values
.map { CountdownCandidate(it.minBy { o -> o.start }, repeats = it.size > 1) }
.sortedBy { it.event.start },
)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
CountdownCandidates.Failed
}
edit { it.copy(candidates = candidates) }
}
}
fun setCalendarShown(id: Long, shown: Boolean) {
edit {
val hidden = it.draft.hiddenCalendars
it.copy(draft = it.draft.copy(hiddenCalendars = if (shown) hidden - id else hidden + id))
}
reloadPreview()
}
private fun edit(change: (WidgetConfigUiState.Ready) -> WidgetConfigUiState.Ready) {
_state.update { if (it is WidgetConfigUiState.Ready) change(it) else it }
}
private fun reloadPreview() {
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
previewJob?.cancel()
previewJob = viewModelScope.launch {
val preview = try {
loadPreview(ready)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// The options stay editable; the preview keeps what it had.
edit { it.copy(previewFailed = it.preview == null) }
return@launch
}
edit { it.copy(preview = preview, previewFailed = false) }
}
}
private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData {
val draft = ready.draft
val settings = prefs.widgetSettings.first()
return when (ready.kind) {
WidgetKind.AGENDA ->
WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, draft.content, draft.hiddenCalendars))
WidgetKind.MONTH -> {
val index = monthIndexOf(today(systemZone()))
val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars)
WidgetPreviewData.Month(source.content, index)
}
WidgetKind.QUICK_ADD, WidgetKind.DATE -> WidgetPreviewData.Today(today(systemZone()))
WidgetKind.UPCOMING -> WidgetPreviewData.Days(context.loadUpcoming(draft.loadContext(settings)).content)
WidgetKind.TODAY -> WidgetPreviewData.Days(context.loadTodayList(draft.loadContext(settings)).content)
WidgetKind.MONTH_DAY -> {
val index = monthIndexOf(today(systemZone()))
val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars)
WidgetPreviewData.MonthDay(context.loadMonthDay(source, draft.content).content, index)
}
WidgetKind.COUNTDOWN ->
WidgetPreviewData.Countdown(context.loadCountdown(draft.loadContext(settings), draft.countdown, null).content)
WidgetKind.WEEK_STRIP -> WidgetPreviewData.Days(context.loadDays(draft.loadContext(settings), ::weekOf).content)
}
}
/** Writes the draft into the widget and redraws it, then reports how that went on [events]. */
fun save() {
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
if (ready.saving) return
edit { it.copy(saving = true) }
viewModelScope.launch {
try {
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
ready.kind.widget().updateWithRefresh(context, glanceId) { state ->
state.putAppearance(ready.draft.appearance)
if (ready.kind.hasContentOptions) state.putAgendaOptions(ready.draft.content)
if (WidgetOption.CALENDARS in ready.kind.options) state.putHiddenCalendars(ready.draft.hiddenCalendars)
if (ready.kind == WidgetKind.COUNTDOWN) state.putCountdownOptions(ready.draft.countdown)
}
_events.send(WidgetConfigEvent.SAVED)
} catch (e: CancellationException) {
throw e
} catch (_: IllegalArgumentException) {
_state.value = WidgetConfigUiState.Missing
} catch (_: Exception) {
edit { it.copy(saving = false) }
_events.send(WidgetConfigEvent.SAVE_FAILED)
}
}
}
}
@@ -1,96 +0,0 @@
package de.jeanlucmakiola.calendula.widget.countdown
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.math.ceil
import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/** What a countdown widget counts down to (#439). */
enum class CountdownMode { NEXT, PINNED }
data class CountdownOptions(
val mode: CountdownMode = CountdownMode.NEXT,
/** The pinned event or series; a series follows its next occurrence. */
val eventId: Long? = null,
/** The pinned event's title when it was chosen, for the config screen. */
val title: String? = null,
)
enum class CountdownUnit { NOW, MINUTES, HOURS, DAYS }
/** "in 25 min", "ends in 3 h", "in 12 days": [running] counts to the end instead of the start. */
data class CountdownLabel(val running: Boolean, val unit: CountdownUnit, val amount: Int)
private fun target(event: EventInstance, now: Instant): Instant = if (event.start <= now) event.end else event.start
/**
* The label for [event] at [now]. Under a minute reads "now", under an hour
* counts minutes (rounded up), under a day whole hours (rounded), and beyond
* that calendar days in [zone], so "in 1 day" is always tomorrow.
*/
fun countdownLabel(event: EventInstance, now: Instant, zone: TimeZone): CountdownLabel {
val running = event.start <= now
val target = target(event, now)
val delta = target - now
return when {
delta < 1.minutes -> CountdownLabel(running, CountdownUnit.NOW, 0)
delta < 1.hours -> CountdownLabel(running, CountdownUnit.MINUTES, ceil(delta / 1.minutes).toInt())
delta < 24.hours -> CountdownLabel(running, CountdownUnit.HOURS, (delta / 1.hours).roundToInt().coerceAtLeast(1))
else -> {
val days = now.toLocalDateTime(zone).date.daysUntil(target.toLocalDateTime(zone).date)
CountdownLabel(running, CountdownUnit.DAYS, days.coerceAtLeast(1))
}
}
}
/**
* When [countdownLabel] next reads differently, so the widget redraws then
* and not before: every minute in the last hour, at each half hour within the
* day, and at midnight or the 24-hour mark beyond that.
*/
fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant {
val target = target(event, now)
val label = countdownLabel(event, now, zone)
val lowerBound: Duration = when (label.unit) {
CountdownUnit.NOW -> Duration.ZERO
CountdownUnit.MINUTES -> (label.amount - 1).minutes
CountdownUnit.HOURS -> maxOf((label.amount - 0.5).hours, 1.hours)
CountdownUnit.DAYS -> {
val midnight = now.toLocalDateTime(zone).date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
return minOf(midnight, target - 24.hours).coerceAtLeast(now + 1.minutes)
}
}
return (target - lowerBound).coerceAtLeast(now + 1.minutes)
}
/**
* The next-event mode's pick: timed, not declined, not over; of those, the one
* whose countdown changes soonest — a running event's end, else a start.
*/
fun pickNextEvent(instances: List<EventInstance>, now: Instant): EventInstance? =
instances
.filter { !it.isAllDay && !it.isDeclined && it.end > now }
.minByOrNull { target(it, now) }
/** A pinned event's occurrence to count to: the running or next one. */
fun pickPinnedOccurrence(occurrences: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
occurrences.filter { it.counted(zone).end > now }.minByOrNull { it.start }
/**
* The span a countdown counts over. An all-day event is stored on UTC
* midnights; it starts and ends at local midnight of those dates instead.
*/
fun EventInstance.counted(zone: TimeZone): EventInstance = if (!isAllDay) this else copy(
start = start.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone),
end = end.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone),
)
@@ -1,167 +0,0 @@
package de.jeanlucmakiola.calendula.widget.countdown
import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.net.toUri
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidgetManager
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.widget.Themed
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
import de.jeanlucmakiola.calendula.widget.hasCalendarPermission
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.widgetState
import de.jeanlucmakiola.calendula.widget.withoutCalendars
import de.jeanlucmakiola.floret.android.goAsync
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import de.jeanlucmakiola.floret.prefs.toEnum
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Instant
private val ModeKey = stringPreferencesKey("cfg_countdown_mode")
private val EventKey = longPreferencesKey("cfg_countdown_event")
private val TitleKey = stringPreferencesKey("cfg_countdown_title")
internal val Preferences.countdownOptions: CountdownOptions
get() = CountdownOptions(this[ModeKey].toEnum(CountdownMode.NEXT), this[EventKey], this[TitleKey])
internal fun MutablePreferences.putCountdownOptions(options: CountdownOptions) {
this[ModeKey] = options.mode.name
if (options.eventId != null) this[EventKey] = options.eventId else remove(EventKey)
if (options.title != null) this[TitleKey] = options.title else remove(TitleKey)
}
/** How far ahead the next-event mode looks; past this it shows no event. */
private val NextEventHorizon = 30.days
/** How far ahead a pinned event's next occurrence is looked for. */
private val PinnedHorizon = 730.days
sealed interface CountdownData {
data object NeedsPermission : CountdownData
data class Ready(
val options: CountdownOptions,
/** The occurrence counted to; null when there is none to show. */
val event: EventInstance?,
val now: Instant,
val today: LocalDate,
val is24Hour: Boolean,
val soften: Boolean,
/** The placed widget, for opening its settings; null in a preview. */
val appWidgetId: Int?,
) : CountdownData
}
internal suspend fun Context.loadCountdown(
load: WidgetLoadContext,
options: CountdownOptions,
appWidgetId: Int?,
): Themed<CountdownData> {
val settings = load.settings
if (!hasCalendarPermission()) return Themed(CountdownData.NeedsPermission, settings.themeMode)
val now = Clock.System.now()
val repository = widgetEntryPoint().calendarRepository()
val event = when (options.mode) {
CountdownMode.NEXT ->
pickNextEvent(repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars), now)
CountdownMode.PINNED -> options.eventId?.let { id ->
pickPinnedOccurrence(repository.eventOccurrences(id, now - 1.days..now + PinnedHorizon), now, systemZone())
}
}
return Themed(
CountdownData.Ready(
options = options,
event = event,
now = now,
today = today(systemZone()),
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
soften = settings.soften,
appWidgetId = appWidgetId,
),
settings.themeMode,
)
}
/** [loadCountdown] for placed widget [id], arming its redraw for when the label next changes. */
internal suspend fun Context.loadPlacedCountdown(id: GlanceId): Themed<CountdownData> {
val appWidgetId = GlanceAppWidgetManager(this).getAppWidgetId(id)
val loaded = loadCountdown(widgetLoadContext(id), widgetState(id).countdownOptions, appWidgetId)
val event = (loaded.content as? CountdownData.Ready)?.event
if (event == null) {
CountdownTicks.cancel(this, appWidgetId)
} else {
val zone = systemZone()
CountdownTicks.schedule(this, appWidgetId, nextLabelChange(event.counted(zone), (loaded.content as CountdownData.Ready).now, zone))
}
return loaded
}
/**
* One alarm per countdown widget, for the moment its label changes. Not a
* wake-up alarm: with the screen off nobody sees the widget, so the redraw
* waits for the device to wake. Exact where allowed (the app holds the exact
* alarm permission for reminders), else within a minute.
*/
internal object CountdownTicks {
private const val ACTION_TICK = "de.jeanlucmakiola.calendula.widget.COUNTDOWN_TICK"
fun schedule(context: Context, appWidgetId: Int, at: Instant) {
val alarms = context.getSystemService(AlarmManager::class.java) ?: return
val intent = pendingIntent(context, appWidgetId)
val millis = at.toEpochMilliseconds()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()) {
alarms.setExact(AlarmManager.RTC, millis, intent)
} else {
alarms.setWindow(AlarmManager.RTC, millis, 60_000L, intent)
}
}
fun cancel(context: Context, appWidgetId: Int) {
context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context, appWidgetId))
}
private fun pendingIntent(context: Context, appWidgetId: Int): PendingIntent = PendingIntent.getBroadcast(
context,
0,
Intent(context, CountdownTickReceiver::class.java)
.setAction(ACTION_TICK)
// Keys the PendingIntent per widget.
.setData("calendula://countdown/$appWidgetId".toUri())
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
/** Reloads the countdown widget whose label just changed; the load arms the next tick. */
class CountdownTickReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return
val appContext = context.applicationContext
goAsync {
try {
val glanceId = GlanceAppWidgetManager(appContext).getGlanceIdBy(appWidgetId)
CountdownWidget().updateWithRefresh(appContext, glanceId)
} catch (_: IllegalArgumentException) {
// The widget is gone.
CountdownTicks.cancel(appContext, appWidgetId)
}
}
}
}
@@ -1,230 +0,0 @@
package de.jeanlucmakiola.calendula.widget.countdown
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.res.Resources
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.action.Action
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
import de.jeanlucmakiola.calendula.widget.formatWidgetTime
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetCardColor
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.themedColor
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.minus
import kotlinx.datetime.toLocalDateTime
/**
* Countdown (#439): to the next timed event, or to a chosen event or series
* ("Vacation · in 12 days"). Redraws when its label changes, see
* [nextLabelChange] and [CountdownTicks]. Tapping opens the event.
*/
class CountdownWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Single
override val previewSizeMode = previewSizeOf(WidgetKind.COUNTDOWN)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.COUNTDOWN)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadPlacedCountdown(id), refreshStamp(context, id))
provideContent {
val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedCountdown(id) }
CountdownWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
class CountdownWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = CountdownWidget()
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
appWidgetIds.forEach { CountdownTicks.cancel(context, it) }
}
}
@Composable
internal fun CountdownWidgetContent(data: CountdownData, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
when (data) {
CountdownData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
is CountdownData.Ready -> {
val event = data.event
when {
event != null -> EventCountdown(event, data)
data.options.mode == CountdownMode.PINNED && data.options.eventId == null ->
Message(context.getString(R.string.widget_countdown_choose), configAction(context, data.appWidgetId))
data.options.mode == CountdownMode.PINNED ->
Message(context.getString(R.string.widget_countdown_over, data.options.title.orEmpty()), configAction(context, data.appWidgetId))
else -> Message(
context.getString(R.string.widget_upcoming_empty),
actionStartActivity(MainActivity.openViewIntent(context, null)),
)
}
}
}
}
}
private fun configAction(context: Context, appWidgetId: Int?): Action? =
appWidgetId?.takeIf { it != AppWidgetManager.INVALID_APPWIDGET_ID }
?.let { actionStartActivity(WidgetConfigActivity.intent(context, it)) }
@Composable
private fun Message(text: String, action: Action?) {
val modifier = if (action != null) GlanceModifier.clickable(action) else GlanceModifier
WidgetMessage(text, modifier = modifier)
}
@Composable
private fun EventCountdown(event: EventInstance, data: CountdownData.Ready) {
val context = LocalContext.current
val label = countdownLabel(event.counted(systemZone()), data.now, systemZone())
val open = actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Agenda,
),
)
// Over a wallpaper the text sits on its own card, as every other widget's rows do.
val card = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
GlanceModifier.cornerRadius(16.dp).background(widgetCardColor())
} else {
GlanceModifier
}
Box(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
Row(
modifier = card.fillMaxSize().clickable(open).padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = GlanceModifier
.width(4.dp)
.fillMaxHeight()
.cornerRadius(2.dp)
.background(
themedColor(
day = eventAccent(event.color, dark = false, data.soften),
night = eventAccent(event.color, dark = true, data.soften),
),
),
) {}
Spacer(GlanceModifier.width(10.dp))
Column(modifier = GlanceModifier.defaultWeight(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp, fontWeight = FontWeight.Medium),
)
Text(
text = countdownText(context.resources, label),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 22.sp, fontWeight = FontWeight.Bold),
)
Text(
text = whenText(context, event, data),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
)
}
}
}
}
internal fun countdownText(res: Resources, label: CountdownLabel): String = when (label.unit) {
CountdownUnit.NOW -> res.getString(if (label.running) R.string.widget_countdown_ending_now else R.string.widget_countdown_now)
CountdownUnit.MINUTES -> res.getQuantityString(
if (label.running) R.plurals.widget_countdown_ends_minutes else R.plurals.widget_countdown_in_minutes,
label.amount,
label.amount,
)
CountdownUnit.HOURS -> res.getQuantityString(
if (label.running) R.plurals.widget_countdown_ends_hours else R.plurals.widget_countdown_in_hours,
label.amount,
label.amount,
)
CountdownUnit.DAYS -> when {
label.amount == 1 && label.running -> res.getString(R.string.widget_countdown_ends_tomorrow)
label.amount == 1 -> res.getString(R.string.widget_countdown_tomorrow)
else -> res.getQuantityString(
if (label.running) R.plurals.widget_countdown_ends_days else R.plurals.widget_countdown_in_days,
label.amount,
label.amount,
)
}
}
/** When it starts, or ends once running: "Today · 14:00", "Fri · All day". */
private fun whenText(context: Context, event: EventInstance, data: CountdownData.Ready): String {
val counted = event.counted(systemZone())
val running = counted.start <= data.now
val at = if (running) counted.end else counted.start
val date = at.toLocalDateTime(systemZone()).date
val day = relativeDayLabel(context, date, data.today)
return when {
event.isAllDay && running -> context.getString(
R.string.widget_countdown_until,
relativeDayLabel(context, date.minus(1, DateTimeUnit.DAY), data.today),
)
event.isAllDay -> "$day · ${context.getString(R.string.event_detail_all_day)}"
running -> context.getString(R.string.agenda_span_ends, "$day · ${formatWidgetTime(at, data.is24Hour)}")
else -> "$day · ${formatWidgetTime(at, data.is24Hour)}"
}
}
@@ -1,130 +0,0 @@
package de.jeanlucmakiola.calendula.widget.date
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.provideContent
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.semantics.contentDescription
import androidx.glance.semantics.semantics
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.loadToday
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetShape
import de.jeanlucmakiola.floret.glance.WidgetShapedBox
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.LocalDate
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import java.time.format.TextStyle as JavaTextStyle
/** 1×1 date (#376): today's weekday above its number on an expressive shape; opens today. */
class DateWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.DATE)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.DATE)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id))
provideContent {
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
DateWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
class DateWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = DateWidget()
}
private val WeekdayLine = 18.dp
@Composable
internal fun DateWidgetContent(today: LocalDate, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
val size = LocalSize.current
val fontScale = context.resources.configuration.fontScale
val side = (minOf(size.width, size.height - WeekdayLine * fontScale) - 8.dp).coerceIn(32.dp, 120.dp)
val label = localizedDateFormatter(widgetLocale(), "EEEEdMMMM").format(today.toJavaLocalDate())
val weekday = TextStyle(
color = GlanceTheme.colors.onSurface,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
)
Column(
modifier = GlanceModifier
.fillMaxSize()
.clickable(actionStartActivity(MainActivity.openDateIntent(context, today, CalendarView.Day)))
.semantics { contentDescription = label },
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
val name = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, widgetLocale())
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 6.dp)
} else {
GlanceModifier
}
Text(text = name.uppercase(widgetLocale()), maxLines = 1, style = weekday, modifier = backing)
Spacer(GlanceModifier.height(2.dp))
WidgetShapedBox(shape = WidgetShape.COOKIE, color = GlanceTheme.colors.primary, size = side) {
Text(
text = today.day.toString(),
maxLines = 1,
style = TextStyle(
color = GlanceTheme.colors.onPrimary,
// Sized to the shape, so held against the font scale.
fontSize = (side.value * 0.45f / fontScale).sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
)
}
}
}
}
@@ -1,88 +0,0 @@
package de.jeanlucmakiola.calendula.widget.month
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.month.DaySeating
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.seating
import de.jeanlucmakiola.calendula.ui.month.spanAt
/** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */
internal data class GridCell<T : Any>(val item: T?, val cols: Int)
/**
* The seven columns as cells and the gaps between them, consecutive empty
* columns merged into one: the day-open taps come from the strip underneath,
* so a gap needs no per-column view. [cellAt] gives what starts at a column
* and how many columns it covers.
*/
internal fun <T : Any> mergeGaps(columns: Int, cellAt: (Int) -> GridCell<T>?): List<GridCell<T>> {
val cells = mutableListOf<GridCell<T>>()
var gap = 0
var col = 0
while (col < columns) {
val cell = cellAt(col)
if (cell == null) {
gap += 1
col += 1
continue
}
if (gap > 0) cells += GridCell(null, gap)
gap = 0
cells += cell
col += cell.cols
}
if (gap > 0) cells += GridCell(null, gap)
return cells
}
/**
* One week of the grid seated with [laneCap] lanes. [showsOverflow] is off when
* the week has no room for even the "+N" row.
*/
internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverflow: Boolean = true) {
private val seating: List<DaySeating> = week.days.indices.map { week.seating(it, laneCap) }
/** Lanes filled from the top; empty lanes below are not drawn. */
val usedLanes: Int = seating.maxOfOrNull { day -> day.seated.maxOfOrNull { it.lane + 1 } ?: 0 } ?: 0
/** Each drawn lane as bars and merged gaps. */
val lanes: List<List<GridCell<EventInstance>>> = List(usedLanes) { lane ->
mergeGaps(week.days.size) { col ->
val seat = seating[col].seated.firstOrNull { it.lane == lane } ?: return@mergeGaps null
val endCol = if (seat.bar) week.spanAt(col, lane)?.endCol ?: col else col
GridCell(seat.event, endCol - col + 1)
}
}
/** The "+N" row as counts and merged gaps; empty when everything fits. */
val overflow: List<GridCell<Int>> = seating.map { it.hidden.size }.let { hidden ->
if (hidden.none { it > 0 }) emptyList()
else mergeGaps(hidden.size) { col -> hidden[col].takeIf { it > 0 }?.let { GridCell(it, 1) } }
}
}
/**
* Event lanes a week row [weekHeight] tall holds under its day number, held to
* [MAX_LANES] so a tall widget's payload stays bounded (#214).
*/
internal fun lanesFitting(weekHeight: Dp): Int =
((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES)
/**
* Seats each week in [lanes] lanes. A week whose events need more gives its
* last lane to the "+N" row instead.
*/
internal fun fitWeeks(weeks: List<MonthWeek>, lanes: Int): List<WeekLayout> = weeks.map { week ->
if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false)
val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes
WeekLayout(week, if (needed <= lanes) lanes else lanes - 1)
}
/** Lanes a week shows at most, however tall the widget. */
internal const val MAX_LANES = 8
private const val DAY_ROW_DP = 18f
private const val LANES_TOP_DP = 2f
private const val LANE_ROW_DP = 15f
private const val UNCAPPED_LANES = 64
@@ -1,169 +0,0 @@
package de.jeanlucmakiola.calendula.widget.month
import android.app.PendingIntent
import android.content.Context
import android.graphics.Typeface
import android.text.SpannableString
import android.text.Spanned
import android.text.style.StrikethroughSpan
import android.text.style.StyleSpan
import android.view.View
import android.widget.RemoteViews
import androidx.compose.ui.graphics.Color
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.floret.glance.RemoteColor
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
import de.jeanlucmakiola.floret.glance.remoteColor
import de.jeanlucmakiola.floret.glance.setImageTint
import de.jeanlucmakiola.floret.glance.setTextColor
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
/** The theme colours the plain-RemoteViews grid draws with. */
internal data class GridColors(
val onSurface: RemoteColor,
val onSurfaceVariant: RemoteColor,
val primary: RemoteColor,
val onPrimary: RemoteColor,
val cell: RemoteColor,
)
/**
* The month's week rows as one plain RemoteViews tree, embedded in the Glance
* widget so its views don't draw on Glance's view-id pool (#372): every event
* gets its bar, and only what can't fit the widget's height folds into "+N".
*
* A host may reapply a new rendering onto the views of the last one rather than
* inflate it afresh, so every container is cleared before it's filled and every
* view state is set both ways, never just when it differs from the layout.
*/
internal fun monthGridViews(
context: Context,
layouts: List<WeekLayout>,
currentMonth: Month,
today: LocalDate,
soften: Boolean,
colors: GridColors,
mode: WidgetThemeMode,
): RemoteViews {
val grid = RemoteViews(context.packageName, R.layout.widget_month_grid)
grid.removeAllViews(R.id.month_grid)
layouts.forEach { grid.addView(R.id.month_grid, weekViews(context, it, currentMonth, today, soften, colors, mode)) }
return grid
}
private fun weekViews(
context: Context,
layout: WeekLayout,
currentMonth: Month,
today: LocalDate,
soften: Boolean,
colors: GridColors,
mode: WidgetThemeMode,
): RemoteViews {
val week = RemoteViews(context.packageName, R.layout.widget_month_week)
week.removeAllViews(R.id.week_lanes)
layout.week.days.forEachIndexed { col, date ->
week.setOnClickPendingIntent(STRIP_IDS[col], openDayIntent(context, date))
// Each day its own rounded cell, as in the app's month grid. Days around
// the month get a shade over their (opaque) cell, so they recede without
// letting a wallpaper show through.
week.setImageTint(context, CELL_IDS[col], colors.cell)
val outside = date.month != currentMonth
week.setViewVisibility(CELL_DIM_IDS[col], if (outside) View.VISIBLE else View.GONE)
if (outside) {
week.setImageTint(context, CELL_DIM_IDS[col], mode.remoteColor(OUTSIDE_SHADE_DAY, OUTSIDE_SHADE_NIGHT))
}
val dayId = DAY_IDS[col]
val isToday = date == today
week.setTextViewText(dayId, if (isToday) bold(date.day.toString()) else date.day.toString())
week.setTextColor(context, dayId, when {
isToday -> colors.onPrimary
date.month == currentMonth -> colors.onSurface
else -> colors.onSurfaceVariant
})
week.setViewVisibility(TODAY_IDS[col], if (isToday) View.VISIBLE else View.GONE)
if (isToday) week.setImageTint(context, TODAY_IDS[col], colors.primary)
}
layout.lanes.forEach { cells ->
val lane = laneViews(context)
cells.forEach { cell ->
val event = cell.item
lane.addView(R.id.lane, if (event == null) gap(context, cell.cols) else bar(context, event, cell.cols, soften, mode))
}
week.addView(R.id.week_lanes, lane)
}
if (layout.showsOverflow && layout.overflow.isNotEmpty()) {
val lane = laneViews(context)
layout.overflow.forEach { cell ->
val count = cell.item
if (count == null) {
lane.addView(R.id.lane, gap(context, cell.cols))
} else {
val more = RemoteViews(context.packageName, R.layout.widget_month_more)
more.setTextViewText(R.id.more, "+$count")
more.setTextColor(context, R.id.more, colors.onSurfaceVariant)
lane.addView(R.id.lane, more)
}
}
week.addView(R.id.week_lanes, lane)
}
return week
}
private fun laneViews(context: Context) =
RemoteViews(context.packageName, R.layout.widget_month_lane).apply { removeAllViews(R.id.lane) }
private fun gap(context: Context, cols: Int) = RemoteViews(context.packageName, GAP_LAYOUTS[cols - 1])
private fun bar(context: Context, event: EventInstance, cols: Int, soften: Boolean, mode: WidgetThemeMode): RemoteViews {
val views = RemoteViews(context.packageName, BAR_LAYOUTS[cols - 1])
val dayFill = eventFill(event.color, dark = false, soften)
val nightFill = eventFill(event.color, dark = true, soften)
views.setImageTint(context, R.id.bar_bg, mode.remoteColor(dayFill, nightFill))
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// A span, like Glance's own decoration: paint flags would drop the text's defaults.
views.setTextViewText(R.id.bar_title, if (event.isDeclined) span(title, StrikethroughSpan()) else title)
views.setTextColor(context, R.id.bar_title, mode.remoteColor(eventInk(dayFill), eventInk(nightFill)))
return views
}
private fun bold(text: String): CharSequence = span(text, StyleSpan(Typeface.BOLD))
private fun span(text: String, span: Any): CharSequence =
SpannableString(text).apply { setSpan(span, 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) }
private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = PendingIntent.getActivity(
context,
date.toEpochDays().toInt(),
MainActivity.openDateIntent(context, date, CalendarView.Month),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
private val STRIP_IDS = intArrayOf(R.id.strip_0, R.id.strip_1, R.id.strip_2, R.id.strip_3, R.id.strip_4, R.id.strip_5, R.id.strip_6)
/** The shade over a day outside the month: a dark cell needs more of it to read as dimmed. */
private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.18f)
private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.50f)
private val CELL_DIM_IDS = intArrayOf(
R.id.cell_dim_0, R.id.cell_dim_1, R.id.cell_dim_2, R.id.cell_dim_3, R.id.cell_dim_4, R.id.cell_dim_5, R.id.cell_dim_6,
)
private val CELL_IDS = intArrayOf(R.id.cell_0, R.id.cell_1, R.id.cell_2, R.id.cell_3, R.id.cell_4, R.id.cell_5, R.id.cell_6)
private val DAY_IDS = intArrayOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6)
private val TODAY_IDS = intArrayOf(
R.id.today_bg_0, R.id.today_bg_1, R.id.today_bg_2, R.id.today_bg_3, R.id.today_bg_4, R.id.today_bg_5, R.id.today_bg_6,
)
private val BAR_LAYOUTS = intArrayOf(
R.layout.widget_month_bar_1, R.layout.widget_month_bar_2, R.layout.widget_month_bar_3, R.layout.widget_month_bar_4,
R.layout.widget_month_bar_5, R.layout.widget_month_bar_6, R.layout.widget_month_bar_7,
)
private val GAP_LAYOUTS = intArrayOf(
R.layout.widget_month_gap_1, R.layout.widget_month_gap_2, R.layout.widget_month_gap_3, R.layout.widget_month_gap_4,
R.layout.widget_month_gap_5, R.layout.widget_month_gap_6, R.layout.widget_month_gap_7,
)
@@ -1,83 +1,76 @@
package de.jeanlucmakiola.calendula.widget.month
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.AndroidRemoteViews
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import androidx.glance.background
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode
import de.jeanlucmakiola.floret.glance.WidgetColorRole
import de.jeanlucmakiola.floret.glance.remoteColor
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
import de.jeanlucmakiola.floret.glance.WidgetHeader
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
import de.jeanlucmakiola.floret.glance.WidgetHeaderActionGap
import de.jeanlucmakiola.floret.glance.WidgetHeaderTextAction
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.updateWithRefresh
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
import de.jeanlucmakiola.calendula.widget.monthIndexOf
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import kotlinx.coroutines.flow.first
import androidx.compose.ui.unit.Dp
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
@@ -90,16 +83,12 @@ import java.util.Locale
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
/** Event rows (lanes) shown per week before the rest collapse into "+N". */
private const val MAX_LANES = 3
private val LANE_HEIGHT = 14.dp
private val DAY_NUMBER_HEIGHT = 18.dp
private val GRID_HPADDING = 8.dp
/**
* Height around the week rows: body padding, the header's bottom padding and
* spacer and the weekday pills' padding, then the header (its 28dp pills, or a title line in its transparent
* pill when that's taller) and the weekday row, the text lines at [fontScale].
*/
private fun gridChromeHeight(fontScale: Float): Dp =
21.dp + maxOf(28.dp, 19.dp * fontScale + 8.dp) + 15.dp * fontScale
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
private fun yearMonthOf(index: Int): YearMonth =
@@ -110,9 +99,9 @@ private fun yearMonthOf(index: Int): YearMonth =
* event bars and titled single-day pills (the in-app lane layout via
* [layoutMonthWeeks]), and prev/next/today navigation.
*
* The week rows are one plain RemoteViews tree ([monthGridViews]) embedded in
* the Glance widget, so a busy month isn't held to Glance's view-id pool (#372);
* the lanes each week shows come from the widget's height. The displayed month lives in Glance
* Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a
* multi-day span renders as a single Box spanning its columns — connected, no
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
* state and is read reactively in the composition ([currentState]) so the arrows
* move it via plain recomposition, not a (here-unreliable) widget session reload.
*
@@ -123,7 +112,7 @@ private fun yearMonthOf(index: Int): YearMonth =
* the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the
* host reports, so the ceiling arrives at half the view count you would expect: a
* grid that emitted a view per cell blew past it after three taps (#214). Every
* row here emits only what it fills, and lanes stop at what the height shows.
* row here emits only what it fills.
*/
class MonthWidget : GlanceAppWidget() {
@@ -133,60 +122,27 @@ class MonthWidget : GlanceAppWidget() {
* Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
* return early from `GlanceAppWidget.resize` — under those the grid is never
* recomposed for a new size and a resized widget clips instead of reflowing.
* Exact costs one serialised copy per host size, so the grid draws only the
* lanes the size can show, folding the rest into "+N".
* Exact costs one serialised copy per host size, which is affordable now that
* a rendering is ~190 views rather than ~740.
*/
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.MONTH)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.MONTH)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val shown = getAppWidgetState<Preferences>(context, id)[MONTH_INDEX_KEY]
?: currentMonthIndex(systemZone())
val stamp = refreshStamp(context, id)
val initialCenter = monthWindowCenter(shown)
// Shared by every size Exact composes, so each loads nothing of its own.
val widgetData = WidgetData(context.loadCachedMonthSource(id, initialCenter, stamp), initialCenter, stamp)
val source = context.loadMonthWidgetSource(shown)
val dark = (context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
// Read fresh (not through the cached source) so toggling the softener
// redraws with the new choice; it's one cheap DataStore read.
val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first()
provideContent {
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(systemZone())
// Reloads when paging leaves the loaded window, and on every redraw,
// which a running session would otherwise never see (#420).
val center = monthWindowCenter(index)
val currentStamp = currentRefreshStamp()
val loaded = widgetData.current(center, currentStamp) {
context.loadCachedMonthSource(id, center, currentStamp)
CalendulaGlanceTheme {
MonthWidgetBody(source = source, dark = dark, soften = soften)
}
val source = loaded.content
// A jump out of the loaded window keeps the last month it covers
// until the reload lands, rather than drawing an empty grid.
var lastShown by remember { mutableIntStateOf(index) }
val shown = when {
source !is MonthWidgetSource.Ready || index in source.months -> index
lastShown in source.months -> lastShown
else -> index.coerceIn(source.months)
}
SideEffect { lastShown = shown }
MonthWidgetContent(
source = source,
index = shown,
appearance = currentState<Preferences>().widgetAppearance,
appTheme = loaded.appTheme,
)
}
}
}
/** The whole month widget showing month [index], shared with its config screen's preview (#292). */
@Composable
internal fun MonthWidgetContent(source: MonthWidgetSource, index: Int, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
MonthWidgetBody(source = source, index = index)
}
}
/**
* Step the displayed month by the `delta` action parameter (±1).
*
@@ -223,53 +179,57 @@ class ShiftMonthAction : ActionCallback {
}
}
/** Jump the displayed month back to the current month, reloading it. See [ShiftMonthAction]. */
/** Jump the displayed month back to the current month. See [ShiftMonthAction]. */
class ResetMonthAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
MonthWidget().updateWithRefresh(context.applicationContext, glanceId)
MonthWidget().update(context.applicationContext, glanceId)
}
}
@Composable
private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.surface)
.padding(horizontal = GRID_HPADDING, vertical = 6.dp),
) {
when (source) {
MonthWidgetSource.NeedsPermission -> {
MonthHeader(label = "Calendula", today = today(systemZone()))
MonthHeader(label = "Calendula")
PermissionMessage()
}
is MonthWidgetSource.Ready -> {
val zone = systemZone()
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
val ym = yearMonthOf(index)
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
// Paging past the loaded months reloads around the new one in a
// live session, where provideGlance doesn't run again.
val context = LocalContext.current
val fontScale = context.resources.configuration.fontScale
val weekHeight = (LocalSize.current.height - gridChromeHeight(fontScale)) / weeks.size
val layouts = fitWeeks(weeks, lanesFitting(weekHeight))
val mode = LocalWidgetThemeMode.current
val colors = GridColors(
onSurface = remoteColor(WidgetColorRole.ON_SURFACE),
onSurfaceVariant = remoteColor(WidgetColorRole.ON_SURFACE_VARIANT),
primary = remoteColor(WidgetColorRole.PRIMARY),
onPrimary = remoteColor(WidgetColorRole.ON_PRIMARY),
cell = remoteColor(monthCellRole()),
)
var loaded by remember(source) { mutableStateOf<MonthWidgetSource.Ready>(source) }
LaunchedEffect(index) {
if (index !in loaded.months) {
(context.loadMonthWidgetSource(index) as? MonthWidgetSource.Ready)
?.let { loaded = it }
}
}
// Column width from the live widget size, minus our H padding.
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
val weeks = layoutMonthWeeks(ym, loaded.weekStart, loaded.instances, zone)
MonthHeader(label = monthLabel(ym, source.today.year), today = source.today)
Spacer(GlanceModifier.height(4.dp))
// No backing behind the grid: the weekday pills and day cells
// each carry their own fill, so a transparent widget stays
// readable without one blob over the wallpaper (#292).
Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
WeekdayHeader(weekStart = source.weekStart)
AndroidRemoteViews(
remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode),
modifier = GlanceModifier.fillMaxWidth().defaultWeight(),
MonthHeader(label = monthLabel(ym, loaded.today.year))
Spacer(GlanceModifier.height(2.dp))
WeekdayHeader(weekStart = loaded.weekStart, colW = colW)
weeks.forEach { week ->
WeekRow(
week = week,
currentMonth = ym.month,
today = loaded.today,
dark = dark,
soften = soften,
colW = colW,
modifier = GlanceModifier.defaultWeight(),
)
}
}
@@ -278,79 +238,89 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
}
@Composable
private fun MonthHeader(label: String, today: LocalDate) {
private fun MonthHeader(label: String) {
val context = LocalContext.current
// Tap the month/year title to open the app on the month view (issue #18),
// rooted over the default home so backing out returns there.
WidgetHeader(
title = label,
titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)),
modifier = GlanceModifier.padding(bottom = 2.dp),
titleInset = 4.dp,
Row(
modifier = GlanceModifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_chevron_left),
HeaderIcon(
resId = R.drawable.ic_widget_chevron_left,
contentDescription = context.getString(R.string.widget_prev_month),
action = actionRunCallback<ShiftMonthAction>(actionParametersOf(ShiftMonthAction.deltaKey to -1)),
style = WidgetActionStyle.TONAL,
onClick = GlanceModifier.clickable(
actionRunCallback<ShiftMonthAction>(
actionParametersOf(ShiftMonthAction.deltaKey to -1),
),
),
)
WidgetHeaderActionGap()
// The "today" pill carries today's number like the app icon, and snaps
// the grid back to the current month in place.
WidgetHeaderTextAction(
text = today.day.toString(),
// Tap the month/year title to open the app on the month view (issue #18),
// rooted over the default home so backing out returns there.
Text(
text = label,
style = TextStyle(
color = GlanceTheme.colors.primary,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier
.defaultWeight()
.clickable(
actionStartActivity(
MainActivity.openViewIntent(context, CalendarView.Month),
),
),
)
// The "today" button snaps the grid back to the current month in place.
HeaderIcon(
resId = R.drawable.ic_widget_today,
contentDescription = context.getString(R.string.widget_today),
action = actionRunCallback<ResetMonthAction>(),
style = WidgetActionStyle.TONAL,
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
)
WidgetHeaderActionGap()
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_chevron_right),
HeaderIcon(
resId = R.drawable.ic_widget_chevron_right,
contentDescription = context.getString(R.string.widget_next_month),
action = actionRunCallback<ShiftMonthAction>(actionParametersOf(ShiftMonthAction.deltaKey to 1)),
style = WidgetActionStyle.TONAL,
)
WidgetHeaderActionGap()
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_add),
contentDescription = context.getString(R.string.widget_new_event),
action = actionStartActivity(MainActivity.openCreateIntent(context, today)),
onClick = GlanceModifier.clickable(
actionRunCallback<ShiftMonthAction>(
actionParametersOf(ShiftMonthAction.deltaKey to 1),
),
),
)
}
}
@Composable
private fun WeekdayHeader(weekStart: DayOfWeek) {
Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) {
private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) {
Box(
modifier = GlanceModifier.size(40.dp).then(onClick),
contentAlignment = Alignment.Center,
) {
Image(
provider = ImageProvider(resId),
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
modifier = GlanceModifier.size(20.dp),
)
}
}
@Composable
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
weekdayNarrowNames(weekStart).forEach { name ->
Box(modifier = GlanceModifier.defaultWeight().padding(horizontal = 1.dp)) {
Text(
text = name,
style = TextStyle(
color = GlanceTheme.colors.onPrimaryContainer,
fontSize = 11.sp,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier
.fillMaxWidth()
.cornerRadius(8.dp)
.background(GlanceTheme.colors.primaryContainer)
.padding(vertical = 1.dp),
)
}
Text(
text = name,
style = TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
fontSize = 11.sp,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier.width(colW),
)
}
}
}
/** The fill of a day cell: one tone off a filled root, the widget surface over a wallpaper. */
private fun monthCellRole(background: WidgetBackground): WidgetColorRole = when (background) {
WidgetBackground.SOLID -> WidgetColorRole.SURFACE_VARIANT
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> WidgetColorRole.WIDGET_BACKGROUND
}
@Composable
private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackground.current)
/** Narrow weekday initials starting at [weekStart], in the device locale.
* Computed outside the composable so the locale read stays observable-safe. */
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
@@ -361,6 +331,266 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
}
}
@Composable
private fun WeekRow(
week: MonthWeek,
currentMonth: Month,
today: LocalDate,
dark: Boolean,
soften: Boolean,
colW: Dp,
modifier: GlanceModifier,
) {
val context = LocalContext.current
val hidden = week.hiddenPerDay()
Box(modifier = modifier.fillMaxWidth()) {
// Day-open targets, one full-height strip per column, sitting *under* the
// content: a tap anywhere in a day still opens it, as in the app, without
// every cell in the column carrying a click target of its own. The content
// above is not clickable, so touches fall through to this layer; only an
// event bar opts out to open its own detail.
Row(modifier = GlanceModifier.fillMaxSize()) {
week.days.forEach { date ->
Box(
modifier = GlanceModifier
.width(colW)
.fillMaxHeight()
.clickable(openDayAction(context, date)),
) {}
}
}
Column(modifier = GlanceModifier.fillMaxWidth()) {
// Day numbers. Fixed height so every week's number row lines up whether
// or not it holds today's larger pill.
Row(
modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT),
verticalAlignment = Alignment.CenterVertically,
) {
week.days.forEach { date ->
DayNumber(
date = date,
isToday = date == today,
inMonth = date.month == currentMonth,
colW = colW,
)
}
}
Spacer(GlanceModifier.height(2.dp))
// One lane row per event row. A multi-day span is a single Box spanning
// its columns (colW * n) so it's connected with no seam and rounded ends.
// Lanes past the last one this week fills emit nothing at all.
repeat(week.usedLanes()) { lane ->
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
Spacer(GlanceModifier.height(1.dp))
}
if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW)
}
}
}
/**
* How many lanes this week actually fills, counting from the top. The grid draws
* that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views
* per day and most weeks leave the last two empty.
*/
private fun MonthWeek.usedLanes(): Int {
for (lane in MAX_LANES - 1 downTo 0) {
val filled = spans.any { it.lane == lane } ||
days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null }
if (filled) return lane + 1
}
return 0
}
/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size)
((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0)
}
/**
* Open [date]'s day view rooted in the month view (so back returns to the month
* grid) — the same target the in-app month grid uses when a day cell is tapped.
* Shared by every tappable part of a day column so, as in the app, a tap anywhere
* on a day opens it; only an event bar on top opts out to open its own detail.
*/
private fun openDayAction(context: Context, date: LocalDate) =
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
/**
* A day's number. Every day but today is a bare centred [Text] carrying the column
* width itself — a wrapping Box costs three views, and there are 42 of these.
* Today keeps its filled circle, which does need the Box.
*/
@Composable
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
val style = TextStyle(
color = when {
isToday -> GlanceTheme.colors.onPrimary
inMonth -> GlanceTheme.colors.onSurface
else -> GlanceTheme.colors.onSurfaceVariant
},
fontSize = 11.sp,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center,
)
if (!isToday) {
Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW))
return
}
Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) {
Box(
modifier = GlanceModifier
.size(DAY_NUMBER_HEIGHT)
.cornerRadius(DAY_NUMBER_HEIGHT / 2)
.background(GlanceTheme.colors.primary),
contentAlignment = Alignment.Center,
) {
Text(text = date.day.toString(), style = style)
}
}
}
/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */
private data class LaneCell(val event: EventInstance?, val cols: Int)
/**
* A lane split into bars and the gaps between them, with consecutive blank columns
* merged into one gap: the week's day-open taps come from the strip underneath, so
* a gap needs no per-column view and a mostly-empty lane collapses to a spacer.
*/
private fun MonthWeek.laneCells(lane: Int): List<LaneCell> {
val cells = mutableListOf<LaneCell>()
var gap = 0
var col = 0
fun closeGap() {
if (gap > 0) cells += LaneCell(null, gap)
gap = 0
}
while (col < 7) {
val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null
when {
span != null -> {
closeGap()
cells += LaneCell(span.event, span.endCol - col + 1)
col = span.endCol + 1
}
timed != null -> {
closeGap()
cells += LaneCell(timed, 1)
col += 1
}
else -> {
gap += 1
col += 1
}
}
}
closeGap()
return cells
}
@Composable
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
week.laneCells(lane).forEach { cell ->
if (cell.event == null) {
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
} else {
SpanBar(
event = cell.event,
dark = dark,
soften = soften,
width = colW * cell.cols,
)
}
}
}
}
/** A single connected, rounded event bar [width] wide with its clipped title. */
@Composable
private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Dp) {
val context = LocalContext.current
val fill = eventFill(event.color, dark, soften)
Box(
modifier = GlanceModifier
.width(width)
.height(LANE_HEIGHT)
.padding(horizontal = 1.dp)
// Tap an event bar to open its detail, rooted in the month view.
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Month,
),
),
),
) {
Box(
modifier = GlanceModifier
.fillMaxSize()
.cornerRadius(4.dp)
.background(fill),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
maxLines = 1,
style = TextStyle(
color = ColorProvider(eventInk(fill)),
fontSize = 9.sp,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
modifier = GlanceModifier.padding(horizontal = 3.dp),
)
}
}
}
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
@Composable
private fun OverflowRow(hidden: List<Int>, colW: Dp) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
var gap = 0
hidden.forEach { count ->
if (count == 0) {
gap += 1
return@forEach
}
if (gap > 0) {
Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
gap = 0
}
Text(
text = "+$count",
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
)
}
if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
}
}
/** The timed single-day event that fills lane [lane] on day [col], if any. */
private fun timedEventAt(week: MonthWeek, lane: Int, col: Int, date: LocalDate): EventInstance? {
val occupied = week.spans
.filter { col in it.startCol..it.endCol && it.lane < MAX_LANES }
.map { it.lane }
.toSet()
val freeSlots = (0 until MAX_LANES).filter { it !in occupied }
val timed = week.timedByDay[date].orEmpty()
return timed.getOrNull(freeSlots.indexOf(lane))
}
@Composable
private fun PermissionMessage() {
val context = LocalContext.current
@@ -375,7 +605,6 @@ private fun PermissionMessage() {
fontSize = 14.sp,
textAlign = TextAlign.Center,
),
modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
@@ -1,9 +1,46 @@
package de.jeanlucmakiola.calendula.widget.month
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
/** Host-facing receiver for the month widget; rendering is [MonthWidget]'s. */
class MonthWidgetReceiver : CalendulaWidgetReceiver() {
/**
* Host-facing receiver for the month widget. Declared in the manifest with the
* `appwidget_info_month` provider metadata; delegates rendering to [MonthWidget].
*/
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
/** First month widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* Last month widget removed. [WidgetRolloverScheduler.sync] cancels only if
* no agenda widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* The `updatePeriodMillis` backstop is the one wake-up the *system* still
* owns, so it doubles as the alarm's self-heal: anything that drops a
* pending alarm without a broadcast (force-stop, battery restriction, an OEM
* freeze) is repaired here rather than on the next app open. Re-arming
* closer to midnight also narrows the inexact delivery window.
*/
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context)
}
}
@@ -1,92 +0,0 @@
package de.jeanlucmakiola.calendula.widget.quickadd
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.provideContent
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize
import androidx.glance.state.PreferencesGlanceStateDefinition
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.loadToday
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetShape
import de.jeanlucmakiola.floret.glance.WidgetShapedIconButton
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.LocalDate
/**
* 1×1 quick add (#435): one shaped "+" that opens the new-event editor on
* today, with the same defaults as the app's own new-event button.
*/
class QuickAddWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Exact: the shape fills whatever square the launcher gives it.
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.QUICK_ADD)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.QUICK_ADD)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id))
provideContent {
// The rollover moves the stamp, so the editor opens on the new day.
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
QuickAddWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
class QuickAddWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = QuickAddWidget()
}
@Composable
internal fun QuickAddWidgetContent(today: LocalDate, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
val size = LocalSize.current
val action = actionStartActivity(MainActivity.openCreateIntent(context, today))
Box(
modifier = GlanceModifier.fillMaxSize().clickable(action),
contentAlignment = Alignment.Center,
) {
WidgetShapedIconButton(
shape = WidgetShape.SUNNY,
icon = ImageProvider(R.drawable.ic_widget_add),
contentDescription = context.getString(R.string.widget_new_event),
action = action,
color = GlanceTheme.colors.primary,
iconTint = GlanceTheme.colors.onPrimary,
size = (minOf(size.width, size.height) - 12.dp).coerceIn(36.dp, 120.dp),
)
}
}
}
@@ -1,451 +0,0 @@
package de.jeanlucmakiola.calendula.widget.split
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
import de.jeanlucmakiola.calendula.widget.Themed
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
import de.jeanlucmakiola.calendula.widget.monthIndexOf
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
import de.jeanlucmakiola.calendula.widget.systemZone
import de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.weekOf
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.widgetCardColor
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
import de.jeanlucmakiola.calendula.widget.widgetSettings
import de.jeanlucmakiola.calendula.widget.widgetState
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import kotlinx.datetime.toJavaLocalDate
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetHeader
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
import de.jeanlucmakiola.floret.glance.WidgetHeaderActionGap
import de.jeanlucmakiola.floret.glance.WidgetHeaderTextAction
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.coroutines.flow.first
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlin.time.Clock
import kotlin.time.Instant
import java.time.format.TextStyle as JavaTextStyle
/** Per-widget state: the shown month (`year * 12 + monthOrdinal`) and the selected day; both follow today when absent. */
private val MONTH_INDEX_KEY = intPreferencesKey("split_month_index")
private val SELECTED_KEY = stringPreferencesKey("split_selected")
/** What the month + day widget draws: the month widget's window, plus what its list needs. */
sealed interface MonthDayData {
data object NeedsPermission : MonthDayData
data class Ready(
val source: MonthWidgetSource.Ready,
val options: AgendaWidgetOptions,
val is24Hour: Boolean,
val now: Instant,
) : MonthDayData
}
/** [loaded]'s month window with what the day list draws by. */
internal suspend fun Context.loadMonthDay(loaded: Themed<MonthWidgetSource>, options: AgendaWidgetOptions): Themed<MonthDayData> {
val ready = loaded.content as? MonthWidgetSource.Ready ?: return Themed(MonthDayData.NeedsPermission, loaded.appTheme)
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
return Themed(
MonthDayData.Ready(
source = ready,
options = options,
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
now = Clock.System.now(),
),
loaded.appTheme,
)
}
/**
* Month + day (#441): a compact month grid beside the selected day's events,
* like Samsung Calendar's. A day in the grid selects it, starting on today;
* the list and its events open the app. The plain month widget keeps
* tap-opens-the-day (#386).
*/
class MonthDayWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.MONTH_DAY)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.MONTH_DAY)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val stamp = refreshStamp(context, id)
val shown = context.widgetState(id)[MONTH_INDEX_KEY] ?: monthIndexOf(today(systemZone()))
val center = monthWindowCenter(shown)
val load: suspend (Int, Long) -> Themed<MonthDayData> = { c, s ->
context.loadMonthDay(context.loadCachedMonthSource(id, c, s), context.widgetLoadContext(id).options)
}
val widgetData = WidgetData(load(center, stamp), center, stamp)
provideContent {
val index = currentState(MONTH_INDEX_KEY) ?: monthIndexOf(today(systemZone()))
val currentCenter = monthWindowCenter(index)
val currentStamp = currentRefreshStamp()
val loaded = widgetData.current(currentCenter, currentStamp) { load(currentCenter, currentStamp) }
val selected = currentState(SELECTED_KEY)?.let(LocalDate::parse)
MonthDayWidgetContent(loaded.content, index, selected, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
class MonthDayWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = MonthDayWidget()
}
/** Selects the tapped day; the list redraws from what's loaded. */
class SelectDayAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val date = parameters[dateKey] ?: return
updateAppWidgetState(context, glanceId) { prefs ->
if (date == today(systemZone()).toString()) prefs.remove(SELECTED_KEY) else prefs[SELECTED_KEY] = date
}
MonthDayWidget().update(context.applicationContext, glanceId)
}
companion object {
val dateKey = ActionParameters.Key<String>("date")
}
}
/** Pages the grid by the `delta` parameter; landing on the current month follows the date again. */
class ShiftMonthDayAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val delta = parameters[deltaKey] ?: 0
val current = monthIndexOf(today(systemZone()))
updateAppWidgetState(context, glanceId) { prefs ->
val next = (prefs[MONTH_INDEX_KEY] ?: current) + delta
if (next == current) prefs.remove(MONTH_INDEX_KEY) else prefs[MONTH_INDEX_KEY] = next
}
MonthDayWidget().update(context.applicationContext, glanceId)
}
companion object {
val deltaKey = ActionParameters.Key<Int>("delta")
}
}
/** Back to this month with today selected. */
class ResetMonthDayAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
updateAppWidgetState(context, glanceId) { prefs ->
prefs.remove(MONTH_INDEX_KEY)
prefs.remove(SELECTED_KEY)
}
MonthDayWidget().update(context.applicationContext, glanceId)
}
}
/** The grid's days: whole weeks from [weekStart] covering [index]'s month. */
internal fun monthGridDays(index: Int, weekStart: DayOfWeek): List<LocalDate> {
val first = LocalDate(index / 12, index % 12 + 1, 1)
val start = weekOf(first, weekStart).start
val last = first.plus(1, DateTimeUnit.MONTH).plus(-1, DateTimeUnit.DAY)
val weeks = (start.daysUntilInclusive(last) + 6) / 7
return List(weeks * 7) { start.plus(it, DateTimeUnit.DAY) }
}
private fun LocalDate.daysUntilInclusive(other: LocalDate): Int =
(other.toEpochDays() - toEpochDays()).toInt() + 1
private val GridGap = 8.dp
@Composable
internal fun MonthDayWidgetContent(
data: MonthDayData,
index: Int,
selected: LocalDate?,
appearance: WidgetAppearance,
appTheme: ThemeMode,
preview: Boolean = false,
) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
when (data) {
MonthDayData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
is MonthDayData.Ready -> {
val source = data.source
val day = selected ?: source.today
val zone = systemZone()
val days = monthGridDays(index, source.weekStart)
val busy = groupAgendaDays(days.first(), days.last(), source.instances, zone).map { it.date }.toSet()
Column(modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp)) {
Header(index, source.today)
Spacer(GlanceModifier.height(4.dp))
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
val gridWidth = (LocalSize.current.width - 16.dp - GridGap) * 0.52f
Box(modifier = GlanceModifier.width(gridWidth).fillMaxHeight()) {
Grid(days, index, source, day, busy, gridWidth)
}
Spacer(GlanceModifier.width(GridGap))
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
DayList(day, data, preview)
}
}
}
}
}
}
}
@Composable
private fun Header(index: Int, today: LocalDate) {
val context = LocalContext.current
WidgetHeader(
title = formatCalendarTitle(
date = java.time.LocalDate.of(index / 12, index % 12 + 1, 1),
locale = widgetLocale(),
currentYear = today.year,
skeleton = "LLLL",
),
titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)),
modifier = GlanceModifier.padding(bottom = 2.dp),
titleInset = 4.dp,
) {
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_chevron_left),
contentDescription = context.getString(R.string.widget_prev_month),
action = actionRunCallback<ShiftMonthDayAction>(actionParametersOf(ShiftMonthDayAction.deltaKey to -1)),
style = WidgetActionStyle.TONAL,
)
WidgetHeaderActionGap()
WidgetHeaderTextAction(
text = today.day.toString(),
contentDescription = context.getString(R.string.widget_today),
action = actionRunCallback<ResetMonthDayAction>(),
style = WidgetActionStyle.TONAL,
)
WidgetHeaderActionGap()
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_chevron_right),
contentDescription = context.getString(R.string.widget_next_month),
action = actionRunCallback<ShiftMonthDayAction>(actionParametersOf(ShiftMonthDayAction.deltaKey to 1)),
style = WidgetActionStyle.TONAL,
)
WidgetHeaderActionGap()
WidgetHeaderAction(
icon = ImageProvider(R.drawable.ic_widget_add),
contentDescription = context.getString(R.string.widget_new_event),
action = actionStartActivity(MainActivity.openCreateIntent(context, today)),
)
}
}
/**
* The month as numbers: selected day filled, today in bold primary, days
* with events dotted, days around the month faded. Three views a day keeps
* a six-week month well inside Glance's view ids (#372).
*/
@Composable
private fun Grid(
days: List<LocalDate>,
index: Int,
source: MonthWidgetSource.Ready,
selected: LocalDate,
busy: Set<LocalDate>,
width: Dp,
) {
val locale = widgetLocale()
val month = index % 12 + 1
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
GlanceModifier.cornerRadius(14.dp).background(widgetCardColor())
} else {
GlanceModifier
}
Column(modifier = backing.fillMaxSize().padding(2.dp)) {
Row(modifier = GlanceModifier.fillMaxWidth()) {
for (i in 0 until 7) {
Text(
text = java.time.DayOfWeek.of((source.weekStart.ordinal + i) % 7 + 1).getDisplayName(JavaTextStyle.NARROW, locale),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp, textAlign = TextAlign.Center),
modifier = GlanceModifier.width((width - 4.dp) / 7),
)
}
}
days.chunked(7).forEach { week ->
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight(), verticalAlignment = Alignment.CenterVertically) {
week.forEach { date ->
DayCell(date, date.month.ordinal + 1 == month, date == source.today, date == selected, date in busy, (width - 4.dp) / 7)
}
}
}
}
}
@Composable
private fun DayCell(date: LocalDate, inMonth: Boolean, isToday: Boolean, isSelected: Boolean, hasEvents: Boolean, width: Dp) {
val ink = when {
isSelected -> GlanceTheme.colors.onPrimary
isToday -> GlanceTheme.colors.primary
inMonth -> GlanceTheme.colors.onSurface
else -> GlanceTheme.colors.outline
}
// Always set: the host reapplies onto reused views, so a dropped background would linger.
val fill = if (isSelected) GlanceTheme.colors.primary else ColorProvider(Color.Transparent)
Column(
modifier = GlanceModifier.cornerRadius(10.dp).background(fill)
.width(width)
.fillMaxHeight()
.clickable(actionRunCallback<SelectDayAction>(actionParametersOf(SelectDayAction.dateKey to date.toString()))),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = date.day.toString(),
maxLines = 1,
style = TextStyle(
color = ink,
fontSize = 11.sp,
fontWeight = if (isToday || isSelected) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center,
),
)
Text(
text = if (hasEvents) "•" else " ",
maxLines = 1,
style = TextStyle(color = if (isSelected) ink else GlanceTheme.colors.primary, fontSize = 7.sp, textAlign = TextAlign.Center),
)
}
}
@Composable
private fun DayList(day: LocalDate, data: MonthDayData.Ready, preview: Boolean) {
val context = LocalContext.current
val source = data.source
val zone = systemZone()
val events = groupAgendaDays(day, day, source.instances, zone).firstOrNull()?.events.orEmpty()
.let { all -> if (data.options.pastDisplay == PastEventDisplay.HIDE) all.filterNot { it.hasEnded(data.now) } else all }
val metrics = compactRowMetrics(data.options.textSize, context.resources.configuration.fontScale)
val openDay = actionStartActivity(MainActivity.openDateIntent(context, day, CalendarView.Month))
Column(modifier = GlanceModifier.fillMaxSize()) {
val date = localizedDateFormatter(widgetLocale(), "EEEdMMM").format(day.toJavaLocalDate())
val label = when (day) {
source.today, source.today.plus(1, DateTimeUnit.DAY) -> "${relativeDayLabel(context, day, source.today)} · $date"
else -> date
}
Text(
text = label,
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = metrics.time, fontWeight = FontWeight.Medium),
modifier = GlanceModifier
.padding(bottom = 4.dp)
.wallpaperBacking(radius = 8.dp)
.padding(horizontal = 6.dp, vertical = 2.dp)
.clickable(openDay),
)
if (events.isEmpty()) {
Box(
modifier = GlanceModifier.fillMaxSize().cornerRadius(14.dp).background(widgetCardColor()).clickable(openDay),
contentAlignment = Alignment.Center,
) {
Text(
text = context.getString(R.string.widget_day_empty),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.time, textAlign = TextAlign.Center),
modifier = GlanceModifier.padding(8.dp),
)
}
} else {
val row: @Composable (Int) -> Unit = { i ->
val event = events[i]
WidgetEventRow(
event = event,
secondary = widgetTimeLabel(context, event, day, data.is24Hour),
soften = source.soften,
metrics = metrics,
position = positionOf(i, events.size),
source = CalendarView.Month,
dimmed = data.options.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now),
)
}
if (preview) {
Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, 8)) { row(it) } }
} else {
LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(events.size) { row(it) } }
}
}
}
}
@@ -1,252 +0,0 @@
package de.jeanlucmakiola.calendula.widget.today
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.semantics.contentDescription
import androidx.glance.semantics.semantics
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
import de.jeanlucmakiola.calendula.widget.EventRowMetrics
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
import de.jeanlucmakiola.calendula.widget.loadDays
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import java.time.format.TextStyle as JavaTextStyle
/**
* Today list (#438): only today's events beside today's date pill. Once
* they're over, or with none at all, it says so and shows tomorrow's first
* one or two events in a quieter style.
*/
class TodayWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Exact: whether one or two of tomorrow's events fit follows the height.
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.TODAY)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.TODAY)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadTodayList(context.widgetLoadContext(id)), refreshStamp(context, id))
provideContent {
val loaded = widgetData.current(currentRefreshStamp()) { context.loadTodayList(context.widgetLoadContext(id)) }
TodayWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
class TodayWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = TodayWidget()
}
internal suspend fun Context.loadTodayList(load: WidgetLoadContext) = loadDays(load) { today, _ ->
today..today.plus(1, DateTimeUnit.DAY)
}
private val PillWidth = 44.dp
private val RowGap = 2.dp
/** What the list side shows: today's events, or the look ahead once none are left. */
internal sealed interface TodayRows {
data class Today(val events: List<EventInstance>) : TodayRows
data class NothingLeft(val tomorrow: List<EventInstance>) : TodayRows
}
internal fun todayRows(data: DaysWidgetData.Ready): TodayRows {
val today = data.eventsOn(data.today)
if (today.none { !it.hasEnded(data.now) }) {
return TodayRows.NothingLeft(data.eventsOn(data.today.plus(1, DateTimeUnit.DAY)))
}
val shown = if (data.options.pastDisplay == PastEventDisplay.HIDE) today.filterNot { it.hasEnded(data.now) } else today
return TodayRows.Today(shown)
}
/** The whole widget, shared with its config preview, which draws its list as a plain column. */
@Composable
internal fun TodayWidgetContent(
data: DaysWidgetData,
appearance: WidgetAppearance,
appTheme: ThemeMode,
preview: Boolean = false,
) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
when (data) {
DaysWidgetData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
is DaysWidgetData.Ready -> Row(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
TodayPill(data.today)
Spacer(GlanceModifier.width(6.dp))
val metrics = compactRowMetrics(data.options.textSize, context.resources.configuration.fontScale)
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
when (val rows = todayRows(data)) {
is TodayRows.Today -> TodayList(rows.events, data, metrics, preview)
is TodayRows.NothingLeft -> NothingLeft(rows.tomorrow, data, metrics)
}
}
}
}
}
}
@Composable
private fun TodayList(events: List<EventInstance>, data: DaysWidgetData.Ready, metrics: EventRowMetrics, preview: Boolean) {
val context = LocalContext.current
val row: @Composable (Int) -> Unit = { index ->
val event = events[index]
WidgetEventRow(
event = event,
secondary = widgetTimeLabel(context, event, data.today, data.is24Hour),
soften = data.soften,
metrics = metrics,
position = positionOf(index, events.size),
source = CalendarView.Day,
dimmed = data.options.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now),
)
}
if (preview) {
val fits = ((LocalSize.current.height - 12.dp + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceAtLeast(1)
Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, fits)) { row(it) } }
} else {
LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(events.size) { row(it) } }
}
}
/** "Nothing left today", then as many of tomorrow's first two events as the height holds. */
@Composable
private fun NothingLeft(tomorrow: List<EventInstance>, data: DaysWidgetData.Ready, metrics: EventRowMetrics) {
val context = LocalContext.current
val fontScale = context.resources.configuration.fontScale
val lineHeight = 20.dp * fontScale
val room = LocalSize.current.height - 12.dp - lineHeight
val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(0, 2)
val ahead = tomorrow.take(fits)
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 8.dp)
} else {
GlanceModifier.padding(horizontal = 4.dp)
}
Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = context.getString(R.string.widget_nothing_left_today),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.title, fontWeight = FontWeight.Medium),
modifier = backing.height(lineHeight),
)
val tomorrowDate = data.today.plus(1, DateTimeUnit.DAY)
val tomorrowLabel = relativeDayLabel(context, tomorrowDate, data.today)
ahead.forEachIndexed { index, event ->
WidgetEventRow(
event = event,
secondary = "$tomorrowLabel · ${widgetTimeLabel(context, event, tomorrowDate, data.is24Hour)}",
soften = data.soften,
metrics = metrics,
position = positionOf(index, ahead.size),
source = CalendarView.Day,
quiet = true,
)
}
}
}
/** Today's weekday and number, filled like the agenda's today pill; opens today. */
@Composable
private fun TodayPill(today: LocalDate) {
val context = LocalContext.current
val locale = widgetLocale()
Column(
modifier = GlanceModifier
.width(PillWidth)
.fillMaxHeight()
.cornerRadius(PillWidth / 2)
.background(GlanceTheme.colors.primary)
.clickable(actionStartActivity(MainActivity.openDateIntent(context, today, CalendarView.Day)))
.semantics { contentDescription = localizedDateFormatter(locale, "EEEEdMMMM").format(today.toJavaLocalDate()) },
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, locale),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onPrimary, fontSize = 11.sp, textAlign = TextAlign.Center),
)
Text(
text = today.day.toString(),
maxLines = 1,
style = TextStyle(
color = GlanceTheme.colors.onPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
),
)
}
}
@@ -1,188 +0,0 @@
package de.jeanlucmakiola.calendula.widget.upcoming
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.provideContent
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.width
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
import de.jeanlucmakiola.calendula.widget.loadDays
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetBackground
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.wallpaperBacking
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import java.time.format.TextStyle as JavaTextStyle
/**
* 2×2 upcoming (#437): today's date on top, then the next events across the
* widget's range in compact rows, as many as its height holds. The date opens
* the agenda, an event opens itself.
*/
class UpcomingWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
// Exact: how many rows show follows the height.
override val sizeMode = SizeMode.Exact
override val previewSizeMode = previewSizeOf(WidgetKind.UPCOMING)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.UPCOMING)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadUpcoming(context.widgetLoadContext(id)), refreshStamp(context, id))
provideContent {
val loaded = widgetData.current(currentRefreshStamp()) { context.loadUpcoming(context.widgetLoadContext(id)) }
UpcomingWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
class UpcomingWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = UpcomingWidget()
}
internal suspend fun Context.loadUpcoming(load: WidgetLoadContext) = loadDays(load) { today, weekStart ->
today..today.plus(load.options.range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
}
/** Most rows a tall widget lists; past this it is an agenda, which is its own widget. */
private const val MAX_ROWS = 8
private val RowGap = 2.dp
/** Every event still to come or running, each once, on the first day it shows. */
internal fun upcomingEvents(data: DaysWidgetData.Ready): List<Pair<LocalDate, EventInstance>> =
data.days
.flatMap { day -> day.events.map { day.date to it } }
.filterNot { (_, event) -> event.hasEnded(data.now) }
.distinctBy { (_, event) -> event.instanceId to event.start }
@Composable
internal fun UpcomingWidgetContent(data: DaysWidgetData, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
val context = LocalContext.current
when (data) {
DaysWidgetData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
is DaysWidgetData.Ready -> {
val fontScale = context.resources.configuration.fontScale
val metrics = compactRowMetrics(data.options.textSize, fontScale)
val headerHeight = 44.dp * fontScale
val room = LocalSize.current.height - 16.dp - headerHeight
val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(1, MAX_ROWS)
val events = upcomingEvents(data).take(fits)
Column(modifier = GlanceModifier.fillMaxSize().padding(8.dp)) {
DateHeader(data.today, headerHeight)
if (events.isEmpty()) {
WidgetMessage(context.getString(R.string.widget_upcoming_empty))
} else {
events.forEachIndexed { index, (day, event) ->
val time = widgetTimeLabel(context, event, day, data.is24Hour)
WidgetEventRow(
event = event,
secondary = if (day == data.today) time else "${relativeDayLabel(context, day, data.today)} · $time",
soften = data.soften,
metrics = metrics,
position = positionOf(index, events.size),
source = CalendarView.Agenda,
)
}
}
}
}
}
}
}
/** Today's number beside its weekday and month; opens the agenda. */
@Composable
private fun DateHeader(today: LocalDate, height: Dp) {
val context = LocalContext.current
val locale = widgetLocale()
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
GlanceModifier.wallpaperBacking(radius = 12.dp).padding(horizontal = 8.dp)
} else {
GlanceModifier.padding(horizontal = 4.dp)
}
Box(modifier = GlanceModifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
Row(
modifier = backing.clickable(actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Agenda))),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = today.day.toString(),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 28.sp, fontWeight = FontWeight.Bold),
)
Spacer(GlanceModifier.width(8.dp))
Column {
Text(
text = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.FULL, locale),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp, fontWeight = FontWeight.Medium),
)
Text(
text = java.time.Month.of(today.month.ordinal + 1).getDisplayName(JavaTextStyle.FULL_STANDALONE, locale),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
)
}
}
}
}
@@ -1,196 +0,0 @@
package de.jeanlucmakiola.calendula.widget.week
import de.jeanlucmakiola.calendula.widget.WidgetKind
import de.jeanlucmakiola.calendula.widget.previewSizeOf
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.LocalContext
import androidx.glance.action.clickable
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.width
import androidx.glance.semantics.contentDescription
import androidx.glance.semantics.semantics
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
import de.jeanlucmakiola.calendula.widget.loadDays
import de.jeanlucmakiola.calendula.widget.weekOf
import de.jeanlucmakiola.calendula.widget.widgetAppearance
import de.jeanlucmakiola.calendula.widget.widgetLocale
import de.jeanlucmakiola.calendula.widget.widgetCardColor
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
import de.jeanlucmakiola.floret.glance.WidgetData
import de.jeanlucmakiola.floret.glance.WidgetMessage
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
import de.jeanlucmakiola.floret.glance.refreshStamp
import de.jeanlucmakiola.floret.glance.themedColor
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.floret.prefs.ThemeMode
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import kotlinx.datetime.toJavaDayOfWeek
import kotlinx.datetime.toJavaLocalDate
import java.time.format.TextStyle as JavaTextStyle
/**
* 4×1 week strip (#436): the current week's seven days from the app's week
* start, today filled, a dot per event colour under each day. A day opens
* its day view.
*/
class WeekStripWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override val sizeMode = SizeMode.Single
override val previewSizeMode = previewSizeOf(WidgetKind.WEEK_STRIP)
override suspend fun providePreview(context: Context, widgetCategory: Int) =
provideSamplePreview(context, WidgetKind.WEEK_STRIP)
override suspend fun provideGlance(context: Context, id: GlanceId) {
val widgetData = WidgetData(context.loadWeek(id), refreshStamp(context, id))
provideContent {
val loaded = widgetData.current(currentRefreshStamp()) { context.loadWeek(id) }
WeekStripWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
}
}
}
class WeekStripWidgetReceiver : CalendulaWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = WeekStripWidget()
}
private suspend fun Context.loadWeek(id: GlanceId) = loadDays(widgetLoadContext(id), ::weekOf)
/** Most dots a day shows; more colours than this read as noise at this size. */
private const val MAX_DOTS = 3
private val DayGap = 4.dp
private val DotSize = 5.dp
@Composable
internal fun WeekStripWidgetContent(data: DaysWidgetData, appearance: WidgetAppearance, appTheme: ThemeMode) {
CalendulaWidgetRoot(appearance, appTheme) {
when (data) {
DaysWidgetData.NeedsPermission ->
WidgetMessage(LocalContext.current.getString(R.string.widget_needs_permission))
is DaysWidgetData.Ready -> Row(
modifier = GlanceModifier.fillMaxSize().padding(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
for (i in 0 until 7) {
if (i > 0) Spacer(GlanceModifier.width(DayGap))
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
DayTile(data.firstDay.plus(i, DateTimeUnit.DAY), data)
}
}
}
}
}
}
@Composable
private fun DayTile(date: LocalDate, data: DaysWidgetData.Ready) {
val context = LocalContext.current
val isToday = date == data.today
val container = if (isToday) GlanceTheme.colors.primary else widgetCardColor()
val ink = when {
isToday -> GlanceTheme.colors.onPrimary
date < data.today -> GlanceTheme.colors.onSurfaceVariant
else -> GlanceTheme.colors.onSurface
}
val locale = widgetLocale()
val colors = data.eventsOn(date).map { it.color }.distinct()
val label = localizedDateFormatter(locale, "EEEEdMMMM").format(date.toJavaLocalDate())
Column(
modifier = GlanceModifier
.fillMaxSize()
.cornerRadius(14.dp)
.background(container)
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Week)))
.semantics {
contentDescription = if (colors.isEmpty()) label else context.resources.getQuantityString(
R.plurals.widget_day_with_events, data.eventsOn(date).size, label, data.eventsOn(date).size,
)
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = date.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, locale),
maxLines = 1,
style = TextStyle(color = ink, fontSize = 11.sp, textAlign = TextAlign.Center),
)
Text(
text = date.day.toString(),
maxLines = 1,
style = TextStyle(color = ink, fontSize = 17.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center),
)
Spacer(GlanceModifier.height(3.dp))
// A dot row is always drawn, so the numbers line up across days.
Row(modifier = GlanceModifier.height(DotSize)) {
colors.take(MAX_DOTS).forEachIndexed { index, color ->
if (index > 0) Spacer(GlanceModifier.width(3.dp))
Dot(dotColor(color, isToday, data.soften))
}
}
}
}
/** A mark on the tile: the accent tone on a card, the lighter fill tone on today's primary. */
@Composable
private fun dotColor(color: Int, onPrimary: Boolean, soften: Boolean): ColorProvider = if (onPrimary) {
themedColor(day = eventFill(color, dark = false, soften), night = eventFill(color, dark = true, soften))
} else {
themedColor(day = eventAccent(color, dark = false, soften), night = eventAccent(color, dark = true, soften))
}
@Composable
private fun Dot(color: ColorProvider) {
Image(
provider = ImageProvider(R.drawable.widget_dot),
contentDescription = null,
colorFilter = ColorFilter.tint(color),
modifier = GlanceModifier.size(DotSize),
)
}
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="@android:color/white"
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM19,19H5V8h14v11zM7,10h5v5H7z"/>
</vector>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFFFFF" />
<solid android:color="#4F7CAC" />
<corners android:radius="3dp" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_preview_cell" />
<corners android:radius="12dp" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="14dp" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_preview_outside" />
<corners android:radius="12dp" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_preview_primary" />
<corners android:radius="14dp" />
</shape>
-5
View File
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFFFFFFF" />
</shape>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="4dp" />
<solid android:color="@android:color/white" />
</shape>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="8dp" />
<solid android:color="@android:color/white" />
</shape>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@android:color/white" />
</shape>
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="4"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="5"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="6"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="7"
android:paddingHorizontal="1dp">
<ImageView
android:id="@+id/bar_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="fitXY"
android:src="@drawable/widget_month_bar_bg" />
<TextView
android:id="@+id/bar_title"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:ellipsize="end"
android:gravity="center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:paddingHorizontal="3dp"
android:textSize="9sp" />
</FrameLayout>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2" />

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