Compare commits

...

60 Commits

Author SHA1 Message Date
c8a4f90404 Merge remote-tracking branch 'origin/release/v2.13.0' into fix/calendar-picker-scrollable
All checks were successful
CI / ci (pull_request) Successful in 5m16s
2026-07-03 15:22:38 +02:00
0a89ff9d0e fix(edit): pin the picker title instead of a large collapsing header
Full-screen selection pickers opened with the LargeTopAppBar expanded, so the
title started below the bar and only settled into it after a scroll — a tall
empty header above a short option list. Give CollapsingScaffold a largeTopBar
flag and have FullScreenPicker use a pinned single-line TopAppBar, so every
picker (calendar, theme, week start, language, reminder, agenda range) shows
its title in the bar from the start. Settings and the calendar manager keep
the large collapsing header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:22:18 +02:00
0718afd5a6 fix(edit): make the new-event calendar picker a scrollable grouped list
The calendar picker was an AlertDialog holding a non-scrolling Column, so
accounts with many calendars overflowed the dialog's fixed height and the
entries past ~9 were unreachable (Codeberg #29).

Replace it with the app's FullScreenPicker (collapsing scaffold + scrollable
content), rendering calendars as connected GroupedRows grouped under their
owning account — the same grouping the visibility filter and calendar manager
use — with a colour chip per row and a check on the current selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:13:27 +02:00
3863e74857 fix(settings): quick-switch write races, override parsing, stale custom fonts
Four fixes across the settings/prefs layer:

- Quick-switch toggles and reorders were read-modify-write against the
  async-echoed UI snapshot, so two rapid interactions reverted each
  other. Writes now go through SettingsPrefs.updateQuickSwitch, an
  atomic transform over the currently-stored value, via intent-level
  ViewModel ops; the MIN_ENABLED floor is re-checked inside the
  transform since the screen's guard reads the stale snapshot.

- parseReminderOverrides treated any unparseable stored value as an
  explicit empty override (no reminder). Only the deliberate 'none'
  sentinel means that now; garbage drops the entry so the calendar
  inherits the global default. Partially-valid values salvage their
  valid minutes.

- Replacing an already-active custom font never refreshed typography:
  the unchanged 'custom' token made AppFontSettings value-equal, so the
  StateFlow never re-emitted. A per-role import stamp now breaks
  equality on re-import (missing key = 0, backward compatible).

- The FontPicker custom preview resolved the font file unmemoized on
  every recomposition (disk stat + fresh FontFamily defeating the
  typeface cache); it's now remembered, keyed on the import stamp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:01:58 +02:00
44d056f31f fix(settings): commit drag reorders unconditionally on release
The order mutation and onReorder call sat after the 160 ms settle
animation inside the same cancellable coroutine; grabbing another row
within that window cancelled the job between animation and commit,
silently reverting a finished reorder. The commit now runs
synchronously in onDragEnd — the settle is purely visual (the live
offset is re-based onto the new slot in the same frame, so nothing
jumps) and safe to interrupt.

Also derive the hovered target slot via derivedStateOf so a drag only
recomposes the column when a slot boundary is crossed, not on every
pixel (the held row's own translation is already draw-phase).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 23:01:58 +02:00
5c513a1b19 fix(settings): make the reminder picker's empty state safe and explicit
Three defects in the v2.13.0 multi-select reminder picker:

- Unchecking the last time on a per-calendar picker silently persisted
  an explicit no-reminder override; an accidental toggle-undo wiped the
  calendar's default. Empty-by-unchecking now reverts to 'Use default'
  (Inherit); deliberate no-reminder is its own exclusive 'None' row
  (reusing reminder_none) on both pickers, so the empty state is
  visible and reachable instead of implicit.

- A custom (non-preset) lead time's row vanished the moment it was
  unchecked, stranding the hand-entered value. Custom values seen this
  session keep their row (unchecked) until the picker closes.

- The optimistic selection seeded once from a possibly-not-yet-loaded
  settings state (initialValue emptyList behind a CalendarProvider-
  gated combine), so a quick first toggle after process-death restore
  overwrote the stored default. The local state now re-syncs from the
  incoming selection until the user first interacts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:01:41 +02:00
037d05b171 fix(reminders): close both gaps in disabled-calendar suppression (#17)
Two holes in the v2.13.0 'disabled calendars no longer notify' fix:

- A snoozed reminder bypassed the filter: ReminderActionReceiver's
  ACTION_SHOW posted unconditionally. The gate now lives once in
  ReminderNotifier.post — the single choke point both receivers use —
  built on a shared ReminderAlert.isForDisabledCalendar predicate that
  never treats calendarId 0L (pre-upgrade snooze intents without
  EXTRA_CALENDAR_ID) as disabled.

- A reminder firing while its calendar was disabled was lost forever:
  the receiver marks the full due set STATE_FIRED (deliberately, to
  stop provider re-broadcasts) and nothing re-scans. Suppressed alerts
  are now stashed in a DataStore-backed SuppressedReminderStore and
  re-posted when the calendar is re-enabled, while the event hasn't
  ended yet; expired entries are purged opportunistically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:01:41 +02:00
e6dd627c85 fix(i18n): drop code-pushed German font strings
German translations must flow through Weblate; strings committed from
code conflict with Weblate's repo state on its next merge. The nine
font-picker strings from the custom-fonts feature fall back to English
until translated in Weblate (MissingTranslation is informational).

The stale-key removal (reminder_custom_with_value) stays: ExtraTranslation
is a fatal lint error, so deletions of retired source strings must
accompany the code change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:36:07 +02:00
262b2b8273 Merge pull request 'feat(theme): user-selectable custom fonts (#19)' (!55) from feat/custom-fonts into release/v2.13.0
Reviewed-on: #55
2026-07-02 08:40:49 +00:00
efca0c86c4 feat(theme): filter custom-font picker to font MIME types
All checks were successful
Translations / check (pull_request) Successful in 29s
CI / ci (pull_request) Successful in 5m5s
Restrict the "Choose file…" document picker to font MIME types
(font/ttf, font/otf, font/sfnt, font/collection plus legacy
application/* font aliases) instead of */*, so only fonts are
selectable. CustomFontStore's Font.Builder validation still guards
anything a provider mislabels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:27:03 +02:00
08cd95cfba Merge remote-tracking branch 'origin/release/v2.13.0' into feat/custom-fonts
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
2026-07-01 15:58:12 +02:00
bfc9a0db39 feat(theme): user-selectable custom fonts (#19)
Add a per-role font picker in Appearance settings. The two Material
typeface roles can be set independently: brand (display/headline) and
plain (title/body/label). Each can be the system default, a bundled
font (Atkinson Hyperlegible, Lora, JetBrains Mono), or a .ttf/.otf the
user loads from the device. The choice rebuilds the app-wide typography
in MainActivity; a null/"system" choice leaves the Material default.

Per the issue discussion the font-size and font-colour parts were
dropped — Android's accessibility font scaling and the dynamic-colour
theme already cover them.

Custom files are copied into app-private storage and validated with
android.graphics.fonts.Font.Builder before replacing the previous one,
so a bad pick can't wedge global text rendering; a missing/unreadable
file degrades to the system font. Each bundled font is previewed in its
own face in the picker. OFL licences + attribution vendored under
licenses/fonts/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:12:47 +02:00
08905990cc Merge pull request 'feat(views): customizable quick-switch cycle + drawer order (#24)' (!54) from feat/quick-switch-view-config into release/v2.13.0
Reviewed-on: #54
2026-07-01 12:09:53 +00:00
b5895b190e Merge remote-tracking branch 'origin/release/v2.13.0' into feat/quick-switch-view-config
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 8m18s
2026-07-01 13:27:13 +02:00
37f0e22911 fix(views): correct snapping + animation for reorderable view lists
Rework ReorderableColumn: the first cut measured each row, but grouped
cards have position-dependent padding, so slot heights varied and rows
snapped to the wrong places with no animation. Now rows are a fixed
uniform pitch, the target slot is the whole-pitches dragged, neighbours
slide aside with a spring, and the held row settles onto its slot before
the order commits. Add a lift (scale + shadow) on the held row and
cancel an in-flight settle if a new drag pre-empts it.

GroupedRow gains gapBelow so the reorderable list can own uniform
inter-row spacing.

On-device verified on Pixel 10.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:25:06 +02:00
4c26450ad4 Merge pull request 'feat(widget): header navigation + fix R8-broken month arrows (#18, #20)' (!53) from feat/widget-header-navigation into release/v2.13.0
Reviewed-on: #53
2026-07-01 10:58:07 +00:00
e5aa90bdd6 docs(changelog): note month-widget day tap opens that day (#18)
All checks were successful
CI / ci (pull_request) Successful in 9m29s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:37:42 +02:00
580dc5a669 feat(widget): tap anywhere on a month-widget day to open it (#18)
Previously only the small day-number was a tap target, so tapping the
blank area of a day cell (or its "+N" overflow) did nothing. Make every
non-event part of a day column open that day — the day number, the empty
lane cells, and the overflow row — via a shared openDayAction, so a tap
anywhere on a day opens it, matching the in-app month grid. Event bars
keep opting out to open their own detail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:23:33 +02:00
b9e800e9fc feat(views): customizable quick-switch cycle + drawer order (#24)
Add a "Views" settings section that lets users pick which views the
top-bar quick-switch button cycles through and drag to reorder them,
plus an independent drag-to-reorder for the navigation drawer's view
list. Two separate configs: a view turned off in the quick-switch
cycle is still reachable from the drawer, which always lists every view.

- QuickSwitchConfig (order + enabled set) and a drawer order persisted
  in SettingsPrefs (comma-joined enum names; "!" marks a disabled view).
  Missing views append enabled and unknown names drop, so a future view
  defaults into both lists.
- The pill cycles through the configured enabled views in order; the
  drawer renders CalendarDrawer from the drawer order. Both threaded
  from CalendarHost via CalendarHostViewModel.
- New ReorderableColumn: dependency-free, measurement-driven drag
  reordering for the short grouped-card settings lists (no LazyColumn,
  since settings are a single verticalScroll column). Commits one write
  per gesture.
- The switch needs two targets, so the last two enabled views can't be
  turned off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:19:50 +02:00
40d70d9f2b feat(widget): open the app from the widget headers (#18, #20)
Tapping a widget header now opens the app on a sensible top-level view,
via a shared WidgetNavRequest.OpenView(view?) — a concrete view roots
there over the default home, a null view resolves to the default home:

- Month widget: the month/year title opens the month view (#18). Its
  "today" button now snaps the grid back to the current month in place
  (the reset that used to sit on the title), so paging + jump-to-today
  are both reachable and the title is free to open the app.
- Agenda widget: the "Upcoming" title opens the default view (#20), so
  users whose home view is Week/Month can reach it in one tap instead of
  drilling through a day or event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:02:14 +02:00
14b19cd902 fix(widget): keep Glance ActionCallback classes under R8 (#18)
The month widget's prev/next/today controls and the agenda widget's
refresh run through Glance `actionRunCallback<T>()`, which persists the
callback's class name into the click PendingIntent and instantiates it
reflectively when tapped. Under R8 full mode (AGP 9 default) those
callback classes — only ever referenced reflectively — were renamed and
lost their no-arg constructor, so the lookup failed silently and the
arrows did nothing on release builds (while actionStartActivity taps,
which need no reflection, kept working).

Keep every ActionCallback's name and constructor. Verified against the
release mapping: ShiftMonthAction/ResetMonthAction/RefreshAgendaAction
now retain their original fully-qualified names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:02:14 +02:00
8d530c5681 Merge feat/per-calendar-multi-reminders: test disabled-calendar reminder filtering (#17) 2026-07-01 00:46:25 +02:00
225f4c3491 test(reminders): cover disabled-calendar alert filtering
Extract the disabled-calendar filtering from EventReminderReceiver into a
pure postableAlerts() function and unit-test it, since the receiver flow
itself is not reachable from a plain JUnit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:46:20 +02:00
11578a5588 Merge feat/per-calendar-multi-reminders: suppress reminders for disabled calendars (#17) 2026-07-01 00:44:15 +02:00
5d887524b1 fix(reminders): suppress notifications for disabled calendars
Reminders fired for every due CalendarAlerts row regardless of whether
the event's calendar had been disabled in-app, even though disabled
calendars are hidden from every other surface. Carry the calendar id
through the alert and filter disabled calendars before posting, while
still marking all due alerts fired so the provider stops re-broadcasting
the suppressed ones.

Closes #17

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:43:56 +02:00
405ff16233 Merge branch 'feat/per-calendar-multi-reminders' into release/v2.13.0 2026-07-01 00:41:12 +02:00
048f407ba1 docs(changelog): record v2.13.0 unreleased changes
Add an [Unreleased] section covering the two user-facing changes in
release/v2.13.0: multiple default reminders per calendar (Codeberg #14,
@moonj) and the fix for editing a single occurrence of a recurring event
(Codeberg #16). The debug-build ribbon is dev-only and intentionally omitted.
Define the [#14] and [#16] links.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:40:46 +02:00
69460bc35a Merge pull request 'Translations update from Weblate' (!45) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Has been skipped
Reviewed-on: #45
2026-06-30 21:48:10 +00:00
Weblate
bf10deaf5c Update translation files
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 7m20s
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-06-30 21:40:44 +00:00
Weblate
b9e861af62 Added translation using Weblate (Russian) 2026-06-30 21:40:44 +00:00
Weblate
2eadda937b Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-06-30 21:40:44 +00:00
Weblate
71b90fa184 Added translation using Weblate (Portuguese) 2026-06-30 21:40:44 +00:00
Weblate
4e9cf88bd4 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-06-30 21:40:44 +00:00
Weblate
b999202629 Added translation using Weblate (Polish) 2026-06-30 21:40:44 +00:00
Weblate
6f0e13a4a2 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-06-30 21:40:44 +00:00
Weblate
9ef7b33800 Added translation using Weblate (Italian) 2026-06-30 21:40:43 +00:00
Weblate
d04b431f2c Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-06-30 21:40:43 +00:00
Weblate
dbeeadff80 Added translation using Weblate (French) 2026-06-30 21:40:43 +00:00
Weblate
f4a169ef5f Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-06-30 21:40:43 +00:00
Weblate
604099b3f2 Added translation using Weblate (Spanish) 2026-06-30 21:40:43 +00:00
Anonymous
a3e6541512 Translated using Weblate (German)
Currently translated at 90.6% (329 of 363 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/de/
2026-06-30 21:40:42 +00:00
a89560953d Merge remote-tracking branch 'origin/release/v2.13.0' into feat/per-calendar-multi-reminders 2026-06-30 16:28:39 +02:00
4e498de051 Merge fix/edit-recurring-this-event into release/v2.13.0
Codeberg #16: 'only this event' recurrence edits now write a DURATION-based
exception (DTEND is rejected by the provider). Includes debug-build markers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:23:44 +02:00
f2fb3d6279 feat(debug): mark debug builds with a distinct icon, label, and ribbon
Make a debug install unmistakable next to the production app, Flutter-style:

- src/debug resource overrides: "Calendula Debug" label and a burnt-orange
  launcher-icon background (production stays slate). The .debug applicationId
  suffix already lets both install side by side.
- DebugRibbon: a "DEBUG" corner ribbon overlaid in MainActivity, gated on
  BuildConfig.DEBUG so release/releaseTest never show it. Enables the
  buildConfig feature for the flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:18:47 +02:00
c2d88e744e fix(edit): write occurrence exceptions with DURATION, not DTEND
Saving "only this event" on a recurring event inserts a modified-occurrence
exception at Events.CONTENT_EXCEPTION_URI. buildOccurrenceExceptionValues set
the occurrence's end as DTEND, but the provider rejects that outright —
CalendarProvider2.checkAllowedInException throws

    IllegalArgumentException: Exceptions can't overwrite dtend

so the insert failed, the save surfaced as SaveUiState.Failed, and the edit
screen reappeared with no change applied. "This and following" and "all events"
never go through the exception insert, which is why only "only this event" broke
(Codeberg #16, verified on a Pixel 10 / Android 16).

An exception is a single instance whose end the provider derives from
DTSTART + DURATION (clearing the inherited RRULE itself), so carry the length as
DURATION and drop DTEND — the same shape AOSP Calendar/Etar use. Verified
on-device: editing one occurrence now applies to just that occurrence and leaves
the rest of the series untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:18:31 +02:00
2ae4c818ba feat(reminders): allow multiple default reminders per calendar
Codeberg #14. Event-level multi-reminders already worked; this widens the
*defaults* layer from a single Int to a List<Int> so a calendar's default can
carry several lead times (e.g. a birthday calendar: one week before *and* on
the day).

- SettingsPrefs: global + per-calendar timed/all-day defaults become List<Int>;
  CalendarReminderOverride.Minutes(List<Int>); resolveDefaultReminder returns a
  list. Storage stays backward-compatible — a legacy single value ("30") parses
  to [30], comma-joined for multiples, "none"/empty for no reminder.
- ReminderDefaultPicker is now multi-select: M3 Checkbox rows over the existing
  grouped-tonal idiom, an exclusive "use default" group for per-calendar
  overrides, and a Custom row that adds an arbitrary lead time to the set.
  Optimistic local state so quick successive toggles don't race the settings
  flow round-trip.
- New events seed their reminder list from the resolved default.

Tests cover none/single/multiple round-trips, legacy single-value parsing, and
per-calendar list overrides. lint + test + assembleDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:19:44 +02:00
fb2a40114a Merge pull request 'release: v2.12.0 — past-event display + week-view title wrap' (!51) from release/v2.12.0 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Successful in 10m22s
Renovate / renovate (push) Successful in 1m9s
Reviewed-on: #51
2026-06-28 20:43:48 +00:00
e1f79df67a release: cut v2.12.0 changelog and fastlane notes
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m9s
Move the accumulated [Unreleased] section under [2.12.0] — 2026-06-28 and
regenerate fastlane/.../changelogs/21200.txt from it (scripts/
sync_changelog_to_fastlane.sh), so the official F-Droid listing and the
Gitea release notes both cover the release's two changes: the past-event
display settings (agenda screen + widget, month/week dimming) and the
overlapping-event title wrapping. versionName is already 2.12.0/21200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:33:58 +02:00
e258a4a7f2 feat(widget): apply the past-event display setting to the agenda widget
Issue #12 asks for hiding finished events in the agenda view *and* its
home-screen widget. The in-app screen already honours the Past events
setting (Show / Dim / Hide); extend the same setting to the "Upcoming"
agenda widget.

The widget loads its events unfiltered and applies the mode reactively in
the composition against a captured "now" — mirroring how the range is read
from per-instance Glance state — so toggling the setting reflects on a live
widget without relying on provideGlance's data preamble re-running. Hiding
drops finished events and any day they empty; dimming fades the colour
stripe and lowers the title's emphasis (Glance has no generic alpha
modifier). setPastEventDisplay now pushes the mode into each widget's Glance
state and recomposes, like setAgendaWidgetRange.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:29:19 +02:00
3f27a47d1e docs(changelog): credit Codeberg #12 (@ptab) for the past-events feature
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:21:24 +02:00
feb87a1e73 docs(changelog): record the week-view title-wrap change for v2.12.0
The overlapping-title fix already shipped its F-Droid note in
fastlane/.../21200.txt but was never added to CHANGELOG.md, so the
release-cut sync (which regenerates the fastlane note from the changelog's
version section) would have dropped it. Add it under [Unreleased] › Changed
alongside the past-event display settings, and define the missing [#13] link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:16:37 +02:00
28e464a616 merge: optionally dim or hide past events (feat/past-event-display) into v2.12.0
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt
2026-06-28 22:11:14 +02:00
93df6d6d62 release: v2.12.0 — wrap event titles in week view
Bump versionName to 2.12.0 (versionCode 21200) and add the changelog.
Merging this into main triggers release.yaml, which builds, signs,
publishes and mints the v2.12.0 tag + Gitea release.

Closes #13
2026-06-28 22:01:26 +02:00
49ddb9437b feat(week): wrap event title to fill slim overlapping blocks
Overlapping events split a day column into narrow lanes where the title
was clipped to one or two characters. Mirror Google Calendar: drop the
time label on overlapping (multi-lane) blocks and wrap the title across
as many lines as the block height allows, so the full title stays
readable without opening the event.

Closes #13
2026-06-28 21:58:22 +02:00
e1cf00999d feat(views): optionally dim or hide past events
Adds two independent display settings under Settings › Appearance, both
defaulting to the current behaviour (off) so nothing changes until a user
opts in:

  • Agenda › "Past events" (Show / Dim / Hide) — events that already ended
    today can be left as-is, faded, or dropped from the list. Hiding also
    removes any day left empty, falling back to the empty state. Re-evaluated
    each minute so rows fade/fall away as they end while the screen is open.

  • Calendar › "Dim completed events" — a separate toggle that fades finished
    events in the month and week grids, kept independent of the agenda setting.

An event counts as completed once its end is at or before now (in-progress
events are never dimmed; all-day events only after their day is fully over),
via a shared EventInstance.hasEnded(now). The grids read the cut-off through
a new LocalDimCutoff CompositionLocal (mirroring LocalShowHourLines) so only
the event chips recompose on the per-minute tick, and only while dimming is on.

Also adds an "Agenda" section header so the agenda rows stand apart in the
now-busier Appearance screen, and documents the feature in the changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 21:48:19 +02:00
2d0d707a7d Merge pull request 'release: v2.11.2 — auto-focus event title (#10)' (!50) from release/v2.11.2 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Successful in 10m29s
Reviewed-on: #50
2026-06-28 10:55:24 +00:00
de1fe31223 feat(event-form): auto-focus the title on a new event, optionally (#10)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 7m11s
Opening the new-event form now puts the cursor in the title field and raises the
keyboard, so the user can type the name straight away instead of tapping the
field first (issue #10). On by default — most events get a title — with a new
"Focus title on new event" switch in Settings → New event form to turn it off.

Only the create form auto-focuses: editing an existing event and opening a
prefilled/imported form never grab focus (guarded by !isEditing && title blank).

- SettingsPrefs: autofocusEventTitle (booleanPreferencesKey), default true.
- Plumbed through SettingsViewModel/UiState (settings switch) and
  EventEditViewModel/UiState (read by the form).
- EventEditScreen: a FocusRequester on the title InlineField, requested once per
  open from a LaunchedEffect when the guard holds.
- Strings (en + de), unit test for the new pref default/round-trip.

Bumps to 2.11.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:47:47 +02:00
273cfce969 Merge pull request 'release: v2.11.1 — register calendar intent filters (#9)' (!49) from release/v2.11.1 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Successful in 11m28s
Reviewed-on: #49
2026-06-28 10:32:42 +00:00
b6a45b7264 fix(intents): register calendar intent filters so Calendula can be the default calendar (#9)
All checks were successful
CI / ci (pull_request) Successful in 7m51s
Calendula didn't declare the intent filters launchers and the system use for
calendar actions, so it never appeared in the "default calendar app" chooser —
on every platform, not just GrapheneOS (issue #9). Android exposes no API for an
app to set itself default, so registering these filters is the only way users can
pick it from the system picker.

Adds to MainActivity:
- MAIN + APP_CALENDAR — the "open the calendar app" action the OS/launchers use.
- VIEW on content://com.android.calendar/time/<epochMillis> and the time/epoch
  mime type — a launcher/clock date tap. The provider's time Uri is parsed into a
  LocalDate and opened on the day view, rooted over the default home view (a new
  sourceless WidgetNavRequest.OpenDate). The .ics import path now ignores the
  calendar provider host so a date tap isn't mistaken for a file to import.

Bumps to 2.11.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:15:56 +02:00
c7ef52e426 Merge pull request 'release: v2.11.0' (!48) from release/v2.11.0 into main
All checks were successful
Release — F-Droid repo + Gitea release / detect (push) Successful in 5s
Release — F-Droid repo + Gitea release / release (push) Successful in 6m55s
Reviewed-on: #48
2026-06-27 21:49:32 +00:00
74 changed files with 3346 additions and 355 deletions

View File

@@ -5,6 +5,80 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Set more than one default reminder per calendar. A calendar's default
reminders — and the global defaults under Settings → Notifications — can now
hold several lead times instead of just one, so new events can start with, say,
a reminder a week before *and* one on the day. The reminder pickers are now
multi-select; per-calendar overrides can still inherit the global default or
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
- Widget headers now open the app. Tapping the month/year title on the month
widget opens the app on the month view, and tapping the "Upcoming" title on
the agenda widget opens it on your default view — so there's a one-tap way
back into the app that lands where you'd expect, instead of only through a day
or event. On the month widget, tapping anywhere on a day — not just the small
date number — now opens that day, and the "today" button snaps the grid back
to the current month in place. Thanks to @rgz46vic and @ptab for the
suggestions ([#18], [#20]).
### Fixed
- Month widget arrows and "today" button work again. On release builds the
prev/next-month arrows and the jump-to-today control on the month widget did
nothing when tapped — code shrinking had stripped the tap handlers behind
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
- Disabled calendars no longer notify. Reminders for events in a calendar you
have disabled (Settings → Calendars) are now suppressed instead of still
popping up — matching how a disabled calendar's events already stay hidden
everywhere else in the app ([#17]).
- Editing a single occurrence of a recurring event works again. Choosing **Only
this event** and saving a change to one event in a repeating series silently
did nothing — the change was rejected and the edit form simply reappeared with
nothing applied. The edited occurrence is now stored correctly, so the change
lands on just that one event and leaves the rest of the series untouched
([#16]).
## [2.12.0] — 2026-06-28
### Added
- See past events your way. Two new settings change how events that have already
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
usual, dim them, or hide them from the list entirely. **Dim completed events**
(Settings → Appearance) separately fades finished events in the month and week
views. Both are off by default, an event only counts as finished once it has
actually ended (events still in progress are never dimmed), and the lists
update on their own as the day goes on. Thanks to @ptab for the suggestion
([#12]).
### Changed
- Readable titles on overlapping events. In the week and day views, events that
overlap split a day into slim columns where the title used to be clipped to a
character or two. The title now wraps across as many lines as the block can
fit, and the time is hidden on those narrow blocks so the name can fill the
space — so you can tell events apart without opening each one. Thanks to @ptab
for the suggestion ([#13]).
## [2.11.2] — 2026-06-28
### Added
- Jump straight to typing a title. When you start a new event, Calendula now puts
the cursor in the title field and opens the keyboard right away, so you can type
the name without an extra tap. It's on by default and only affects creating an
event — editing never grabs focus — and a new **Focus title on new event**
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
suggestion ([#10]).
## [2.11.1] — 2026-06-28
### Fixed
- Calendula can now be set as your default calendar app. It registers the
calendar-app intent filters the system uses, so it appears in the chooser when
you tap a date in a launcher or clock — and opening one takes you straight to
that day. Android has no way for an app to make itself the default, so you pick
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
## [2.11.0] — 2026-06-27 ## [2.11.0] — 2026-06-27
### Added ### Added
@@ -685,3 +759,12 @@ automatically, with zero telemetry and no internet permission.
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6 [#6]: https://codeberg.org/jlmakiola/calendula/issues/6
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7 [#7]: https://codeberg.org/jlmakiola/calendula/issues/7
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8 [#8]: https://codeberg.org/jlmakiola/calendula/issues/8
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20

View File

@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag + // which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21100 versionCode = 21200
versionName = "2.11.0" versionName = "2.12.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -90,6 +90,8 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
// BuildConfig.DEBUG gates the in-app debug ribbon (see DebugRibbon).
buildConfig = true
} }
// Don't embed AGP's dependency-metadata block in the APK signing block. It's // Don't embed AGP's dependency-metadata block in the APK signing block. It's

View File

@@ -16,6 +16,18 @@
-keep class * extends androidx.room.RoomDatabase { *; } -keep class * extends androidx.room.RoomDatabase { *; }
-dontwarn androidx.room.paging.** -dontwarn androidx.room.paging.**
# Glance runs an @Composable's `actionRunCallback<T>()` by persisting the
# callback's fully-qualified class name into the click PendingIntent, then
# reflectively instantiating it (Class.forName(name).newInstance()) when the tap
# fires. Under R8 full mode (AGP 9 default) these ActionCallback classes — only
# ever referenced reflectively — get renamed or have their no-arg constructor
# stripped, so the lookup fails silently and the tap does nothing. In the month
# and agenda widgets that broke every run-callback control (the prev/next/today
# month arrows and the agenda refresh) in release builds while actionStartActivity
# taps, which ride a PendingIntent and need no reflection, kept working. Keep
# every ActionCallback's name and constructor intact.
-keep class * implements androidx.glance.appwidget.action.ActionCallback { <init>(...); }
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from # WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
# the fully-qualified class name persisted in the WorkSpec, so the class must # the fully-qualified class name persisted in the WorkSpec, so the class must
# keep both its name and a no-arg constructor. Glance renders every widget # keep both its name and a no-arg constructor. Glance renders every widget

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug-only launcher-icon background. Production is slate (#5C6B7A); the
debug build paints the adaptive-icon background burnt orange instead, so the
debug icon reads at a glance as "not the real app" on the home screen. The
off-white foreground mark contrasts on both. See drawable/ic_launcher_background.
-->
<resources>
<color name="ic_launcher_background">#FFB23B00</color>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug-build resource overrides. Merged on top of src/main for the `debug`
build type only (release/releaseTest keep the production values), so the
debug app is unmistakable on the launcher: its own label, alongside the
real app thanks to the `.debug` applicationId suffix.
-->
<resources>
<string name="app_name">Calendula Debug</string>
</resources>

View File

@@ -59,6 +59,35 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Be selectable as the system calendar app. Android has no API for
an app to make itself the default, so registering the filters
launchers and the OS use is what lets the user pick Calendula
from the system chooser when a date action fires (issue #9).
APP_CALENDAR is the "open the calendar app" action; the VIEW
filters below catch a tapped date. -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.APP_CALENDAR" />
</intent-filter>
<!-- A launcher/clock date tap fires ACTION_VIEW on the provider's
time Uri (content://com.android.calendar/time/<epochMillis>);
some surfaces use the time/epoch mime type. We open the day view
on that date (MainActivity.calendarTimeDateOrNull). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:scheme="content"
android:host="com.android.calendar"
android:pathPrefix="/time" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="time/epoch" />
</intent-filter>
<!-- Open a .ics file (file manager / email attachment / browser). --> <!-- Open a .ics file (file manager / email attachment / browser). -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />

View File

@@ -8,6 +8,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -29,12 +30,19 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -94,10 +102,24 @@ class MainActivity : AppCompatActivity() {
val use24Hour = remember(settings.timeFormat, context) { val use24Hour = remember(settings.timeFormat, context) {
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context)) settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
} }
// The user's custom-font choice, resolved to a Material typography
// (issue #19). Recomputed only when a token — or the custom-font
// re-import stamp AppFontSettings carries, so replacing the file
// behind an active "custom" token still refreshes — changes;
// "system for both" returns the default scale untouched.
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
val typography = remember(fonts, context) {
calendulaTypography(
brand = resolveFontFamily(fonts.brand, FontRole.BRAND, context),
plain = resolveFontFamily(fonts.plain, FontRole.PLAIN, context),
)
}
CalendulaTheme( CalendulaTheme(
darkTheme = darkTheme, darkTheme = darkTheme,
dynamicColor = settings.dynamicColor, dynamicColor = settings.dynamicColor,
typography = typography,
) { ) {
Box(modifier = Modifier.fillMaxSize()) {
CompositionLocalProvider( CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour, LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines, LocalShowHourLines provides settings.showHourLines,
@@ -112,6 +134,10 @@ class MainActivity : AppCompatActivity() {
onImportConsumed = { requestedImportUri = null }, onImportConsumed = { requestedImportUri = null },
) )
} }
// A persistent corner marker so a debug build is never
// mistaken for the production app; compiled out of release.
if (BuildConfig.DEBUG) DebugRibbon()
}
pendingCrashReport?.let { report -> pendingCrashReport?.let { report ->
CrashReportDialog( CrashReportDialog(
report = report, report = report,
@@ -156,10 +182,39 @@ class MainActivity : AppCompatActivity() {
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java) Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
else -> null else -> null
} ?: return null } ?: return null
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
// it's a navigation, not a file to import, so [navRequestOrNull] owns it.
if (uri.host == CALENDAR_PROVIDER_HOST) return null
return uri.takeIf { it.scheme == "content" || it.scheme == "file" } return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
} }
/**
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
* <epochMillis>`. Null for any other intent. The matching manifest filter is
* what lets users pick Calendula from the system calendar chooser (issue #9).
*/
private fun Intent.calendarTimeDateOrNull(): LocalDate? {
if (action != Intent.ACTION_VIEW) return null
val uri = data ?: return null
if (uri.host != CALENDAR_PROVIDER_HOST) return null
val segments = uri.pathSegments
if (segments.firstOrNull() != "time") return null
val millis = segments.getOrNull(1)?.toLongOrNull() ?: return null
return Instant.fromEpochMilliseconds(millis)
.toLocalDateTime(TimeZone.currentSystemDefault()).date
}
private fun Intent.navRequestOrNull(): WidgetNavRequest? { private fun Intent.navRequestOrNull(): WidgetNavRequest? {
// An external date tap (launcher/clock) has no widget source, so it opens
// the day view rooted over the default home view (OpenDate source = null).
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
// A widget header tap: open a top-level view with no date drill-in. The
// empty string carried by [openViewIntent] means "the default home view".
if (hasExtra(EXTRA_OPEN_VIEW)) {
val name = getStringExtra(EXTRA_OPEN_VIEW).orEmpty()
return WidgetNavRequest.OpenView(CalendarView.entries.firstOrNull { it.name == name })
}
val source = sourceViewOrNull() val source = sourceViewOrNull()
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L) val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
return when { return when {
@@ -201,12 +256,20 @@ class MainActivity : AppCompatActivity() {
} }
companion object { companion object {
// The calendar provider's authority/host. A date tap arrives as
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID" private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN" private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END" private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO" private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE" private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
// A widget header tap asking to open a top-level view (no date drill-in).
// Its value is the target [CalendarView] name, or "" for the default view.
private const val EXTRA_OPEN_VIEW = "de.jeanlucmakiola.calendula.extra.OPEN_VIEW"
// The [CalendarView] (by name) of the widget a launch came from. Roots the // The [CalendarView] (by name) of the widget a launch came from. Roots the
// in-app back stack in that view; absent for non-widget launches (reminders). // in-app back stack in that view; absent for non-widget launches (reminders).
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW" private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
@@ -270,5 +333,19 @@ class MainActivity : AppCompatActivity() {
putExtra(EXTRA_DATE_ISO, date.toString()) putExtra(EXTRA_DATE_ISO, date.toString())
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
/**
* Open the app on a top-level [view] with no date drill-in — a widget
* header tap. A null [view] opens the user's default home view (the agenda
* widget's "Upcoming" title); a concrete view roots there over the default
* home (the month widget's month/year title → [CalendarView.Month]). The
* per-view data URI keeps distinct headers' PendingIntents from collapsing.
*/
fun openViewIntent(context: Context, view: CalendarView?): Intent =
Intent(context, MainActivity::class.java).apply {
data = "calendula://view/${view?.name ?: "default"}".toUri()
putExtra(EXTRA_OPEN_VIEW, view?.name ?: "")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
} }
} }

View File

@@ -118,8 +118,14 @@ internal fun buildEventUpdateValues(
* provider clone the series row and apply these on top. Unlike the series * provider clone the series row and apply these on top. Unlike the series
* update there is no dirty check — the exception is a fresh row, so every * update there is no dirty check — the exception is a fresh row, so every
* form-backed column is written (empty optionals as explicit NULLs, since the * form-backed column is written (empty optionals as explicit NULLs, since the
* clone starts from the parent's values). An exception is a single event: * clone starts from the parent's values).
* DTEND, never RRULE/DURATION. *
* The occurrence's length travels as DURATION, never DTEND: the provider
* rejects DTEND on an exception outright (`CalendarProvider2`:
* "Exceptions can't overwrite dtend") and derives the instance end from
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
* "only this event" fail on-device (Codeberg #16).
*/ */
internal fun buildOccurrenceExceptionValues( internal fun buildOccurrenceExceptionValues(
form: EventForm, form: EventForm,
@@ -131,7 +137,7 @@ internal fun buildOccurrenceExceptionValues(
put(CalendarContract.Events.TITLE, form.title.trim()) put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0) put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, times.dtStartMillis) put(CalendarContract.Events.DTSTART, times.dtStartMillis)
put(CalendarContract.Events.DTEND, times.dtEndMillis) put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue()) put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())

View File

@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.data.fonts
import android.content.Context
import android.net.Uri
import de.jeanlucmakiola.calendula.domain.FontRole
import java.io.File
import java.util.Locale
import android.graphics.fonts.Font as PlatformFont
/**
* Storage for user-loaded custom fonts (issue #19). A font a user picks via the
* system file picker is copied into the app's private storage — one file per
* [FontRole] — so the selection survives even if the original is later moved or
* deleted, and never leaves the app. The chosen file is validated as a real font
* before it replaces the previous one, so a bad pick can't wedge the app-wide
* typography.
*/
object CustomFontStore {
private fun dir(context: Context): File = File(context.filesDir, "fonts")
/** The stored font file for [role] (may not exist yet). */
fun file(context: Context, role: FontRole): File =
File(dir(context), "${role.name.lowercase(Locale.ROOT)}.ttf")
fun exists(context: Context, role: FontRole): Boolean =
file(context, role).let { it.exists() && it.length() > 0 }
/**
* Copy [uri] into per-role storage, first validating that it parses as a
* font. Returns true on success; on any failure the previous file is left
* untouched and false is returned (the caller keeps the old selection).
*/
fun import(context: Context, role: FontRole, uri: Uri): Boolean {
val target = file(context, role)
target.parentFile?.mkdirs()
val tmp = File(target.parentFile, "${role.name.lowercase(Locale.ROOT)}.tmp")
return try {
context.contentResolver.openInputStream(uri)?.use { input ->
tmp.outputStream().use { output -> input.copyTo(output) }
} ?: return false
// Font.Builder throws IOException for anything that isn't a valid
// font file (API 29+, which is our minSdk) — a cheap, reliable check.
PlatformFont.Builder(tmp).build()
if (target.exists() && !target.delete()) return false
tmp.renameTo(target)
} catch (_: Exception) {
tmp.delete()
false
}
}
/** Forget the custom font for [role] (used when switching away from it). */
fun clear(context: Context, role: FontRole) {
file(context, role).delete()
}
}

View File

@@ -8,10 +8,14 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -38,6 +42,19 @@ sealed interface WeekStartPref {
*/ */
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR } enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
/**
* How the Agenda screen treats events that have already finished today: [SHOW]
* leaves them as-is (the historical behaviour), [DIM] fades them, [HIDE] drops
* them from the list entirely.
*/
enum class PastEventDisplay { SHOW, DIM, HIDE }
/** Parse a stored [PastEventDisplay.name]; unknown/null falls back to [default]. */
fun parsePastEventDisplay(
stored: String?,
default: PastEventDisplay = PastEventDisplay.SHOW,
): PastEventDisplay = PastEventDisplay.entries.firstOrNull { it.name == stored } ?: default
/** /**
* Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the * Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the
* device's `DateFormat.is24HourFormat` value). * device's `DateFormat.is24HourFormat` value).
@@ -91,6 +108,47 @@ class SettingsPrefs @Inject constructor(
store.edit { it[DYNAMIC_COLOR_KEY] = enabled } store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
} }
/**
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
* strings — "system", "custom", or a bundled font's token — resolved to a
* FontFamily at the theme layer; an unknown token degrades to the default.
*/
val brandFont: Flow<String> = store.data.map { prefs ->
prefs[BRAND_FONT_KEY] ?: FONT_SYSTEM_TOKEN
}
val plainFont: Flow<String> = store.data.map { prefs ->
prefs[PLAIN_FONT_KEY] ?: FONT_SYSTEM_TOKEN
}
suspend fun setBrandFont(token: String) {
store.edit { it[BRAND_FONT_KEY] = token }
}
suspend fun setPlainFont(token: String) {
store.edit { it[PLAIN_FONT_KEY] = token }
}
/**
* A per-role bump counter for the user-loaded custom font. Re-importing
* overwrites the same file under the same "custom" token, so the token alone
* can't signal the change; this stamp — carried into AppFontSettings — breaks
* value equality so the resolved FontFamily (and its previews) refresh. A
* missing key is 0 (existing installs); the first import bumps it to 1.
*/
val brandFontStamp: Flow<Int> = store.data.map { prefs -> prefs[BRAND_FONT_STAMP_KEY] ?: 0 }
val plainFontStamp: Flow<Int> = store.data.map { prefs -> prefs[PLAIN_FONT_STAMP_KEY] ?: 0 }
/** Bump [role]'s custom-font stamp after a re-import so equality-keyed caches refresh. */
suspend fun bumpCustomFontStamp(role: FontRole) {
val key = when (role) {
FontRole.BRAND -> BRAND_FONT_STAMP_KEY
FontRole.PLAIN -> PLAIN_FONT_STAMP_KEY
}
store.edit { it[key] = (it[key] ?: 0) + 1 }
}
suspend fun setWeekStart(pref: WeekStartPref) { suspend fun setWeekStart(pref: WeekStartPref) {
store.edit { it[WEEK_START_KEY] = pref.storageValue() } store.edit { it[WEEK_START_KEY] = pref.storageValue() }
} }
@@ -116,6 +174,31 @@ class SettingsPrefs @Inject constructor(
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled } store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
} }
/**
* How the Agenda screen treats events that already ended today. Defaults to
* [PastEventDisplay.SHOW] — the historical behaviour; users opt into dimming
* or hiding.
*/
val pastEventDisplay: Flow<PastEventDisplay> = store.data.map { prefs ->
prefs[PAST_EVENT_DISPLAY_KEY].toEnum(PastEventDisplay.SHOW)
}
suspend fun setPastEventDisplay(mode: PastEventDisplay) {
store.edit { it[PAST_EVENT_DISPLAY_KEY] = mode.name }
}
/**
* Whether the month/week grids fade events that have already finished.
* Defaults to OFF — independent of the Agenda's [pastEventDisplay].
*/
val dimCompletedEvents: Flow<Boolean> = store.data.map { prefs ->
prefs[DIM_COMPLETED_EVENTS_KEY] ?: false
}
suspend fun setDimCompletedEvents(enabled: Boolean) {
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
}
/** /**
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to * How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
* [AgendaRange.Month] — a month of upcoming events. Independent of the * [AgendaRange.Month] — a month of upcoming events. Independent of the
@@ -164,6 +247,47 @@ class SettingsPrefs @Inject constructor(
store.edit { it[DEFAULT_VIEW_KEY] = view.name } store.edit { it[DEFAULT_VIEW_KEY] = view.name }
} }
/**
* Quick-switch button customisation (#24): the ordered full view list plus
* which views are enabled in the cycle. Stored comma-joined by enum name, a
* "!" prefix marking a disabled view (e.g. "Month,Week,!Day,Agenda"). Missing
* views are appended enabled and unknown names dropped, so a future view
* defaults into the cycle. An absent key means [QuickSwitchConfig.Default].
*/
val quickSwitchConfig: Flow<QuickSwitchConfig> = store.data.map { prefs ->
parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
}
suspend fun setQuickSwitchConfig(config: QuickSwitchConfig) {
store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) }
}
/**
* Atomic read-modify-write of the quick-switch config: [transform] sees the
* value currently stored, parsed inside the edit block, not the async-echoed
* UI snapshot. Two rapid intents (a toggle then a drag) therefore compose
* instead of each re-serialising a stale copy of the other field over it.
*/
suspend fun updateQuickSwitch(transform: (QuickSwitchConfig) -> QuickSwitchConfig) {
store.edit { prefs ->
val current = parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
prefs[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(transform(current))
}
}
/**
* Navigation-drawer view order (#24). Comma-joined enum names; missing views
* are appended in default order and unknown names dropped. Absent key means
* [IMPLEMENTED_VIEWS] (the historical fixed order).
*/
val drawerViewOrder: Flow<List<CalendarView>> = store.data.map { prefs ->
parseViewOrder(prefs[DRAWER_VIEW_ORDER_KEY])
}
suspend fun setDrawerViewOrder(order: List<CalendarView>) {
store.edit { it[DRAWER_VIEW_ORDER_KEY] = order.joinToString(",") { view -> view.name } }
}
/** /**
* Optional event-form fields shown by default (the rest hide behind * Optional event-form fields shown by default (the rest hide behind
* "more fields"). Stored comma-joined by enum name: an absent key means * "more fields"). Stored comma-joined by enum name: an absent key means
@@ -182,6 +306,20 @@ class SettingsPrefs @Inject constructor(
} }
} }
/**
* Whether opening the new-event form focuses the title field and raises the
* keyboard straight away (issue #10). Default ON — a new event almost always
* gets a title, so this saves a tap; users who set the time/calendar first
* can turn it off. Only the create form auto-focuses — editing never does.
*/
val autofocusEventTitle: Flow<Boolean> = store.data.map { prefs ->
prefs[AUTOFOCUS_EVENT_TITLE_KEY] ?: true
}
suspend fun setAutofocusEventTitle(enabled: Boolean) {
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
}
/** /**
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON — * Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
* for users whose only calendar app this is, reminders are essential; the * for users whose only calendar app this is, reminders are essential; the
@@ -224,35 +362,36 @@ class SettingsPrefs @Inject constructor(
} }
/** /**
* The default reminder lead time (minutes before start) prefilled on new * The default reminder lead times (minutes before start) prefilled on new
* **timed** events. `null` = no default reminder — the prior behaviour, kept * **timed** events. The empty list = no default reminder — the prior
* as the factory default so existing users aren't surprised by reminders they * behaviour, kept as the factory default so existing users aren't surprised by
* never asked for. Stored as a string so "none" is distinct from a numeric * reminders they never asked for. Stored as a comma-joined list of minutes, or
* value (and from an unset key, which is also "none"). Per-calendar overrides * "none"/empty for no reminder (also the unset state). A legacy single value
* in [perCalendarReminderOverride] take precedence; all-day events instead use * ("30") parses transparently to a one-element list. Per-calendar overrides in
* [perCalendarReminderOverride] take precedence; all-day events instead use
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder]. * [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
*/ */
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs -> val defaultReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes() prefs[DEFAULT_REMINDER_KEY].toReminderList()
} }
suspend fun setDefaultReminderMinutes(minutes: Int?) { suspend fun setDefaultReminderMinutes(minutes: List<Int>) {
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE } store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() }
} }
/** /**
* The default reminder lead time prefilled on new **all-day** events, in * The default reminder lead times prefilled on new **all-day** events, in
* minutes before the start of the day. All-day events want day-scale lead * minutes before the start of the day. All-day events want day-scale lead
* times ("1 day before"), so they have their own default rather than reusing * times ("1 day before"), so they have their own default rather than reusing
* the timed one. `null` = no default. Per-calendar overrides do **not** apply * the timed one. Empty list = no default. Per-calendar overrides do **not**
* to all-day events — they always use this global value. * apply to all-day events — they always use this global value.
*/ */
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs -> val defaultAllDayReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes() prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList()
} }
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) { suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE } store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() }
} }
/** /**
@@ -289,13 +428,13 @@ class SettingsPrefs @Inject constructor(
/** /**
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events, * Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
* keyed by calendar id. A calendar **present** in the map overrides the global * keyed by calendar id. A calendar **present** in the map overrides the global
* timed default for its new events: a `null` value means "no reminder", an int * timed default for its new events: an **empty** list means "no reminder", a
* means that lead time. A calendar **absent** from the map inherits the global * non-empty list means those lead times. A calendar **absent** from the map
* default. Serialised as `id=value;id=value`, with `none` for an explicit * inherits the global default. Serialised as `id=value;id=value`, where a value
* no-reminder override. (All-day events ignore this and use * is a comma-joined minute list or `none` for an explicit no-reminder override.
* [defaultAllDayReminderMinutes].) * (All-day events ignore this and use [defaultAllDayReminderMinutes].)
*/ */
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs -> val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]) parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
} }
@@ -312,7 +451,7 @@ class SettingsPrefs @Inject constructor(
* events, with the same semantics as [perCalendarReminderOverride] (absent = * events, with the same semantics as [perCalendarReminderOverride] (absent =
* inherit the global all-day default; present null = no reminder). * inherit the global all-day default; present null = no reminder).
*/ */
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs -> val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]) parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
} }
@@ -381,17 +520,60 @@ class SettingsPrefs @Inject constructor(
.toSet() .toSet()
} }
/** Parse a plain comma-joined view order, completed to every implemented view. */
private fun parseViewOrder(stored: String?): List<CalendarView> =
completeViewOrder(
stored?.split(',').orEmpty()
.mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } },
)
/** Parse the quick-switch config; "!"-prefixed names are disabled. */
private fun parseQuickSwitch(stored: String?): QuickSwitchConfig {
if (stored == null) return QuickSwitchConfig.Default
val parsed = stored.split(',').mapNotNull { raw ->
val token = raw.trim()
val disabled = token.startsWith("!")
val name = if (disabled) token.drop(1) else token
CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled }
}
val order = completeViewOrder(parsed.map { it.first })
// Only views explicitly stored disabled are excluded; anything appended
// (a view added in a later release) defaults into the cycle.
val disabled = parsed.filter { it.second }.map { it.first }.toSet()
return QuickSwitchConfig(order, order.filterNot { it in disabled }.toSet())
}
private fun serializeQuickSwitch(config: QuickSwitchConfig): String =
completeViewOrder(config.order).joinToString(",") { view ->
if (view in config.enabled) view.name else "!${view.name}"
}
/** Keep the given order (de-duplicated), then append any views it omits. */
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
val ordered = seen.distinct()
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
}
companion object { companion object {
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
internal val PLAIN_FONT_STAMP_KEY = intPreferencesKey("plain_font_stamp")
internal val WEEK_START_KEY = stringPreferencesKey("week_start") internal val WEEK_START_KEY = stringPreferencesKey("week_start")
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range") internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range") internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar") internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format") internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines") internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done") internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
internal val ALLOW_COLOR_UNSUPPORTED_KEY = internal val ALLOW_COLOR_UNSUPPORTED_KEY =
@@ -440,43 +622,46 @@ sealed interface CalendarReminderOverride {
data object Inherit : CalendarReminderOverride data object Inherit : CalendarReminderOverride
/** Explicit "no reminder" for this calendar, regardless of the global default. */ /** Explicit "no reminder" for this calendar, regardless of the global default. */
data object None : CalendarReminderOverride data object None : CalendarReminderOverride
/** A specific lead time in minutes before the event start. */ /** Specific lead times in minutes before the event start (non-empty). */
data class Minutes(val minutes: Int) : CalendarReminderOverride data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
} }
/** /**
* The lead time to prefill on a new event: the matching per-calendar override * The lead times to prefill on a new event: the matching per-calendar override
* if [calendarId] has one for this event kind, otherwise the global default for * if [calendarId] has one for this event kind, otherwise the global default for
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed * that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so * events consult [timedOverrides] / [timedGlobal]. The empty list = no reminder.
* it can be unit-tested. * Pure so it can be unit-tested.
*/ */
fun resolveDefaultReminder( fun resolveDefaultReminder(
timedGlobal: Int?, timedGlobal: List<Int>,
allDayGlobal: Int?, allDayGlobal: List<Int>,
timedOverrides: Map<Long, Int?>, timedOverrides: Map<Long, List<Int>>,
allDayOverrides: Map<Long, Int?>, allDayOverrides: Map<Long, List<Int>>,
calendarId: Long?, calendarId: Long?,
isAllDay: Boolean, isAllDay: Boolean,
): Int? { ): List<Int> {
val overrides = if (isAllDay) allDayOverrides else timedOverrides val overrides = if (isAllDay) allDayOverrides else timedOverrides
val global = if (isAllDay) allDayGlobal else timedGlobal val global = if (isAllDay) allDayGlobal else timedGlobal
return if (calendarId != null && overrides.containsKey(calendarId)) { return if (calendarId != null && overrides.containsKey(calendarId)) {
overrides[calendarId] overrides.getValue(calendarId)
} else { } else {
global global
} }
} }
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */ /**
private fun MutableMap<Long, Int?>.applyOverride( * Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the
* key; [None] and an empty [Minutes] both store the empty list).
*/
private fun MutableMap<Long, List<Int>>.applyOverride(
calendarId: Long, calendarId: Long,
override: CalendarReminderOverride, override: CalendarReminderOverride,
) { ) {
when (override) { when (override) {
CalendarReminderOverride.Inherit -> remove(calendarId) CalendarReminderOverride.Inherit -> remove(calendarId)
CalendarReminderOverride.None -> put(calendarId, null) CalendarReminderOverride.None -> put(calendarId, emptyList())
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes) is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
} }
} }
@@ -502,24 +687,44 @@ private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) {
private const val NONE = "none" private const val NONE = "none"
private const val ENTRY_SEP = ";" private const val ENTRY_SEP = ";"
private const val KEY_VALUE_SEP = "=" private const val KEY_VALUE_SEP = "="
private const val LIST_SEP = ","
private fun String?.toReminderMinutes(): Int? = when (this) { /** Distinct, ascending lead times — the canonical form stored and resolved. */
null, "", NONE -> null private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
else -> toIntOrNull()
/**
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
* list; a comma-joined list → its minutes; a legacy single value ("30") → a
* one-element list. Non-numeric parts are dropped defensively.
*/
private fun String?.toReminderList(): List<Int> = when {
this == null || isEmpty() || this == NONE -> emptyList()
else -> split(LIST_SEP).mapNotNull { it.trim().toIntOrNull() }.normalizeReminders()
} }
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> { /** Serialise lead times for storage: "none" when empty, else comma-joined. */
private fun List<Int>.toStoredReminders(): String =
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
if (stored.isNullOrBlank()) return emptyMap() if (stored.isNullOrBlank()) return emptyMap()
return stored.split(ENTRY_SEP).mapNotNull { entry -> return stored.split(ENTRY_SEP).mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null val id = parts[0].toLongOrNull() ?: return@mapNotNull null
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null // Only the deliberate "none" sentinel means an explicit no-reminder
id to value // override (empty list); a non-sentinel value that parses to no valid
// minutes is garbage and drops the entry, so the calendar inherits the
// global default rather than silently reading as "no reminder".
when (val value = parts[1]) {
NONE -> id to emptyList()
else -> value.toReminderList().takeIf { it.isNotEmpty() }?.let { id to it }
?: return@mapNotNull null
}
}.toMap() }.toMap()
} }
private fun serializeReminderOverrides(map: Map<Long, Int?>): String = private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" } map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E = private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default

View File

@@ -8,6 +8,7 @@ import android.content.pm.PackageManager
import android.provider.CalendarContract import android.provider.CalendarContract
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -16,6 +17,29 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/**
* True when [this] alert belongs to a calendar the user disabled in-app, so its
* reminder must be suppressed (mirroring the event filtering in
* CalendarRepositoryImpl). Alerts whose calendar is unknown (id 0L — e.g. a
* pre-upgrade snooze PendingIntent minted before EXTRA_CALENDAR_ID existed) are
* never treated as disabled. This is the one predicate the disabled-calendar
* gate is built from: [postableAlerts] here and the choke point in
* [ReminderNotifier.post] both use it.
*/
internal fun ReminderAlert.isForDisabledCalendar(disabledCalendarIds: Set<Long>): Boolean =
calendarId != 0L && calendarId in disabledCalendarIds
/**
* The due alerts that should actually surface as notifications: everything
* except alerts whose calendar the user has disabled in-app. The caller still
* marks the full due set fired, so suppressed alerts are not re-broadcast by the
* provider.
*/
internal fun postableAlerts(
due: List<ReminderAlert>,
disabledCalendarIds: Set<Long>,
): List<ReminderAlert> = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) }
/** /**
* Becomes the app that turns the calendar provider's reminder alarms into * Becomes the app that turns the calendar provider's reminder alarms into
* visible notifications (the Etar model — the provider broadcasts * visible notifications (the Etar model — the provider broadcasts
@@ -32,6 +56,8 @@ class EventReminderReceiver : BroadcastReceiver() {
@Inject lateinit var alertStore: ReminderAlertStore @Inject lateinit var alertStore: ReminderAlertStore
@Inject lateinit var notifier: ReminderNotifier @Inject lateinit var notifier: ReminderNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs @Inject lateinit var settingsPrefs: SettingsPrefs
@Inject lateinit var calendarPrefs: CalendarPrefs
@Inject lateinit var suppressedStore: SuppressedReminderStore
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
@@ -46,8 +72,17 @@ class EventReminderReceiver : BroadcastReceiver() {
if (settingsPrefs.remindersEnabled.first()) { if (settingsPrefs.remindersEnabled.first()) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val due = alertStore.dueAlerts(now) val due = alertStore.dueAlerts(now)
due.forEach { notifier.post(it) } val disabled = calendarPrefs.disabledCalendarIds.first()
val postable = postableAlerts(due, disabled)
// Suppress reminders for disabled calendars, but still mark
// every due alert fired so the provider stops re-broadcasting
// the suppressed ones. Stash those suppressed alerts so
// re-enabling their calendar can recover them (they would
// otherwise stay STATE_FIRED forever with no re-scan).
postable.forEach { notifier.post(it) }
alertStore.markFired(due.map { it.alertId }, now) alertStore.markFired(due.map { it.alertId }, now)
suppressedStore.stash(due - postable.toSet(), now)
suppressedStore.purgeExpired(now)
} }
} finally { } finally {
pendingResult.finish() pendingResult.finish()

View File

@@ -75,6 +75,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
private const val EXTRA_ALERT_ID = "alert_id" private const val EXTRA_ALERT_ID = "alert_id"
private const val EXTRA_EVENT_ID = "event_id" private const val EXTRA_EVENT_ID = "event_id"
private const val EXTRA_CALENDAR_ID = "calendar_id"
private const val EXTRA_BEGIN = "begin" private const val EXTRA_BEGIN = "begin"
private const val EXTRA_END = "end" private const val EXTRA_END = "end"
private const val EXTRA_TITLE = "title" private const val EXTRA_TITLE = "title"
@@ -87,6 +88,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
this.action = action this.action = action
putExtra(EXTRA_ALERT_ID, alert.alertId) putExtra(EXTRA_ALERT_ID, alert.alertId)
putExtra(EXTRA_EVENT_ID, alert.eventId) putExtra(EXTRA_EVENT_ID, alert.eventId)
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
putExtra(EXTRA_BEGIN, alert.beginMillis) putExtra(EXTRA_BEGIN, alert.beginMillis)
putExtra(EXTRA_END, alert.endMillis) putExtra(EXTRA_END, alert.endMillis)
putExtra(EXTRA_TITLE, alert.title) putExtra(EXTRA_TITLE, alert.title)
@@ -113,6 +115,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
return ReminderAlert( return ReminderAlert(
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L), alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L), eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L), beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
endMillis = intent.getLongExtra(EXTRA_END, 0L), endMillis = intent.getLongExtra(EXTRA_END, 0L),
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(), title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),

View File

@@ -16,6 +16,7 @@ import javax.inject.Singleton
data class ReminderAlert( data class ReminderAlert(
val alertId: Long, val alertId: Long,
val eventId: Long, val eventId: Long,
val calendarId: Long,
val beginMillis: Long, val beginMillis: Long,
val endMillis: Long, val endMillis: Long,
/** Raw event title; may be blank — the notifier substitutes "(no title)". */ /** Raw event title; may be blank — the notifier substitutes "(no title)". */
@@ -66,11 +67,12 @@ class AndroidReminderAlertStore @Inject constructor(
ReminderAlert( ReminderAlert(
alertId = c.getLong(0), alertId = c.getLong(0),
eventId = c.getLong(1), eventId = c.getLong(1),
beginMillis = c.getLong(2), calendarId = c.getLong(2),
endMillis = c.getLong(3), beginMillis = c.getLong(3),
title = c.getString(4).orEmpty(), endMillis = c.getLong(4),
location = c.getString(5)?.takeIf { it.isNotBlank() }, title = c.getString(5).orEmpty(),
isAllDay = c.getInt(6) == 1, location = c.getString(6)?.takeIf { it.isNotBlank() },
isAllDay = c.getInt(7) == 1,
), ),
) )
} }
@@ -102,6 +104,7 @@ class AndroidReminderAlertStore @Inject constructor(
val PROJECTION = arrayOf( val PROJECTION = arrayOf(
CalendarContract.CalendarAlerts._ID, CalendarContract.CalendarAlerts._ID,
CalendarContract.CalendarAlerts.EVENT_ID, CalendarContract.CalendarAlerts.EVENT_ID,
CalendarContract.CalendarAlerts.CALENDAR_ID,
CalendarContract.CalendarAlerts.BEGIN, CalendarContract.CalendarAlerts.BEGIN,
CalendarContract.CalendarAlerts.END, CalendarContract.CalendarAlerts.END,
CalendarContract.CalendarAlerts.TITLE, CalendarContract.CalendarAlerts.TITLE,

View File

@@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -33,6 +34,7 @@ import javax.inject.Singleton
class ReminderNotifier @Inject constructor( class ReminderNotifier @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val settingsPrefs: SettingsPrefs, private val settingsPrefs: SettingsPrefs,
private val calendarPrefs: CalendarPrefs,
) { ) {
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */ /** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
@@ -44,6 +46,11 @@ class ReminderNotifier @Inject constructor(
} }
suspend fun post(alert: ReminderAlert) { suspend fun post(alert: ReminderAlert) {
// The single choke point for the disabled-calendar gate: it covers both
// the provider broadcast (EventReminderReceiver) and a snoozed re-show
// (ReminderActionReceiver), so a calendar disabled after a snooze no
// longer notifies — without either receiver duplicating the check.
if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return
ensureChannel() ensureChannel()
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) } val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
val is24Hour = settingsPrefs.timeFormat.first() val is24Hour = settingsPrefs.timeFormat.first()

View File

@@ -0,0 +1,130 @@
package de.jeanlucmakiola.calendula.data.reminders
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
/**
* Still relevant while the event has not ended: a reminder for an event that is
* already over is pointless to re-surface. Falls back to the begin time when the
* end is unknown (0L). Used both to decide what to re-post and to purge the stash.
*/
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
/**
* Local stash of reminder alerts that fired while their calendar was disabled
* in-app. [EventReminderReceiver] marks every due alert `STATE_FIRED` regardless
* (so the provider stops re-broadcasting the suppressed ones), which would
* otherwise lose those reminders forever — there is no re-scan. Stashing lets
* [de.jeanlucmakiola.calendula.ui.calendars.CalendarsViewModel] re-post them if
* the user re-enables the calendar before the event is over.
*
* Persisted in the shared preferences DataStore as a set of self-describing
* strings (one per alert); the stash never reaches a screen, so there is no
* domain model. Entries whose event has already ended are dropped on the next
* stash/recover/purge, so the stash only ever holds a handful of pending alerts.
*/
@Singleton
class SuppressedReminderStore @Inject constructor(
private val store: DataStore<Preferences>,
) {
/** Add [alerts] to the stash, replacing any existing entry with the same id. */
suspend fun stash(alerts: List<ReminderAlert>, nowMillis: Long) {
if (alerts.isEmpty()) return
store.edit { prefs ->
val byId = decodeAll(prefs).associateByTo(mutableMapOf()) { it.alertId }
alerts.forEach { byId[it.alertId] = it }
prefs.putStash(byId.values.filter { it.isRelevantAt(nowMillis) })
}
}
/**
* Remove and return the still-relevant stashed alerts belonging to any of
* [calendarIds]; drops expired entries for every calendar in passing.
*/
suspend fun recoverFor(calendarIds: Set<Long>, nowMillis: Long): List<ReminderAlert> {
val recovered = mutableListOf<ReminderAlert>()
store.edit { prefs ->
val kept = decodeAll(prefs).filter { alert ->
when {
!alert.isRelevantAt(nowMillis) -> false // expired: drop
alert.calendarId in calendarIds -> { recovered += alert; false }
else -> true
}
}
prefs.putStash(kept)
}
return recovered
}
/** Drop entries whose event has already ended — cheap opportunistic cleanup. */
suspend fun purgeExpired(nowMillis: Long) {
store.edit { prefs ->
prefs.putStash(decodeAll(prefs).filter { it.isRelevantAt(nowMillis) })
}
}
private fun decodeAll(prefs: Preferences): List<ReminderAlert> =
prefs[KEY].orEmpty().mapNotNull { decodeStashEntry(it) }
private fun MutablePreferences.putStash(alerts: List<ReminderAlert>) {
val encoded = alerts.map { encodeStashEntry(it) }.toSet()
if (encoded.isEmpty()) remove(KEY) else set(KEY, encoded)
}
private companion object {
val KEY = stringSetPreferencesKey("suppressed_reminders")
}
}
// One stash entry as a delimited string. The '|' separator is safe because every
// free-text field is Base64-encoded first (that alphabet never contains '|'), and
// a null location is stored as a distinct sentinel that Base64 also never yields.
private const val FIELD_SEP = "|"
private const val NULL_LOCATION = "-"
internal fun encodeStashEntry(alert: ReminderAlert): String = listOf(
alert.alertId.toString(),
alert.eventId.toString(),
alert.calendarId.toString(),
alert.beginMillis.toString(),
alert.endMillis.toString(),
if (alert.isAllDay) "1" else "0",
alert.title.toBase64(),
alert.location?.toBase64() ?: NULL_LOCATION,
).joinToString(FIELD_SEP)
/** Reverse of [encodeStashEntry]; returns null for a malformed entry (dropped). */
internal fun decodeStashEntry(raw: String): ReminderAlert? {
val parts = raw.split(FIELD_SEP)
if (parts.size != 8) return null
return try {
ReminderAlert(
alertId = parts[0].toLong(),
eventId = parts[1].toLong(),
calendarId = parts[2].toLong(),
beginMillis = parts[3].toLong(),
endMillis = parts[4].toLong(),
title = parts[6].fromBase64(),
location = parts[7].takeIf { it != NULL_LOCATION }?.fromBase64(),
isAllDay = parts[5] == "1",
)
} catch (e: NumberFormatException) {
null
} catch (e: IllegalArgumentException) { // bad Base64
null
}
}
private fun String.toBase64(): String =
Base64.getEncoder().encodeToString(toByteArray(Charsets.UTF_8))
private fun String.fromBase64(): String =
String(Base64.getDecoder().decode(this), Charsets.UTF_8)

View File

@@ -0,0 +1,8 @@
package de.jeanlucmakiola.calendula.domain
/**
* The two Material 3 typeface roles a user can set independently (issue #19):
* [BRAND] drives the display/headline styles (expression), [PLAIN] the
* title/body/label styles (readability). Each defaults to the system typeface.
*/
enum class FontRole { BRAND, PLAIN }

View File

@@ -40,6 +40,14 @@ data class EventInstance(
val location: String?, val location: String?,
) )
/**
* Whether this event has finished relative to [now] — its end is at or before
* the current instant. An in-progress event (already started but not yet ended)
* is *not* considered ended. All-day events end at the exclusive next-midnight,
* so they only count as ended once their day is fully over.
*/
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
data class EventDetail( data class EventDetail(
val instance: EventInstance, val instance: EventInstance,
val description: String?, val description: String?,

View File

@@ -79,6 +79,11 @@ fun CalendarHost(
// correcting it. Brief blank first frame, matching the onboarding gate above. // correcting it. Brief blank first frame, matching the onboarding gate above.
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
// View customisation (#24): the quick-switch cycle and drawer order. Both have
// sensible non-empty initial values, so they're ready before the first frame.
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
var viewStack by rememberSaveable(stateSaver = viewStackSaver) { var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView)) mutableStateOf(listOf(defaultView))
} }
@@ -196,7 +201,9 @@ fun CalendarHost(
dismissCoveringOverlays() dismissCoveringOverlays()
createDateIso = null createDateIso = null
pendingDayIso = req.dateIso pendingDayIso = req.dateIso
viewStack = viewBaseStack(defaultView, req.source).drillToDay() // No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits.
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
onWidgetNavConsumed() onWidgetNavConsumed()
} }
is WidgetNavRequest.OpenEvent -> { is WidgetNavRequest.OpenEvent -> {
@@ -210,6 +217,17 @@ fun CalendarHost(
detailKey = key detailKey = key
onWidgetNavConsumed() onWidgetNavConsumed()
} }
is WidgetNavRequest.OpenView -> {
// A widget header tap: land on a top-level view with no date
// drill-in. Reveal it by dropping any covering overlay, then root
// the stack on the target (null → the default home) over the
// default home — so backing out returns to the default, then exits.
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = null
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
onWidgetNavConsumed()
}
is WidgetNavRequest.Create -> { is WidgetNavRequest.Create -> {
// External "new event" entries (QS tile / launcher shortcut / // External "new event" entries (QS tile / launcher shortcut /
// widget) must land on top of whatever is open — the form overlay // widget) must land on top of whatever is open — the form overlay
@@ -258,6 +276,8 @@ fun CalendarHost(
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
) )
CalendarView.Day -> DayScreen( CalendarView.Day -> DayScreen(
selectedView = currentView, selectedView = currentView,
@@ -267,6 +287,8 @@ fun CalendarHost(
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso, initialDateIso = pendingDayIso,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
) )
CalendarView.Month -> MonthScreen( CalendarView.Month -> MonthScreen(
selectedView = currentView, selectedView = currentView,
@@ -275,6 +297,8 @@ fun CalendarHost(
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
) )
CalendarView.Agenda -> AgendaScreen( CalendarView.Agenda -> AgendaScreen(
selectedView = currentView, selectedView = currentView,
@@ -283,6 +307,8 @@ fun CalendarHost(
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
) )
} }
} }

View File

@@ -5,8 +5,10 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
@@ -26,4 +28,21 @@ class CalendarHostViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5_000L), started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null, initialValue = null,
) )
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
.map { it.cycle }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS,
)
/** Order of the views in the navigation drawer (#24); every view always shown. */
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS,
)
} }

View File

@@ -13,8 +13,13 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
* detail-key channel and leave the base view untouched.) * detail-key channel and leave the base view untouched.)
*/ */
sealed interface WidgetNavRequest { sealed interface WidgetNavRequest {
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */ /**
data class OpenDate(val dateIso: String, val source: CalendarView) : WidgetNavRequest * Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over
* [source]. A null [source] means the request came from outside the app (a
* launcher/clock date tap, issue #9) rather than a widget, so it roots over
* the default home view instead of a widget's view.
*/
data class OpenDate(val dateIso: String, val source: CalendarView?) : WidgetNavRequest
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */ /** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
data class OpenEvent( data class OpenEvent(
@@ -26,4 +31,13 @@ sealed interface WidgetNavRequest {
/** Open the create-event form prefilled for [dateIso] (today when null). */ /** Open the create-event form prefilled for [dateIso] (today when null). */
data class Create(val dateIso: String?) : WidgetNavRequest data class Create(val dateIso: String?) : WidgetNavRequest
/**
* Open the app rooted on a top-level [view] with no date drill-in — a widget
* header tap. A null [view] means "the user's default home view" (the agenda
* widget's "Upcoming" title, issue #20); a concrete view roots there over the
* default home (the month widget's month/year title → [CalendarView.Month],
* issue #18), so backing out returns to the default view, then exits.
*/
data class OpenView(val view: CalendarView?) : WidgetNavRequest
} }

View File

@@ -45,6 +45,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -53,7 +54,9 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
@@ -61,12 +64,15 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.Position import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
@@ -91,11 +97,14 @@ fun AgendaScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(), viewModel: AgendaViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle() val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -114,6 +123,7 @@ fun AgendaScreen(
CalendarDrawer( CalendarDrawer(
currentView = selectedView, currentView = selectedView,
currentDate = anchor, currentDate = anchor,
viewOrder = drawerViewOrder,
onSelectView = { view -> onSelectView = { view ->
onSelectView(view) onSelectView(view)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
@@ -134,7 +144,7 @@ fun AgendaScreen(
topBar = { topBar = {
AgendaTopBar( AgendaTopBar(
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
@@ -178,6 +188,7 @@ fun AgendaScreen(
} }
AgendaContent( AgendaContent(
state = state, state = state,
pastDisplay = pastDisplay,
onRetry = viewModel::goToToday, onRetry = viewModel::goToToday,
onEventClick = onEventClick, onEventClick = onEventClick,
modifier = Modifier modifier = Modifier
@@ -275,6 +286,7 @@ private fun AgendaRangeBanner(
@Composable @Composable
private fun AgendaContent( private fun AgendaContent(
state: AgendaUiState, state: AgendaUiState,
pastDisplay: PastEventDisplay,
onRetry: () -> Unit, onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -284,11 +296,31 @@ private fun AgendaContent(
is AgendaUiState.Failure -> Box(modifier) { is AgendaUiState.Failure -> Box(modifier) {
CalendarFailure(reason = state.reason, onRetry = onRetry) CalendarFailure(reason = state.reason, onRetry = onRetry)
} }
is AgendaUiState.Success -> is AgendaUiState.Success -> {
if (state.days.isEmpty()) { val now by rememberCurrentMinute()
// Hiding drops finished events — and any day they leave empty; dimming
// keeps them but fades the row. Recomputed each minute so events fall
// away (or fade) as they end while the screen stays open.
val days = if (pastDisplay == PastEventDisplay.HIDE) {
state.days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(now) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
}
} else {
state.days
}
if (days.isEmpty()) {
AgendaEmpty(modifier) AgendaEmpty(modifier)
} else { } else {
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier) AgendaList(
days = days,
today = state.today,
dimPast = pastDisplay == PastEventDisplay.DIM,
now = now,
onEventClick = onEventClick,
modifier = modifier,
)
}
} }
} }
} }
@@ -296,7 +328,10 @@ private fun AgendaContent(
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun AgendaList( private fun AgendaList(
state: AgendaUiState.Success, days: List<AgendaDay>,
today: LocalDate,
dimPast: Boolean,
now: Instant,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -305,9 +340,9 @@ private fun AgendaList(
// Bottom inset clears the FAB stack so the last row stays tappable. // Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) { ) {
state.days.forEach { day -> days.forEach { day ->
stickyHeader(key = "header-${day.date}") { stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = state.today) AgendaDayHeader(date = day.date, today = today)
} }
itemsIndexed( itemsIndexed(
items = day.events, items = day.events,
@@ -316,6 +351,7 @@ private fun AgendaList(
AgendaEventRow( AgendaEventRow(
event = event, event = event,
position = positionOf(index, day.events.size), position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now),
modifier = calendarAnimateItem(), modifier = calendarAnimateItem(),
onClick = { onEventClick(event) }, onClick = { onEventClick(event) },
) )
@@ -348,13 +384,14 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
private fun AgendaEventRow( private fun AgendaEventRow(
event: EventInstance, event: EventInstance,
position: Position, position: Position,
dimmed: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow( GroupedRow(
modifier = modifier, modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
title = title, title = title,
summary = agendaTimeSummary(event), summary = agendaTimeSummary(event),
position = position, position = position,

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
@@ -53,6 +54,18 @@ class AgendaViewModel @Inject constructor(
private val weekStartDay = settingsPrefs.weekStart private val weekStartDay = settingsPrefs.weekStart
.map { it.resolveFirstDay(Locale.getDefault()) } .map { it.resolveFirstDay(Locale.getDefault()) }
/**
* How to treat events that already ended today (show / dim / hide). A display
* concern only, so it rides alongside the data state rather than re-querying;
* the screen combines it with a per-minute "now" to fade or drop past rows.
*/
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = PastEventDisplay.SHOW,
)
private val zone = TimeZone.currentSystemDefault() private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate private val todayDate: LocalDate

View File

@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
import de.jeanlucmakiola.calendula.data.reminders.SuppressedReminderStore
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -45,6 +47,8 @@ class CalendarsViewModel @Inject constructor(
private val icsExporter: IcsExporter, private val icsExporter: IcsExporter,
private val prefs: CalendarPrefs, private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs, private val settingsPrefs: SettingsPrefs,
private val suppressedStore: SuppressedReminderStore,
private val notifier: ReminderNotifier,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -144,7 +148,10 @@ class CalendarsViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
val current = prefs.disabledCalendarIds.first() val current = prefs.disabledCalendarIds.first()
val next = if (disabled) current + id else current - id val next = if (disabled) current + id else current - id
if (next != current) prefs.setDisabledCalendarIds(next) if (next != current) {
prefs.setDisabledCalendarIds(next)
if (!disabled) recoverReminders(setOf(id))
}
} }
} }
@@ -157,7 +164,29 @@ class CalendarsViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
val current = prefs.disabledCalendarIds.first() val current = prefs.disabledCalendarIds.first()
val next = if (disabled) current + ids else current - ids.toSet() val next = if (disabled) current + ids else current - ids.toSet()
if (next != current) prefs.setDisabledCalendarIds(next) if (next != current) {
prefs.setDisabledCalendarIds(next)
if (!disabled) recoverReminders(current intersect ids.toSet())
}
}
}
/**
* Re-post the reminders that fired while [reEnabledIds] were disabled and are
* still relevant (event not yet over), then drop them from the stash. Runs
* after the disabled set is written, so the notifier's own disabled gate lets
* them through. Best-effort at re-enable time: it mirrors the receiver gates
* (reminders on + postable), and there is no later re-scan, so alerts left
* unposted because those gates are closed are simply released.
*/
private suspend fun recoverReminders(reEnabledIds: Set<Long>) {
if (reEnabledIds.isEmpty()) return
val recovered = suppressedStore.recoverFor(reEnabledIds, System.currentTimeMillis())
if (recovered.isNotEmpty() &&
settingsPrefs.remindersEnabled.first() &&
notifier.canPost()
) {
recovered.forEach { notifier.post(it) }
} }
} }

View File

@@ -59,6 +59,7 @@ fun CalendarDrawer(
onSelectView: (CalendarView) -> Unit, onSelectView: (CalendarView) -> Unit,
onJumpToDate: (LocalDate) -> Unit, onJumpToDate: (LocalDate) -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
) { ) {
var showDatePicker by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) }
@@ -73,10 +74,10 @@ fun CalendarDrawer(
DrawerHeader() DrawerHeader()
DrawerSectionHeader(stringResource(R.string.view_section)) DrawerSectionHeader(stringResource(R.string.view_section))
IMPLEMENTED_VIEWS.forEachIndexed { index, view -> viewOrder.forEachIndexed { index, view ->
GroupedRow( GroupedRow(
title = stringResource(view.labelRes), title = stringResource(view.labelRes),
position = positionOf(index, IMPLEMENTED_VIEWS.size), position = positionOf(index, viewOrder.size),
selected = view == currentView, selected = view == currentView,
minHeight = 56.dp, minHeight = 56.dp,
leading = { Icon(view.icon, contentDescription = null) }, leading = { Icon(view.icon, contentDescription = null) },

View File

@@ -45,11 +45,39 @@ val IMPLEMENTED_VIEWS: List<CalendarView> =
/** Next view in [available], wrapping around. Falls back to Month if absent. */ /** Next view in [available], wrapping around. Falls back to Month if absent. */
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView { fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
if (available.isEmpty()) return this
val i = available.indexOf(this) val i = available.indexOf(this)
if (i < 0) return available.firstOrNull() ?: CalendarView.Month if (i < 0) return available.first()
return available[(i + 1) % available.size] return available[(i + 1) % available.size]
} }
/**
* The user's customisation of the top-bar quick-switch button (#24): which views
* it cycles through ([enabled]) and in what [order]. [order] always lists every
* implemented view — the settings screen reorders the whole set — while [cycle]
* is the subset the pill actually steps through, in [order]. The navigation
* drawer keeps its own separate order and always lists every view, so a view
* disabled here stays reachable there.
*/
data class QuickSwitchConfig(
val order: List<CalendarView>,
val enabled: Set<CalendarView>,
) {
/** Views the pill steps through, in [order]. */
val cycle: List<CalendarView> get() = order.filter { it in enabled }
companion object {
/** All views, in default order, all enabled. */
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
/**
* Fewest views that keep the switch meaningful — a "switch" needs at
* least two targets, so the settings screen blocks disabling below this.
*/
const val MIN_ENABLED = 2
}
}
/** /**
* The top-level view back stack (bottom → top): the [default] home view always * The top-level view back stack (bottom → top): the [default] home view always
* sits at the bottom. Pressing back pops one level until only the home view * sits at the bottom. Pressing back pops one level until only the home view

View File

@@ -0,0 +1,50 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
/**
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
* the app. Deliberately a stark, un-themed marker (not a product component) so a
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
* call site so it never reaches a release build. Non-interactive: it's a plain
* label with no pointer handler, so taps fall through to whatever is beneath it.
*
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
* so it overlays the UI.
*/
@Composable
fun BoxScope.DebugRibbon() {
Text(
text = "DEBUG",
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.TopEnd)
.zIndex(1f)
// Push the band out so its midline crosses the very corner, then
// rotate it to the classic 45° ribbon.
.offset(x = 36.dp, y = 24.dp)
.rotate(45f)
.background(Color(0xFFB23B00))
.width(140.dp)
.padding(vertical = 2.dp),
)
}

View File

@@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -57,10 +58,16 @@ fun positionOf(index: Int, count: Int): Position = when {
} }
/** /**
* The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar] * The app's standard full-screen list scaffold. By default a collapsing
* whose title shrinks into the bar (next to the back button) as the content * [LargeTopAppBar] whose title shrinks into the bar (next to the back button) as
* scrolls. Content is a scrollable column that feeds the toolbar via nested * the content scrolls — used by Settings and the calendar manager, where the
* scroll. Used by Settings and the calendar manager so they share one shell. * large header sets the page. Content is a scrollable column that feeds the
* toolbar via nested scroll.
*
* Set [largeTopBar] to false for a pinned, single-line [TopAppBar] instead: the
* title sits in the bar from the start, with no expanded header to scroll past.
* Preferred for selection pickers, where the tall header is just wasted space
* above the options.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -68,12 +75,16 @@ fun CollapsingScaffold(
title: String, title: String,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
largeTopBar: Boolean = true,
snackbarHost: @Composable () -> Unit = {}, snackbarHost: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
val scrollBehavior = val scrollBehavior = if (largeTopBar) {
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
} else {
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
}
Scaffold( Scaffold(
modifier = modifier modifier = modifier
.predictiveBack(onBack = onBack) .predictiveBack(onBack = onBack)
@@ -81,22 +92,34 @@ fun CollapsingScaffold(
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.nestedScroll(scrollBehavior.nestedScrollConnection), .nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
LargeTopAppBar( val navigationIcon = @Composable {
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.settings_back), contentDescription = stringResource(R.string.settings_back),
) )
} }
}, }
val colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.surface,
)
if (largeTopBar) {
LargeTopAppBar(
title = { Text(title) },
navigationIcon = navigationIcon,
actions = actions, actions = actions,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors( colors = colors,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
) )
} else {
TopAppBar(
title = { Text(title) },
navigationIcon = navigationIcon,
actions = actions,
scrollBehavior = scrollBehavior,
colors = colors,
)
}
}, },
snackbarHost = snackbarHost, snackbarHost = snackbarHost,
) { innerPadding -> ) { innerPadding ->
@@ -142,6 +165,9 @@ fun GroupedRow(
dimmed: Boolean = false, dimmed: Boolean = false,
container: Color? = null, container: Color? = null,
minHeight: Dp = 72.dp, minHeight: Dp = 72.dp,
// The 2.dp separation between rows in a run. Suppressed by the reorderable
// list, which owns uniform spacing itself so every slot has the same pitch.
gapBelow: Boolean = true,
leading: @Composable (() -> Unit)? = null, leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
@@ -160,9 +186,10 @@ fun GroupedRow(
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full, topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
) )
} }
val gap = when (position) { val gap = when {
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp) !gapBelow -> Modifier
Position.Bottom, Position.Alone -> Modifier position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
else -> Modifier
} }
val itemColors = if (selected) { val itemColors = if (selected) {
ListItemDefaults.colors( ListItemDefaults.colors(

View File

@@ -34,7 +34,7 @@ private val LineThickness = 2.dp
* the middle of a minute. Drives the "now" line in the day/week grids. * the middle of a minute. Drives the "now" line in the day/week grids.
*/ */
@Composable @Composable
private fun rememberCurrentMinute(): State<Instant> { fun rememberCurrentMinute(): State<Instant> {
val instant = remember { mutableStateOf(Clock.System.now()) } val instant = remember { mutableStateOf(Clock.System.now()) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (true) {

View File

@@ -0,0 +1,16 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.compositionLocalOf
import kotlin.time.Instant
/**
* The cut-off instant before which a calendar event counts as "completed" and is
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
* the "dim completed events" setting is on, or `null` when it is off (nothing
* dims). Provided per grid screen so only the event chips that read it recompose
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
*/
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
/** Opacity applied to a completed/past event chip when it is dimmed. */
const val EventDimAlpha = 0.4f

View File

@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -23,9 +24,11 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import android.view.WindowManager import android.view.WindowManager
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -43,8 +46,9 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
/** /**
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that * Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a * reuses the app's [CollapsingScaffold] (back button + full width), but with a
* picker is visually identical to a Settings sub-page and uses the full width. * pinned single-line title rather than the large collapsing header — a picker is
* a short list, so the tall header would only be empty space to scroll past.
* [content] places the connected grouped rows; selecting one calls [onDismiss]. * [content] places the connected grouped rows; selecting one calls [onDismiss].
*/ */
@Composable @Composable
@@ -69,7 +73,12 @@ fun FullScreenPicker(
(view.parent as? DialogWindowProvider)?.window (view.parent as? DialogWindowProvider)?.window
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) ?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
} }
CollapsingScaffold(title = title, onBack = onDismiss, content = content) CollapsingScaffold(
title = title,
onBack = onDismiss,
largeTopBar = false,
content = content,
)
} }
} }
@@ -111,10 +120,19 @@ fun <T> OptionPicker(
} }
/** /**
* Reminder-default picker, full-screen: the grouped list (with an optional "Use * Reminder-default picker, full-screen and **multi-select**: each [presets]
* default reminder" row and a "None" row), the [presets] as lead-time rows, and * lead time (plus any chosen custom value) is a checkbox row that toggles
* a "Custom" row that expands an inline number field plus a segmented unit * independently, so a default can carry several reminders ("1 week before" *and*
* selector. Returns the choice as a [CalendarReminderOverride]. * "on the day"). Two exclusive checkmark rows sit above the list: an explicit
* "No reminder" (both pickers, so the empty state stays visible and reachable)
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
* defers to the global default. Clearing the last checked time reverts to "Use
* default" on a per-calendar picker (so an accidental toggle-undo can't silently
* wipe the calendar's default) and to explicit [CalendarReminderOverride.None]
* on the global default (where empty legitimately means no reminder). A "Custom"
* row expands an inline number field plus a unit selector to add an arbitrary
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
* back.
*/ */
@Composable @Composable
fun ReminderDefaultPicker( fun ReminderDefaultPicker(
@@ -125,57 +143,91 @@ fun ReminderDefaultPicker(
onSelect: (CalendarReminderOverride) -> Unit, onSelect: (CalendarReminderOverride) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes // Optimistic local state: once the user edits, the chosen override is
val customSelected = selectedMinutes != null && selectedMinutes !in presets // authoritative while the picker is open, so quick successive toggles compose
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected }) // on each other instead of racing the round-trip through the settings flow
// (which would drop a toggle made before the previous write echoes back).
var current by remember { mutableStateOf(selected) }
// Whether the user has toggled anything yet. Until then, [current] keeps
// mirroring [selected]: a picker that first composed against the settings
// flow's initialValue (empty — reachable after process-death restore straight
// onto the Notifications page, before the CalendarProvider-gated combine
// resolves) then adopts the real stored default instead of persisting empty
// on the first tap. After the first edit, local state is authoritative.
var userEdited by remember { mutableStateOf(false) }
LaunchedEffect(selected) {
if (!userEdited) current = selected
}
val inherits = current is CalendarReminderOverride.Inherit
val isNone = current is CalendarReminderOverride.None
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
// Custom (non-preset) lead times seen this session, so unchecking one keeps
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
// which would strand a hand-entered value with no way to re-check it.
val seenCustom = remember { mutableSetOf<Int>() }
seenCustom += selectedMinutes.filter { it !in presets }
// Presets plus every custom value seen this session, each as its own row.
val rows = presets + seenCustom.sorted()
var customExpanded by rememberSaveable { mutableStateOf(false) } var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf(seed.first) } var amountText by rememberSaveable { mutableStateOf("") }
var unit by rememberSaveable { mutableStateOf(seed.second) } var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
val options = buildList { fun apply(override: CalendarReminderOverride) {
if (allowInherit) add(CalendarReminderOverride.Inherit) userEdited = true
add(CalendarReminderOverride.None) current = override
presets.forEach { add(CalendarReminderOverride.Minutes(it)) } onSelect(override)
} }
val rowCount = options.size + 1 // + the custom row fun emit(minutes: List<Int>) = apply(reminderOverrideForMinutes(minutes, allowInherit))
fun toggle(minute: Int) =
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
FullScreenPicker(title = title, onDismiss = onDismiss) { FullScreenPicker(title = title, onDismiss = onDismiss) {
options.forEachIndexed { index, option -> // Exclusive choices in their own group above the multi-select list, since
val isSelected = option == selected // each is mutually exclusive with picking specific times: "use default"
// (per-calendar only) and an explicit "no reminder" (both pickers, so the
// empty state stays visible and deliberately reachable).
if (allowInherit) {
GroupedRow( GroupedRow(
title = reminderOverrideLabel(option), title = stringResource(R.string.reminder_use_default),
position = positionOf(index, rowCount), position = Position.Top,
selected = isSelected, selected = inherits,
trailing = if (isSelected) { trailing = if (inherits) {
{ SelectedCheck() } { SelectedCheck() }
} else { } else {
null null
}, },
onClick = { onClick = { apply(CalendarReminderOverride.Inherit) },
onSelect(option) )
onDismiss() }
GroupedRow(
title = stringResource(R.string.reminder_none),
position = if (allowInherit) Position.Bottom else Position.Alone,
selected = isNone,
trailing = if (isNone) {
{ SelectedCheck() }
} else {
null
}, },
onClick = { apply(CalendarReminderOverride.None) },
)
Spacer(Modifier.height(24.dp))
val rowCount = rows.size + 1 // + the custom row
rows.forEachIndexed { index, minute ->
val checked = minute in selectedMinutes
GroupedRow(
title = reminderLeadTimeLabel(minute),
position = positionOf(index, rowCount),
selected = checked,
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
onClick = { toggle(minute) },
) )
} }
// When expanded, the Custom row connects downward into the editor card // When expanded, the Custom row connects downward into the editor card
// so the two read as one grouped container (the per-calendar pattern). // so the two read as one grouped container (the per-calendar pattern).
GroupedRow( GroupedRow(
title = if (customSelected) { title = stringResource(R.string.event_edit_reminder_custom),
stringResource( position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
R.string.reminder_custom_with_value,
reminderLeadTimeLabel(selectedMinutes!!),
)
} else {
stringResource(R.string.event_edit_reminder_custom)
},
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
selected = customSelected,
trailing = if (customSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { customExpanded = !customExpanded }, onClick = { customExpanded = !customExpanded },
) )
AnimatedVisibility( AnimatedVisibility(
@@ -189,14 +241,36 @@ fun ReminderDefaultPicker(
unit = unit, unit = unit,
onUnitChange = { unit = it }, onUnitChange = { unit = it },
onConfirm = { minutes -> onConfirm = { minutes ->
onSelect(CalendarReminderOverride.Minutes(minutes)) emit(selectedMinutes + minutes)
onDismiss() amountText = ""
customExpanded = false
}, },
) )
} }
} }
} }
/**
* The override a lead-time set maps to when emitted from [ReminderDefaultPicker].
* A non-empty set is [CalendarReminderOverride.Minutes]; an empty set (the last
* time was unchecked) reverts to [CalendarReminderOverride.Inherit] on a
* per-calendar picker ([allowInherit]) so an accidental toggle-undo can't
* silently wipe the calendar's default, and to explicit
* [CalendarReminderOverride.None] on the global default (where empty legitimately
* means no reminder). Pure so it can be unit-tested.
*/
internal fun reminderOverrideForMinutes(
minutes: List<Int>,
allowInherit: Boolean,
): CalendarReminderOverride {
val norm = minutes.distinct().sorted()
return when {
norm.isNotEmpty() -> CalendarReminderOverride.Minutes(norm)
allowInherit -> CalendarReminderOverride.Inherit
else -> CalendarReminderOverride.None
}
}
/** /**
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom * The expanded "Custom" lead-time editor: a tonal card connected to the Custom
* row above it (matching the grouped-row system, so the two read as one * row above it (matching the grouped-row system, so the two read as one
@@ -428,18 +502,3 @@ fun agendaRangeLabel(range: AgendaRange): String = when (range) {
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days) is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)
} }
@Composable
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
}
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
minutes == null -> "" to ReminderUnit.Minutes
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
else -> minutes.toString() to ReminderUnit.Minutes
}

View File

@@ -0,0 +1,183 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */
val ReorderableRowHeight: Dp = 64.dp
private val RowGap: Dp = 2.dp
/**
* A vertical list whose rows can be dragged into a new order by their handle.
*
* Built for the short, fixed grouped-card lists in Settings (#24) — no external
* dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings
* screens are a single [androidx.compose.foundation.verticalScroll] column, which
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
* uniform gap, so a row's target slot is simply how many whole pitches it has
* been dragged. The held row follows the finger while the others slide out of
* its way (animated); on release the order is committed immediately with a
* single [onReorder] call, then the held row eases into its new slot as a
* purely visual settle — safe to interrupt with another drag, since the
* commit itself never waits on it.
*
* [rowContent] receives the [Position] for the row's place in the order (to reuse
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
* and a `dragHandle` [Modifier] to attach to the element that starts a drag.
*/
@Composable
fun <T> ReorderableColumn(
items: List<T>,
keyOf: (T) -> Any,
onReorder: (List<T>) -> Unit,
modifier: Modifier = Modifier,
rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit,
) {
val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() }
val scope = rememberCoroutineScope()
// Local working copy; re-seeded when the incoming list changes (including the
// echo of our own committed order).
var order by remember(items) { mutableStateOf(items) }
var draggedKey by remember { mutableStateOf<Any?>(null) }
// Live translation of the held row from its slot (px); also drives the
// release settle — re-based onto the row's new slot once the order commits
// (see onDragEnd), then eased down to zero.
var dragOffset by remember { mutableFloatStateOf(0f) }
// The running release/cancel settle, cancelled if a new drag pre-empts it.
// Purely visual — the order commit (see onDragEnd) never depends on it.
var settleJob by remember { mutableStateOf<Job?>(null) }
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
// Whole slots dragged → the slot the held row currently hovers over. Derived
// so recomposition only fires when the *target slot* actually changes, not on
// every dragged pixel: dragOffset moves every frame during a drag, but the
// held row's own translation is already applied in the draw phase via
// graphicsLayer below, so only the neighbours' shift (which depends on this)
// needs to recompose, and only when a slot boundary is actually crossed.
// (Read as a plain val, not `by`, below: a delegated property has a custom
// getter and Kotlin won't smart-cast it in the `when` over `targetIndex` in
// the loop, so the derived value is captured into a real local instead.)
val targetIndex = remember(items, pitchPx) {
derivedStateOf {
val idx = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
idx?.let { (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) }
}
}.value
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
order.forEachIndexed { index, item ->
val key = keyOf(item)
val isDragged = key == draggedKey
// Slide neighbours by one pitch to open the gap the held row will drop
// into. Snap (not animate) once idle, so committing the new order — which
// moves each row's slot — doesn't visibly fight a lingering animation.
val shift = when {
draggedIndex == null || targetIndex == null || isDragged -> 0f
index in (draggedIndex + 1)..targetIndex -> -pitchPx
index in targetIndex until draggedIndex -> pitchPx
else -> 0f
}
val animatedShift by animateFloatAsState(
targetValue = shift,
animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(),
label = "reorderShift",
)
val dragHandle = Modifier.pointerInput(key) {
detectDragGestures(
onDragStart = {
settleJob?.cancel()
draggedKey = key
dragOffset = 0f
},
onDrag = { change, amount ->
change.consume()
dragOffset += amount.y
},
onDragEnd = {
val from = order.indexOfFirst { keyOf(it) == key }
if (from < 0) return@detectDragGestures
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
if (to != from) {
// Commit synchronously and unconditionally, before the settle
// animation below runs — the commit must not depend on that
// coroutine reaching its end, or a new drag starting within the
// ~160ms settle window would cancel it and silently revert an
// already-finished reorder.
order = order.toMutableList().apply { add(to, removeAt(from)) }
onReorder(order)
// The row now lays out at slot `to` instead of `from`; re-base
// the live offset onto that new slot (same visual position,
// expressed relative to the new one) so the settle below eases
// it the rest of the way instead of jumping.
dragOffset -= (to - from) * pitchPx
}
settleJob = scope.launch {
// Purely visual from here: ease the held row onto its slot, then
// release the drag state. Safe to cancel — the order was already
// committed above.
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
draggedKey = null
dragOffset = 0f
}
},
onDragCancel = {
settleJob = scope.launch {
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
draggedKey = null
dragOffset = 0f
}
},
)
}
Box(
Modifier
.height(ReorderableRowHeight)
.zIndex(if (isDragged) 1f else 0f)
.graphicsLayer {
translationY = if (isDragged) dragOffset else animatedShift
if (isDragged) {
scaleX = 1.02f
scaleY = 1.02f
shadowElevation = 8.dp.toPx()
shape = RoundedCornerShape(20.dp)
clip = false
}
},
) {
rowContent(item, positionOf(index, order.size), dragHandle, isDragged)
}
}
}
}

View File

@@ -72,6 +72,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
@@ -116,6 +117,8 @@ fun DayScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialDateIso: String? = null, initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(), viewModel: DayViewModel = hiltViewModel(),
@@ -175,6 +178,7 @@ fun DayScreen(
CalendarDrawer( CalendarDrawer(
currentView = selectedView, currentView = selectedView,
currentDate = date, currentDate = date,
viewOrder = drawerViewOrder,
onSelectView = { view -> onSelectView = { view ->
onSelectView(view) onSelectView(view)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
@@ -196,7 +200,7 @@ fun DayScreen(
DayTopBar( DayTopBar(
date = date, date = date,
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,

View File

@@ -37,6 +37,7 @@ import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Contacts import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.EventAvailable
@@ -81,6 +82,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
@@ -118,12 +121,15 @@ import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.OptionCard
@@ -479,6 +485,17 @@ private fun EventEditContent(
?: MaterialTheme.colorScheme.primary ?: MaterialTheme.colorScheme.primary
val gap = 12.dp val gap = 12.dp
// Issue #10: on a fresh create form (setting on, nothing typed yet) focus the
// title and raise the keyboard so the user can type straight away. Keyed on
// Unit so it runs once per open — not on every keystroke — and guarded so
// editing or an imported/prefilled form never grabs focus.
val titleFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (state.autofocusTitle && !state.isEditing && form.title.isBlank()) {
runCatching { titleFocusRequester.requestFocus() }
}
}
// Per-event colour applicability for the resolved calendar: // Per-event colour applicability for the resolved calendar:
// - palette calendars (Google, …) and local calendars always support it; // - palette calendars (Google, …) and local calendars always support it;
// - synced calendars with no palette only when the user opted in, and even // - synced calendars with no palette only when the user opted in, and even
@@ -506,6 +523,10 @@ private fun EventEditContent(
placeholder = stringResource(R.string.event_edit_title_hint), placeholder = stringResource(R.string.event_edit_title_hint),
textStyle = MaterialTheme.typography.headlineMedium textStyle = MaterialTheme.typography.headlineMedium
.copy(fontWeight = FontWeight.SemiBold), .copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.focusRequester(titleFocusRequester),
) )
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Box( Box(
@@ -926,7 +947,7 @@ private fun EventEditContent(
} }
if (showCalendarPicker) { if (showCalendarPicker) {
CalendarPickerDialog( CalendarPicker(
calendars = state.calendars, calendars = state.calendars,
selectedId = form.calendarId, selectedId = form.calendarId,
onSelect = { onSelect = {
@@ -1914,34 +1935,64 @@ private fun ScheduleRow(
} }
} }
/**
* Full-screen calendar picker: a scrollable list of every writable calendar,
* grouped under its owning account like the visibility filter and calendar
* manager. Replaces the former fixed-height [AlertDialog], which clipped the
* list past ~9 rows so accounts with many calendars had unreachable entries
* (#29). Selecting a row calls [onSelect]; the caller closes the picker.
*/
@Composable @Composable
private fun CalendarPickerDialog( private fun CalendarPicker(
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
selectedId: Long?, selectedId: Long?,
onSelect: (Long) -> Unit, onSelect: (Long) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val dark = isSystemInDarkTheme() // Group by owning account (name, else type, else the calendar's own name),
AlertDialog( // preserving the provider's order within and across groups — the same
onDismissRequest = onDismiss, // grouping [groupByAccount] applies for the drawer filter.
title = { Text(stringResource(R.string.event_detail_calendar)) }, val groups = remember(calendars) {
text = { calendars
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { .groupBy {
calendars.forEach { calendar -> it.accountName.takeIf(String::isNotBlank)
OptionCard( ?: it.accountType.takeIf(String::isNotBlank)
label = calendar.displayName, ?: it.displayName
}
.toList()
}
FullScreenPicker(
title = stringResource(R.string.event_detail_calendar),
onDismiss = onDismiss,
) {
groups.forEach { (account, cals) ->
Text(
text = account,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
cals.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId
GroupedRow(
title = calendar.displayName,
position = positionOf(index, cals.size),
selected = isSelected,
leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
onClick = { onSelect(calendar.id) }, onClick = { onSelect(calendar.id) },
icon = Icons.Default.CalendarMonth,
// The calendar's own colour carries its identity.
iconTint = pastelize(calendar.color, dark),
supportingText = calendar.accountName.takeIf { it.isNotBlank() },
selected = calendar.id == selectedId,
) )
} }
} }
}, }
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
} }

View File

@@ -29,6 +29,12 @@ data class EventEditUiState(
val hiddenFields: List<EventFormField> = emptyList(), val hiddenFields: List<EventFormField> = emptyList(),
/** True while editing an existing event (the calendar is then fixed). */ /** True while editing an existing event (the calendar is then fixed). */
val isEditing: Boolean = false, val isEditing: Boolean = false,
/**
* Whether to focus the title and raise the keyboard when this is a fresh
* create form (issue #10). Mirrors the settings flag; the screen only acts
* on it for a new event with an empty title, never when editing/importing.
*/
val autofocusTitle: Boolean = true,
/** /**
* True while an edit changed the recurrence rule — the save-scope dialog * True while an edit changed the recurrence rule — the save-scope dialog
* then drops "only this event" (an exception row can't carry a rule). * then drops "only this event" (an exception row can't carry a rule).

View File

@@ -108,10 +108,10 @@ class EventEditViewModel @Inject constructor(
) )
private data class ReminderDefaults( private data class ReminderDefaults(
val timed: Int?, val timed: List<Int>,
val allDay: Int?, val allDay: List<Int>,
val timedOverrides: Map<Long, Int?>, val timedOverrides: Map<Long, List<Int>>,
val allDayOverrides: Map<Long, Int?>, val allDayOverrides: Map<Long, List<Int>>,
) )
private data class ExternalInputs( private data class ExternalInputs(
@@ -119,6 +119,7 @@ class EventEditViewModel @Inject constructor(
val lastUsed: Long?, val lastUsed: Long?,
val defaultFields: Set<EventFormField>, val defaultFields: Set<EventFormField>,
val allowColorOnUnsupported: Boolean, val allowColorOnUnsupported: Boolean,
val autofocusTitle: Boolean,
) )
/** /**
@@ -160,6 +161,7 @@ class EventEditViewModel @Inject constructor(
prefs.lastUsedCalendarId, prefs.lastUsedCalendarId,
settingsPrefs.defaultFormFields, settingsPrefs.defaultFormFields,
settingsPrefs.allowColorOnUnsupportedCalendars, settingsPrefs.allowColorOnUnsupportedCalendars,
settingsPrefs.autofocusEventTitle,
::ExternalInputs, ::ExternalInputs,
).flowOn(io), ).flowOn(io),
colorPalette, colorPalette,
@@ -178,6 +180,7 @@ class EventEditViewModel @Inject constructor(
visibleFields = visibleFields, visibleFields = visibleFields,
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(), hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
isEditing = local.editTarget != null, isEditing = local.editTarget != null,
autofocusTitle = external.autofocusTitle,
// A modified-occurrence exception can't carry its own rule, so // A modified-occurrence exception can't carry its own rule, so
// the scope dialog drops "only this event" after a rule change. // the scope dialog drops "only this event" after a rule change.
recurrenceChanged = local.editTarget != null && recurrenceChanged = local.editTarget != null &&
@@ -257,7 +260,7 @@ class EventEditViewModel @Inject constructor(
// Re-check after suspending: bail if the form closed or the user edited. // Re-check after suspending: bail if the form closed or the user edited.
val form = _form.value ?: return@launch val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch if (_editTarget.value != null || _remindersTouched.value) return@launch
val default = resolveDefaultReminder( val reminders = resolveDefaultReminder(
timedGlobal = defaults.timed, timedGlobal = defaults.timed,
allDayGlobal = defaults.allDay, allDayGlobal = defaults.allDay,
timedOverrides = defaults.timedOverrides, timedOverrides = defaults.timedOverrides,
@@ -265,7 +268,6 @@ class EventEditViewModel @Inject constructor(
calendarId = targetId, calendarId = targetId,
isAllDay = form.isAllDay, isAllDay = form.isAllDay,
) )
val reminders = listOfNotNull(default)
_form.value = form.copy(reminders = reminders) _form.value = form.copy(reminders = reminders)
// Surface the section so an auto-applied default is visible and // Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field. // removable, even when Reminders isn't a default-shown field.

View File

@@ -36,6 +36,8 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@@ -44,6 +46,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -60,10 +63,15 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
@@ -91,12 +99,22 @@ fun MonthScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(), viewModel: MonthViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val month by viewModel.month.collectAsStateWithLifecycle() val month by viewModel.month.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick).
val nowState = rememberCurrentMinute()
val dimCutoff by remember(dimCompleted) {
derivedStateOf { if (dimCompleted) nowState.value else null }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -142,6 +160,7 @@ fun MonthScreen(
CalendarDrawer( CalendarDrawer(
currentView = selectedView, currentView = selectedView,
currentDate = LocalDate(month.year, month.month, 1), currentDate = LocalDate(month.year, month.month, 1),
viewOrder = drawerViewOrder,
onSelectView = { view -> onSelectView = { view ->
onSelectView(view) onSelectView(view)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
@@ -163,7 +182,7 @@ fun MonthScreen(
MonthTopBar( MonthTopBar(
month = month, month = month,
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
@@ -193,6 +212,7 @@ fun MonthScreen(
.fillMaxSize(), .fillMaxSize(),
) { ) {
WeekdayHeader(weekStart = weekStart) WeekdayHeader(weekStart = weekStart)
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
MonthContent( MonthContent(
state = state, state = state,
slideDir = slideDir, slideDir = slideDir,
@@ -204,6 +224,7 @@ fun MonthScreen(
} }
} }
} }
}
} }
@Composable @Composable
@@ -558,6 +579,8 @@ private fun MonthBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val shape = RoundedCornerShape( val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp, topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp, bottomStart = if (continuesLeft) 0.dp else 4.dp,
@@ -565,7 +588,7 @@ private fun MonthBar(
bottomEnd = if (continuesRight) 0.dp else 4.dp, bottomEnd = if (continuesRight) 0.dp else 4.dp,
) )
Box( Box(
modifier = modifier modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(event.color, dark), shape) .background(pastelize(event.color, dark), shape)
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
.semantics { contentDescription = title }, .semantics { contentDescription = title },

View File

@@ -59,6 +59,14 @@ class MonthViewModel @Inject constructor(
initialValue = DayOfWeek.MONDAY, initialValue = DayOfWeek.MONDAY,
) )
/** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
private val todayDate: LocalDate private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date get() = Clock.System.now().toLocalDateTime(zone).date

View File

@@ -7,9 +7,11 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import android.widget.Toast
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import android.text.format.DateFormat import android.text.format.DateFormat
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -41,15 +43,19 @@ import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Gavel import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -59,6 +65,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -73,6 +80,7 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -85,6 +93,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
@@ -93,13 +102,20 @@ import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn
import de.jeanlucmakiola.calendula.ui.common.ReorderableRowHeight
import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.labelRes import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.OptionPicker import de.jeanlucmakiola.calendula.ui.common.OptionPicker
@@ -113,13 +129,17 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import java.time.format.TextStyle as JavaTextStyle import java.time.format.TextStyle as JavaTextStyle
import java.util.Calendar import java.util.Calendar
/** The settings sub-screens reached from the hub's category rows. */ /** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { Appearance, EventForm, Notifications } private enum class SettingsSection { Appearance, Views, EventForm, Notifications }
/** /**
* Token-based accent for a leading icon chip (container / on-container pair). * Token-based accent for a leading icon chip (container / on-container pair).
@@ -164,6 +184,13 @@ fun SettingsScreen(
) { ) {
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }) AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
} }
AnimatedVisibility(
visible = section == SettingsSection.Views,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
AnimatedVisibility( AnimatedVisibility(
visible = section == SettingsSection.EventForm, visible = section == SettingsSection.EventForm,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
@@ -202,6 +229,13 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) }, leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Appearance) }, onClick = { onOpenSection(SettingsSection.Appearance) },
) )
GroupedRow(
title = stringResource(R.string.settings_section_views),
summary = stringResource(R.string.settings_views_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.SwapVert, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Views) },
)
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_section_event_form), title = stringResource(R.string.settings_section_event_form),
summary = stringResource(R.string.settings_event_form_subtitle), summary = stringResource(R.string.settings_event_form_subtitle),
@@ -474,6 +508,19 @@ private fun AppearanceScreen(
var showDefaultView by remember { mutableStateOf(false) } var showDefaultView by remember { mutableStateOf(false) }
var showAgendaScreenRange by remember { mutableStateOf(false) } var showAgendaScreenRange by remember { mutableStateOf(false) }
var showAgendaWidgetRange by remember { mutableStateOf(false) } var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showPastEvents by remember { mutableStateOf(false) }
var showBrandFont by remember { mutableStateOf(false) }
var showPlainFont by remember { mutableStateOf(false) }
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
// A picked file that didn't parse as a font: tell the user and keep the old choice.
val context = LocalContext.current
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
LaunchedEffect(Unit) {
viewModel.fontImportFailed.collect {
Toast.makeText(context, importFailedMessage, Toast.LENGTH_LONG).show()
}
}
CollapsingScaffold( CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance), title = stringResource(R.string.settings_section_appearance),
@@ -510,6 +557,24 @@ private fun AppearanceScreen(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Fonts — the two Material typeface roles, each independently choosable
// (issue #19). Headings = brand (display/headline); body = plain
// (title/body/label). Both default to the system typeface.
GroupedRow(
title = stringResource(R.string.settings_font_headings),
summary = fontLabel(fonts.brand),
position = Position.Top,
onClick = { showBrandFont = true },
)
GroupedRow(
title = stringResource(R.string.settings_font_body),
summary = fontLabel(fonts.plain),
position = Position.Bottom,
onClick = { showPlainFont = true },
)
Spacer(Modifier.height(16.dp))
// Calendar — view, week, and timeline formatting // Calendar — view, week, and timeline formatting
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_default_view), title = stringResource(R.string.settings_default_view),
@@ -532,7 +597,7 @@ private fun AppearanceScreen(
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_hour_lines), title = stringResource(R.string.settings_hour_lines),
summary = stringResource(R.string.settings_hour_lines_summary), summary = stringResource(R.string.settings_hour_lines_summary),
position = Position.Bottom, position = Position.Middle,
trailing = { trailing = {
Switch( Switch(
checked = state.showHourLines, checked = state.showHourLines,
@@ -541,16 +606,36 @@ private fun AppearanceScreen(
}, },
onClick = { viewModel.setShowHourLines(!state.showHourLines) }, onClick = { viewModel.setShowHourLines(!state.showHourLines) },
) )
GroupedRow(
title = stringResource(R.string.settings_dim_completed),
summary = stringResource(R.string.settings_dim_completed_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.dimCompletedEvents,
onCheckedChange = viewModel::setDimCompletedEvents,
)
},
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
)
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Agenda // Agenda — the only group labelled, so its agenda-specific rows are easy
// to pick out among the otherwise unheadered Appearance settings.
SectionHeader(stringResource(R.string.settings_agenda_header))
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_agenda_range), title = stringResource(R.string.settings_agenda_range),
summary = agendaRangeLabel(state.agendaScreenRange), summary = agendaRangeLabel(state.agendaScreenRange),
position = Position.Top, position = Position.Top,
onClick = { showAgendaScreenRange = true }, onClick = { showAgendaScreenRange = true },
) )
GroupedRow(
title = stringResource(R.string.settings_past_events),
summary = pastEventDisplayLabel(state.pastEventDisplay),
position = Position.Middle,
onClick = { showPastEvents = true },
)
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_agenda_widget_range), title = stringResource(R.string.settings_agenda_widget_range),
summary = agendaRangeLabel(state.agendaWidgetRange), summary = agendaRangeLabel(state.agendaWidgetRange),
@@ -581,6 +666,28 @@ private fun AppearanceScreen(
onDismiss = { showTheme = false }, onDismiss = { showTheme = false },
) )
} }
if (showBrandFont) {
FontPicker(
title = stringResource(R.string.settings_font_headings),
role = FontRole.BRAND,
selected = fonts.brand,
stamp = fonts.brandStamp,
onSelect = { viewModel.setFont(FontRole.BRAND, it) },
onImport = { viewModel.importCustomFont(FontRole.BRAND, it) },
onDismiss = { showBrandFont = false },
)
}
if (showPlainFont) {
FontPicker(
title = stringResource(R.string.settings_font_body),
role = FontRole.PLAIN,
selected = fonts.plain,
stamp = fonts.plainStamp,
onSelect = { viewModel.setFont(FontRole.PLAIN, it) },
onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) },
onDismiss = { showPlainFont = false },
)
}
if (showWeekStart) { if (showWeekStart) {
OptionPicker( OptionPicker(
title = stringResource(R.string.settings_week_start), title = stringResource(R.string.settings_week_start),
@@ -619,6 +726,16 @@ private fun AppearanceScreen(
onDismiss = { showTimeFormat = false }, onDismiss = { showTimeFormat = false },
) )
} }
if (showPastEvents) {
OptionPicker(
title = stringResource(R.string.settings_past_events),
options = PastEventDisplay.entries,
selected = state.pastEventDisplay,
label = { pastEventDisplayLabel(it) },
onSelect = viewModel::setPastEventDisplay,
onDismiss = { showPastEvents = false },
)
}
if (showDefaultView) { if (showDefaultView) {
OptionPicker( OptionPicker(
title = stringResource(R.string.settings_default_view), title = stringResource(R.string.settings_default_view),
@@ -631,6 +748,128 @@ private fun AppearanceScreen(
} }
} }
/**
* Views (#24): reorder the top-bar quick-switch cycle and choose which views it
* steps through, plus reorder the navigation-drawer list. Two independent lists —
* a view disabled in the quick-switch cycle is still reachable from the drawer,
* which always lists every view. The switch needs at least two targets, so the
* last [QuickSwitchConfig.MIN_ENABLED] enabled views can't be turned off.
*/
@Composable
private fun ViewsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
CollapsingScaffold(
title = stringResource(R.string.settings_section_views),
onBack = onBack,
) {
val config = state.quickSwitchConfig
SectionHeader(stringResource(R.string.settings_quick_switch_header))
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
Spacer(Modifier.height(8.dp))
// Turning a view off is blocked once only the minimum remain enabled.
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
ReorderableColumn(
items = config.order,
keyOf = { it },
onReorder = { viewModel.setQuickSwitchOrder(it) },
) { view, position, dragHandle, isDragging ->
val checked = view in config.enabled
ViewRow(
view = view,
position = position,
isDragging = isDragging,
dragHandle = dragHandle,
dimmed = !checked,
trailing = {
Switch(
checked = checked,
// Keep the last two on: with fewer, the pill can't switch.
enabled = !checked || canDisable,
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
)
},
)
}
Spacer(Modifier.height(24.dp))
SectionHeader(stringResource(R.string.settings_drawer_order_header))
SettingsHint(stringResource(R.string.settings_drawer_order_hint))
Spacer(Modifier.height(8.dp))
ReorderableColumn(
items = state.drawerViewOrder,
keyOf = { it },
onReorder = { viewModel.setDrawerViewOrder(it) },
) { view, position, dragHandle, isDragging ->
ViewRow(
view = view,
position = position,
isDragging = isDragging,
dragHandle = dragHandle,
)
}
}
}
/** One reorderable view row: the view's icon and name, an optional [trailing]
* control, and a drag handle carrying the [dragHandle] gesture modifier. */
@Composable
private fun ViewRow(
view: CalendarView,
position: Position,
isDragging: Boolean,
dragHandle: Modifier,
dimmed: Boolean = false,
trailing: @Composable (() -> Unit)? = null,
) {
GroupedRow(
title = stringResource(view.labelRes),
position = position,
dimmed = dimmed,
minHeight = ReorderableRowHeight,
// The reorderable column owns the inter-row spacing (uniform pitch).
gapBelow = false,
container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null,
leading = {
Icon(
imageVector = view.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Row(verticalAlignment = Alignment.CenterVertically) {
trailing?.invoke()
if (trailing != null) Spacer(Modifier.width(8.dp))
Box(
modifier = dragHandle.size(48.dp),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = stringResource(R.string.reorder_drag_handle),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
)
}
/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */
@Composable
private fun SettingsHint(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
@Composable @Composable
private fun EventFormScreen( private fun EventFormScreen(
state: SettingsUiState, state: SettingsUiState,
@@ -673,6 +912,30 @@ private fun EventFormScreen(
) )
} }
// Auto-focus the title on a new event (issue #10) — on by default, since
// most events get a title; raising the keyboard saves a tap. Off lets you
// set the time/calendar first without the keyboard in the way.
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_autofocus_title),
summary = stringResource(R.string.settings_autofocus_title_hint),
position = Position.Alone,
leading = {
Icon(
imageVector = Icons.Default.Keyboard,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Switch(
checked = state.autofocusEventTitle,
onCheckedChange = { viewModel.setAutofocusEventTitle(it) },
)
},
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
)
// Per-event colour on calendars that publish no colour set (some // Per-event colour on calendars that publish no colour set (some
// CalDAV) — off by default, with the honest caveat that the colour may // CalDAV) — off by default, with the honest caveat that the colour may
// not survive their next sync. Local and palette calendars ignore it. // not survive their next sync. Local and palette calendars ignore it.
@@ -912,7 +1175,7 @@ private fun NotificationsScreen(
presets = REMINDER_PRESETS, presets = REMINDER_PRESETS,
selected = state.defaultReminderMinutes.toReminderChoice(), selected = state.defaultReminderMinutes.toReminderChoice(),
allowInherit = false, allowInherit = false,
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) }, onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
onDismiss = { showDefaultReminder = false }, onDismiss = { showDefaultReminder = false },
) )
} }
@@ -922,7 +1185,7 @@ private fun NotificationsScreen(
presets = ALLDAY_REMINDER_PRESETS, presets = ALLDAY_REMINDER_PRESETS,
selected = state.defaultAllDayReminderMinutes.toReminderChoice(), selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
allowInherit = false, allowInherit = false,
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) }, onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
onDismiss = { showAllDayReminder = false }, onDismiss = { showAllDayReminder = false },
) )
} }
@@ -971,13 +1234,13 @@ private fun NotificationsScreen(
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */ /** Which calendar + event kind a per-calendar reminder-override dialog targets. */
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean) private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
/** A global default (null = none) as a picker choice for selection highlighting. */ /** A global default (empty = none) as a picker choice for selection highlighting. */
private fun Int?.toReminderChoice(): CalendarReminderOverride = private fun List<Int>.toReminderChoice(): CalendarReminderOverride =
if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this) if (isEmpty()) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */ /** A picked choice as global-default minutes (Inherit isn't offered for globals). */
private fun CalendarReminderOverride.toMinutesOrNull(): Int? = private fun CalendarReminderOverride.toMinutesList(): List<Int> =
(this as? CalendarReminderOverride.Minutes)?.minutes (this as? CalendarReminderOverride.Minutes)?.minutes ?: emptyList()
/** /**
* Whether Calendula is exempt from battery optimisation, re-read on every * Whether Calendula is exempt from battery optimisation, re-read on every
@@ -1052,27 +1315,33 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
} }
/** The stored override for [calendarId], as a picker choice (absent → inherit). */ /** The stored override for [calendarId], as a picker choice (absent → inherit). */
private fun Map<Long, Int?>.choiceFor(calendarId: Long): CalendarReminderOverride = when { private fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
!containsKey(calendarId) -> CalendarReminderOverride.Inherit !containsKey(calendarId) -> CalendarReminderOverride.Inherit
this[calendarId] == null -> CalendarReminderOverride.None this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!) else -> CalendarReminderOverride.Minutes(this.getValue(calendarId))
} }
/** Label for a global-default choice: null → "None", else the lead time. */ /** Label for a global-default choice: empty → "None", else the lead times joined. */
@Composable @Composable
private fun reminderChoiceLabel(minutes: Int?): String = private fun reminderChoiceLabel(minutes: List<Int>): String {
if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes) if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
// reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather
// than a (non-composable) joinToString transform.
val parts = ArrayList<String>(minutes.size)
for (m in minutes) parts.add(reminderLeadTimeLabel(m))
return parts.joinToString(", ")
}
/** Row summary for a calendar: its override, or the inherited global default. */ /** Row summary for a calendar: its override, or the inherited global default. */
@Composable @Composable
private fun calendarOverrideSummary( private fun calendarOverrideSummary(
choice: CalendarReminderOverride, choice: CalendarReminderOverride,
globalDefault: Int?, globalDefault: List<Int>,
): String = when (choice) { ): String = when (choice) {
CalendarReminderOverride.Inherit -> CalendarReminderOverride.Inherit ->
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault)) stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
CalendarReminderOverride.None -> stringResource(R.string.reminder_none) CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes) is CalendarReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1123,6 +1392,152 @@ private fun themeLabel(mode: ThemeMode): String = stringResource(
}, },
) )
/** The summary label for a stored font token (issue #19). */
@Composable
private fun fontLabel(token: String): String = when (token) {
FONT_SYSTEM_TOKEN -> stringResource(R.string.settings_font_system)
FONT_CUSTOM_TOKEN -> stringResource(R.string.settings_font_custom_selected)
else -> BundledFont.fromToken(token)?.let { stringResource(it.labelRes) }
?: stringResource(R.string.settings_font_system)
}
/**
* MIME types offered to the document picker so it lists only font files. Covers
* the modern `font/` types plus the legacy `application/` font aliases some
* providers still report. Anything that slips through is still validated by
* [de.jeanlucmakiola.calendula.data.fonts.CustomFontStore] before use.
*/
private val FONT_PICKER_MIME_TYPES = arrayOf(
"font/ttf",
"font/otf",
"font/sfnt",
"font/collection",
"application/x-font-ttf",
"application/x-font-otf",
"application/font-sfnt",
"application/vnd.ms-opentype",
)
/**
* Full-screen font chooser for one [FontRole]: the system default, each bundled
* font (previewed in its own face), and "Choose file…" which opens the system
* picker to load a .ttf/.otf. Selecting a system/bundled option applies at once;
* a file is validated and imported by the caller, switching to the custom font
* on success.
*/
@Composable
private fun FontPicker(
title: String,
role: FontRole,
selected: String,
stamp: Int,
onSelect: (String) -> Unit,
onImport: (Uri) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri ->
if (uri != null) {
onImport(uri)
onDismiss()
}
}
// System default + the bundled fonts + the "Choose file…" row.
val rowCount = BundledFont.entries.size + 2
val isCustom = selected == FONT_CUSTOM_TOKEN
// Resolving the custom face stats the disk and builds a fresh FontFamily, so
// memoise it; re-keyed on [stamp] (bumped on re-import) so a replaced file
// refreshes the preview while plain recompositions reuse the cached family.
val customPreview = remember(role, isCustom, stamp) {
if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null
}
FullScreenPicker(title = title, onDismiss = onDismiss) {
FontOptionRow(
label = stringResource(R.string.settings_font_system),
preview = FontFamily.Default,
selected = selected == FONT_SYSTEM_TOKEN,
position = positionOf(0, rowCount),
onClick = {
onSelect(FONT_SYSTEM_TOKEN)
onDismiss()
},
)
BundledFont.entries.forEachIndexed { index, font ->
FontOptionRow(
label = stringResource(font.labelRes),
preview = font.family,
selected = selected == font.token,
position = positionOf(index + 1, rowCount),
onClick = {
onSelect(font.token)
onDismiss()
},
)
}
FontOptionRow(
label = if (isCustom) {
stringResource(R.string.settings_font_custom_selected)
} else {
stringResource(R.string.settings_font_choose_file)
},
// A loaded font previews in its own face; otherwise show an upload cue.
preview = customPreview,
leadingIcon = if (isCustom) null else Icons.Default.UploadFile,
selected = isCustom,
position = positionOf(rowCount - 1, rowCount),
onClick = { launcher.launch(FONT_PICKER_MIME_TYPES) },
)
}
}
/**
* One row in the [FontPicker]: the font's name, a leading "Ag" sample rendered in
* the option's own [preview] face (or an [leadingIcon] cue when there's nothing
* to preview), and a check when it's the current selection.
*/
@Composable
private fun FontOptionRow(
label: String,
preview: FontFamily?,
selected: Boolean,
position: Position,
onClick: () -> Unit,
leadingIcon: ImageVector? = null,
) {
GroupedRow(
title = label,
position = position,
selected = selected,
leading = {
if (preview != null) {
Text(
text = "Ag",
fontFamily = preview,
style = MaterialTheme.typography.titleLarge,
)
} else if (leadingIcon != null) {
Icon(imageVector = leadingIcon, contentDescription = null)
}
},
trailing = if (selected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
onClick = onClick,
)
}
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */ /** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
private val WEEK_START_OPTIONS: List<WeekStartPref> = private val WEEK_START_OPTIONS: List<WeekStartPref> =
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) } listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
@@ -1145,6 +1560,26 @@ private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
}, },
) )
/** A small primary-coloured group label, matching the Calendars settings screen. */
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
}
@Composable
private fun pastEventDisplayLabel(mode: PastEventDisplay): String = stringResource(
when (mode) {
PastEventDisplay.SHOW -> R.string.settings_past_events_show
PastEventDisplay.DIM -> R.string.settings_past_events_dim
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
},
)
@Composable @Composable
private fun languageLabel(tag: String?): String = private fun languageLabel(tag: String?): String =
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag) if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.settings package de.jeanlucmakiola.calendula.ui.settings
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
@@ -8,6 +9,8 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
/** /**
* Settings screen state (M4). Persisted preferences are instant to read, so * Settings screen state (M4). Persisted preferences are instant to read, so
@@ -24,6 +27,10 @@ data class SettingsUiState(
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO, val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
/** Whether the week/day timeline draws an hour separator line (v2.11). */ /** Whether the week/day timeline draws an hour separator line (v2.11). */
val showHourLines: Boolean = false, val showHourLines: Boolean = false,
/** How the Agenda screen treats events that already ended today. */
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
/** Whether the month/week grids fade events that have already finished. */
val dimCompletedEvents: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */ /** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month, val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */ /** How far ahead the agenda widget shows events (v2.11). */
@@ -32,29 +39,35 @@ data class SettingsUiState(
val agendaShowRangeBar: Boolean = true, val agendaShowRangeBar: Boolean = true,
/** The calendar view the app opens on, and the home of the view back stack (M1). */ /** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week, val defaultView: CalendarView = CalendarView.Week,
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
/** Order of the views in the navigation drawer (#24); every view is always listed. */
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
/** Optional event-form fields shown by default (rest behind "more fields"). */ /** Optional event-form fields shown by default (rest behind "more fields"). */
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS, val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
val autofocusEventTitle: Boolean = true,
/** Whether Calendula posts reminder notifications (v1.4). */ /** Whether Calendula posts reminder notifications (v1.4). */
val remindersEnabled: Boolean = true, val remindersEnabled: Boolean = true,
/** /**
* The default reminder lead time (minutes) prefilled on new timed events; * The default reminder lead times (minutes) prefilled on new timed events;
* null = no default reminder. Per-calendar overrides take precedence. * empty = no default reminder. Per-calendar overrides take precedence.
*/ */
val defaultReminderMinutes: Int? = null, val defaultReminderMinutes: List<Int> = emptyList(),
/** The default reminder lead time prefilled on new all-day events; null = none. */ /** The default reminder lead times prefilled on new all-day events; empty = none. */
val defaultAllDayReminderMinutes: Int? = null, val defaultAllDayReminderMinutes: List<Int> = emptyList(),
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */ /** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME, val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
/** How long the notification "Snooze" action defers a reminder; default 10 min. */ /** How long the notification "Snooze" action defers a reminder; default 10 min. */
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES, val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
/** /**
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a * Per-calendar overrides of [defaultReminderMinutes] for timed events: a
* calendar present in the map overrides the global default (null value = no * calendar present in the map overrides the global default (empty value = no
* reminder); absent = inherit the global default. * reminder); absent = inherit the global default.
*/ */
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(), val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */ /** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(), val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Writable calendars, shown as per-calendar reminder-override rows. */ /** Writable calendars, shown as per-calendar reminder-override rows. */
val writableCalendars: List<CalendarSource> = emptyList(), val writableCalendars: List<CalendarSource> = emptyList(),
/** /**

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.settings package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context import android.content.Context
import android.net.Uri
import android.os.Build import android.os.Build
import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState
@@ -10,20 +11,31 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
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_RANGE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@@ -33,12 +45,14 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs, private val prefs: SettingsPrefs,
repository: CalendarRepository, repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
@ApplicationContext private val appContext: Context, @ApplicationContext private val appContext: Context,
) : ViewModel() { ) : ViewModel() {
@@ -96,15 +110,32 @@ class SettingsViewModel @Inject constructor(
) { view, screenRange, widgetRange, timeFormat, showHourLines -> ) { view, screenRange, widgetRange, timeFormat, showHourLines ->
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines) ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
}, },
combine(
prefs.agendaShowRangeBar, prefs.agendaShowRangeBar,
) { base, defaults, overrides, views, showRangeBar -> prefs.autofocusEventTitle,
prefs.pastEventDisplay,
prefs.dimCompletedEvents,
// View customisation (#24) folded into one flow so it fits this
// group — the outer combine is already at its five-arg limit.
combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer ->
ViewCustomization(quickSwitch, drawer)
},
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
},
) { base, defaults, overrides, views, misc ->
base.copy( base.copy(
defaultView = views.defaultView, defaultView = views.defaultView,
agendaScreenRange = views.agendaScreenRange, agendaScreenRange = views.agendaScreenRange,
agendaWidgetRange = views.agendaWidgetRange, agendaWidgetRange = views.agendaWidgetRange,
timeFormat = views.timeFormat, timeFormat = views.timeFormat,
showHourLines = views.showHourLines, showHourLines = views.showHourLines,
agendaShowRangeBar = showRangeBar, agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
dimCompletedEvents = misc.dimCompletedEvents,
quickSwitchConfig = misc.viewCustomization.quickSwitch,
drawerViewOrder = misc.viewCustomization.drawerOrder,
allowColorOnUnsupportedCalendars = defaults.allowColor, allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder, defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder, defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -120,6 +151,29 @@ class SettingsViewModel @Inject constructor(
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable), initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
) )
/**
* The app-wide custom-font choice (issue #19). Its own flow — both this
* screen and [MainActivity] (which builds the typography) read it
* independently of the main settings combine.
*/
val fontState: StateFlow<AppFontSettings> = combine(
prefs.brandFont,
prefs.plainFont,
prefs.brandFontStamp,
prefs.plainFontStamp,
) { brand, plain, brandStamp, plainStamp ->
AppFontSettings(brand = brand, plain = plain, brandStamp = brandStamp, plainStamp = plainStamp)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AppFontSettings(),
)
// Emitted when a picked font file couldn't be read as a font; the screen
// surfaces it and the previous selection stays put.
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
// Serialises widget redraws so a rapid flip-and-flip-back can't run two // Serialises widget redraws so a rapid flip-and-flip-back can't run two
// updateAll calls at once — concurrent calls coalesce around a stale read // updateAll calls at once — concurrent calls coalesce around a stale read
// and can leave the widget on the intermediate value. Held sequentially, // and can leave the widget on the intermediate value. Held sequentially,
@@ -128,15 +182,15 @@ class SettingsViewModel @Inject constructor(
private data class ReminderDefaults( private data class ReminderDefaults(
val allowColor: Boolean, val allowColor: Boolean,
val defaultReminder: Int?, val defaultReminder: List<Int>,
val allDayReminder: Int?, val allDayReminder: List<Int>,
val allDayReminderTime: Int, val allDayReminderTime: Int,
val snoozeMinutes: Int, val snoozeMinutes: Int,
) )
private data class ReminderOverrides( private data class ReminderOverrides(
val timed: Map<Long, Int?>, val timed: Map<Long, List<Int>>,
val allDay: Map<Long, Int?>, val allDay: Map<Long, List<Int>>,
val calendars: List<CalendarSource>, val calendars: List<CalendarSource>,
) )
@@ -148,6 +202,19 @@ class SettingsViewModel @Inject constructor(
val showHourLines: Boolean, val showHourLines: Boolean,
) )
private data class MiscSettings(
val showRangeBar: Boolean,
val autofocusEventTitle: Boolean,
val pastEventDisplay: PastEventDisplay,
val dimCompletedEvents: Boolean,
val viewCustomization: ViewCustomization,
)
private data class ViewCustomization(
val quickSwitch: QuickSwitchConfig,
val drawerOrder: List<CalendarView>,
)
fun setThemeMode(mode: ThemeMode) { fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch { prefs.setThemeMode(mode) } viewModelScope.launch { prefs.setThemeMode(mode) }
} }
@@ -156,6 +223,35 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setDynamicColor(enabled) } viewModelScope.launch { prefs.setDynamicColor(enabled) }
} }
fun setFont(role: FontRole, token: String) {
viewModelScope.launch {
when (role) {
FontRole.BRAND -> prefs.setBrandFont(token)
FontRole.PLAIN -> prefs.setPlainFont(token)
}
}
}
/**
* Import a user-picked font file for [role]. Copies and validates it off the
* main thread; on success the role switches to the custom font, otherwise the
* previous choice is kept and [fontImportFailed] fires.
*/
fun importCustomFont(role: FontRole, uri: Uri) {
viewModelScope.launch {
val ok = withContext(io) { CustomFontStore.import(appContext, role, uri) }
if (ok) {
// Bump the stamp so replacing an already-active custom font (same
// file, same "custom" token) still breaks font-settings equality
// and refreshes the resolved typeface.
prefs.bumpCustomFontStamp(role)
setFont(role, FONT_CUSTOM_TOKEN)
} else {
_fontImportFailed.emit(Unit)
}
}
}
fun setWeekStart(pref: WeekStartPref) { fun setWeekStart(pref: WeekStartPref) {
viewModelScope.launch { viewModelScope.launch {
prefs.setWeekStart(pref) prefs.setWeekStart(pref)
@@ -201,6 +297,26 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setShowHourLines(enabled) } viewModelScope.launch { prefs.setShowHourLines(enabled) }
} }
fun setPastEventDisplay(mode: PastEventDisplay) {
viewModelScope.launch {
prefs.setPastEventDisplay(mode)
// The agenda widget honours this setting too, so push it into each
// instance's Glance state and recompose — same reliable-update path as
// setAgendaWidgetRange (updateAll alone won't re-run the data preamble).
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) {
viewModelScope.launch { prefs.setDimCompletedEvents(enabled) }
}
fun setDefaultView(view: CalendarView) { fun setDefaultView(view: CalendarView) {
viewModelScope.launch { prefs.setDefaultView(view) } viewModelScope.launch { prefs.setDefaultView(view) }
} }
@@ -209,15 +325,45 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) } viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
} }
/**
* Enable or disable [view] in the quick-switch cycle. Goes through an atomic
* read-modify-write so a concurrent reorder can't clobber this toggle (and
* vice versa) by re-serialising a stale snapshot. The MIN_ENABLED floor is
* re-checked inside the transform: the screen's own guard reads the
* async-echoed snapshot, so two quick disable-taps could both look allowed
* yet compose to a below-minimum cycle.
*/
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
viewModelScope.launch {
prefs.updateQuickSwitch { config ->
val next = if (enabled) config.enabled + view else config.enabled - view
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
}
}
}
/** Reorder the quick-switch views (atomic read-modify-write; keeps the enabled set). */
fun setQuickSwitchOrder(order: List<CalendarView>) {
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
}
fun setDrawerViewOrder(order: List<CalendarView>) {
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
}
fun setRemindersEnabled(enabled: Boolean) { fun setRemindersEnabled(enabled: Boolean) {
viewModelScope.launch { prefs.setRemindersEnabled(enabled) } viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
} }
fun setDefaultReminderMinutes(minutes: Int?) { fun setAutofocusEventTitle(enabled: Boolean) {
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
}
fun setDefaultReminderMinutes(minutes: List<Int>) {
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) } viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
} }
fun setDefaultAllDayReminderMinutes(minutes: Int?) { fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) } viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
} }

View File

@@ -0,0 +1,129 @@
package de.jeanlucmakiola.calendula.ui.theme
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
import de.jeanlucmakiola.calendula.domain.FontRole
import java.io.File
/** Stored token for "use the device default typeface" — the default for both roles. */
const val FONT_SYSTEM_TOKEN = "system"
/** Stored token for "use the font the user loaded from a file" (per role). */
const val FONT_CUSTOM_TOKEN = "custom"
/**
* A font checked into the app that a user can pick without providing a file
* (issue #19). [token] is the stable value persisted in preferences; [family] is
* the resolved Compose family. Kept deliberately small and readability-focused.
*/
enum class BundledFont(
val token: String,
@StringRes val labelRes: Int,
val family: FontFamily,
) {
/** Purpose-built for low-vision legibility (Braille Institute). */
AtkinsonHyperlegible(
token = "atkinson_hyperlegible",
labelRes = R.string.font_atkinson_hyperlegible,
family = FontFamily(
Font(R.font.atkinson_hyperlegible_regular, FontWeight.Normal),
Font(R.font.atkinson_hyperlegible_bold, FontWeight.Bold),
),
),
/** A serif alternative; shipped as a variable font, weights via its wght axis. */
Lora(
token = "lora",
labelRes = R.string.font_lora,
family = FontFamily(
Font(R.font.lora, FontWeight.Normal, variationSettings = weightAxis(400)),
Font(R.font.lora, FontWeight.Medium, variationSettings = weightAxis(500)),
Font(R.font.lora, FontWeight.Bold, variationSettings = weightAxis(700)),
),
),
/** A monospace option. */
JetBrainsMono(
token = "jetbrains_mono",
labelRes = R.string.font_jetbrains_mono,
family = FontFamily(
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
Font(R.font.jetbrains_mono_bold, FontWeight.Bold),
),
);
companion object {
fun fromToken(token: String?): BundledFont? = entries.firstOrNull { it.token == token }
}
}
private fun weightAxis(weight: Int) = FontVariation.Settings(FontVariation.weight(weight))
/** The two roles' current font tokens (see [FontRole]); the app-wide typeface choice. */
data class AppFontSettings(
val brand: String = FONT_SYSTEM_TOKEN,
val plain: String = FONT_SYSTEM_TOKEN,
// Per-role custom-font re-import stamp: bumped when the "custom" file is
// replaced, so a same-token re-import still breaks equality and refreshes the
// resolved family. 0 for existing installs / no import yet (issue #19).
val brandStamp: Int = 0,
val plainStamp: Int = 0,
)
/**
* Resolve a stored [token] for [role] to a Compose [FontFamily], or null to mean
* "leave the Material default" (the system typeface). A custom token whose file
* is missing or unreadable also resolves to null, so a deleted file degrades to
* the system font rather than crashing text layout.
*/
fun resolveFontFamily(token: String, role: FontRole, context: Context): FontFamily? = when (token) {
FONT_SYSTEM_TOKEN -> null
FONT_CUSTOM_TOKEN -> customFontFamily(CustomFontStore.file(context, role))
else -> BundledFont.fromToken(token)?.family
}
private fun customFontFamily(file: File): FontFamily? =
if (file.exists() && file.length() > 0) {
runCatching { FontFamily(Font(file)) }.getOrNull()
} else {
null
}
/**
* Build the app typography from the chosen [brand] and [plain] families. A null
* family leaves that role's Material default (system typeface) in place, so the
* common "system for both" case returns [CalendulaTypography] untouched. Brand
* drives display + headline; plain drives title + body + label (M3's two
* typeface roles).
*/
fun calendulaTypography(brand: FontFamily?, plain: FontFamily?): Typography {
if (brand == null && plain == null) return CalendulaTypography
val base = CalendulaTypography
fun TextStyle.withFamily(family: FontFamily?): TextStyle =
if (family != null) copy(fontFamily = family) else this
return base.copy(
displayLarge = base.displayLarge.withFamily(brand),
displayMedium = base.displayMedium.withFamily(brand),
displaySmall = base.displaySmall.withFamily(brand),
headlineLarge = base.headlineLarge.withFamily(brand),
headlineMedium = base.headlineMedium.withFamily(brand),
headlineSmall = base.headlineSmall.withFamily(brand),
titleLarge = base.titleLarge.withFamily(plain),
titleMedium = base.titleMedium.withFamily(plain),
titleSmall = base.titleSmall.withFamily(plain),
bodyLarge = base.bodyLarge.withFamily(plain),
bodyMedium = base.bodyMedium.withFamily(plain),
bodySmall = base.bodySmall.withFamily(plain),
labelLarge = base.labelLarge.withFamily(plain),
labelMedium = base.labelMedium.withFamily(plain),
labelSmall = base.labelSmall.withFamily(plain),
)
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme import androidx.compose.material3.MotionScheme
import androidx.compose.material3.Typography
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -15,15 +16,18 @@ import androidx.compose.ui.platform.LocalContext
* - System light/dark. * - System light/dark.
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme * - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
* derived from [CalendulaSeed]. * derived from [CalendulaSeed].
* - A user-chosen [typography] (custom fonts, issue #19), defaulting to the
* Material scale on the system typeface.
* *
* The Settings screen (later) can override useDynamicColor and themePreference, * The Settings screen overrides darkTheme, dynamicColor, and typography; the
* but the V1 foundation just follows the system. * bare defaults (used by the crash screen) just follow the system.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun CalendulaTheme( fun CalendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, dynamicColor: Boolean = true,
typography: Typography = CalendulaTypography,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val colorScheme = when { val colorScheme = when {
@@ -42,7 +46,7 @@ fun CalendulaTheme(
// the bouncy variant felt overdone in review (2026-06-11). // the bouncy variant felt overdone in review (2026-06-11).
MaterialExpressiveTheme( MaterialExpressiveTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = CalendulaTypography, typography = typography,
motionScheme = MotionScheme.standard(), motionScheme = MotionScheme.standard(),
content = content, content = content,
) )

View File

@@ -45,7 +45,9 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@@ -55,6 +57,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -73,11 +76,16 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
@@ -124,11 +132,21 @@ fun WeekScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(), viewModel: WeekViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle() val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick).
val nowState = rememberCurrentMinute()
val dimCutoff by remember(dimCompleted) {
derivedStateOf { if (dimCompleted) nowState.value else null }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -180,6 +198,7 @@ fun WeekScreen(
CalendarDrawer( CalendarDrawer(
currentView = selectedView, currentView = selectedView,
currentDate = weekStart, currentDate = weekStart,
viewOrder = drawerViewOrder,
onSelectView = { view -> onSelectView = { view ->
onSelectView(view) onSelectView(view)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
@@ -201,7 +220,7 @@ fun WeekScreen(
WeekTopBar( WeekTopBar(
weekStart = weekStart, weekStart = weekStart,
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
@@ -221,6 +240,7 @@ fun WeekScreen(
) )
}, },
) { innerPadding -> ) { innerPadding ->
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
WeekContent( WeekContent(
state = state, state = state,
slideDir = slideDir, slideDir = slideDir,
@@ -236,6 +256,7 @@ fun WeekScreen(
) )
} }
} }
}
} }
@Composable @Composable
@@ -550,8 +571,10 @@ private fun AllDayBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
Box( Box(
modifier = modifier modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp)) .background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
@@ -692,6 +715,7 @@ private fun DayColumnCard(
EventBlock( EventBlock(
block = block, block = block,
dark = dark, dark = dark,
height = height,
onClick = { onEventClick(block.event) }, onClick = { onEventClick(block.event) },
modifier = Modifier modifier = Modifier
.offset(x = laneWidth * block.lane, y = top) .offset(x = laneWidth * block.lane, y = top)
@@ -712,6 +736,7 @@ private fun DayColumnCard(
private fun EventBlock( private fun EventBlock(
block: TimedBlock, block: TimedBlock,
dark: Boolean, dark: Boolean,
height: Dp,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -720,9 +745,26 @@ private fun EventBlock(
val locale = currentLocale() val locale = currentLocale()
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" + val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale) minToHm(block.endMin, use24Hour, locale)
val showTime = block.endMin - block.startMin >= 45 // Only full-width (non-overlapping) blocks that are tall enough show the time.
// On narrow overlapping columns we drop it so the title can wrap to fill the
// whole block, mirroring Google Calendar.
val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1
val density = LocalDensity.current
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// Wrap the title across as many lines as the block can fit (minus the 2.dp
// top/bottom padding and the reserved time line) instead of clipping it to a
// single character on slim, overlapping blocks.
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
Box( Box(
modifier = modifier modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp)) .background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp) .padding(horizontal = 4.dp, vertical = 2.dp)
@@ -732,18 +774,15 @@ private fun EventBlock(
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2, maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f), color = Color.Black.copy(alpha = 0.85f),
) )
if (showTime) { if (showTime) {
// Narrow columns can't fit "13:0014:00" on one line, so let it
// wrap to a second line (after the dash) instead of clipping the
// end time.
Text( Text(
text = timeLabel, text = timeLabel,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 2, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f), color = Color.Black.copy(alpha = 0.6f),
) )

View File

@@ -50,6 +50,14 @@ class WeekViewModel @Inject constructor(
private val zone = TimeZone.currentSystemDefault() private val zone = TimeZone.currentSystemDefault()
private val locale: Locale = Locale.getDefault() private val locale: Locale = Locale.getDefault()
/** Whether to fade events that have already finished (display concern only). */
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
private val todayDate: LocalDate private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date get() = Clock.System.now().toLocalDateTime(zone).date

View File

@@ -4,6 +4,7 @@ import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
@@ -24,6 +25,7 @@ import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import java.util.Locale import java.util.Locale
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant
/** /**
* How far either side of today the month widget pre-loads. The displayed month * How far either side of today the month widget pre-loads. The displayed month
@@ -52,6 +54,8 @@ sealed interface AgendaWidgetData {
* range is sliced from this in the composition (read reactively from * range is sliced from this in the composition (read reactively from
* Glance state), so a range change is pure recomposition rather than a * Glance state), so a range change is pure recomposition rather than a
* flaky widget session restart — the same approach the month widget uses. * 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>, val days: List<AgendaDay>,
/** Resolved clock convention for event time labels (the time-format pref). */ /** Resolved clock convention for event time labels (the time-format pref). */
@@ -60,6 +64,10 @@ sealed interface AgendaWidgetData {
val weekStart: DayOfWeek, val weekStart: DayOfWeek,
/** Saved range pref — the fallback when an instance has no Glance state yet. */ /** Saved range pref — the fallback when an instance has no Glance state yet. */
val savedRange: AgendaRange, val savedRange: AgendaRange,
/** Saved past-event display pref — the fallback before Glance state is set. */
val savedPastDisplay: PastEventDisplay,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant,
) : AgendaWidgetData ) : AgendaWidgetData
} }
@@ -108,6 +116,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
val ep = widgetEntryPoint() val ep = widgetEntryPoint()
val prefs = ep.settingsPrefs() val prefs = ep.settingsPrefs()
val savedRange = prefs.agendaWidgetRange.first() val savedRange = prefs.agendaWidgetRange.first()
val savedPastDisplay = prefs.pastEventDisplay.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault()) val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
// Load the widest selectable window once; the displayed range is sliced in // Load the widest selectable window once; the displayed range is sliced in
// the composition from Glance state, so changing the range is a plain // the composition from Glance state, so changing the range is a plain
@@ -122,6 +131,8 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
is24Hour = is24Hour, is24Hour = is24Hour,
weekStart = weekStart, weekStart = weekStart,
savedRange = savedRange, savedRange = savedRange,
savedPastDisplay = savedPastDisplay,
now = Clock.System.now(),
) )
} }

View File

@@ -43,11 +43,15 @@ import androidx.glance.text.Text
import androidx.glance.text.TextStyle import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R 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.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
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.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
@@ -79,6 +83,14 @@ import java.util.Locale
*/ */
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range") 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")
class AgendaWidget : GlanceAppWidget() { class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition override val stateDefinition = PreferencesGlanceStateDefinition
@@ -125,11 +137,26 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
// to the saved pref for a freshly placed widget), then the wide // to the saved pref for a freshly placed widget), then the wide
// pre-loaded window is sliced to it — pure recomposition. // pre-loaded window is sliced to it — pure recomposition.
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange) val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
val pastDisplay =
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
val rangeEnd = data.today.plus( val rangeEnd = data.today.plus(
range.dayCount(data.today, data.weekStart) - 1, range.dayCount(data.today, data.weekStart) - 1,
DateTimeUnit.DAY, DateTimeUnit.DAY,
) )
val visibleDays = data.days.filter { it.date <= rangeEnd } // Slice to the chosen range, then drop finished events (and any day
// they leave empty) when the mode is Hide — so "Upcoming" really is.
val visibleDays = data.days
.filter { it.date <= rangeEnd }
.let { days ->
if (pastDisplay == PastEventDisplay.HIDE) {
days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(data.now) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
}
} else {
days
}
}
if (visibleDays.isEmpty()) { if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title) WidgetMessage(R.string.agenda_empty_title)
} else { } else {
@@ -143,7 +170,13 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
items(rows.size) { index -> items(rows.size) { index ->
when (val row = rows[index]) { when (val row = rows[index]) {
is AgendaRow.Header -> DayHeaderRow(row.date, row.today) is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
is AgendaRow.Event -> EventRow(row.event, dark, data.is24Hour) is AgendaRow.Event -> EventRow(
event = row.event,
dark = dark,
is24Hour = data.is24Hour,
dimmed = pastDisplay == PastEventDisplay.DIM &&
row.event.hasEnded(data.now),
)
} }
} }
} }
@@ -160,6 +193,8 @@ private fun AgendaHeader() {
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp), modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically, 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(
text = context.getString(R.string.widget_agenda_title), text = context.getString(R.string.widget_agenda_title),
style = TextStyle( style = TextStyle(
@@ -167,7 +202,11 @@ private fun AgendaHeader() {
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
), ),
modifier = GlanceModifier.defaultWeight(), modifier = GlanceModifier
.defaultWeight()
.clickable(
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
),
) )
IconButton( IconButton(
resId = R.drawable.ic_widget_refresh, resId = R.drawable.ic_widget_refresh,
@@ -224,9 +263,15 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
} }
@Composable @Composable
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean) { private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
val context = androidx.glance.LocalContext.current val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) } val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// 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 = pastelize(event.color, dark).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it
}
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
Row( Row(
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxWidth() .fillMaxWidth()
@@ -249,14 +294,14 @@ private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean) {
.width(5.dp) .width(5.dp)
.height(36.dp) .height(36.dp)
.cornerRadius(3.dp) .cornerRadius(3.dp)
.background(pastelize(event.color, dark)), .background(stripeColor),
) {} ) {}
Spacer(GlanceModifier.width(10.dp)) Spacer(GlanceModifier.width(10.dp))
Column(modifier = GlanceModifier.defaultWeight()) { Column(modifier = GlanceModifier.defaultWeight()) {
Text( Text(
text = title, text = title,
maxLines = 1, maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp), style = TextStyle(color = titleColor, fontSize = 14.sp),
) )
Text( Text(
text = eventTimeSummary(context, event, is24Hour), text = eventTimeSummary(context, event, is24Hour),

View File

@@ -194,6 +194,8 @@ private fun MonthHeader(label: String) {
), ),
), ),
) )
// 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(
text = label, text = label,
style = TextStyle( style = TextStyle(
@@ -204,16 +206,17 @@ private fun MonthHeader(label: String) {
), ),
modifier = GlanceModifier modifier = GlanceModifier
.defaultWeight() .defaultWeight()
.clickable(actionRunCallback<ResetMonthAction>()), .clickable(
actionStartActivity(
MainActivity.openViewIntent(context, CalendarView.Month),
),
),
) )
// The "today" button snaps the grid back to the current month in place.
HeaderIcon( HeaderIcon(
resId = R.drawable.ic_widget_today, resId = R.drawable.ic_widget_today,
contentDescription = context.getString(R.string.widget_today), contentDescription = context.getString(R.string.widget_today),
onClick = GlanceModifier.clickable( onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
actionStartActivity(
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
),
),
) )
HeaderIcon( HeaderIcon(
resId = R.drawable.ic_widget_chevron_right, resId = R.drawable.ic_widget_chevron_right,
@@ -301,6 +304,15 @@ private fun WeekRow(
} }
} }
/**
* 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))
@Composable @Composable
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) { private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
val context = LocalContext.current val context = LocalContext.current
@@ -308,11 +320,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
modifier = GlanceModifier modifier = GlanceModifier
.width(colW) .width(colW)
.height(DAY_NUMBER_HEIGHT) .height(DAY_NUMBER_HEIGHT)
// Tap a day number to open that day, rooted in the month view so back .clickable(openDayAction(context, date)),
// returns to the month grid.
.clickable(
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)),
),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Box( Box(
@@ -339,6 +347,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
@Composable @Composable
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) { private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) { Row(modifier = GlanceModifier.fillMaxWidth()) {
var col = 0 var col = 0
while (col < 7) { while (col < 7) {
@@ -352,7 +361,14 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
if (timed != null) { if (timed != null) {
SpanBar(event = timed, dark = dark, width = colW) SpanBar(event = timed, dark = dark, width = colW)
} else { } else {
Box(GlanceModifier.width(colW).height(LANE_HEIGHT)) {} // Empty lane cell: a tap opens that day, so blank space in a
// day column is a day-open target just like the number is.
Box(
GlanceModifier
.width(colW)
.height(LANE_HEIGHT)
.clickable(openDayAction(context, week.days[col])),
) {}
} }
col += 1 col += 1
} }
@@ -401,14 +417,20 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
@Composable @Composable
private fun OverflowRow(week: MonthWeek, colW: Dp) { private fun OverflowRow(week: MonthWeek, colW: Dp) {
val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) { Row(modifier = GlanceModifier.fillMaxWidth()) {
week.days.forEachIndexed { col, date -> week.days.forEachIndexed { col, date ->
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES } val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0) val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size) val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
// The overflow row is part of the day column too: tapping it (whether
// it shows "+N" or is blank) opens that day, same as the app.
Box( Box(
modifier = GlanceModifier.width(colW).height(LANE_HEIGHT), modifier = GlanceModifier
.width(colW)
.height(LANE_HEIGHT)
.clickable(openDayAction(context, date)),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
if (hidden > 0) { if (hidden > 0) {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Calendula</string> <string name="app_name">Calendula</string>
<string name="app_tagline">Ein moderner Kalender.</string> <string name="app_tagline">Ein moderner Kalender.</string>
<string name="state_loading">Lädt…</string> <string name="state_loading">Lädt…</string>
<string name="state_retry">Erneut versuchen</string> <string name="state_retry">Erneut versuchen</string>
<string name="state_failure_unknown">Etwas ist schiefgelaufen.</string> <string name="state_failure_unknown">Etwas ist schiefgelaufen.</string>
@@ -10,7 +10,6 @@
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string> <string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string> <string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string> <string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
<!-- Permission-Flow (F1) --> <!-- Permission-Flow (F1) -->
<string name="permission_rationale_title">Alle Termine, schön im Blick</string> <string name="permission_rationale_title">Alle Termine, schön im Blick</string>
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string> <string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
@@ -26,7 +25,6 @@
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string> <string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string> <string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
<string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string> <string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
<!-- Monatsansicht (S1) --> <!-- Monatsansicht (S1) -->
<string name="month_prev">Vorheriger Monat</string> <string name="month_prev">Vorheriger Monat</string>
<string name="month_next">Nächster Monat</string> <string name="month_next">Nächster Monat</string>
@@ -35,14 +33,11 @@
<string name="month_open_menu">Menü öffnen</string> <string name="month_open_menu">Menü öffnen</string>
<string name="month_action_settings">Einstellungen</string> <string name="month_action_settings">Einstellungen</string>
<string name="month_a11y_today_prefix">Heute</string> <string name="month_a11y_today_prefix">Heute</string>
<!-- Wochenansicht (S2) --> <!-- Wochenansicht (S2) -->
<string name="week_today_action">Diese Woche</string> <string name="week_today_action">Diese Woche</string>
<string name="week_number_label">KW</string> <string name="week_number_label">KW</string>
<!-- Tagesansicht (S3) --> <!-- Tagesansicht (S3) -->
<string name="day_today_action">Heute</string> <string name="day_today_action">Heute</string>
<!-- Event-Detail-Screen (S4) --> <!-- Event-Detail-Screen (S4) -->
<string name="event_detail_back">Zurück</string> <string name="event_detail_back">Zurück</string>
<string name="event_detail_edit">Bearbeiten</string> <string name="event_detail_edit">Bearbeiten</string>
@@ -61,7 +56,6 @@
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string> <string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
<string name="dialog_cancel">Abbrechen</string> <string name="dialog_cancel">Abbrechen</string>
<string name="dialog_ok">OK</string> <string name="dialog_ok">OK</string>
<!-- Termin-Formular (v1.2 Erstellen) --> <!-- Termin-Formular (v1.2 Erstellen) -->
<string name="event_edit_new_title">Neuer Termin</string> <string name="event_edit_new_title">Neuer Termin</string>
<string name="event_edit_close">Schließen</string> <string name="event_edit_close">Schließen</string>
@@ -94,7 +88,6 @@
<string name="reminder_unit_weeks">Wochen</string> <string name="reminder_unit_weeks">Wochen</string>
<string name="event_edit_availability">Verfügbarkeit</string> <string name="event_edit_availability">Verfügbarkeit</string>
<string name="event_edit_visibility">Sichtbarkeit</string> <string name="event_edit_visibility">Sichtbarkeit</string>
<!-- Termin-Formular — eigene Terminfarbe --> <!-- Termin-Formular — eigene Terminfarbe -->
<string name="event_edit_color">Farbe</string> <string name="event_edit_color">Farbe</string>
<string name="event_edit_color_default">Kalenderfarbe</string> <string name="event_edit_color_default">Kalenderfarbe</string>
@@ -103,7 +96,6 @@
<string name="event_edit_color_unsupported">Für diesen Kalender nicht verfügbar</string> <string name="event_edit_color_unsupported">Für diesen Kalender nicht verfügbar</string>
<string name="event_edit_color_unsupported_hint">Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren.</string> <string name="event_edit_color_unsupported_hint">Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren.</string>
<string name="event_edit_color_sync_warning">Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung.</string> <string name="event_edit_color_sync_warning">Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung.</string>
<!-- Termin-Formular — Speicher-Konflikt (v2.0) --> <!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
<string name="event_edit_conflict_title">Termin wurde extern geändert</string> <string name="event_edit_conflict_title">Termin wurde extern geändert</string>
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string> <string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
@@ -113,7 +105,6 @@
<string name="event_edit_conflict_discard_hint">Der Termin bleibt, wie er jetzt ist</string> <string name="event_edit_conflict_discard_hint">Der Termin bleibt, wie er jetzt ist</string>
<string name="event_edit_gone_title">Termin wurde gelöscht</string> <string name="event_edit_gone_title">Termin wurde gelöscht</string>
<string name="event_edit_gone_body">Dieser Termin wurde zwischenzeitlich gelöscht, etwa auf einem anderen Gerät. Deine Änderungen können nicht mehr gespeichert werden.</string> <string name="event_edit_gone_body">Dieser Termin wurde zwischenzeitlich gelöscht, etwa auf einem anderen Gerät. Deine Änderungen können nicht mehr gespeichert werden.</string>
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) --> <!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string> <string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string> <string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
@@ -156,7 +147,6 @@
<string name="event_attendee_tentative">Vorläufig</string> <string name="event_attendee_tentative">Vorläufig</string>
<string name="event_attendee_needs_action">Keine Antwort</string> <string name="event_attendee_needs_action">Keine Antwort</string>
<string name="event_attendee_unknown"></string> <string name="event_attendee_unknown"></string>
<!-- Event-Detail — vollständiges Auslesen (v0.6) --> <!-- Event-Detail — vollständiges Auslesen (v0.6) -->
<string name="event_detail_reminders">Erinnerungen</string> <string name="event_detail_reminders">Erinnerungen</string>
<string name="event_detail_timezone">Zeitzone</string> <string name="event_detail_timezone">Zeitzone</string>
@@ -196,10 +186,8 @@
<item quantity="one">%d Stunde</item> <item quantity="one">%d Stunde</item>
<item quantity="other">%d Stunden</item> <item quantity="other">%d Stunden</item>
</plurals> </plurals>
<!-- Geteilte Event-Strings --> <!-- Geteilte Event-Strings -->
<string name="event_untitled">(Ohne Titel)</string> <string name="event_untitled">(Ohne Titel)</string>
<!-- Erinnerungs-Benachrichtigungen (v1.4) --> <!-- Erinnerungs-Benachrichtigungen (v1.4) -->
<string name="reminder_channel_name">Termin-Erinnerungen</string> <string name="reminder_channel_name">Termin-Erinnerungen</string>
<string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string> <string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string>
@@ -215,23 +203,19 @@
<string name="reminder_onboarding_skip_button">Später</string> <string name="reminder_onboarding_skip_button">Später</string>
<string name="reminder_action_snooze">Schlummern</string> <string name="reminder_action_snooze">Schlummern</string>
<string name="reminder_action_dismiss">Verwerfen</string> <string name="reminder_action_dismiss">Verwerfen</string>
<!-- View-Switcher (M1) --> <!-- View-Switcher (M1) -->
<string name="view_month">Monat</string> <string name="view_month">Monat</string>
<string name="view_week">Woche</string> <string name="view_week">Woche</string>
<string name="view_day">Tag</string> <string name="view_day">Tag</string>
<string name="view_agenda">Agenda</string> <string name="view_agenda">Agenda</string>
<string name="view_section">Ansicht</string> <string name="view_section">Ansicht</string>
<!-- Zu Datum springen (Navigationsleiste) --> <!-- Zu Datum springen (Navigationsleiste) -->
<string name="drawer_jump_to_date">Zu Datum springen</string> <string name="drawer_jump_to_date">Zu Datum springen</string>
<!-- Agenda-Ansicht --> <!-- Agenda-Ansicht -->
<string name="agenda_today_action">Heute</string> <string name="agenda_today_action">Heute</string>
<string name="agenda_header_today">Heute</string> <string name="agenda_header_today">Heute</string>
<string name="agenda_header_tomorrow">Morgen</string> <string name="agenda_header_tomorrow">Morgen</string>
<string name="agenda_empty_title">Alles erledigt</string> <string name="agenda_empty_title">Alles erledigt</string>
<!-- Terminsuche --> <!-- Terminsuche -->
<string name="search_action">Suchen</string> <string name="search_action">Suchen</string>
<string name="search_hint">Termine suchen</string> <string name="search_hint">Termine suchen</string>
@@ -239,7 +223,6 @@
<string name="search_clear">Löschen</string> <string name="search_clear">Löschen</string>
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string> <string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
<string name="search_empty">Keine Termine passen zu „%1$s“.</string> <string name="search_empty">Keine Termine passen zu „%1$s“.</string>
<!-- Startbildschirm-Widgets --> <!-- Startbildschirm-Widgets -->
<string name="widget_agenda_title">Anstehend</string> <string name="widget_agenda_title">Anstehend</string>
<string name="widget_agenda_label">Calendula Agenda</string> <string name="widget_agenda_label">Calendula Agenda</string>
@@ -250,19 +233,15 @@
<string name="widget_prev_month">Vorheriger Monat</string> <string name="widget_prev_month">Vorheriger Monat</string>
<string name="widget_next_month">Nächster Monat</string> <string name="widget_next_month">Nächster Monat</string>
<string name="widget_today">Heute</string> <string name="widget_today">Heute</string>
<!-- Verknüpfungen (Long-Press auf das App-Symbol) --> <!-- Verknüpfungen (Long-Press auf das App-Symbol) -->
<string name="shortcut_new_event_short">Neuer Termin</string> <string name="shortcut_new_event_short">Neuer Termin</string>
<string name="shortcut_new_event_long">Neuen Termin erstellen</string> <string name="shortcut_new_event_long">Neuen Termin erstellen</string>
<!-- Schnelleinstellungen-Kachel --> <!-- Schnelleinstellungen-Kachel -->
<string name="qs_tile_new_event_label">Neuer Termin</string> <string name="qs_tile_new_event_label">Neuer Termin</string>
<string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string> <string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string>
<string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string> <string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string>
<!-- Kalender-Filter (M3) --> <!-- Kalender-Filter (M3) -->
<string name="filter_title">Kalender</string> <string name="filter_title">Kalender</string>
<!-- Einstellungen (M4) --> <!-- Einstellungen (M4) -->
<string name="settings_title">Einstellungen</string> <string name="settings_title">Einstellungen</string>
<string name="settings_back">Zurück</string> <string name="settings_back">Zurück</string>
@@ -302,6 +281,8 @@
</plurals> </plurals>
<string name="settings_section_event_form">Termin-Formular</string> <string name="settings_section_event_form">Termin-Formular</string>
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string> <string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
<string name="settings_autofocus_title">Titel bei neuem Termin fokussieren</string>
<string name="settings_autofocus_title_hint">Beim Anlegen eines neuen Termins den Cursor direkt ins Titelfeld setzen und die Tastatur öffnen.</string>
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string> <string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
<string name="settings_color_unsupported_hint">Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden.</string> <string name="settings_color_unsupported_hint">Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden.</string>
<string name="settings_section_notifications">Benachrichtigungen</string> <string name="settings_section_notifications">Benachrichtigungen</string>
@@ -314,7 +295,6 @@
<string name="reminder_none">Keine</string> <string name="reminder_none">Keine</string>
<string name="reminder_use_default">Standard-Erinnerung verwenden</string> <string name="reminder_use_default">Standard-Erinnerung verwenden</string>
<string name="reminder_custom_amount">Anzahl</string> <string name="reminder_custom_amount">Anzahl</string>
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
<string name="reminder_custom_set">Übernehmen</string> <string name="reminder_custom_set">Übernehmen</string>
<string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string> <string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string>
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string> <string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>
@@ -342,7 +322,6 @@
<string name="settings_about_logo_desc">Calendula-App-Symbol</string> <string name="settings_about_logo_desc">Calendula-App-Symbol</string>
<string name="settings_report_problem">Problem melden</string> <string name="settings_report_problem">Problem melden</string>
<string name="settings_report_problem_hint">Absturzbericht senden oder Issue-Tracker öffnen</string> <string name="settings_report_problem_hint">Absturzbericht senden oder Issue-Tracker öffnen</string>
<!-- Calendar manager --> <!-- Calendar manager -->
<string name="calendars_title">Kalender</string> <string name="calendars_title">Kalender</string>
<string name="calendars_local_header">Deine Kalender</string> <string name="calendars_local_header">Deine Kalender</string>
@@ -397,7 +376,6 @@
<item quantity="one">%d bereits in diesem Kalender übersprungen.</item> <item quantity="one">%d bereits in diesem Kalender übersprungen.</item>
<item quantity="other">%d bereits in diesem Kalender übersprungen.</item> <item quantity="other">%d bereits in diesem Kalender übersprungen.</item>
</plurals> </plurals>
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar --> <!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
<string name="crash_dialog_title">Calendula ist abgestürzt</string> <string name="crash_dialog_title">Calendula ist abgestürzt</string>
<string name="crash_dialog_message">Calendula wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string> <string name="crash_dialog_message">Calendula wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -274,6 +274,15 @@
<string name="settings_default_view">Default view</string> <string name="settings_default_view">Default view</string>
<string name="settings_dynamic_color">Dynamic colour</string> <string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string> <string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
<string name="settings_font_headings">Headings font</string>
<string name="settings_font_body">Body font</string>
<string name="settings_font_system">System default</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="settings_font_choose_file">Choose file…</string>
<string name="settings_font_custom_selected">Custom font</string>
<string name="settings_font_import_failed">Couldn\'t read that file as a font</string>
<string name="settings_week_start">Week starts on</string> <string name="settings_week_start">Week starts on</string>
<string name="settings_week_start_auto">Automatic</string> <string name="settings_week_start_auto">Automatic</string>
<string name="settings_time_format">Time format</string> <string name="settings_time_format">Time format</string>
@@ -282,6 +291,13 @@
<string name="settings_time_format_24h">24-hour (14:00)</string> <string name="settings_time_format_24h">24-hour (14:00)</string>
<string name="settings_hour_lines">Hour lines</string> <string name="settings_hour_lines">Hour lines</string>
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string> <string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
<string name="settings_dim_completed">Dim completed events</string>
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
<string name="settings_past_events">Past events</string>
<string name="settings_past_events_show">Show</string>
<string name="settings_past_events_dim">Dim</string>
<string name="settings_past_events_hide">Hide</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Agenda range</string> <string name="settings_agenda_range">Agenda range</string>
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string> <string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
<string name="settings_agenda_widget_range">Agenda widget range</string> <string name="settings_agenda_widget_range">Agenda widget range</string>
@@ -301,8 +317,16 @@
<item quantity="one">%d day</item> <item quantity="one">%d day</item>
<item quantity="other">%d days</item> <item quantity="other">%d days</item>
</plurals> </plurals>
<string name="settings_section_views">Views</string>
<string name="settings_quick_switch_header">Quick-switch button</string>
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
<string name="settings_drawer_order_header">Navigation menu</string>
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
<string name="reorder_drag_handle">Drag to reorder</string>
<string name="settings_section_event_form">New event form</string> <string name="settings_section_event_form">New event form</string>
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string> <string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
<string name="settings_autofocus_title">Focus title on new event</string>
<string name="settings_autofocus_title_hint">When you start a new event, place the cursor in the title field and open the keyboard right away.</string>
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string> <string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string> <string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
<string name="settings_section_notifications">Notifications</string> <string name="settings_section_notifications">Notifications</string>
@@ -315,7 +339,6 @@
<string name="reminder_none">None</string> <string name="reminder_none">None</string>
<string name="reminder_use_default">Use default reminder</string> <string name="reminder_use_default">Use default reminder</string>
<string name="reminder_custom_amount">Amount</string> <string name="reminder_custom_amount">Amount</string>
<string name="reminder_custom_with_value">Custom (%1$s)</string>
<string name="reminder_custom_set">Set</string> <string name="reminder_custom_set">Set</string>
<string name="settings_calendar_reminders_title">Per-calendar reminders</string> <string name="settings_calendar_reminders_title">Per-calendar reminders</string>
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string> <string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
@@ -334,6 +357,7 @@
<string name="settings_translate_hint">Add or improve a language on Weblate</string> <string name="settings_translate_hint">Add or improve a language on Weblate</string>
<!-- Hub category subtitles --> <!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Theme, default view, week start</string> <string name="settings_appearance_subtitle">Theme, default view, week start</string>
<string name="settings_views_subtitle">Quick-switch button and menu order</string>
<string name="settings_event_form_subtitle">Default fields for new events</string> <string name="settings_event_form_subtitle">Default fields for new events</string>
<string name="settings_notifications_subtitle">Event reminders</string> <string name="settings_notifications_subtitle">Event reminders</string>
<string name="settings_section_about">About</string> <string name="settings_section_about">About</string>

View File

@@ -199,11 +199,12 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved") assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin") assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L) assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND]) // The occurrence's length travels as DURATION, never DTEND — the provider
.isEqualTo(1_781_164_800_000L + 5_400_000L) // rejects DTEND on an exception ("Exceptions can't overwrite dtend") and
// A single occurrence never carries its own rule. // derives the end from DTSTART + DURATION, clearing the inherited RRULE.
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE) assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
} }
@Test @Test

View File

@@ -5,7 +5,11 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -90,6 +94,14 @@ class SettingsPrefsTest {
assertThat(prefs.showHourLines.first()).isTrue() assertThat(prefs.showHourLines.first()).isTrue()
} }
@Test
fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.autofocusEventTitle.first()).isTrue()
prefs.setAutofocusEventTitle(false)
assertThat(prefs.autofocusEventTitle.first()).isFalse()
}
@Test @Test
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest { fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
@@ -141,6 +153,94 @@ class SettingsPrefsTest {
assertThat(prefs.defaultFormFields.first()).isEmpty() assertThat(prefs.defaultFormFields.first()).isEmpty()
} }
@Test
fun `quick-switch defaults to every view enabled in default order`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val config = prefs.quickSwitchConfig.first()
assertThat(config.order).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
assertThat(config.enabled).containsExactlyElementsIn(IMPLEMENTED_VIEWS)
assertThat(config.cycle).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
}
@Test
fun `quick-switch config round-trips order and disabled views`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val config = QuickSwitchConfig(
order = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day),
enabled = setOf(CalendarView.Agenda, CalendarView.Month),
)
prefs.setQuickSwitchConfig(config)
val loaded = prefs.quickSwitchConfig.first()
assertThat(loaded.order).containsExactly(
CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day,
).inOrder()
assertThat(loaded.enabled).containsExactly(CalendarView.Agenda, CalendarView.Month)
assertThat(loaded.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
}
@Test
fun `quick-switch parse appends missing views enabled and drops unknowns`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
// Only Day (disabled) and Week stored, plus a bogus name; Month & Agenda absent.
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.QUICK_SWITCH_VIEWS_KEY] = "!Day,Week,Hologram"
m
}
val config = prefs.quickSwitchConfig.first()
// Stored order first (Day, Week), then the omitted views in default order.
assertThat(config.order).containsExactly(
CalendarView.Day, CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
).inOrder()
// Day was explicitly disabled; the appended Month & Agenda default enabled.
assertThat(config.enabled).containsExactly(
CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
)
}
@Test
fun `updateQuickSwitch transforms the stored value, so intents compose`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
// A toggle then a reorder, each expressed against the *stored* config —
// the reorder must not resurrect the just-disabled view (the stale-UI-
// snapshot race this API exists to prevent).
prefs.updateQuickSwitch { it.copy(enabled = it.enabled - CalendarView.Day) }
val newOrder = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day)
prefs.updateQuickSwitch { it.copy(order = newOrder) }
val config = prefs.quickSwitchConfig.first()
assertThat(config.order).containsExactlyElementsIn(newOrder).inOrder()
assertThat(config.enabled).containsExactly(
CalendarView.Agenda, CalendarView.Month, CalendarView.Week,
)
}
@Test
fun `drawer order defaults to the implemented order and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.drawerViewOrder.first()).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
prefs.setDrawerViewOrder(
listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month),
)
assertThat(prefs.drawerViewOrder.first()).containsExactly(
CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month,
).inOrder()
}
@Test
fun `drawer order parse appends missing views and drops unknowns`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.DRAWER_VIEW_ORDER_KEY] = "Agenda,Nope,Week"
m
}
assertThat(prefs.drawerViewOrder.first()).containsExactly(
CalendarView.Agenda, CalendarView.Week, CalendarView.Month, CalendarView.Day,
).inOrder()
}
@Test @Test
fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest { fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir) val store = newDataStore(tempDir)
@@ -175,22 +275,38 @@ class SettingsPrefsTest {
} }
@Test @Test
fun `default reminder is none until set`(@TempDir tempDir: Path) = runTest { fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultReminderMinutes.first()).isNull() assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
} }
@Test @Test
fun `default reminder round-trips, including none`(@TempDir tempDir: Path) = runTest { fun `default reminder round-trips none, single, and multiple lead times`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setDefaultReminderMinutes(30) prefs.setDefaultReminderMinutes(listOf(30))
assertThat(prefs.defaultReminderMinutes.first()).isEqualTo(30) assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
prefs.setDefaultReminderMinutes(null) // Several lead times persist, normalised to distinct ascending order.
assertThat(prefs.defaultReminderMinutes.first()).isNull() prefs.setDefaultReminderMinutes(listOf(10_080, 0, 30, 30))
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(0, 30, 10_080).inOrder()
prefs.setDefaultReminderMinutes(emptyList())
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
} }
@Test @Test
fun `garbage stored default reminder reads as none`(@TempDir tempDir: Path) = runTest { fun `legacy single-value default reminder parses as a one-element list`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
// The pre-multi-reminder format stored a bare integer string.
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "30"
m
}
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
}
@Test
fun `garbage stored default reminder reads as empty`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir) val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store) val prefs = SettingsPrefs(store)
store.updateData { p -> store.updateData { p ->
@@ -198,7 +314,7 @@ class SettingsPrefsTest {
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon" m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
m m
} }
assertThat(prefs.defaultReminderMinutes.first()).isNull() assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
} }
@Test @Test
@@ -206,65 +322,85 @@ class SettingsPrefsTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty() assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15)) prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15, 10_080)))
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None) prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
prefs.perCalendarReminderOverride.first().let { map -> prefs.perCalendarReminderOverride.first().let { map ->
assertThat(map).containsExactly(7L, 15, 9L, null) assertThat(map).containsExactly(7L, listOf(15, 10_080), 9L, emptyList<Int>())
} }
// Inherit drops the override entirely (absent != null value). // Inherit drops the override entirely (absent != an empty-list value).
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit) prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
prefs.perCalendarReminderOverride.first().let { map -> prefs.perCalendarReminderOverride.first().let { map ->
assertThat(map).containsExactly(7L, 15) assertThat(map).containsExactly(7L, listOf(15, 10_080))
assertThat(map.containsKey(9L)).isFalse() assertThat(map.containsKey(9L)).isFalse()
} }
} }
@Test
fun `garbage per-calendar override values drop the entry, the none sentinel stays explicit`(
@TempDir tempDir: Path,
) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
// Only "none" deliberately means an explicit no-reminder override; a
// value with no valid minutes is garbage and must drop its entry so the
// calendar inherits the global default instead of silently losing
// reminders. Partially-valid values keep their salvageable minutes.
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.CALENDAR_REMINDER_OVERRIDE_KEY] = "5=none;7=abc;8=;9=30,abc;11=15"
m
}
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(
5L, emptyList<Int>(), 9L, listOf(30), 11L, listOf(15),
)
}
@Test @Test
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest { fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull() assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
prefs.setDefaultAllDayReminderMinutes(1_440) prefs.setDefaultAllDayReminderMinutes(listOf(1_440))
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEqualTo(1_440) assertThat(prefs.defaultAllDayReminderMinutes.first()).containsExactly(1_440)
prefs.setDefaultAllDayReminderMinutes(null) prefs.setDefaultAllDayReminderMinutes(emptyList())
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull() assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
} }
@Test @Test
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest { fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15)) prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15)))
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440)) prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(1_440)))
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15) assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440) assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(1_440))
// Clearing the all-day override leaves the timed one untouched. // Clearing the all-day override leaves the timed one untouched.
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit) prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty() assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15) assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
} }
@Test @Test
fun `resolveDefaultReminder picks the kind-matching override or global`() { fun `resolveDefaultReminder picks the kind-matching override or global`() {
val timed = mapOf(7L to 15, 9L to null) val timed = mapOf(7L to listOf(15, 10_080), 9L to emptyList<Int>())
val allDay = mapOf(7L to 2_880) val allDay = mapOf(7L to listOf(2_880))
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder( fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
timedGlobal = 30, timedGlobal = listOf(30),
allDayGlobal = 1_440, allDayGlobal = listOf(1_440),
timedOverrides = timed, timedOverrides = timed,
allDayOverrides = allDay, allDayOverrides = allDay,
calendarId = calendarId, calendarId = calendarId,
isAllDay = isAllDay, isAllDay = isAllDay,
) )
// Timed: minutes override, explicit none, inherit global, no calendar. // Timed: multi-value override, explicit none, inherit global, no calendar.
assertThat(resolve(7L, isAllDay = false)).isEqualTo(15) assertThat(resolve(7L, isAllDay = false)).containsExactly(15, 10_080).inOrder()
assertThat(resolve(9L, isAllDay = false)).isNull() assertThat(resolve(9L, isAllDay = false)).isEmpty()
assertThat(resolve(5L, isAllDay = false)).isEqualTo(30) assertThat(resolve(5L, isAllDay = false)).containsExactly(30)
assertThat(resolve(null, isAllDay = false)).isEqualTo(30) assertThat(resolve(null, isAllDay = false)).containsExactly(30)
// All-day: its own override wins; absent → all-day global; a timed-only // All-day: its own override wins; absent → all-day global; a timed-only
// override (cal 9) does not bleed into all-day. // override (cal 9) does not bleed into all-day.
assertThat(resolve(7L, isAllDay = true)).isEqualTo(2_880) assertThat(resolve(7L, isAllDay = true)).containsExactly(2_880)
assertThat(resolve(9L, isAllDay = true)).isEqualTo(1_440) assertThat(resolve(9L, isAllDay = true)).containsExactly(1_440)
assertThat(resolve(5L, isAllDay = true)).isEqualTo(1_440) assertThat(resolve(5L, isAllDay = true)).containsExactly(1_440)
} }
@Test @Test
@@ -303,6 +439,19 @@ class SettingsPrefsTest {
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1) assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
} }
@Test
fun `custom-font stamps default to zero and bump per role independently`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
// Existing installs have no stamp key — that must read as 0.
assertThat(prefs.brandFontStamp.first()).isEqualTo(0)
assertThat(prefs.plainFontStamp.first()).isEqualTo(0)
prefs.bumpCustomFontStamp(FontRole.BRAND)
prefs.bumpCustomFontStamp(FontRole.BRAND)
prefs.bumpCustomFontStamp(FontRole.PLAIN)
assertThat(prefs.brandFontStamp.first()).isEqualTo(2)
assertThat(prefs.plainFontStamp.first()).isEqualTo(1)
}
@Test @Test
fun `explicit week-start prefs resolve regardless of locale`() { fun `explicit week-start prefs resolve regardless of locale`() {
assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US)) assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US))

View File

@@ -0,0 +1,71 @@
package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class PostableAlertsTest {
private fun alert(alertId: Long, calendarId: Long) = ReminderAlert(
alertId = alertId,
eventId = alertId * 10,
calendarId = calendarId,
beginMillis = 0L,
endMillis = 0L,
title = "Event $alertId",
location = null,
isAllDay = false,
)
@Test
fun `keeps alerts when no calendar is disabled`() {
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 200))
val postable = postableAlerts(due, disabledCalendarIds = emptySet())
assertThat(postable).isEqualTo(due)
}
@Test
fun `drops alerts for a disabled calendar`() {
val keep = alert(1, calendarId = 100)
val drop = alert(2, calendarId = 200)
val postable = postableAlerts(listOf(keep, drop), disabledCalendarIds = setOf(200))
assertThat(postable).containsExactly(keep)
}
@Test
fun `drops every alert when all their calendars are disabled`() {
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
val postable = postableAlerts(due, disabledCalendarIds = setOf(100))
assertThat(postable).isEmpty()
}
@Test
fun `keeps multiple alerts from the same enabled calendar`() {
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
val postable = postableAlerts(due, disabledCalendarIds = setOf(999))
assertThat(postable).isEqualTo(due)
}
@Test
fun `alert with unknown calendar id 0 is never treated as disabled`() {
// Pre-upgrade snooze PendingIntents carry no calendar id (defaults to 0L).
val preUpgrade = alert(1, calendarId = 0L)
assertThat(preUpgrade.isForDisabledCalendar(disabledCalendarIds = setOf(0L))).isFalse()
assertThat(postableAlerts(listOf(preUpgrade), disabledCalendarIds = setOf(0L)))
.containsExactly(preUpgrade)
}
@Test
fun `isForDisabledCalendar matches only the disabled ids`() {
assertThat(alert(1, calendarId = 200).isForDisabledCalendar(setOf(200))).isTrue()
assertThat(alert(1, calendarId = 100).isForDisabledCalendar(setOf(200))).isFalse()
}
}

View File

@@ -0,0 +1,129 @@
package de.jeanlucmakiola.calendula.data.reminders
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class SuppressedReminderStoreTest {
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("test_prefs.preferences_pb").toFile() },
)
private fun alert(
alertId: Long,
calendarId: Long,
endMillis: Long = Long.MAX_VALUE,
title: String = "Event $alertId",
location: String? = null,
) = ReminderAlert(
alertId = alertId,
eventId = alertId * 10,
calendarId = calendarId,
beginMillis = 0L,
endMillis = endMillis,
title = title,
location = location,
isAllDay = false,
)
@Test
fun `encode then decode round-trips every field including delimiters`() {
val original = alert(
alertId = 7,
calendarId = 42,
endMillis = 123_456_789L,
// Free-text with the field separator and other awkward characters.
title = "Lunch | with | Alice",
location = "Café, 3rd floor | room B",
).copy(beginMillis = 100L, isAllDay = true)
val decoded = decodeStashEntry(encodeStashEntry(original))
assertThat(decoded).isEqualTo(original)
}
@Test
fun `decode returns null for a malformed entry`() {
assertThat(decodeStashEntry("not-a-valid-entry")).isNull()
}
@Test
fun `null location round-trips`() {
val original = alert(1, calendarId = 1, location = null)
assertThat(decodeStashEntry(encodeStashEntry(original))).isEqualTo(original)
}
@Test
fun `recoverFor returns and removes only the re-enabled calendars`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
val keep = alert(1, calendarId = 100)
val recoverA = alert(2, calendarId = 200)
val recoverB = alert(3, calendarId = 200)
store.stash(listOf(keep, recoverA, recoverB), nowMillis = 0L)
val recovered = store.recoverFor(setOf(200L), nowMillis = 0L)
assertThat(recovered).containsExactly(recoverA, recoverB)
// The still-disabled calendar's alert stays stashed; the recovered ones are gone.
assertThat(store.recoverFor(setOf(100L, 200L), nowMillis = 0L)).containsExactly(keep)
}
@Test
fun `stash drops alerts whose event already ended`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
val past = alert(1, calendarId = 100, endMillis = 500L)
val future = alert(2, calendarId = 100, endMillis = 2_000L)
store.stash(listOf(past, future), nowMillis = 1_000L)
assertThat(store.recoverFor(setOf(100L), nowMillis = 1_000L)).containsExactly(future)
}
@Test
fun `purgeExpired removes only entries past their event end`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
// Stash both before "now" so neither is dropped on write, then advance time.
store.stash(
listOf(
alert(1, calendarId = 100, endMillis = 500L),
alert(2, calendarId = 100, endMillis = 2_000L),
),
nowMillis = 0L,
)
store.purgeExpired(nowMillis = 1_000L)
assertThat(store.recoverFor(setOf(100L), nowMillis = 0L).map { it.alertId })
.containsExactly(2L)
}
@Test
fun `stash replaces an existing entry with the same alert id`() = runTest {
val store = SuppressedReminderStore(newDataStore(tempDir))
store.stash(listOf(alert(1, calendarId = 100, title = "old")), nowMillis = 0L)
store.stash(listOf(alert(1, calendarId = 100, title = "new")), nowMillis = 0L)
val recovered = store.recoverFor(setOf(100L), nowMillis = 0L)
assertThat(recovered).hasSize(1)
assertThat(recovered.single().title).isEqualTo("new")
}
@Test
fun `isRelevantAt is true up to the event end and false after`() {
val a = alert(1, calendarId = 1, endMillis = 1_000L)
assertThat(a.isRelevantAt(999L)).isTrue()
assertThat(a.isRelevantAt(1_000L)).isTrue()
assertThat(a.isRelevantAt(1_001L)).isFalse()
}
@TempDir
lateinit var tempDir: Path
}

View File

@@ -0,0 +1,35 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import org.junit.jupiter.api.Test
/**
* The pure emit rule behind [ReminderDefaultPicker]: a non-empty selection is
* [CalendarReminderOverride.Minutes] (normalised), while clearing the last time
* reverts to [CalendarReminderOverride.Inherit] on a per-calendar picker (so an
* accidental toggle-undo can't silently wipe the calendar's default) and to
* explicit [CalendarReminderOverride.None] on the global default.
*/
class ReminderOverrideForMinutesTest {
@Test
fun `empty selection on a per-calendar picker reverts to inherit`() {
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = true))
.isEqualTo(CalendarReminderOverride.Inherit)
}
@Test
fun `empty selection on the global default is explicit none`() {
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = false))
.isEqualTo(CalendarReminderOverride.None)
}
@Test
fun `a non-empty selection is normalised minutes regardless of scope`() {
assertThat(reminderOverrideForMinutes(listOf(30, 10, 10, 60), allowInherit = true))
.isEqualTo(CalendarReminderOverride.Minutes(listOf(10, 30, 60)))
assertThat(reminderOverrideForMinutes(listOf(30, 10, 10, 60), allowInherit = false))
.isEqualTo(CalendarReminderOverride.Minutes(listOf(10, 30, 60)))
}
}

View File

@@ -91,4 +91,33 @@ class ViewBackStackTest {
assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda)) assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda))
.containsExactly(CalendarView.Agenda) .containsExactly(CalendarView.Agenda)
} }
@Test
fun `next cycles through the configured views in order, wrapping around`() {
val cycle = listOf(CalendarView.Month, CalendarView.Agenda)
assertThat(CalendarView.Month.next(cycle)).isEqualTo(CalendarView.Agenda)
// Wraps back to the first from the last.
assertThat(CalendarView.Agenda.next(cycle)).isEqualTo(CalendarView.Month)
}
@Test
fun `next from a view outside the cycle lands on the first enabled view`() {
// Day was disabled in the quick-switch cycle but is still the current view.
val cycle = listOf(CalendarView.Week, CalendarView.Month)
assertThat(CalendarView.Day.next(cycle)).isEqualTo(CalendarView.Week)
}
@Test
fun `next on an empty cycle stays put`() {
assertThat(CalendarView.Week.next(emptyList())).isEqualTo(CalendarView.Week)
}
@Test
fun `quick-switch cycle keeps enabled views in their configured order`() {
val config = QuickSwitchConfig(
order = listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Month, CalendarView.Week),
enabled = setOf(CalendarView.Agenda, CalendarView.Month),
)
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
}
} }

View File

@@ -0,0 +1,61 @@
package de.jeanlucmakiola.calendula.ui.theme
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class FontsTest {
@Test
fun `fromToken round-trips every bundled font`() {
BundledFont.entries.forEach { font ->
assertThat(BundledFont.fromToken(font.token)).isEqualTo(font)
}
}
@Test
fun `fromToken is null for system, custom, and unknown tokens`() {
assertThat(BundledFont.fromToken(FONT_SYSTEM_TOKEN)).isNull()
assertThat(BundledFont.fromToken(FONT_CUSTOM_TOKEN)).isNull()
assertThat(BundledFont.fromToken("not-a-font")).isNull()
assertThat(BundledFont.fromToken(null)).isNull()
}
@Test
fun `a bumped custom-font stamp breaks font-settings equality`() {
// Re-importing keeps the same "custom" token, so only the stamp can make
// the settings re-emit and invalidate remember() keys downstream.
val before = AppFontSettings(brand = FONT_CUSTOM_TOKEN, brandStamp = 1)
assertThat(before.copy(brandStamp = 2)).isNotEqualTo(before)
assertThat(before.copy(plainStamp = 1)).isNotEqualTo(before)
}
@Test
fun `system for both roles returns the untouched default typography`() {
assertThat(calendulaTypography(brand = null, plain = null))
.isSameInstanceAs(CalendulaTypography)
}
@Test
fun `brand drives display and headline, plain drives title, body, and label`() {
val brand = BundledFont.Lora.family
val plain = BundledFont.JetBrainsMono.family
val typography = calendulaTypography(brand = brand, plain = plain)
assertThat(typography.displayLarge.fontFamily).isEqualTo(brand)
assertThat(typography.headlineSmall.fontFamily).isEqualTo(brand)
assertThat(typography.titleLarge.fontFamily).isEqualTo(plain)
assertThat(typography.bodyMedium.fontFamily).isEqualTo(plain)
assertThat(typography.labelSmall.fontFamily).isEqualTo(plain)
}
@Test
fun `a null role keeps that role's default family while the other is applied`() {
val plain = BundledFont.Lora.family
val typography = calendulaTypography(brand = null, plain = plain)
// Brand styles keep the Material default; plain styles switch.
assertThat(typography.displayLarge.fontFamily)
.isEqualTo(CalendulaTypography.displayLarge.fontFamily)
assertThat(typography.bodyLarge.fontFamily).isEqualTo(plain)
}
}

View File

@@ -0,0 +1,6 @@
### Fixed
- Calendula can now be set as your default calendar app. It registers the
calendar-app intent filters the system uses, so it appears in the chooser when
you tap a date in a launcher or clock — and opening one takes you straight to
that day. Android has no way for an app to make itself the default, so you pick
it once from the system picker. Thanks to @abrossimow for the report ([#9]).

View File

@@ -0,0 +1,7 @@
### Added
- Jump straight to typing a title. When you start a new event, Calendula now puts
the cursor in the title field and opens the keyboard right away, so you can type
the name without an extra tap. It's on by default and only affects creating an
event — editing never grabs focus — and a new **Focus title on new event**
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
suggestion ([#10]).

View File

@@ -0,0 +1,19 @@
### Added
- See past events your way. Two new settings change how events that have already
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
usual, dim them, or hide them from the list entirely. **Dim completed events**
(Settings → Appearance) separately fades finished events in the month and week
views. Both are off by default, an event only counts as finished once it has
actually ended (events still in progress are never dimmed), and the lists
update on their own as the day goes on. Thanks to @ptab for the suggestion
([#12]).
### Changed
- Readable titles on overlapping events. In the week and day views, events that
overlap split a day into slim columns where the title used to be clipped to a
character or two. The title now wraps across as many lines as the block can
fit, and the time is hidden on those narrow blocks so the name can fill the
space — so you can tell events apart without opening each one. Thanks to @ptab
for the suggestion ([#13]).

View File

@@ -0,0 +1,92 @@
Copyright 2020 Braille Institute of America, Inc.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

17
licenses/fonts/README.md Normal file
View File

@@ -0,0 +1,17 @@
# Bundled fonts
Calendula bundles the following fonts as optional user-selectable typefaces
(Settings → Appearance → Fonts). Each is licensed under the SIL Open Font
License 1.1; the full license text is included alongside this file.
| Font | Files (`app/src/main/res/font/`) | Copyright | License |
|------|----------------------------------|-----------|---------|
| Atkinson Hyperlegible | `atkinson_hyperlegible_regular.ttf`, `atkinson_hyperlegible_bold.ttf` | © 2020 Braille Institute of America, Inc. | [OFL](AtkinsonHyperlegible-OFL.txt) |
| Lora (variable) | `lora.ttf` | © 2011 Cyreal | [OFL](Lora-OFL.txt) |
| JetBrains Mono | `jetbrains_mono_regular.ttf`, `jetbrains_mono_bold.ttf` | © 2020 The JetBrains Mono Project Authors | [OFL](JetBrainsMono-OFL.txt) |
The device's default typeface (Roboto on most Android builds) remains the
default and is not bundled.
Fonts a user loads from their own device via "Choose file…" are copied into the
app's private storage and are not redistributed.