Compare commits

...

75 Commits

Author SHA1 Message Date
6abfef00f2 docs(fdroid): warn against premature submodules:true in official recipe
fdroidserver's initsubmodules() raises NoSubmodulesException on any build
entry whose commit lacks .gitmodules, so `submodules: true` is a hard build
failure on every release before the floret-kit submodule lands (verified
2026-06-28 against fdroiddata MR !41553). Document that it must be added
only in the recipe entry targeting the first release that ships the
floret-kit composite build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:32:23 +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
b1151b6516 refactor(translations): move Help translate into the App language page
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m58s
Per review, it shouldn't be a top-level Settings entry: the "Help translate"
link now sits at the top of the full-screen App language picker (OptionPicker
gains an optional header slot). Updated README + changelog wording to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:18:06 +02:00
1664625bc5 feat(translations): add "Help translate" link to the Weblate engage page
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m39s
Invite community translations: a new Settings > Help translate row (next to
App language) opens the project's Weblate engage page, plus a Translations
section in the README. Documented in the 2.11.0 changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:10:06 +02:00
e8b87446f7 docs(changelog): document settings redesign + auto-backup for 2.11.0
All checks were successful
Translations / check (pull_request) Successful in 7s
CI / ci (pull_request) Successful in 9m33s
Add the Calendars manager redesign, Notifications reorg, QS-tile move,
event-form field icons, and automatic local-calendar backup (#8) to the
2.11.0 changelog; sync the F-Droid per-version changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:57:44 +02:00
dc478516cd fix(backup): stop orphaned backup work when disabled
Turning automatic backup off only cancelled the periodic work, so a
run-now that kept failing (e.g. its folder was deleted) retried forever
and spammed failure notifications.

- Cancel the immediate "run now" work too when backup is disabled.
- Worker no-ops (no retry) when the toggle is off, so already-queued work
  can't revive itself.
- Reconcile scheduled work against saved settings on every app launch —
  re-arms after a reinstall and clears orphaned work once backup is off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:35:58 +02:00
8945b877b2 Merge pull request 'feat(backup): automatic periodic .ics export of local calendars (#8)' (!47) from feat/auto-backup into release/v2.11.0
Reviewed-on: #47
2026-06-27 20:22:22 +00:00
1424f0ffc0 feat(backup): connect one-time export + automatic backup into one card
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m44s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:17:24 +02:00
548e581554 feat(backup): automatic periodic .ics export of local calendars (#8)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 8m47s
Scheduled one-way export of local calendars to a user-chosen folder,
overwriting calendula-backup.ics each run — the manual backup, automated.
Not a sync; stays INTERNET-free (provider reads + local file write).

- WorkManager periodic job (deps: work-runtime-ktx, documentfile); worker
  pulls collaborators via a Hilt @EntryPoint, so no custom WorkerFactory
  wiring. First periodic run is delayed one interval so it can't race the
  immediate "run now" feedback run; the writer also overwrites the canonical
  file and cleans up any "(1)" duplicates from earlier races.
- SettingsPrefs: enabled, interval (minutes, floored at 30), folder Uri,
  last-run status; persisted SAF write grant.
- UI in Calendars > Backup: toggle, folder picker, amount+unit interval
  dialog, last-run status line. Notifies after repeated failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:13:55 +02:00
a6ae4d7e1d Merge pull request 'feat(settings): settings UX polish + Calendars manager redesign' (!46) from feat/settings-improvements into release/v2.11.0
Reviewed-on: #46
2026-06-27 19:47:43 +00:00
614f4f2d75 feat(calendars): account header overflow menu + restyle
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m9s
Replace the per-account toggle switch and nested manage row with a single
trailing overflow (⋮) menu holding both account-level actions:
- "Enable all" / "Disable all" — toggle every calendar in the group
- "Manage in app" (synced) / "Add calendar" (local)

The dropdown is styled to fit: rounded corners, a distinct floating
surface (surfaceContainerLowest + lifted shadow) so it stands clear of
the cards, and a divider separating the two actions. Shortened the manage
label to "Manage in app" and dropped the now-unused account a11y strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:27:20 +02:00
e6736b049a feat(settings): settings UX polish + Calendars manager redesign
Settings:
- Event-form field rows show each field's own icon (shared
  EventFormFieldVisuals, reused by the editor and settings)
- Move "Add Quick Settings tile" to a top-level Settings hub row
- Notifications: reliable-delivery + snooze moved above the
  per-calendar block, which now folds behind one expandable section

Calendars manager:
- Local and synced calendars now use one collapsible group card
- Source-branded headers: each account shows its app's launcher icon
  (Google Calendar, DAVx5, …) loaded from PackageManager; local shows
  a device chip
- Per-account toggle-all switch (CalendarsViewModel.setAccountDisabled)
- Management action ("Add calendar" / "Manage in app") is a nested row
  inside the expanded group; removed the dead "Add account" row
- A fully deactivated account dims its header, not just the switch
- Group headers use the normal row colour; their options sit one tone
  darker (GroupedRow gains an optional container colour)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:39:07 +02:00
301904be6d docs(changelog): note agenda range bar and Appearance tidy-up in 2.11.0
Expand the #4 entry to cover the in-agenda range bar (date header + session
range switch) and add a Changed note for the Appearance regrouping and the
friendlier agenda empty state. Regenerate the F-Droid changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:11:29 +02:00
3ca7200624 Merge pull request 'feat(agenda): limit how far ahead the agenda screen and widget show (#4)' (!44) from feat/agenda-range-limit into release/v2.11.0 2026-06-27 15:08:34 +00:00
c30a153552 fix(widget): drive agenda range via reactive Glance state
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m22s
The agenda widget loaded and sliced its data in the provideGlance preamble
and captured it as a non-reactive local. updateAll() reliably recomposes a
live Glance session but does not reliably re-run that preamble, so a range
change was redrawn against the stale slice (intermittently — only when no
session was alive did it pick up the new range). This is the same platform
limitation the month widget already works around.

Mirror that pattern: load the widest selectable window once, store the range
in per-instance Glance state (AGENDA_RANGE_KEY), read it reactively via
currentState, and slice in the composition. The settings setter writes the
state into each instance and recomposes, so a range change now reflects via
plain recomposition regardless of session lifecycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:03:48 +02:00
bb92d02000 fix(widget): refresh from setter, serialized with a mutex
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m29s
The debounced observer was unreliable: drop(1) plus the ViewModel lifecycle
could swallow a change after re-entering the app, so a single range change
sometimes didn't refresh the widget. Refresh directly from the settings
setters instead (always fires on a real change), serialized through a mutex
so a rapid flip-and-flip-back can't run two updateAll calls at once and
strand the widget on the intermediate value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:44:52 +02:00
cd703e9d54 fix(widget): debounce widget refresh on pref change
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m38s
Refreshing the widget directly from the setter raced when a setting was
flipped and flipped back quickly: two concurrent updateAll calls could
coalesce around a stale read and leave the widget on the intermediate value.
Observe the agenda-widget-range and week-start prefs instead and push a
single debounced updateAll once changes settle, so the widget always
converges to the final value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:37:57 +02:00
f27b0e269d fix(widget): refresh agenda widget when its range (or week start) changes
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m18s
The agenda widget only re-read agendaWidgetRange on the next data-change /
midnight / periodic refresh (or re-placement), so a settings change appeared
to do nothing until then. Push an updateAll from the settings setters:
the agenda widget on an agenda-widget-range change, and both widgets on a
week-start change (month weekday header + the agenda widget's "this week").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:30:27 +02:00
0f31981d26 refactor(agenda): one toggle for the whole range bar
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m43s
Consolidate the separate "range title" and "range button" settings into a
single "Range bar" toggle, now that the header and switcher live on one bar.
On by default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:25:46 +02:00
6471c48528 refactor(agenda): put range header and pill on one bar
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m46s
Move the session range pill from the bottom-left corner onto the same top
bar as the "Showing all upcoming events for …" header — header on the left,
pill on the right, each still independently toggleable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:22:33 +02:00
f8a2569831 feat(agenda): warmer empty state + "upcoming" wording
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m30s
Reword the range header to "Showing all upcoming events for". Replace the
dry empty state ("Nothing scheduled" + subtitle) with a single warmer line
("You're all caught up"), drop the subtitle, and switch the icon to a
coffee cup. The empty title is shared with the agenda widget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:20:33 +02:00
6ba7f38887 feat(agenda): range header banner + toggles for banner and pill
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 4m29s
Move the "Showing all events for <window>" line out of the picker and into
the agenda view as an always-visible header above the list. Add two
settings (Agenda group, both ON by default) to toggle the range header
banner and the bottom-left range pill independently. The picker keeps its
two-list grouping but no longer carries the header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:08:23 +02:00
366059b012 feat(agenda): split range picker into two lists with a concrete header
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m27s
Group the range options into calendar-aligned (today / this week / this
month) and rolling (next 7 / 30 days / custom) lists. Add a header showing
the concrete span currently in effect — a single date for Today, the month
and year for This month, otherwise a start–end span — so it's clear what
the agenda is showing. The header appears only where a window is supplied
(the agenda pill), not in Settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:59:56 +02:00
3cf1439850 feat(agenda): bottom-left pill to override the range for the session
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m4s
Add a tonal pill in the agenda's bottom-left corner showing the current
range; tapping it opens the existing AgendaRangePicker as a session-only
override. The override lives in AgendaViewModel (in-memory), so it survives
view switches and rotation but resets to the saved default when the app is
relaunched. The pill fills with the primary container while an override is
active, and a hint in the picker spells out the temporary nature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:50:18 +02:00
b2bfdc0f42 refactor(settings): regroup Appearance into theme / calendar / agenda
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m0s
Drop the uniform two-per-card grouping for meaningful sections: theme &
colour (2), calendar — default view, week start, time format, hour lines
(4), and agenda (2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:36:19 +02:00
7c9830deda Merge release/v2.11.0 into feat/agenda-range-limit
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m26s
Integrate #3/#5/#6 (week-start, time-format, hour-lines) into the agenda
range branch. SettingsViewModel folds all five view prefs into one combine;
WidgetData keeps the range-based window and adds the resolved is24Hour.

Also, in the same Appearance section:
- regroup the rows into four titled-by-spacing groups (theme & colour /
  calendar layout / timeline display / agenda) instead of one long card.
- add an explanatory description line under the title of each agenda-range
  picker (PickerDescription).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:31:44 +02:00
5e98059388 Merge pull request 'feat(timeline): optional hour separator lines in week & day view (#5)' (!43) from feat/timeline-hour-lines into release/v2.11.0
Reviewed-on: #43
2026-06-27 13:18:26 +00:00
53c0482760 Merge pull request 'feat(settings): add 12/24-hour time format toggle (#6)' (!42) from feat/time-format-toggle into release/v2.11.0
Reviewed-on: #42
2026-06-27 13:18:05 +00:00
f8efa4d3e4 Merge branch 'feat/time-format-toggle' into feat/timeline-hour-lines
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m51s
2026-06-27 14:54:38 +02:00
93cc264fbf Merge remote-tracking branch 'origin/release/v2.11.0' into feat/time-format-toggle
All checks were successful
Translations / check (pull_request) Successful in 7s
CI / ci (pull_request) Successful in 9m45s
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values/strings.xml
2026-06-27 14:53:45 +02:00
585cdac4ab Merge pull request 'feat(settings): allow week to start on any day (#3)' (!41) from feat/week-start-any-day into release/v2.11.0
Reviewed-on: #41
2026-06-27 12:47:45 +00:00
9994e8c534 chore(release): prepare v2.11.0
Bump versionName to 2.11.0 (versionCode 21100) and add the 2.11.0
changelog: any-day week start (#3), 12/24-hour time format (#6),
optional timeline hour lines (#5), and agenda range limits (#4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:34:48 +02:00
766c2ffcf8 feat(agenda): add calendar-aligned "this week"/"this month" ranges
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 11m22s
Extend AgendaRange with calendar-aligned windows alongside the rolling
ones: ThisWeek runs through the end of the current week (respecting the
week-start preference — a Monday start means everything before next
Monday), ThisMonth through the last day of the current month. dayCount now
takes the anchor day and week-start; the agenda screen and widget resolve
the week-start preference and pass it through. Rolling options relabelled
("Today", "Next 7 days", "Next 30 days") to read distinctly from the new
calendar-aligned ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:25:59 +02:00
add88fbadf feat(agenda): limit how far ahead the agenda screen and widget show
Add independent agendaScreenRange and agendaWidgetRange preferences, each a
rolling window: 1 day / 1 week / 1 month / custom 1–365 days (default Month).
Rolling (not calendar-aligned) so the span never degenerates near a period
boundary. The in-app Agenda screen and the agenda widget each read their own
setting, configured via a new AgendaRangePicker with an inline custom-days
editor.

Closes #4 (Codeberg)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:15:46 +02:00
09a1aecb76 feat(timeline): optional hour separator lines in week & day view
All checks were successful
Translations / check (pull_request) Successful in 4s
CI / ci (pull_request) Successful in 11m25s
Add a showHourLines preference (default off — the existing clean look).
When on, the week and day timelines draw a faint outline-variant line at
each hour boundary, sitting over the column background but beneath event
blocks. The toggle is provided app-wide via LocalShowHourLines and applied
through a reusable hourSeparatorLines() modifier.

Closes #5 (Codeberg)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:07:09 +02:00
c91608e48a feat(settings): add 12/24-hour time format toggle
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 11m16s
Add a TimeFormatPref (Auto/12h/24h) preference. Auto follows the device's
24-hour system setting; the others force a clock app-wide. The resolved
convention is provided once at the app root via LocalUse24HourFormat, so
every in-app time label reads it without per-screen plumbing.

A shared, pure TimeFormat helper (formatTimeOfDay / formatMinuteOfDay /
formatHourLabel / timeOfDayFormatter) is now the single source for all
time-of-day rendering. Routing every site through it also fixes a
pre-existing inconsistency: the week/day timeline gutters and the agenda
screen + widget hard-coded 24h, while event detail/edit/search/reminders
followed the locale — so a 12h-locale user previously saw mixed formats.

Covered: week & day timelines (gutter + event blocks), agenda screen and
widget, event detail/edit, search, and reminder notifications.

Closes #6 (Codeberg)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:02:22 +02:00
47e8036000 feat(settings): allow week to start on any day
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m7s
Replace the AUTO/MONDAY/SUNDAY week-start enum with a sealed type
(Auto + Day(DayOfWeek)) so users can pick any of the seven days as the
first day of the week. The picker now lists "Automatic" plus all seven
localised weekday names; labels come from java.time display names rather
than per-day string resources.

Stored values round-trip by DayOfWeek.name, so the legacy MONDAY/SUNDAY
preferences migrate transparently. Garbage values fall back to Auto.

Closes #3 (Codeberg)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:45:01 +02:00
07a8faf2df Merge pull request 'release: v2.10.0' (!40) from release/v2.10.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 6m11s
Reviewed-on: #40
2026-06-25 12:53:12 +00:00
54b059e744 chore(release): prepare v2.10.0
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m6s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:31:24 +02:00
d0c01ab8c4 Merge pull request 'feat(calendars): in-app enable/disable for calendars' (!38) from feat/disable-calendar into release/v2.10.0
Reviewed-on: #38
2026-06-25 12:28:31 +00:00
bf10fee19f Merge remote-tracking branch 'origin/release/v2.10.0' into feat/disable-calendar
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 8m57s
# Conflicts:
#	CHANGELOG.md
2026-06-25 14:23:07 +02:00
2e88c40eff Merge pull request 'feat(about): add Ko-fi support link' (!39) from feat/kofi-support into release/v2.10.0
Reviewed-on: #39
2026-06-25 12:21:37 +00:00
26e538a9ba docs(changelog): note Ko-fi support link
All checks were successful
Translations / check (pull_request) Successful in 7s
CI / ci (pull_request) Successful in 9m16s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:07:40 +02:00
814daa8151 docs(changelog): note in-app calendar enable/disable
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 4m49s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:06:39 +02:00
450fc7f7af Merge remote-tracking branch 'origin/main' into feat/disable-calendar 2026-06-25 14:05:45 +02:00
be6d10d126 feat(calendars): add app-side enable/disable for calendars
Introduce a second, heavier visibility level above the per-view hide
filter. A disabled calendar is removed from the app's surfaces entirely —
its events drop out of all views and search, and it disappears from the
drawer filter list, the event-form calendar picker and the import target
picker. It stays listed only in Settings → Calendars, where a per-row
switch toggles it, so it can always be brought back.

- CalendarPrefs: disabledCalendarIds set + setter, mirroring the hidden
  set (DataStore comma-separated string); never touches the system
  VISIBLE/SYNC_EVENTS flags, so it's app-local and reversible.
- CalendarRepositoryImpl: instances()/searchEvents() exclude
  calendarId ∈ (hidden ∪ disabled). distinctUntilChanged() on instances
  collapses the transient duplicate emission both DataStore-derived sets
  produce when either is toggled.
- FilterViewModel: drop disabled calendars from the drawer filter list.
- EventEditViewModel: exclude disabled from writableCalendars; a
  last-used preselect on a now-disabled calendar falls back to the first
  remaining writable one.
- ImportViewModel: exclude disabled from the import target list.
- CalendarsScreen/ViewModel: per-row enable/disable Switch on both the
  local and synced groups; disabled rows render dimmed (new GroupedRow
  `dimmed` flag) while keeping the toggle live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:55:10 +02:00
a1e8c05c7b docs(roadmap): mark v2.8.0 work shipped, capture disable-calendar
Update roadmap to reflect v2.8.0 releases (attendee editing, full-text
search, QS tile, now-line, contact pickers) and the week-numbers-in-month
rejection; capture the in-app 'disable a calendar' feature plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:39:01 +02:00
cecd0c2234 docs(readme): revert F-Droid badge to height 56
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:34:27 +02:00
43eb5e23e8 docs(readme): enlarge F-Droid badge to height 80
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:33:41 +02:00
946addbbcd docs(readme): use official 'Support me on Ko-fi' beige badge
Swap the Buy-me-a-coffee pill for Ko-fi's beige support badge, matched
to the F-Droid badge height (56).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:32:21 +02:00
8a63aa86a7 Update README.md 2026-06-25 11:28:44 +00:00
0ddbbb11e8 Update README.md 2026-06-25 11:28:19 +00:00
575b1c7eb7 Update ReadMe.md 2026-06-25 11:25:55 +00:00
dd61e0ed37 docs(readme): shrink Ko-fi button for visual balance
The solid Ko-fi pill read heavier than the F-Droid badge at equal
height; drop it to 46 so the two feel balanced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:23:13 +02:00
376ad5f394 docs(readme): put F-Droid and Ko-fi buttons inline at equal height
Both at height 56 on one line — equal heights align on the baseline,
so no table/border workaround is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:19:57 +02:00
8a9e6f0ee8 docs(readme): stack F-Droid and Ko-fi buttons, drop bordered table
The table centered them but Gitea draws cell borders we can't strip.
Stack the two badges on separate centered lines instead — no borders,
each keeps its own size.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:09:29 +02:00
913b49e962 docs(readme): vertically center the F-Droid and Ko-fi buttons
Wrap both badges in a table row so the different-height buttons
align on a shared midline instead of the text baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:08:12 +02:00
96f4d4dc84 docs(readme): restore F-Droid badge to original height
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:07:04 +02:00
f4789c535e docs(readme): align Ko-fi button with F-Droid badge
Use Ko-fi's raster PNG button (the SVG was ignored and rendered
oversized) and match both buttons to height 56 so they sit inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:06:03 +02:00
00220c5f0e docs(readme): use official Ko-fi button next to F-Droid badge
Replace the small shields Ko-fi badge with Ko-fi's official
"Support me" button, placed beside the Get it on F-Droid badge
as a peer call-to-action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:04:15 +02:00
b7a5f16c84 docs(readme): replace inline sections with doc links
Move the Building section into docs/BUILDING.md and collapse Building,
Architecture, and Roadmap into a compact Documentation block linking to
the dedicated docs, keeping the README focused on what/install/support.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:56:26 +02:00
2c38912834 docs(readme): surface Ko-fi + official F-Droid, tidy install
- Move support to the top: Ko-fi badge in the header badge row,
  drop the redundant bottom Support section
- Add "Get it on F-Droid" button now that Calendula is on the
  official repo
- Restructure Install: lead with official F-Droid, keep the
  self-hosted repo as the faster-updates lane

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:53:01 +02:00
a9c67a5827 feat(about): add Ko-fi support link
Add an optional way for users to support development:

- About card: tonal "Support development" button opening ko-fi.com
  via ACTION_VIEW (no INTERNET permission), EN + DE strings
- F-Droid metadata: Donate field in self-hosted and official drafts
- README: short Support section with the Ko-fi link

No perks/rewards attached — kept as a plain donation link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:39:49 +02:00
a69b161178 Merge pull request 'release: v2.9.0' (!37) from release/v2.9.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 8m38s
Reviewed-on: #37
2026-06-25 08:11:37 +00:00
024deadf78 chore(release): prepare v2.9.0
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m5s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:56:54 +02:00
599c3c03e7 Merge pull request 'feat(ui): consistent motion polish, predictive back & reduced-motion' (!36) from feat/ui-polish-animations into release/v2.9.0
Reviewed-on: #36
2026-06-25 07:52:32 +00:00
5d48c90408 docs(changelog): note motion polish, predictive back & reduced-motion
All checks were successful
CI / ci (pull_request) Successful in 4m27s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:20:45 +02:00
c6d15d0118 feat(ui): consistent motion polish, predictive back & reduced-motion support
Centralise the app's motion vocabulary in CalendarTransitions.kt and route
every surface through it so animation is consistent app-wide:

- Shared expand/collapse, list-item and fade-through helpers; the event-edit
  expand pattern is now the shared one (no duplicate).
- Settings reminder-override rows and the reminder Custom field now expand/
  collapse instead of bare-fading.
- Search and agenda rows animate (fade/relocate) via animateItem.
- Month/week/day slide and the onboarding gates honour the new helpers.
- View switching (month/week/day/agenda) now fades through instead of
  snapping — lateral navigation per M3, while in-view paging keeps its slide.

Add full predictive-back support:

- enableOnBackInvokedCallback in the manifest.
- New Modifier.predictiveBack(onBack) drives the standard preview transform
  (scale/shift/round) following the back gesture; applied to detail, edit,
  search, settings (+ sub-screens & calendar manager via CollapsingScaffold),
  the calendar editor and import — each keeping its existing back semantics.

Reduced-motion guardrail throughout: rememberReduceMotion() (reads the OS
"remove animations" setting, which Compose ignores by default) collapses
spatial motion to a quick fade and skips the back preview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:19:35 +02:00
79e083b4d4 Merge pull request 'feat: configurable default view + widget-aware back navigation' (!35) from feat/view-back-stack into release/v2.9.0
Reviewed-on: #35
2026-06-25 06:25:15 +00:00
119a8afe8e feat(nav): interactive month widget + history-retracing back
All checks were successful
Translations / check (pull_request) Successful in 28s
CI / ci (pull_request) Successful in 4m18s
On-device review follow-ups:

- Month widget grid is now tappable. Day numbers open that day and event
  bars open the event's detail, both rooted in the month view so back
  returns to the grid. Previously only the prev/next/today header
  controls responded — the grid cells were never clickable.

- Pill/drawer view switches now build a visit history instead of
  collapsing to the default view. Back retraces the views you moved
  through (a not-yet-visited view is pushed; revisiting one collapses the
  loop back to it), down to the default, then exits. Widget launches
  still reset to their own view context.

Refs #1, #2 (reported by @devinside).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:17:23 +02:00
b537e143f8 feat(nav): default view setting + widget-aware back stack
Replace CalendarHost's single global view slot with a top-level view
back stack rooted at a user-configurable default view. A lateral move
(pill/drawer/widget) replaces the non-home top; a date tap drills the
day view on top; a base-level BackHandler pops one level until only the
home view remains, then the system exits.

Widgets now carry their source view (EXTRA_SOURCE_VIEW) so a launch
roots the stack in that widget's view: backing out of a day/event opened
from the agenda widget returns to Agenda, and from the month widget to
Month, instead of always landing on Week. Reminder taps keep the
separate detail-key channel and leave the base view untouched.

Add a Default view setting (Settings -> Appearance) backing the stack's
home view; defaults to Week so existing users see no change.

Resolves the two Codeberg reports from @devinside:
- #1 [FR] Option to set default view
- #2 [Bug] Widget UX improvement

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:07:00 +02:00
70 changed files with 3953 additions and 549 deletions

View File

@@ -5,6 +5,7 @@ title: "Crash: "
labels: labels:
- bug - bug
- crash - crash
- priority:high
--- ---
<!-- <!--

View File

@@ -3,7 +3,7 @@ name: Feature request
about: Suggest an idea or improvement about: Suggest an idea or improvement
title: "" title: ""
labels: labels:
- enhancement - feat
--- ---
### What would you like Calendula to do? ### What would you like Calendula to do?

View File

@@ -0,0 +1,19 @@
---
name: Question
about: Ask how something works or get help using Calendula
title: ""
labels:
- question
---
### Your question
### What you've tried
<!-- so far, if anything -->
### Context
- Calendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:

View File

@@ -230,7 +230,7 @@ pass on the existing controls; new toggles ride in with their own features.
**Tier 3 — platform reach (depends on Tier 2)** **Tier 3 — platform reach (depends on Tier 2)**
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)* 7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; optional quick-settings tile still open 8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; ~~quick-settings tile~~ *(done, v2.8.0 — "New event" QS tile)*
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)* **Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global 9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
@@ -251,37 +251,38 @@ pass on the existing controls; new toggles ride in with their own features.
decision (snooze must re-fire an alarm). decision (snooze must re-fire an alarm).
**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)* **Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
12. **Attendee editing** *(next — promoted from the gated "Locations & People" 12. **Attendee editing** *(shipped — merged into release/v2.8.0, `feat/attendee-editing`
bucket, owner says high-importance 2026-06-22)* — attendees are already MR !29 / commit `b0f34ff`, 2026-06-22)* — closed the last big read-only gap in
*read* (queried, mapped, shown on the detail screen since v0.6) but the the event model: attendees were already *read* (queried, mapped, shown on the
event form can't *write* them, the last big read-only gap in the event detail screen since v0.6) and are now *writable* from the form. An attendees
model. Add an attendees section to `EventEditScreen` / `EventForm`: add by section on `EventEditScreen` / `EventForm` adds by typed email **or** the
typed email (contact-picker entry can follow later), edit/remove rows, set no-permission contact picker, edits/removes rows, and sets role (required /
role (required / optional) — writing `CalendarContract.Attendees` rows on optional) — writing `CalendarContract.Attendees` rows on insert + dirty-checked
insert + dirty-checked update, mirroring the reminders-diff pattern. update, mirroring the reminders-diff pattern. The sync-adapter *invitation*
**Caveat to resolve first:** writing `Attendees` rows triggers sync-adapter caveat was **settled record-only** (Calendula never sends invites; the backend
*invitation* behavior that differs by backend (Google auto-emails invites; decides delivery) — full sketch + decision in "Attendee editing" under Locations
DAVx5/CalDAV writes the `ATTENDEE` property and lets the server decide) — & People below.
decide and document what Calendula promises before building. Full sketch in
"Attendee editing" under Locations & People below.
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)** **Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage) - Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
- Locations & People — contact address picker (no-permission, one-shot) is the safe entry; OSM autocomplete needs INTERNET - Locations & People — the no-permission contact pickers already shipped (location v2.8.0, attendee email v2.8.0); what remains gated is OSM autocomplete (needs INTERNET)
- Move event to another calendar — sync-adapter minefield (copy+delete model) - Move event to another calendar — sync-adapter minefield (copy+delete model)
**Bottom — deprioritised, not important** **Bottom — deprioritised, not important**
- Duplicate event (detail action → prefilled create form) — moved here - Duplicate event (detail action → prefilled create form) — moved here
2026-06-17; cheap but low value, pick up only if asked 2026-06-17; cheap but low value, pick up only if asked
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts, **Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts.
full-text search. Pulled in opportunistically, not sequenced. Pulled in opportunistically, not sequenced.
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics` Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected). export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected),
**Next committed work is Tier 5 #12 — attendee editing** (the last read-only gap and Tier 5 #12 — attendee editing — shipped in release/v2.8.0, closing the last
in the event model). After it: the theme-group ideas and Tier 2/3 leftovers read-only gap in the event model. v2.8.0 also cleared most Tier 2/3 leftovers
(quick-settings tile, now-line, week numbers in month, full-text search). full-text search, the "New event" Quick Settings tile, and the now-line all
shipped there. **No tier work is currently committed as next;** the remaining
candidates are unscheduled theme-group ideas (pinch-to-zoom, tablet/foldable,
accessibility pass) plus the gated go/no-go items.
## Navigation & views ## Navigation & views
@@ -291,24 +292,22 @@ in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
- Agenda view (fourth view: upcoming events grouped by day; also the - Agenda view (fourth view: upcoming events grouped by day; also the
natural data source for a future widget) natural data source for a future widget)
- Jump to date — drawer date picker (un-cut from V1) - Jump to date — drawer date picker (un-cut from V1)
- Current-time "now" line in day/week — standard in every calendar, cheap, - ~~Current-time "now" line in day/week~~ **shipped v2.8.0**
currently absent. Daily-driver polish. - Week numbers in the **month** grid — **rejected** (owner decision): clutters
- Week numbers in the **month** grid — week view already shows the badge the view and shrinks the day cells; the badge stays week-view-only.
(`WeekNumberBadge`, `WeekScreen.kt`); extend to month for ISO/European users.
- Pinch-to-zoom time scale in day/week - Pinch-to-zoom time scale in day/week
- Tablet / foldable layouts *(was v3.0)* - Tablet / foldable layouts *(was v3.0)*
- Full-text search *(was v3.0)* — promote out of "fill-in": for a daily driver - ~~Full-text search~~ **shipped v2.8.0** for a daily driver with real event
with real event history, finding an event is core completeness, not optional. history, finding an event is core completeness, not optional.
## Event editing & creation ## Event editing & creation
- Duplicate event (detail action → prefilled create form) - Duplicate event (detail action → prefilled create form)
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form) - ~~**Per-event color**~~ *(shipped v2.4.0)*`EVENT_COLOR` / `EVENT_COLOR_KEY`
*(next)* — chosen to follow the in-progress tap-to-create + calendar from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard
management work: reuses the color-picker component and palette plumbing picker in the form, falling back to the calendar color when unset. Reused the
being built for local calendar management, and finishes the create/edit color-picker component and palette plumbing from local calendar management and
theme. `EVENT_COLOR` / `EVENT_COLOR_KEY` from the calendar's color list finished the create/edit theme.
(`Colors` table, `TYPE_EVENT`); falls back to the calendar color when unset.
## Calendars & accounts ## Calendars & accounts
@@ -337,7 +336,63 @@ in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by `.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
UID). Closed the silent data-loss gap. UID). Closed the silent data-loss gap.
## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)* ### Disable a calendar in-app *(captured 2026-06-25)*
A second, heavier visibility level **above** the existing per-view filter. Today
the drawer's calendar filter (`hiddenCalendarIds` in `CalendarPrefs`) only hides
a calendar's *events* from the month/week/day/agenda views — the calendar itself
still clutters the drawer filter list, the event-form calendar picker, and the
import target picker. "Disable" removes a calendar from the app's surfaces
entirely; "hide" stays the lightweight, frequently-toggled control.
**Two-level model (both kept):**
- **Hidden** (existing) — `hiddenCalendarIds`; a quick per-view checkbox in the
drawer. Toggles events on/off in the views; the calendar stays listed
everywhere. Operates only over the *enabled* calendars.
- **Disabled** (new) — the calendar is gone from the app: not in the drawer
filter list, not in the event-form picker, not in the import picker, and its
events never appear (it's gated out of `instances()` like a hidden one). It
remains visible **only** in Settings → Calendars, where the enable/disable
toggle lives, so it can be brought back.
**Storage — app-side (DataStore), mirrors the hidden set.** Add
`disabledCalendarIds: Set<Long>` + `setDisabledCalendarIds(...)` to
`CalendarPrefs` (comma-separated string key, same shape as `hiddenCalendarIds`).
**Does not touch** `CalendarContract.Calendars.VISIBLE` / `SYNC_EVENTS` — purely
a Calendula-local preference, so other calendar apps are unaffected and the sync
adapters stay out of it (privacy-clean, reversible).
**Where the disabled set is applied:**
- `CalendarRepositoryImpl.instances()` + `searchEvents()` — exclude
`calendarId ∈ (hidden disabled)` so a disabled calendar's events never show
and aren't searchable. (`repository.calendars()` itself stays unfiltered/raw —
the screens that need everything still get everything.)
- `FilterViewModel.state` — drop disabled calendars from the drawer filter list
(you can't hide/show what's disabled).
- `EventEditViewModel.writableCalendars` — exclude disabled, so you can't create
into a calendar you've removed from the app. Handle the last-used-calendar
preselect falling on a now-disabled calendar (fall back to first enabled
writable).
- `ImportViewModel` — exclude disabled from the import target list.
- `CalendarsScreen` / `CalendarsViewModel` — the **only** surface that lists
disabled calendars; add `setDisabled(id, Boolean)` and a per-calendar toggle.
**UI — Settings → Calendars (no new tab).** Add an enable/disable control to each
row on the existing `CalendarsScreen` (both the local and the synced/read-only
groups — disabling is an app-side view choice, independent of write access).
Disabled rows render visibly de-emphasised (dimmed) but keep the toggle so
they're re-enableable. Follow the project dialog/list conventions (M3 grouped
list, `option-card-modal-style-default` if a confirm/selection surface is needed).
**Decided behaviour:**
- All calendars disabled → views show the existing empty state.
- A disabled calendar that still holds events: events simply vanish from
views/search until re-enabled (no data touched — it's a filter, not a delete).
- Deep links / notifications pointing at an event in a disabled calendar still
open its detail screen — detail is a direct id lookup, not an `instances()`
query, so disabling never strands an existing link.
## Reminders — defaults & delivery reliability *(shipped v2.6.0; built on `feat/default-reminders`)*
Two themes bundled because both are "make reminders trustworthy" — the core of Two themes bundled because both are "make reminders trustworthy" — the core of
the "Calendula is your only calendar app" promise. the "Calendula is your only calendar app" promise.
@@ -414,18 +469,20 @@ it directly undermines the feature's premise, so it rides in here.
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
are notorious for suppressing it). are notorious for suppressing it).
### Open decisions (resolve before building) ### Decisions made (as shipped in v2.6.0)
1. Self-schedule via `AlarmManager` vs trust the provider broadcast 1. **Provider broadcast kept** — did not self-schedule via `AlarmManager`; the
(reliability vs simplicity + battery cost). battery-optimisation exemption is the reliability lever (simplicity + battery
2. All-day reminder representation (minutes-before vs absolute time-of-day). cost won over the exact-alarm subsystem).
3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs 2. **All-day reminders = minutes-before-midnight** (day-scale presets); absolute
a list inside the Notifications sub-page). time-of-day ("9am the day before") deferred — see the deferred list above.
3. **Per-calendar overrides live in the Notifications sub-page** (override list),
governing timed events only.
### Later (round two) ### Round two
- Snooze + dismiss actions on the notification (snooze needs an - ~~Snooze + dismiss actions on the notification~~ *(shipped in release/v2.8.0,
exact-alarm / WorkManager decision) — Tier 4 #13. Tier 4 #11)* — snooze re-fires a snooze-only exact alarm.
## Sharing & interop ## Sharing & interop
@@ -438,8 +495,8 @@ it directly undermines the feature's premise, so it rides in here.
## Platform & launchers ## Platform & launchers
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets - ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0** - ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
optional quick-settings tile still open - ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
## Quality & reliability ## Quality & reliability
@@ -455,9 +512,10 @@ it directly undermines the feature's premise, so it rides in here.
Beyond classic calendar-client scope; discussed, deliberately not planned Beyond classic calendar-client scope; discussed, deliberately not planned
in detail yet: in detail yet:
- **Contact address picker** for the location field via the system picker - ~~**Contact address picker** for the location field via the system picker
(`ACTION_PICK` on postal addresses) — one-shot, needs no READ_CONTACTS, (`ACTION_PICK`)~~ **shipped v2.8.0** — one-shot, needs no READ_CONTACTS, fits
fits the privacy story. Same mechanism later for picking emails. the privacy story. The same no-permission mechanism was then reused for the
attendee email picker (v2.8.0).
- **OSM address autocomplete** in the location field (type "Brandenburger - **OSM address autocomplete** in the location field (type "Brandenburger
Tor" → tap suggestion → resolved address inserted). Backend would be Tor" → tap suggestion → resolved address inserted). Backend would be
Photon (Nominatim's public policy forbids autocomplete). **Requires the Photon (Nominatim's public policy forbids autocomplete). **Requires the

View File

@@ -5,6 +5,129 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.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
### Added
- Start the week on any day. The **Week starts on** setting (Settings →
Appearance) now offers every weekday — not just Monday or Sunday — alongside
the automatic, locale-based default. Thanks to @zmaherdev for the suggestion
([#3]).
- Choose a 12- or 24-hour clock. A new **Time format** setting (Settings →
Appearance) lets you force a 12-hour (2:00 PM) or 24-hour (14:00) clock, or
follow the system setting automatically. It applies everywhere times appear —
the week and day timelines, agenda, event details, search and reminders — so
the format is now consistent across the whole app. Thanks to @zmaherdev for
the suggestion ([#6]).
- Optional hour lines in the timeline. A new **Hour lines** switch (Settings →
Appearance) draws a faint separator at each hour in the week and day views,
making it easier to see when events start and end. Off by default. Thanks to
@zmaherdev for the suggestion ([#5]).
- Limit how far ahead the agenda looks, and switch it on the fly. New **Agenda
range** and **Agenda widget range** settings (Settings → Appearance) let the
agenda screen and its home-screen widget each show just today, the rest of this
week, the rest of this month, a rolling 7 or 30 days, or a custom number of
days — "This week" follows your week-start. The agenda also gains a bar at the
top naming the exact dates in view, with a button to switch the range just for
the current session (it resets when you reopen the app); the bar can be turned
off. Thanks to @zmaherdev for the suggestion ([#4]).
- Automatic backup of local calendars. A new **Automatic backup** option
(Settings → Calendars → Backup) periodically exports your local calendars to an
`.ics` file in a folder you choose, on an interval you set (from 30 minutes up).
Useful for file-based syncing such as Syncthing, or simply as a safety net — it
is a one-way export and never touches your synced accounts. Thanks to @shield
for the discussion that inspired it ([#7], [#8]).
- Field icons in the event-form settings. Each optional-field toggle (Settings →
Event form) now shows the same icon the field uses in the new-event form, so
the list is easier to scan.
- Help translate Calendula. A **Help translate** link at the top of **Settings →
App language** opens the project's Weblate, where you can add or improve a
language in your browser — no coding needed. Contributions in any language are
welcome.
### Changed
- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,
agenda), and the agenda's empty state now reads "You're all caught up".
- Reworked **Settings → Calendars**. Local and synced calendars are now grouped
into collapsible, source-branded cards — each account shows its app's icon —
with a per-account menu to enable or disable all of its calendars at once or
open the account in its source app.
- Reorganised **Settings → Notifications**: reliable-delivery and snooze settings
moved up with the other global reminder options, and the per-calendar reminder
overrides now fold into a single expandable section.
- Moved **Add Quick Settings tile** out of the event-form section to its own
top-level entry in Settings.
## [2.10.0] — 2026-06-25
### Added
- Turn a calendar off across the whole app. Settings → Calendars now has a
switch on every calendar — both your own and synced ones. Switching one off
removes it everywhere: its events disappear from all views and search, and it
drops out of the drawer's hide/show filter, the event-form calendar picker and
the import target picker. Unlike hiding (the quick per-view checkbox in the
drawer), a disabled calendar leaves the app entirely until you turn it back on.
Nothing is deleted and no other app is affected — it's a Calendula-only view
choice — so you can re-enable it any time from the same screen, where disabled
calendars stay listed but dimmed.
- An optional way to support development. Settings → About now has a "Support
development" button that opens Ko-fi in your browser. It's a plain donation
link with no perks attached, and it needs no new permissions — Calendula still
has no internet access of its own and just hands the link to your browser.
## [2.9.0] — 2026-06-25
### Added
- Choose the view Calendula opens on. A new **Default view** setting (Settings →
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
time you start the app, instead of always opening on Week. Thanks to
@devinside for the suggestion ([#1]).
- The month widget is now interactive: tap any day to open it, or tap an event to
open its details. Previously only the month's prev/next/today controls
responded. Thanks to @devinside for spotting this ([#2]).
- Predictive back. Swiping back from a screen — an event, the editor, search or
settings — now follows your finger, shrinking the screen to preview what's
behind so you can see where Back will take you before you let go; slide it back
to cancel. The preview needs Android 14 or newer; on older versions Back works
as before.
- Respect for "Remove animations". If you've turned animations off in your
device's Accessibility settings, Calendula now honours it everywhere: motion
collapses to a quick fade and the back preview is skipped.
### Changed
- Smoother, more consistent motion throughout. Expanding sections, list updates
in search and agenda, and the onboarding screens now animate the same way
across the app, instead of some places sliding or growing while others popped
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
snapping, while paging within a view keeps its slide.
### Fixed
- Home-screen widgets now back out the way you came in. A day or event opened
from the Agenda widget keeps you in the agenda context, and from the Month
widget in the month context — pressing Back returns there instead of dropping
you on the week view. More broadly, the app now keeps a real view history:
switching views and drilling into a day are retraced by Back one step at a
time, down to your default view before the app exits. Thanks to @devinside for
reporting ([#2]).
## [2.8.0] — 2026-06-23 ## [2.8.0] — 2026-06-23
### Added ### Added
@@ -572,3 +695,14 @@ automatically, with zero telemetry and no internet permission.
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner - Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
- F-Droid metadata stubs (DE + EN short/full descriptions) - F-Droid metadata stubs (DE + EN short/full descriptions)
- `.planning/` project-tracking documents - `.planning/` project-tracking documents
[#1]: https://codeberg.org/jlmakiola/calendula/issues/1
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2
[#3]: https://codeberg.org/jlmakiola/calendula/issues/3
[#4]: https://codeberg.org/jlmakiola/calendula/issues/4
[#5]: https://codeberg.org/jlmakiola/calendula/issues/5
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10

View File

@@ -15,6 +15,12 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
</p> </p>
<p>
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
&nbsp;
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
</p>
<p> <p>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">&nbsp;
@@ -62,19 +68,27 @@ database, no sync stack reinvented.
- Real Material 3 Expressive throughout — dynamic color (Android 12+), - Real Material 3 Expressive throughout — dynamic color (Android 12+),
expressive motion and shapes, light/dark theme expressive motion and shapes, light/dark theme
- German and English UI, per-app language setting - German and English UI, per-app language setting — and [open to community
translations](#-translations)
- **Zero telemetry, zero analytics, no internet permission** — your data - **Zero telemetry, zero analytics, no internet permission** — your data
never leaves the device never leaves the device
## 📦 Install ## 📦 Install
Calendula ships through a self-hosted F-Droid repository; every version tag ### F-Droid (recommended)
is built, signed, and published there automatically.
1. Install an F-Droid client ([F-Droid](https://f-droid.org), Droid-ify, Neo Calendula is on the **official [F-Droid](https://f-droid.org) repository**
Store, …). just search for **Calendula** in any F-Droid client, or
2. Add the repository — open this link on your phone, or paste it under [install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
*Settings → Repositories → Add*:
### Self-hosted repo (latest builds)
New versions are built, signed, and published to a self-hosted repository the
moment each tag lands — usually a few days ahead of the official repo, which
rebuilds on F-Droid's own schedule. Add it for the freshest builds:
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
link below on your phone):
``` ```
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425 https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
@@ -84,36 +98,27 @@ is built, signed, and published there automatically.
fingerprint (SHA-256): fingerprint (SHA-256):
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub> `C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
3. Refresh, search for **Calendula**, install. Updates arrive like any 2. Refresh, search for **Calendula**, install.
other F-Droid app.
Alternatively, build from source — see below. Both channels share the same signing key, so you can switch between them
without reinstalling. Or build from source — see below.
## 🛠 Building ## 📚 Documentation
Requires Android SDK 36+ and JDK 17. The Gradle wrapper is checked in: - **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
```bash ## 🌍 Translations
./gradlew assembleDebug # debug APK
./gradlew test # JVM unit tests
./gradlew lint # Android lint
```
If your default JDK is not 17, set `JAVA_HOME` explicitly. Calendula ships in German and English, and you're warmly invited to add your
language. Translations are managed on a self-hosted **Weblate**:
## 🏗 Architecture **→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
Single-activity Compose app, layered `UI → Repository → DataSource → No coding needed — register on the Weblate server, pick (or request) a language,
CalendarContract`, observer-driven refresh, JVM-first tests. The full tour — and translate the strings in your browser. You can also reach this link in the
including the recurring-write and reminder pipelines — lives in app from the top of **Settings → App language**.
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## 🗺 Roadmap
Shipped: read (v1.0), write (v1.1v2.0), reminder delivery (v1.4).
Next up: power-user features — widget, search, tablet layouts. The living
roadmap is in [.planning/ROADMAP.md](.planning/ROADMAP.md), the release
history in [CHANGELOG.md](CHANGELOG.md).
## 📜 License ## 📜 License

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 = 20800 versionCode = 21102
versionName = "2.8.0" versionName = "2.11.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -152,6 +152,9 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3) implementation(libs.androidx.glance.material3)

View File

@@ -39,6 +39,7 @@
<application <application
android:name=".CalendulaApp" android:name=".CalendulaApp"
android:allowBackup="true" android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -58,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

@@ -1,8 +1,16 @@
package de.jeanlucmakiola.calendula package de.jeanlucmakiola.calendula
import android.app.Application import android.app.Application
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/** /**
* Application entry point. Registered as android:name=".CalendulaApp" * Application entry point. Registered as android:name=".CalendulaApp"
@@ -16,5 +24,24 @@ class CalendulaApp : Application() {
// Install first thing so startup crashes are captured too (privacy- // Install first thing so startup crashes are captured too (privacy-
// respecting, on-device; the user submits the report by hand). // respecting, on-device; the user submits the report by hand).
CrashReporter.install(this) CrashReporter.install(this)
reconcileAutoBackup()
}
/**
* Bring the scheduled auto-backup work back in line with the saved settings
* on every launch — re-arms it after a reinstall and, crucially, cancels any
* orphaned work once backup has been turned off.
*/
private fun reconcileAutoBackup() {
val deps = EntryPointAccessors.fromApplication(this, BackupWorker.Deps::class.java)
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
val prefs = deps.settingsPrefs()
BackupScheduler.apply(
context = this@CalendulaApp,
enabled = prefs.autoBackupEnabled.first(),
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
hasFolder = prefs.autoBackupFolderUri.first() != null,
)
}
} }
} }

View File

@@ -9,10 +9,13 @@ 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
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.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.IntentCompat import androidx.core.content.IntentCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -20,14 +23,21 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
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.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.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 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() {
@@ -80,9 +90,20 @@ class MainActivity : AppCompatActivity() {
ThemeMode.LIGHT -> false ThemeMode.LIGHT -> false
ThemeMode.DARK -> true ThemeMode.DARK -> true
} }
// The app-wide clock convention: the time-format preference resolved
// against the device's 24-hour system setting, provided once here so
// every time label reads it via LocalUse24HourFormat.
val context = LocalContext.current
val use24Hour = remember(settings.timeFormat, context) {
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
}
CalendulaTheme( CalendulaTheme(
darkTheme = darkTheme, darkTheme = darkTheme,
dynamicColor = settings.dynamicColor, dynamicColor = settings.dynamicColor,
) {
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines,
) { ) {
RootScreen( RootScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -93,6 +114,7 @@ class MainActivity : AppCompatActivity() {
requestedImportUri = requestedImportUri, requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null }, onImportConsumed = { requestedImportUri = null },
) )
}
pendingCrashReport?.let { report -> pendingCrashReport?.let { report ->
CrashReportDialog( CrashReportDialog(
report = report, report = report,
@@ -137,21 +159,64 @@ 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" }
} }
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when { /**
* 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? {
// 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) }
val source = sourceViewOrNull()
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
return when {
// Launcher long-press "New event" shortcut. Static shortcut intents // Launcher long-press "New event" shortcut. Static shortcut intents
// can't carry typed extras, so the action alone signals create-on-today. // can't carry typed extras, so the action alone signals create-on-today.
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null) action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
getBooleanExtra(EXTRA_CREATE, false) -> getBooleanExtra(EXTRA_CREATE, false) ->
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO)) WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
getStringExtra(EXTRA_DATE_ISO) != null -> // A widget event tap carries both the occurrence and its source view;
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!) // reminders (no source) fall through to [detailKeyOrNull] instead.
source != null && eventId != -1L -> WidgetNavRequest.OpenEvent(
eventId = eventId,
beginMillis = getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
endMillis = getLongExtra(EXTRA_END_MILLIS, 0L),
source = source,
)
source != null && getStringExtra(EXTRA_DATE_ISO) != null ->
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!, source)
else -> null else -> null
} }
}
/** The view the launching widget represents, if any (see [EXTRA_SOURCE_VIEW]). */
private fun Intent.sourceViewOrNull(): CalendarView? =
getStringExtra(EXTRA_SOURCE_VIEW)
?.let { name -> CalendarView.entries.firstOrNull { it.name == name } }
private fun Intent.detailKeyOrNull(): LongArray? { private fun Intent.detailKeyOrNull(): LongArray? {
// A widget event tap (source present) is routed through [navRequestOrNull]
// so it can also set the base view; only sourceless reminder taps land here.
if (sourceViewOrNull() != null) return null
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L) val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
if (eventId == -1L) return null if (eventId == -1L) return null
return longArrayOf( return longArrayOf(
@@ -162,12 +227,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"
// 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).
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
// Fired by the launcher long-press "New event" shortcut (res/xml/ // Fired by the launcher long-press "New event" shortcut (res/xml/
// shortcuts.xml hardcodes this string — keep the two in sync). // shortcuts.xml hardcodes this string — keep the two in sync).
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT" const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
@@ -190,14 +263,35 @@ class MainActivity : AppCompatActivity() {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
/** Open the day view anchored on [date] (home-screen widgets). */ /**
fun openDateIntent(context: Context, date: LocalDate): Intent = * Open the day view anchored on [date], drilled in over the launching
* widget's [source] view (home-screen widgets). Backing out of the day
* returns to [source], then to the default home view.
*/
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
Intent(context, MainActivity::class.java).apply { Intent(context, MainActivity::class.java).apply {
data = "calendula://date/$date".toUri() data = "calendula://date/$date".toUri()
putExtra(EXTRA_DATE_ISO, date.toString()) putExtra(EXTRA_DATE_ISO, date.toString())
putExtra(EXTRA_SOURCE_VIEW, source.name)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
/**
* Open one occurrence's detail from a widget event tap, rooting the back
* stack in the widget's [source] view. Same occurrence-key shape as
* [eventDetailIntent]; the [source] extra is what distinguishes a widget
* tap (sets the base view) from a reminder tap (leaves it untouched).
*/
fun openEventIntent(
context: Context,
eventId: Long,
beginMillis: Long,
endMillis: Long,
source: CalendarView,
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
putExtra(EXTRA_SOURCE_VIEW, source.name)
}
/** Open the create-event form prefilled for [date] (home-screen widgets). */ /** Open the create-event form prefilled for [date] (home-screen widgets). */
fun openCreateIntent(context: Context, date: LocalDate): Intent = fun openCreateIntent(context: Context, date: LocalDate): Intent =
Intent(context, MainActivity::class.java).apply { Intent(context, MainActivity::class.java).apply {

View File

@@ -0,0 +1,176 @@
package de.jeanlucmakiola.calendula.data.backup
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
/**
* Schedules and runs the automatic periodic `.ics` backup of local calendars
* (issue #8). One-way export only — it mirrors the manual "Back up" into a
* user-chosen folder on an interval; it is not a sync. Stays INTERNET-free:
* everything is local provider reads + a local file write.
*/
object BackupScheduler {
private const val WORK_NAME = "auto-backup"
private const val WORK_NAME_NOW = "auto-backup-now"
/**
* Reconcile the scheduled work with the current settings: enqueue a unique
* periodic request when backup is on and a folder is set, otherwise cancel
* it. WorkManager persists the request across reboots on its own.
*
* The first run is delayed by one interval so it never overlaps an immediate
* [runNow] (two simultaneous writes would race and leave a "(1)" duplicate).
*/
fun apply(context: Context, enabled: Boolean, intervalMinutes: Long, hasFolder: Boolean) {
val workManager = WorkManager.getInstance(context)
if (!enabled || !hasFolder) {
workManager.cancelUniqueWork(WORK_NAME)
// Also cancel any pending immediate run — otherwise a failing run-now
// keeps retrying after the user has turned backup off.
workManager.cancelUniqueWork(WORK_NAME_NOW)
return
}
// WorkManager's own floor is 15 min; the UI floors the user choice at 30.
val interval = intervalMinutes.coerceAtLeast(15L)
val request = PeriodicWorkRequestBuilder<BackupWorker>(interval, TimeUnit.MINUTES)
.setInitialDelay(interval, TimeUnit.MINUTES)
.build()
workManager.enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
}
/**
* Run one export immediately (e.g. right after enabling or changing the
* folder) for instant feedback. Unique + REPLACE so rapid taps coalesce into
* a single run rather than racing each other.
*/
fun runNow(context: Context) {
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_NAME_NOW,
ExistingWorkPolicy.REPLACE,
OneTimeWorkRequestBuilder<BackupWorker>().build(),
)
}
}
/**
* Exports the local calendars to `calendula-backup.ics` in the configured folder,
* overwriting the previous file. Pulls its collaborators through a Hilt
* [EntryPoint] so it works under WorkManager's default (no-arg) worker factory —
* no custom factory / Application wiring needed. Records the outcome for the
* settings status line, and notifies after repeated failures.
*/
class BackupWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun settingsPrefs(): SettingsPrefs
fun repository(): CalendarRepository
fun exporter(): IcsExporter
}
override suspend fun doWork(): Result {
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
val prefs = deps.settingsPrefs()
// Respect the toggle even for already-queued work: if backup was turned
// off, no-op (and don't retry) so a lingering run can't revive itself.
if (!prefs.autoBackupEnabled.first()) return Result.success()
val folder = prefs.autoBackupFolderUri.first()
?: return Result.failure() // nothing to write to — leave scheduling to settings
val now = System.currentTimeMillis()
return try {
val events = deps.repository().exportEvents()
val content = IcsWriter().writeCalendar(events, Clock.System.now())
deps.exporter().writeToFolder(folder.toUri(), BACKUP_FILE_NAME, content)
prefs.recordAutoBackupRun(success = true, atMillis = now)
Result.success()
} catch (e: Exception) {
prefs.recordAutoBackupRun(success = false, atMillis = now)
if (prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) {
notifyFailure(applicationContext)
}
Log.w(TAG, "Automatic backup failed", e)
Result.retry()
}
}
private fun notifyFailure(context: Context) {
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!canPost) return
val manager = NotificationManagerCompat.from(context)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.backup_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply { description = context.getString(R.string.backup_channel_description) },
)
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
val tap = launch?.let {
PendingIntent.getActivity(
context, 0, it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.backup_failed_title))
.setContentText(context.getString(R.string.backup_failed_text))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setAutoCancel(true)
.apply { tap?.let(::setContentIntent) }
.build()
try {
manager.notify(NOTIFICATION_ID, notification)
} catch (e: SecurityException) {
Log.w(TAG, "Could not post backup-failure notification", e)
}
}
companion object {
const val BACKUP_FILE_NAME = "calendula-backup.ics"
private const val FAILURE_NOTIFY_THRESHOLD = 2
private const val CHANNEL_ID = "backup"
private const val NOTIFICATION_ID = 2
private const val TAG = "BackupWorker"
}
}

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
@@ -55,11 +56,12 @@ class CalendarRepositoryImpl @Inject constructor(
.reQuery { dataSource.calendars() } .reQuery { dataSource.calendars() }
.flowOn(io) .flowOn(io)
// Instances are filtered by the app-side hidden-calendar set (M3): an event // Instances are filtered by the app-side hidden disabled calendar sets
// is dropped whenever the user has hidden its calendar. Re-runs when the // (M3): an event is dropped whenever the user has hidden *or* disabled its
// provider ticks *or* the hidden set changes — toggling a calendar in the // calendar. Re-runs when the provider ticks *or* either set changes —
// filter sheet updates every view immediately. [calendars] stays unfiltered // toggling a calendar in the filter sheet or the calendar manager updates
// so the filter sheet can list and re-enable hidden calendars. // every view immediately. [calendars] stays unfiltered so those screens can
// list and re-enable hidden/disabled calendars.
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> = override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
combine( combine(
ticks ticks
@@ -71,10 +73,17 @@ class CalendarRepositoryImpl @Inject constructor(
) )
}, },
prefs.hiddenCalendarIds, prefs.hiddenCalendarIds,
) { instances, hidden -> prefs.disabledCalendarIds,
if (hidden.isEmpty()) instances ) { instances, hidden, disabled ->
else instances.filterNot { it.calendarId in hidden } val excluded = hidden + disabled
}.flowOn(io) if (excluded.isEmpty()) instances
else instances.filterNot { it.calendarId in excluded }
}
// hidden and disabled both derive from one DataStore, so toggling
// either makes both re-emit and combine briefly surfaces the same
// list twice — collapse the duplicate so views don't re-render for it.
.distinctUntilChanged()
.flowOn(io)
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) { override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId) dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
@@ -82,9 +91,9 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) { override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
if (query.isBlank()) return@withContext emptyList() if (query.isBlank()) return@withContext emptyList()
val hidden = prefs.hiddenCalendarIds.first() val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
dataSource.searchEvents(query) dataSource.searchEvents(query)
.let { if (hidden.isEmpty()) it else it.filterNot { e -> e.calendarId in hidden } } .let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
} }
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> = override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.ics
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.documentfile.provider.DocumentFile
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
@@ -28,6 +29,36 @@ class IcsExporter @Inject constructor(
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})") } ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
} }
/**
* Write [content] to [fileName] inside the persisted SAF tree [folder],
* overwriting it if it already exists (the automatic-backup destination).
* Requires a persisted write grant on [folder]. Throws on failure.
*/
fun writeToFolder(folder: Uri, fileName: String, content: String) {
val dir = DocumentFile.fromTreeUri(context, folder)
?: throw IOException("Backup folder is not accessible")
if (!dir.exists() || !dir.canWrite()) {
throw IOException("Backup folder is missing or not writable")
}
// Reuse the canonical file if present and clean up any "name (1).ics"
// duplicates SAF may have created if two runs ever raced — so we always
// converge on a single overwritten file.
val base = fileName.substringBeforeLast('.')
val ext = fileName.substringAfterLast('.', "")
var target: DocumentFile? = null
for (child in dir.listFiles()) {
val name = child.name ?: continue
when {
name == fileName -> target = child
name.startsWith("$base (") && name.endsWith(".$ext") -> child.delete()
}
}
val file = target
?: dir.createFile(MIME_CALENDAR, fileName)
?: throw IOException("Could not create backup file in the chosen folder")
writeDocument(file.uri, content)
}
/** /**
* Stage [content] in a private cache file and return a shareable content * Stage [content] in a private cache file and return a shareable content
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to * Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
@@ -42,5 +73,6 @@ class IcsExporter @Inject constructor(
private companion object { private companion object {
const val SHARE_DIR = "shared_ics" const val SHARE_DIR = "shared_ics"
const val MIME_CALENDAR = "text/calendar"
} }
} }

View File

@@ -39,6 +39,31 @@ class CalendarPrefs @Inject constructor(
} }
} }
/**
* App-side preference for "calendars the user has disabled in this app" — a
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from
* every surface (drawer filter, event-form picker, import picker) and its
* events never appear; it stays listed only in Settings → Calendars so it can
* be re-enabled. Stored exactly like the hidden set; never touches the
* system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected.
*/
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
prefs[DISABLED_IDS_KEY].orEmpty()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.toSet()
}
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
store.edit { prefs ->
if (ids.isEmpty()) {
prefs.remove(DISABLED_IDS_KEY)
} else {
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
}
}
}
/** /**
* The calendar the user last created an event in; preselected in the * The calendar the user last created an event in; preselected in the
* event form. Null until the first event is created. * event form. Null until the first event is created.
@@ -53,6 +78,7 @@ class CalendarPrefs @Inject constructor(
companion object { companion object {
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids") internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id") internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
} }
} }

View File

@@ -5,8 +5,13 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
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.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
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
@@ -18,18 +23,39 @@ import javax.inject.Singleton
/** Light/dark override. SYSTEM follows the device setting. */ /** Light/dark override. SYSTEM follows the device setting. */
enum class ThemeMode { SYSTEM, LIGHT, DARK } enum class ThemeMode { SYSTEM, LIGHT, DARK }
/** Week-start override. AUTO derives the first day from the active locale. */ /**
enum class WeekStartPref { AUTO, MONDAY, SUNDAY } * Week-start override. [Auto] derives the first day from the active locale;
* [Day] pins a specific weekday (any of the seven).
*/
sealed interface WeekStartPref {
data object Auto : WeekStartPref
data class Day(val day: DayOfWeek) : WeekStartPref
}
/** /**
* Resolve the preference to a concrete first-day-of-week. AUTO reads the * Clock convention for time-of-day labels. AUTO follows the device's 24-hour
* locale's convention (e.g. Monday in DE, Sunday in en-US). * system setting; the others force a 12- or 24-hour clock app-wide.
*/
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
/**
* Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the
* device's `DateFormat.is24HourFormat` value).
*/
fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
TimeFormatPref.AUTO -> systemIs24Hour
TimeFormatPref.TWELVE_HOUR -> false
TimeFormatPref.TWENTY_FOUR_HOUR -> true
}
/**
* Resolve the preference to a concrete first-day-of-week. [WeekStartPref.Auto]
* reads the locale's convention (e.g. Monday in DE, Sunday in en-US).
*/ */
fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) { fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
WeekStartPref.MONDAY -> DayOfWeek.MONDAY is WeekStartPref.Day -> day
WeekStartPref.SUNDAY -> DayOfWeek.SUNDAY
// java.time.DayOfWeek.value is ISO 1..7 (Mon..Sun) — same numbering kotlinx uses. // java.time.DayOfWeek.value is ISO 1..7 (Mon..Sun) — same numbering kotlinx uses.
WeekStartPref.AUTO -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value) WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
} }
/** /**
@@ -54,7 +80,7 @@ class SettingsPrefs @Inject constructor(
} }
val weekStart: Flow<WeekStartPref> = store.data.map { prefs -> val weekStart: Flow<WeekStartPref> = store.data.map { prefs ->
prefs[WEEK_START_KEY].toEnum(WeekStartPref.AUTO) parseWeekStart(prefs[WEEK_START_KEY])
} }
suspend fun setThemeMode(mode: ThemeMode) { suspend fun setThemeMode(mode: ThemeMode) {
@@ -66,7 +92,76 @@ class SettingsPrefs @Inject constructor(
} }
suspend fun setWeekStart(pref: WeekStartPref) { suspend fun setWeekStart(pref: WeekStartPref) {
store.edit { it[WEEK_START_KEY] = pref.name } store.edit { it[WEEK_START_KEY] = pref.storageValue() }
}
/** Clock convention for time-of-day labels (v2.11). Defaults to AUTO (system). */
val timeFormat: Flow<TimeFormatPref> = store.data.map { prefs ->
prefs[TIME_FORMAT_KEY].toEnum(TimeFormatPref.AUTO)
}
suspend fun setTimeFormat(pref: TimeFormatPref) {
store.edit { it[TIME_FORMAT_KEY] = pref.name }
}
/**
* Whether the week/day timeline draws a faint separator line at each hour
* (v2.11). Defaults to OFF — the historical clean look; users opt in.
*/
val showHourLines: Flow<Boolean> = store.data.map { prefs ->
prefs[SHOW_HOUR_LINES_KEY] ?: false
}
suspend fun setShowHourLines(enabled: Boolean) {
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
}
/**
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
* [AgendaRange.Month] — a month of upcoming events. Independent of the
* widget's [agendaWidgetRange].
*/
val agendaScreenRange: Flow<AgendaRange> = store.data.map { prefs ->
parseAgendaRange(prefs[AGENDA_SCREEN_RANGE_KEY], AgendaRange.Month)
}
suspend fun setAgendaScreenRange(range: AgendaRange) {
store.edit { it[AGENDA_SCREEN_RANGE_KEY] = range.storageValue() }
}
/** How far ahead the agenda **widget** shows events (v2.11). Defaults to Month. */
val agendaWidgetRange: Flow<AgendaRange> = store.data.map { prefs ->
parseAgendaRange(prefs[AGENDA_WIDGET_RANGE_KEY], AgendaRange.Month)
}
suspend fun setAgendaWidgetRange(range: AgendaRange) {
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
}
/**
* Whether the agenda shows its top range bar — the "showing …" header and
* the session range switcher (v2.11). Default ON.
*/
val agendaShowRangeBar: Flow<Boolean> = store.data.map { prefs ->
prefs[AGENDA_SHOW_RANGE_BAR_KEY] ?: true
}
suspend fun setAgendaShowRangeBar(enabled: Boolean) {
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
}
/**
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
* the historical hard-coded startup view — so existing users see no change
* until they pick another. Also the bottom of the in-app view back stack, so
* back from any other view returns here before exiting.
*/
val defaultView: Flow<CalendarView> = store.data.map { prefs ->
prefs[DEFAULT_VIEW_KEY].toEnum(CalendarView.Week)
}
suspend fun setDefaultView(view: CalendarView) {
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
} }
/** /**
@@ -87,6 +182,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
@@ -233,6 +342,52 @@ class SettingsPrefs @Inject constructor(
} }
} }
// --- Automatic backup (issue #8) ------------------------------------
/** Whether periodic automatic `.ics` export of local calendars is on. Default OFF. */
val autoBackupEnabled: Flow<Boolean> = store.data.map { it[AUTO_BACKUP_ENABLED_KEY] ?: false }
suspend fun setAutoBackupEnabled(enabled: Boolean) {
store.edit { it[AUTO_BACKUP_ENABLED_KEY] = enabled }
}
/** Export interval in minutes. Default daily; floored at [MIN_BACKUP_INTERVAL]. */
val autoBackupIntervalMinutes: Flow<Long> = store.data.map { prefs ->
(prefs[AUTO_BACKUP_INTERVAL_KEY] ?: DEFAULT_BACKUP_INTERVAL).coerceAtLeast(MIN_BACKUP_INTERVAL)
}
suspend fun setAutoBackupIntervalMinutes(minutes: Long) {
store.edit { it[AUTO_BACKUP_INTERVAL_KEY] = minutes.coerceAtLeast(MIN_BACKUP_INTERVAL) }
}
/** Persisted SAF tree Uri of the destination folder, or null if unset. */
val autoBackupFolderUri: Flow<String?> = store.data.map { it[AUTO_BACKUP_FOLDER_KEY] }
suspend fun setAutoBackupFolderUri(uri: String?) {
store.edit { prefs ->
if (uri == null) prefs.remove(AUTO_BACKUP_FOLDER_KEY) else prefs[AUTO_BACKUP_FOLDER_KEY] = uri
}
}
/** Outcome of the last automatic run, for the settings status line. */
val autoBackupStatus: Flow<BackupStatus> = store.data.map { prefs ->
BackupStatus(
lastRun = prefs[AUTO_BACKUP_LAST_RUN_KEY] ?: 0L,
lastSuccess = prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] ?: true,
consecutiveFailures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0,
)
}
/** Record an automatic run's outcome; resets the failure streak on success. */
suspend fun recordAutoBackupRun(success: Boolean, atMillis: Long) {
store.edit { prefs ->
prefs[AUTO_BACKUP_LAST_RUN_KEY] = atMillis
prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] = success
val failures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0
prefs[AUTO_BACKUP_FAILURES_KEY] = if (success) 0 else failures + 1
}
}
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) { private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
null -> DEFAULT_FORM_FIELDS null -> DEFAULT_FORM_FIELDS
else -> stored.split(',') else -> stored.split(',')
@@ -244,7 +399,14 @@ class SettingsPrefs @Inject constructor(
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 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_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
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 =
@@ -266,9 +428,27 @@ class SettingsPrefs @Inject constructor(
stringPreferencesKey("per_calendar_allday_reminder_override") stringPreferencesKey("per_calendar_allday_reminder_override")
internal val DEFAULT_FORM_FIELDS = internal val DEFAULT_FORM_FIELDS =
setOf(EventFormField.Location, EventFormField.Description) setOf(EventFormField.Location, EventFormField.Description)
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
internal val AUTO_BACKUP_LAST_RUN_KEY = longPreferencesKey("auto_backup_last_run")
internal val AUTO_BACKUP_LAST_SUCCESS_KEY = booleanPreferencesKey("auto_backup_last_success")
internal val AUTO_BACKUP_FAILURES_KEY = intPreferencesKey("auto_backup_failures")
/** Default automatic-backup interval: daily. */
const val DEFAULT_BACKUP_INTERVAL = 1_440L
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
const val MIN_BACKUP_INTERVAL = 30L
} }
} }
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
data class BackupStatus(
/** Epoch millis of the last run, or 0 if it has never run. */
val lastRun: Long,
val lastSuccess: Boolean,
val consecutiveFailures: Int,
)
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */ /** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
sealed interface CalendarReminderOverride { sealed interface CalendarReminderOverride {
/** No override — the calendar uses the global default. */ /** No override — the calendar uses the global default. */
@@ -315,6 +495,25 @@ private fun MutableMap<Long, Int?>.applyOverride(
} }
} }
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
private const val WEEK_START_AUTO = "AUTO"
private fun WeekStartPref.storageValue(): String = when (this) {
WeekStartPref.Auto -> WEEK_START_AUTO
is WeekStartPref.Day -> day.name
}
/**
* Parse the stored week-start value. "AUTO"/null/garbage → [WeekStartPref.Auto];
* a [DayOfWeek] name → [WeekStartPref.Day]. The legacy "MONDAY"/"SUNDAY" enum
* values migrate transparently, since both are valid day names.
*/
private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) {
null, WEEK_START_AUTO -> WeekStartPref.Auto
else -> DayOfWeek.entries.firstOrNull { it.name == stored }
?.let { WeekStartPref.Day(it) } ?: WeekStartPref.Auto
}
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 = "="

View File

@@ -46,7 +46,7 @@ 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) due.forEach { notifier.post(it) }
alertStore.markFired(due.map { it.alertId }, now) alertStore.markFired(due.map { it.alertId }, now)
} }
} finally { } finally {

View File

@@ -14,6 +14,9 @@ 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.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import kotlinx.coroutines.flow.first
import java.time.ZoneId import java.time.ZoneId
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@@ -29,6 +32,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class ReminderNotifier @Inject constructor( class ReminderNotifier @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val settingsPrefs: SettingsPrefs,
) { ) {
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */ /** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
@@ -39,15 +43,18 @@ class ReminderNotifier @Inject constructor(
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled() return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
} }
fun post(alert: ReminderAlert) { suspend fun post(alert: ReminderAlert) {
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()
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
val time = reminderTimeText( val time = reminderTimeText(
beginMillis = alert.beginMillis, beginMillis = alert.beginMillis,
endMillis = alert.endMillis, endMillis = alert.endMillis,
isAllDay = alert.isAllDay, isAllDay = alert.isAllDay,
zone = ZoneId.systemDefault(), zone = ZoneId.systemDefault(),
locale = Locale.getDefault(), locale = Locale.getDefault(),
is24Hour = is24Hour,
) )
val text = listOfNotNull(time, alert.location).joinToString(" · ") val text = listOfNotNull(time, alert.location).joinToString(" · ")
val notification = NotificationCompat.Builder(context, CHANNEL_ID) val notification = NotificationCompat.Builder(context, CHANNEL_ID)

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.data.reminders package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -25,6 +26,7 @@ fun reminderTimeText(
isAllDay: Boolean, isAllDay: Boolean,
zone: ZoneId, zone: ZoneId,
locale: Locale, locale: Locale,
is24Hour: Boolean,
): String { ): String {
if (isAllDay) { if (isAllDay) {
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
@@ -40,16 +42,18 @@ fun reminderTimeText(
} }
} }
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) val timeFormat = timeOfDayFormatter(is24Hour, locale)
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone) val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
val end = Instant.ofEpochMilli(endMillis).atZone(zone) val end = Instant.ofEpochMilli(endMillis).atZone(zone)
return if (begin.toLocalDate() == end.toLocalDate()) { return if (begin.toLocalDate() == end.toLocalDate()) {
timeFormat.format(begin) + RANGE + timeFormat.format(end) timeFormat.format(begin) + RANGE + timeFormat.format(end)
} else { } else {
val dateTimeFormat = DateTimeFormatter // Cross-day: medium date + the chosen short time, joined per side. Built
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) // from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
.withLocale(locale) // applies to the time portion too.
dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end) val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
dateTime(begin) + RANGE + dateTime(end)
} }
} }

View File

@@ -1,5 +1,7 @@
package de.jeanlucmakiola.calendula.ui package de.jeanlucmakiola.calendula.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -12,15 +14,22 @@ 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
import androidx.compose.runtime.saveable.listSaver
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.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.calendula.ui.common.calendarFadeThrough
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
import de.jeanlucmakiola.calendula.ui.day.DayScreen import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
@@ -35,14 +44,24 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
/** /**
* Holds the active top-level view (spec M1) and swaps between the calendar * Holds the top-level view back stack (spec M1) and swaps between the calendar
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher * screens. Each screen owns its own ViewModel and date anchor; the view-switcher
* pill in their top bars writes back here via [onSelectView]. * pill in their top bars writes back here via [onSelectView].
* *
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
* A lateral switch (pill / drawer) builds a visit history so back retraces it
* (see [selectView]); a widget launch resets the stack to its own view; a date
* tap drills the day view on top. Pressing back pops one level, and the
* base-level [BackHandler] hands off to the system to exit once only the home
* view remains. So back from a widget-opened screen returns to that widget's
* view, then home; and back through pill switches walks the views in reverse.
*
* [requestedDetailKey] is an externally requested occurrence (a tapped * [requestedDetailKey] is an externally requested occurrence (a tapped
* reminder notification routed through MainActivity): it opens the detail * reminder notification routed through MainActivity): it opens the detail
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed] * overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
* so a later recomposition can't re-open it. * so a later recomposition can't re-open it. (A widget event tap instead arrives
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
* widget's view.)
*/ */
@Composable @Composable
fun CalendarHost( fun CalendarHost(
@@ -53,15 +72,24 @@ fun CalendarHost(
onWidgetNavConsumed: () -> Unit = {}, onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null, requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {}, onImportConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(),
) { ) {
var view by rememberSaveable { mutableStateOf(CalendarView.Week) } // Wait for the persisted default view before seeding the stack, so the app
val onSelectView: (CalendarView) -> Unit = { view = it } // opens straight on the user's choice instead of flashing a placeholder and
// correcting it. Brief blank first frame, matching the onboarding gate above.
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView))
}
val view = viewStack.last()
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
// Tapping a day in the month grid opens the day view anchored to that date. // Tapping a day in the month grid opens the day view anchored to that date.
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) } var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
val onOpenDay: (LocalDate) -> Unit = { date -> val onOpenDay: (LocalDate) -> Unit = { date ->
pendingDayIso = date.toString() pendingDayIso = date.toString()
view = CalendarView.Day viewStack = viewStack.drillToDay()
} }
// The event-detail screen (S4) is a full-screen destination hoisted here so // The event-detail screen (S4) is a full-screen destination hoisted here so
@@ -156,17 +184,32 @@ fun CalendarHost(
importForm = null importForm = null
} }
// A home-screen widget launch asks to open a date (→ day view) or start a // A home-screen widget launch asks to open a date (→ day view), open an
// create. Handled once and cleared, mirroring [requestedDetailKey]. // event's detail, or start a create. Handled once and cleared, mirroring
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
// view so backing out returns there (then home), not to the default.
LaunchedEffect(widgetNavRequest) { LaunchedEffect(widgetNavRequest) {
when (val req = widgetNavRequest) { when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> { is WidgetNavRequest.OpenDate -> {
// Reveal the day view: drop any overlay that would cover it, so // Drill the day view in over the widget's view: drop any overlay
// an external date-open doesn't land under an open Settings/form. // that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays() dismissCoveringOverlays()
createDateIso = null createDateIso = null
pendingDayIso = req.dateIso pendingDayIso = req.dateIso
view = CalendarView.Day // 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()
}
is WidgetNavRequest.OpenEvent -> {
// Root the stack in the widget's view, then open the occurrence
// detail over it (same key shape as a tapped event / reminder).
dismissCoveringOverlays()
createDateIso = null
viewStack = viewBaseStack(defaultView, req.source)
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
heldKey = key
detailKey = key
onWidgetNavConsumed() onWidgetNavConsumed()
} }
is WidgetNavRequest.Create -> { is WidgetNavRequest.Create -> {
@@ -189,10 +232,29 @@ fun CalendarHost(
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
// Base-level back: pop the view stack while no overlay covers it (each overlay
// owns its own BackHandler and takes precedence). Disabled at the home view,
// so back there falls through to the system and exits the app.
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
editKey != null || showSettings || showCalendars || importUri != null ||
importForm != null
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
viewStack = viewStack.dropLast(1)
}
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
when (view) { // Switching between the peer views (month/week/day/agenda) is lateral
// navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. AnimatedContent keyed on the view type.
val viewSwitch = calendarFadeThrough()
AnimatedContent(
targetState = view,
transitionSpec = { viewSwitch },
label = "view-switch",
) { currentView ->
when (currentView) {
CalendarView.Week -> WeekScreen( CalendarView.Week -> WeekScreen(
selectedView = view, selectedView = currentView,
onSelectView = onSelectView, onSelectView = onSelectView,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
@@ -200,7 +262,7 @@ fun CalendarHost(
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
) )
CalendarView.Day -> DayScreen( CalendarView.Day -> DayScreen(
selectedView = view, selectedView = currentView,
onSelectView = onSelectView, onSelectView = onSelectView,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
@@ -209,7 +271,7 @@ fun CalendarHost(
initialDateIso = pendingDayIso, initialDateIso = pendingDayIso,
) )
CalendarView.Month -> MonthScreen( CalendarView.Month -> MonthScreen(
selectedView = view, selectedView = currentView,
onSelectView = onSelectView, onSelectView = onSelectView,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
@@ -217,7 +279,7 @@ fun CalendarHost(
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
) )
CalendarView.Agenda -> AgendaScreen( CalendarView.Agenda -> AgendaScreen(
selectedView = view, selectedView = currentView,
onSelectView = onSelectView, onSelectView = onSelectView,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
@@ -225,6 +287,7 @@ fun CalendarHost(
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
) )
} }
}
// Search overlay — below detail/edit in the Box so a tapped result's // Search overlay — below detail/edit in the Box so a tapped result's
// detail screen draws on top, and closing it returns to the results. // detail screen draws on top, and closing it returns to the results.
@@ -338,3 +401,9 @@ fun CalendarHost(
} }
} }
} }
/** Persists the view back stack across config change / process death by ordinal. */
private val viewStackSaver = listSaver<List<CalendarView>, Int>(
save = { stack -> stack.map(CalendarView::ordinal) },
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
)

View File

@@ -0,0 +1,29 @@
package de.jeanlucmakiola.calendula.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
/**
* Supplies [CalendarHost] with the user's default startup view (M1). Held as a
* nullable [StateFlow] so the host can wait for DataStore's first emission before
* seeding its view back stack — rendering nothing for that first frame rather
* than flashing the old hard-coded Week view and then correcting it.
*/
@HiltViewModel
class CalendarHostViewModel @Inject constructor(
prefs: SettingsPrefs,
) : ViewModel() {
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
}

View File

@@ -2,6 +2,9 @@ package de.jeanlucmakiola.calendula.ui
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.compose.animation.Crossfade
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -20,6 +23,7 @@ import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun RootScreen( fun RootScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -51,13 +55,19 @@ fun RootScreen(
onDispose { lifecycle.removeObserver(obs) } onDispose { lifecycle.removeObserver(obs) }
} }
if (hasPermission) { // Cross-fade the one-time onboarding gates so granting permission / finishing
// onboarding eases into the next screen instead of snapping. A fade carries no
// spatial motion, so it stays appropriate under "remove animations" too.
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
if (granted) {
// Second onboarding gate (v1.4, one-time): reminder notifications. // Second onboarding gate (v1.4, one-time): reminder notifications.
// Null until DataStore's first emission — render nothing for that // Null until DataStore's first emission — render nothing for that
// frame instead of flashing the wrong screen. // frame instead of flashing the wrong screen.
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel() val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle() val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
when (onboardingDone) { Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
when (done) {
true -> CalendarHost( true -> CalendarHost(
modifier = modifier, modifier = modifier,
requestedDetailKey = requestedDetailKey, requestedDetailKey = requestedDetailKey,
@@ -73,10 +83,12 @@ fun RootScreen(
) )
null -> {} null -> {}
} }
}
} else { } else {
PermissionScreen( PermissionScreen(
onGranted = { hasPermission = true }, onGranted = { hasPermission = true },
modifier = modifier, modifier = modifier,
) )
} }
}
} }

View File

@@ -1,14 +1,33 @@
package de.jeanlucmakiola.calendula.ui package de.jeanlucmakiola.calendula.ui
import de.jeanlucmakiola.calendula.ui.common.CalendarView
/** /**
* A navigation a home-screen widget asked the app to perform when launched. * A navigation a home-screen widget asked the app to perform when launched.
* Parsed from the launch intent in MainActivity and consumed once by * Parsed from the launch intent in MainActivity and consumed once by
* [CalendarHost] (event taps reuse the existing reminder detail-key channel, so * [CalendarHost]. Every request carries the [source] view of the widget it came
* they are not modelled here). * from (the agenda widget → [CalendarView.Agenda], the month widget →
* [CalendarView.Month]) so the in-app back stack roots itself in that view:
* backing out of the opened date/event returns to the widget's own view, not the
* default home. (Reminder notifications are not widgets — they keep the separate
* 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). */ /**
data class OpenDate(val dateIso: String) : 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]. */
data class OpenEvent(
val eventId: Long,
val beginMillis: Long,
val endMillis: Long,
val source: CalendarView,
) : 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

View File

@@ -0,0 +1,111 @@
package de.jeanlucmakiola.calendula.ui.agenda
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
* How far ahead the agenda (screen or widget) shows events, starting at today.
*
* Two flavours:
* - **Rolling** windows of a fixed length: [Day] (today), [Week] (7 days),
* [Month] (30 days), [Custom] (1365 days). These never degenerate.
* - **Calendar-aligned** windows that end at a period boundary: [ThisWeek] runs
* through the end of the current week (respecting the week-start preference —
* a Monday start means "everything before next Monday"); [ThisMonth] runs
* through the last day of the current month. These shrink as the period ends.
*/
sealed interface AgendaRange {
data object Day : AgendaRange
data object Week : AgendaRange
data object Month : AgendaRange
data object ThisWeek : AgendaRange
data object ThisMonth : AgendaRange
data class Custom(val days: Int) : AgendaRange
companion object {
/** Allowed bounds for a [Custom] day count. */
const val MIN_CUSTOM_DAYS = 1
const val MAX_CUSTOM_DAYS = 365
}
}
private const val DAYS_PER_WEEK = 7
/**
* Inclusive number of days the window spans, starting at (and including)
* [anchor]. The calendar-aligned ranges depend on the anchor day and, for
* [AgendaRange.ThisWeek], the [weekStart] preference.
*/
fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (this) {
AgendaRange.Day -> 1
AgendaRange.Week -> DAYS_PER_WEEK
AgendaRange.Month -> 30
AgendaRange.ThisWeek -> {
// Days elapsed since the week's first day (0 when today *is* the start),
// so the window is the rest of the week through the day before it repeats.
val sinceWeekStart = ((anchor.dayOfWeek.ordinal - weekStart.ordinal) + DAYS_PER_WEEK) % DAYS_PER_WEEK
DAYS_PER_WEEK - sinceWeekStart
}
AgendaRange.ThisMonth -> {
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth()
daysInMonth - anchor.day + 1
}
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
}
/** Stored representation: a fixed token, or `CUSTOM:<days>`. */
fun AgendaRange.storageValue(): String = when (this) {
AgendaRange.Day -> "DAY"
AgendaRange.Week -> "WEEK"
AgendaRange.Month -> "MONTH"
AgendaRange.ThisWeek -> "THIS_WEEK"
AgendaRange.ThisMonth -> "THIS_MONTH"
is AgendaRange.Custom -> "$CUSTOM_PREFIX$days"
}
/** Parse a stored value; unknown/garbage falls back to [default]. */
fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when {
stored == "DAY" -> AgendaRange.Day
stored == "WEEK" -> AgendaRange.Week
stored == "MONTH" -> AgendaRange.Month
stored == "THIS_WEEK" -> AgendaRange.ThisWeek
stored == "THIS_MONTH" -> AgendaRange.ThisMonth
stored != null && stored.startsWith(CUSTOM_PREFIX) -> {
val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull()
if (days != null) {
AgendaRange.Custom(days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS))
} else {
default
}
}
else -> default
}
/**
* The concrete span the range covers, starting at [start] through [end]
* (inclusive), for a human-readable header:
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
* - everything else → "start end" ("27 Jun 3 Jul 2026")
*/
fun agendaRangeWindowSummary(
range: AgendaRange,
start: LocalDate,
end: LocalDate,
locale: Locale,
): String {
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
return when (range) {
AgendaRange.Day -> medium.format(javaStart)
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} ${medium.format(javaEnd)}"
}
}
private const val CUSTOM_PREFIX = "CUSTOM:"

View File

@@ -2,22 +2,26 @@ package de.jeanlucmakiola.calendula.ui.agenda
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
@@ -35,7 +39,10 @@ import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
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.clip import androidx.compose.ui.draw.clip
@@ -47,6 +54,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
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
@@ -57,6 +67,9 @@ 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.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -87,11 +100,13 @@ fun AgendaScreen(
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) }
val isOnToday = when (val s = state) { val isOnToday = when (val s = state) {
is AgendaUiState.Success -> s.anchor == s.today is AgendaUiState.Success -> s.anchor == s.today
else -> true else -> true
} }
val successState = state as? AgendaUiState.Success
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -134,16 +149,127 @@ fun AgendaScreen(
) )
}, },
) { innerPadding -> ) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
// One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
) {
AgendaRangeBanner(
range = s.range,
start = s.anchor,
end = s.rangeEnd,
modifier = Modifier.weight(1f),
)
AgendaRangePill(
range = s.range,
isOverride = s.rangeIsOverride,
onClick = { showRangePicker = true },
)
}
}
AgendaContent( AgendaContent(
state = state, state = state,
onRetry = viewModel::goToToday, onRetry = viewModel::goToToday,
onEventClick = onEventClick, onEventClick = onEventClick,
modifier = Modifier modifier = Modifier
.padding(innerPadding) .weight(1f)
.fillMaxSize(), .fillMaxWidth(),
) )
} }
} }
}
if (showRangePicker) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_range),
description = stringResource(R.string.agenda_range_override_hint),
selected = successState?.range ?: AgendaRange.Month,
onSelect = viewModel::setRangeOverride,
onDismiss = { showRangePicker = false },
)
}
}
/**
* A compact tonal pill showing the agenda's current range. Tapping it opens the
* range picker as a session-only override. Filled with the primary container
* while an override is active, so the temporary state is obvious.
*/
@Composable
private fun AgendaRangePill(
range: AgendaRange,
isOverride: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val container = if (isOverride) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
val content = if (isOverride) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
color = container,
contentColor = content,
shape = RoundedCornerShape(50),
modifier = modifier.clickable(onClick = onClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Icon(
imageVector = Icons.Filled.DateRange,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = agendaRangeLabel(range),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
/**
* A header naming the concrete window currently shown, e.g. "Showing all events
* for · Today, 27 Jun 2026" / "This week, 27 Jun 3 Jul" / "This month, June 2026".
*/
@Composable
private fun AgendaRangeBanner(
range: AgendaRange,
start: LocalDate,
end: LocalDate,
modifier: Modifier = Modifier,
) {
val locale = currentLocale()
val window = agendaRangeWindowSummary(range, start, end, locale)
Column(modifier = modifier) {
Text(
text = stringResource(R.string.agenda_range_showing_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "${agendaRangeLabel(range)}, $window",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
} }
@Composable @Composable
@@ -190,6 +316,7 @@ private fun AgendaList(
AgendaEventRow( AgendaEventRow(
event = event, event = event,
position = positionOf(index, day.events.size), position = positionOf(index, day.events.size),
modifier = calendarAnimateItem(),
onClick = { onEventClick(event) }, onClick = { onEventClick(event) },
) )
} }
@@ -221,11 +348,13 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
private fun AgendaEventRow( private fun AgendaEventRow(
event: EventInstance, event: EventInstance,
position: Position, position: Position,
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,
title = title, title = title,
summary = agendaTimeSummary(event), summary = agendaTimeSummary(event),
position = position, position = position,
@@ -250,7 +379,7 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Icon( Icon(
imageVector = Icons.Filled.EventAvailable, imageVector = Icons.Filled.Coffee,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp), modifier = Modifier.size(48.dp),
@@ -261,13 +390,6 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.agenda_empty_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
} }
} }
@@ -334,15 +456,17 @@ private fun agendaTimeSummary(event: EventInstance): String {
val time = if (event.isAllDay) { val time = if (event.isAllDay) {
stringResource(R.string.event_detail_all_day) stringResource(R.string.event_detail_all_day)
} else { } else {
"${formatTime(event.start)} ${formatTime(event.end)}" val is24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
"${formatTime(event.start, is24Hour, locale)} ${formatTime(event.end, is24Hour, locale)}"
} }
val location = event.location?.takeIf { it.isNotBlank() } val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time return if (location != null) "$time · $location" else time
} }
private fun formatTime(instant: Instant): String { private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String {
val t = instant.toLocalDateTime(zone).time val t = instant.toLocalDateTime(zone).time
return "%02d:%02d".format(t.hour, t.minute) return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
} }
private fun formatAgendaDate(date: LocalDate): String { private fun formatAgendaDate(date: LocalDate): String {

View File

@@ -53,5 +53,13 @@ sealed interface AgendaUiState {
val anchor: LocalDate, val anchor: LocalDate,
val today: LocalDate, val today: LocalDate,
val days: List<AgendaDay>, val days: List<AgendaDay>,
/** The range currently in effect — the saved default or a session override. */
val range: AgendaRange,
/** True when [range] is a temporary in-view override of the saved default. */
val rangeIsOverride: Boolean,
/** Last day the current [range] covers (inclusive), for the range header. */
val rangeEnd: LocalDate,
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
val showRangeBar: Boolean,
) : AgendaUiState ) : AgendaUiState
} }

View File

@@ -5,6 +5,8 @@ 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.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -17,10 +19,13 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import java.util.Locale
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime import kotlinx.datetime.atTime
import kotlinx.datetime.plus import kotlinx.datetime.plus
@@ -30,16 +35,24 @@ import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
/** How far ahead the agenda loads events from its anchor day. */
internal const val AGENDA_WINDOW_DAYS = 60
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel @HiltViewModel
class AgendaViewModel @Inject constructor( class AgendaViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
// The saved agenda range plus the range-bar visibility toggle.
private val agendaSettings = combine(
settingsPrefs.agendaScreenRange,
settingsPrefs.agendaShowRangeBar,
) { range, showBar -> AgendaSettings(range, showBar) }
// First day of the week, for the calendar-aligned "this week" range.
private val weekStartDay = settingsPrefs.weekStart
.map { it.resolveFirstDay(Locale.getDefault()) }
private val zone = TimeZone.currentSystemDefault() private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate private val todayDate: LocalDate
@@ -48,14 +61,32 @@ class AgendaViewModel @Inject constructor(
private val _anchor = MutableStateFlow(todayDate) private val _anchor = MutableStateFlow(todayDate)
val anchor: StateFlow<LocalDate> = _anchor val anchor: StateFlow<LocalDate> = _anchor
val state: StateFlow<AgendaUiState> = _anchor // A transient, in-view override of the saved agenda range. Held in memory
.flatMapLatest { anchor -> // (not persisted), so it survives view switches and rotation within the
val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone) // session but resets to the saved default when the app is relaunched.
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
val state: StateFlow<AgendaUiState> =
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
AgendaParams(
anchor = anchor,
range = override ?: settings.range,
rangeIsOverride = override != null && override != settings.range,
weekStart = weekStart,
showRangeBar = settings.showBar,
)
}
.flatMapLatest { params ->
val window = agendaRange(
params.anchor,
params.range.dayCount(params.anchor, params.weekStart) - 1,
zone,
)
combine( combine(
repository.calendars(), repository.calendars(),
repository.instances(range), repository.instances(window),
) { calendars, instances -> ) { calendars, instances ->
buildState(anchor, calendars, instances) buildState(params, calendars, instances)
} }
} }
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
@@ -75,16 +106,47 @@ class AgendaViewModel @Inject constructor(
_anchor.value = date _anchor.value = date
} }
/** Temporarily override the agenda range for this session (the bottom-left pill). */
fun setRangeOverride(range: AgendaRange) {
_rangeOverride.value = range
}
private data class AgendaSettings(
val range: AgendaRange,
val showBar: Boolean,
)
private data class AgendaParams(
val anchor: LocalDate,
val range: AgendaRange,
val rangeIsOverride: Boolean,
val weekStart: DayOfWeek,
val showRangeBar: Boolean,
)
private fun buildState( private fun buildState(
anchor: LocalDate, params: AgendaParams,
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
): AgendaUiState { ): AgendaUiState {
if (calendars.isEmpty()) { if (calendars.isEmpty()) {
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured) return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
} }
val anchor = params.anchor
val days = groupAgendaDays(anchor, instances, zone) val days = groupAgendaDays(anchor, instances, zone)
return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days) val rangeEnd = anchor.plus(
params.range.dayCount(anchor, params.weekStart) - 1,
DateTimeUnit.DAY,
)
return AgendaUiState.Success(
anchor = anchor,
today = todayDate,
days = days,
range = params.range,
rangeIsOverride = params.rangeIsOverride,
rangeEnd = rangeEnd,
showRangeBar = params.showRangeBar,
)
} }
} }

View File

@@ -4,14 +4,17 @@ import android.accounts.AccountManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.BackHandler import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -27,25 +30,33 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Notes import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.OpenInNew import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -59,27 +70,43 @@ 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.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
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.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
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.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf
import java.time.LocalDate import java.time.LocalDate
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */ /** Sentinel [editorId] meaning "the editor is composing a new calendar". */
@@ -98,8 +125,10 @@ fun CalendarsScreen(
viewModel: CalendarsViewModel = hiltViewModel(), viewModel: CalendarsViewModel = hiltViewModel(),
) { ) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle() val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle() val error by viewModel.error.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar. // null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
// [editorSession] bumps on every open so the editor's field state resets for // [editorSession] bumps on every open so the editor's field state resets for
@@ -134,14 +163,21 @@ fun CalendarsScreen(
CalendarsList( CalendarsList(
local = calendars.filter { it.isLocal }, local = calendars.filter { it.isLocal },
synced = calendars.filterNot { it.isLocal }, synced = calendars.filterNot { it.isLocal },
disabledIds = disabledIds,
error = error, error = error,
onConsumeError = viewModel::consumeError, onConsumeError = viewModel::consumeError,
backupResult = backupResult, backupResult = backupResult,
onExportBackup = viewModel::exportBackup, onExportBackup = viewModel::exportBackup,
onConsumeBackupResult = viewModel::consumeBackupResult, onConsumeBackupResult = viewModel::consumeBackupResult,
autoBackup = autoBackup,
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
onBack = onBack, onBack = onBack,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id }, onEdit = { calendar -> editorSession++; editorId = calendar.id },
onSetDisabled = viewModel::setDisabled,
onSetAccountDisabled = viewModel::setAccountDisabled,
) )
} }
} }
@@ -150,17 +186,28 @@ fun CalendarsScreen(
private fun CalendarsList( private fun CalendarsList(
local: List<CalendarSource>, local: List<CalendarSource>,
synced: List<CalendarSource>, synced: List<CalendarSource>,
disabledIds: Set<Long>,
error: Boolean, error: Boolean,
onConsumeError: () -> Unit, onConsumeError: () -> Unit,
backupResult: BackupResult?, backupResult: BackupResult?,
onExportBackup: (android.net.Uri) -> Unit, onExportBackup: (android.net.Uri) -> Unit,
onConsumeBackupResult: () -> Unit, onConsumeBackupResult: () -> Unit,
autoBackup: AutoBackupUiState,
onSetAutoBackupEnabled: (Boolean) -> Unit,
onSetAutoBackupInterval: (Long) -> Unit,
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onAdd: () -> Unit, onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit, onEdit: (CalendarSource) -> Unit,
onSetDisabled: (Long, Boolean) -> Unit,
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Accounts the user has folded shut; empty = all expanded (keeps every
// calendar visible by default, the section is collapsible for tidiness).
var collapsedAccounts by remember { mutableStateOf(emptySet<String>()) }
var localExpanded by remember { mutableStateOf(true) }
val writeErrorText = stringResource(R.string.calendars_write_error) val writeErrorText = stringResource(R.string.calendars_write_error)
LaunchedEffect(error) { LaunchedEffect(error) {
@@ -176,6 +223,13 @@ private fun CalendarsList(
contract = ActivityResultContracts.CreateDocument("text/calendar"), contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { uri -> uri?.let(onExportBackup) } ) { uri -> uri?.let(onExportBackup) }
// SAF folder picker for the automatic-backup destination; the VM persists the
// write grant so background runs can keep writing to it.
val pickFolder = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri -> uri?.let(onSetAutoBackupFolder) }
var showInterval by remember { mutableStateOf(false) }
val backupFailedText = stringResource(R.string.calendars_backup_failed) val backupFailedText = stringResource(R.string.calendars_backup_failed)
LaunchedEffect(backupResult) { LaunchedEffect(backupResult) {
when (val r = backupResult) { when (val r = backupResult) {
@@ -200,36 +254,51 @@ private fun CalendarsList(
onBack = onBack, onBack = onBack,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
) { ) {
// Local (device-only) calendars — the calendars the app owns. The // What the per-calendar / per-account switches below actually do.
// "Add calendar" entry closes the group as its final row. HintText(stringResource(R.string.calendars_disable_hint))
SectionHeader(stringResource(R.string.calendars_local_header))
// Local (device-only) calendars — one collapsible group. The header's
// "+" adds a calendar; the switch enables/disables them all at once;
// tapping a calendar row opens its editor.
val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds }
CalendarGroup(
title = stringResource(R.string.calendars_local_header),
expanded = localExpanded,
bodyHasRows = local.isNotEmpty(),
headerDisabled = localDisabled,
leading = { Box(dimIf(localDisabled)) { LeadingAvatar(Icons.Default.PhoneAndroid) } },
manageIcon = Icons.Default.Add,
manageLabel = stringResource(R.string.calendars_add),
onManage = onAdd,
onToggleExpand = { localExpanded = !localExpanded },
showToggleAll = local.isNotEmpty(),
allEnabled = local.none { it.id in disabledIds },
onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) },
) {
if (local.isEmpty()) { if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty)) HintText(stringResource(R.string.calendars_local_empty))
} } else {
val localCount = local.size + 1
local.forEachIndexed { index, calendar -> local.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds
GroupedRow( GroupedRow(
title = calendar.displayName, title = calendar.displayName,
summary = calendar.description, summary = calendar.description,
position = positionOf(index, localCount), position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
leading = { CalendarColorChip(calendar.color) }, container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = { trailing = {
Icon( EnableSwitch(
Icons.Default.Edit, calendarName = calendar.displayName,
contentDescription = stringResource(R.string.calendars_edit_title), enabled = !disabled,
tint = MaterialTheme.colorScheme.onSurfaceVariant, onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
modifier = Modifier.size(20.dp),
) )
}, },
onClick = { onEdit(calendar) }, onClick = { onEdit(calendar) },
) )
} }
GroupedRow( }
title = stringResource(R.string.calendars_add), }
position = positionOf(local.size, localCount),
leading = { AddAvatar() },
onClick = onAdd,
)
// Backup — local calendars have no sync, so a .ics export is their only // Backup — local calendars have no sync, so a .ics export is their only
// safety net. Offered only when there is something to back up. // safety net. Offered only when there is something to back up.
@@ -237,42 +306,106 @@ private fun CalendarsList(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_backup_header)) SectionHeader(stringResource(R.string.calendars_backup_header))
HintText(stringResource(R.string.calendars_backup_hint)) HintText(stringResource(R.string.calendars_backup_hint))
// One connected card: the one-time export on top, then automatic
// backup (and its folder/interval rows when on).
GroupedRow( GroupedRow(
title = stringResource(R.string.calendars_backup_action), title = stringResource(R.string.calendars_backup_action),
position = Position.Alone, position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) }, leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = { onClick = {
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") } runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
}, },
) )
GroupedRow(
title = stringResource(R.string.calendars_auto_backup),
summary = stringResource(R.string.calendars_auto_backup_hint),
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
leading = { LeadingAvatar(Icons.Default.Schedule) },
trailing = {
Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled)
},
onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) },
)
if (autoBackup.enabled) {
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_folder),
summary = rememberFolderName(autoBackup.folderUri)
?: stringResource(R.string.calendars_auto_backup_folder_unset),
position = Position.Middle,
onClick = { runCatching { pickFolder.launch(null) } },
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_interval),
summary = backupIntervalLabel(autoBackup.intervalMinutes),
position = Position.Bottom,
onClick = { showInterval = true },
)
HintText(backupStatusText(autoBackup.status))
}
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Synced calendars — read-only, grouped by account, each with a // Synced calendars — read-only, grouped by account. Each account is a
// per-account "manage in source app" link. // collapsible group whose header opens the source app (icon) and toggles
// all of its calendars at once (switch).
SectionHeader(stringResource(R.string.calendars_synced_header)) SectionHeader(stringResource(R.string.calendars_synced_header))
HintText(stringResource(R.string.calendars_synced_hint)) HintText(stringResource(R.string.calendars_synced_hint))
synced synced
.groupBy { it.accountName.ifBlank { it.accountType } } .groupBy { it.accountName.ifBlank { it.accountType } }
.forEach { (account, cals) -> .forEach { (account, cals) ->
AccountHeader(account = account, accountType = cals.first().accountType) val expanded = account !in collapsedAccounts
val accountType = cals.first().accountType
val accountDisabled = cals.all { it.id in disabledIds }
Spacer(Modifier.height(16.dp))
CalendarGroup(
title = account,
expanded = expanded,
bodyHasRows = true,
headerDisabled = accountDisabled,
leading = { Box(dimIf(accountDisabled)) { SourceLogo(accountType) } },
manageIcon = Icons.AutoMirrored.Filled.OpenInNew,
manageLabel = stringResource(R.string.calendars_manage_in_app),
onManage = {
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
},
onToggleExpand = {
collapsedAccounts = if (expanded) {
collapsedAccounts + account
} else {
collapsedAccounts - account
}
},
showToggleAll = true,
allEnabled = cals.none { it.id in disabledIds },
onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) },
) {
cals.forEachIndexed { index, calendar -> cals.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds
GroupedRow( GroupedRow(
title = calendar.displayName, title = calendar.displayName,
position = positionOf(index, cals.size), position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
leading = { CalendarColorChip(calendar.color) }, container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
EnableSwitch(
calendarName = calendar.displayName,
enabled = !disabled,
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
)
},
) )
} }
} }
Spacer(Modifier.height(8.dp)) }
GroupedRow( }
title = stringResource(R.string.calendars_add_account),
position = Position.Alone, if (showInterval) {
leading = { AddAvatar() }, BackupIntervalDialog(
onClick = { currentMinutes = autoBackup.intervalMinutes,
runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) } onConfirm = onSetAutoBackupInterval,
}, onDismiss = { showInterval = false },
) )
} }
} }
@@ -295,10 +428,9 @@ private fun CalendarEditor(
var confirmDelete by remember { mutableStateOf(false) } var confirmDelete by remember { mutableStateOf(false) }
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
BackHandler(onBack = onClose)
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
.predictiveBack(onBack = onClose)
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface), .background(MaterialTheme.colorScheme.surface),
topBar = { topBar = {
@@ -425,6 +557,30 @@ private fun CalendarEditor(
} }
} }
/**
* The per-row enable/disable control. Checked = the calendar is shown in the
* app; unchecking disables it (events, filters and pickers all drop it) without
* touching any provider data. Carries its own content description so the toggle
* is self-describing to screen readers even on a dimmed row.
*/
@Composable
private fun EnableSwitch(
calendarName: String,
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName)
Switch(
checked = enabled,
onCheckedChange = onToggle,
modifier = Modifier.semantics { contentDescription = label },
)
}
/** Fade a leading element to the M3 disabled emphasis when [disabled]. */
private fun dimIf(disabled: Boolean): Modifier =
if (disabled) Modifier.alpha(0.38f) else Modifier
/** Tonal field card matching the event editor's design (icon + content). */ /** Tonal field card matching the event editor's design (icon + content). */
@Composable @Composable
private fun EditorCard( private fun EditorCard(
@@ -456,32 +612,178 @@ private fun EditorCard(
} }
} }
/**
* One collapsible calendar group rendered as a connected card. The header row is
* the card's top (tap to expand/collapse): a [leading] source mark (the account
* app's logo, or a device chip for local), the group [title] and a trailing
* overflow (⋮) menu holding the account-level actions — enable/disable every
* calendar at once (when [showToggleAll]) and the manage/add action
* ([manageIcon] / [manageLabel] → [onManage]). The group's calendars render as
* the rows below via [body].
*
* The header keeps the standard row colour; the calendars it reveals sit one tone
* darker, so the group reads as a header over nested content. Once [headerDisabled]
* (every calendar switched off) the header fades to the disabled emphasis so the
* whole group reads as off — not just via the menu. [bodyHasRows] is false when no
* calendar rows follow (e.g. an empty local group), so the header stays a
* standalone card rather than a top edge with nothing beneath it.
*/
@Composable @Composable
private fun AccountHeader(account: String, accountType: String) { private fun CalendarGroup(
val context = LocalContext.current title: String,
Row( expanded: Boolean,
modifier = Modifier bodyHasRows: Boolean,
.fillMaxWidth() headerDisabled: Boolean,
.padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp), leading: @Composable () -> Unit,
verticalAlignment = Alignment.CenterVertically, manageIcon: ImageVector,
) { manageLabel: String,
Text( onManage: () -> Unit,
text = account, onToggleExpand: () -> Unit,
style = MaterialTheme.typography.titleSmall, showToggleAll: Boolean,
fontWeight = FontWeight.SemiBold, allEnabled: Boolean,
modifier = Modifier.weight(1f), onToggleAll: (Boolean) -> Unit,
body: @Composable ColumnScope.() -> Unit,
) {
GroupedRow(
title = title,
position = if (expanded && bodyHasRows) Position.Top else Position.Alone,
dimmed = headerDisabled,
leading = leading,
trailing = {
CalendarGroupMenu(
title = title,
showToggleAll = showToggleAll,
allEnabled = allEnabled,
onToggleAll = onToggleAll,
manageIcon = manageIcon,
manageLabel = manageLabel,
onManage = onManage,
)
},
onClick = onToggleExpand,
)
AnimatedVisibility(
visible = expanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
Column(content = body)
}
}
/**
* The account header's overflow (⋮) menu: the two account-level actions that
* don't fit on one line — "Enable/Disable all" (when [showToggleAll]) and the
* manage/add action. A rounded, tonal dropdown matching the app's surfaces, with
* the two actions divided for clear separation.
*/
@Composable
private fun CalendarGroupMenu(
title: String,
showToggleAll: Boolean,
allEnabled: Boolean,
onToggleAll: (Boolean) -> Unit,
manageIcon: ImageVector,
manageLabel: String,
onManage: () -> Unit,
) {
var open by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { open = true }) {
Icon(
Icons.Default.MoreVert,
contentDescription = stringResource(R.string.calendars_account_menu_a11y, title),
)
}
DropdownMenu(
expanded = open,
onDismissRequest = { open = false },
shape = RoundedCornerShape(20.dp),
// A distinct tone + a lifted shadow so the menu reads as floating
// above the cards (which sit at surfaceContainerHigh) rather than
// blending into them.
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
tonalElevation = 0.dp,
shadowElevation = 6.dp,
) {
if (showToggleAll) {
DropdownMenuItem(
text = {
Text(
stringResource(
if (allEnabled) R.string.calendars_disable_all
else R.string.calendars_enable_all,
),
)
},
leadingIcon = {
Icon(
if (allEnabled) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = null,
)
},
onClick = {
open = false
onToggleAll(!allEnabled)
},
)
HorizontalDivider(Modifier.padding(horizontal = 12.dp, vertical = 4.dp))
}
DropdownMenuItem(
text = { Text(manageLabel) },
leadingIcon = { Icon(manageIcon, contentDescription = null) },
onClick = {
open = false
onManage()
},
) )
OutlinedButton(onClick = {
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
}) {
Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.calendars_manage_in_app))
} }
} }
} }
/** Neutral circular chip carrying an arbitrary icon — matches [AddAvatar]'s shape. */ /**
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
* round 40dp chip, so each synced account is recognisable at a glance. We load
* whatever app owns the account from [PackageManager] rather than bundling brand
* logos — always accurate, nothing to license. Falls back to a neutral cloud
* chip when no installed app resolves for the account.
*/
@Composable
private fun SourceLogo(accountType: String) {
val context = LocalContext.current
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
if (logo != null) {
Image(
bitmap = logo,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
)
} else {
LeadingAvatar(Icons.Default.Cloud)
}
}
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
val pm = context.packageManager
val candidates = buildList {
curatedSourcePackage(accountType)?.let { add(it) }
AccountManager.get(context).authenticatorTypes
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
?.packageName
?.let { add(it) }
}
for (pkg in candidates) {
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
if (bitmap != null) return bitmap.asImageBitmap()
}
return null
}
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
@Composable @Composable
private fun LeadingAvatar(icon: ImageVector) { private fun LeadingAvatar(icon: ImageVector) {
Box( Box(
@@ -500,25 +802,6 @@ private fun LeadingAvatar(icon: ImageVector) {
} }
} }
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
@Composable
private fun AddAvatar() {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
}
@Composable @Composable
private fun SectionHeader(text: String) { private fun SectionHeader(text: String) {
Text( Text(
@@ -539,6 +822,95 @@ private fun HintText(text: String) {
) )
} }
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
@Composable
private fun rememberFolderName(uriString: String?): String? {
val context = LocalContext.current
return remember(uriString) {
uriString?.let {
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
}
}
}
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
@Composable
private fun backupIntervalLabel(minutes: Long): String {
val duration = when {
minutes % MINUTES_PER_WEEK == 0L ->
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
minutes % MINUTES_PER_DAY == 0L ->
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
minutes % 60L == 0L ->
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
else ->
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
}
return stringResource(R.string.calendars_auto_backup_every, duration)
}
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
@Composable
private fun backupStatusText(status: BackupStatus): String {
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
val relative = DateUtils.getRelativeTimeSpanString(
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
).toString()
return if (status.lastSuccess) {
stringResource(R.string.calendars_auto_backup_status_ok, relative)
} else {
stringResource(R.string.calendars_auto_backup_status_failed, relative)
}
}
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
@Composable
private fun BackupIntervalDialog(
currentMinutes: Long,
onConfirm: (Long) -> Unit,
onDismiss: () -> Unit,
) {
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
val units = stringArrayResource(R.array.backup_interval_units).toList()
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
text = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
Spacer(Modifier.width(12.dp))
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.calendars_auto_backup_interval_min),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(onClick = {
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
onDismiss()
}) { Text(stringResource(R.string.reminder_custom_set)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
private const val MINUTES_PER_DAY = 1_440L
private const val MINUTES_PER_WEEK = 10_080L
/** /**
* Pick the app to open for managing a synced calendar's account. The account's * Pick the app to open for managing a synced calendar's account. The account's
* own authenticator package (resolved from [AccountManager], no permission * own authenticator package (resolved from [AccountManager], no permission

View File

@@ -1,12 +1,19 @@
package de.jeanlucmakiola.calendula.ui.calendars package de.jeanlucmakiola.calendula.ui.calendars
import android.content.Context
import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
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
@@ -15,6 +22,8 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -31,8 +40,11 @@ import javax.inject.Inject
*/ */
@HiltViewModel @HiltViewModel
class CalendarsViewModel @Inject constructor( class CalendarsViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: CalendarRepository, private val repository: CalendarRepository,
private val icsExporter: IcsExporter, private val icsExporter: IcsExporter,
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -46,6 +58,36 @@ class CalendarsViewModel @Inject constructor(
initialValue = emptyList(), initialValue = emptyList(),
) )
/**
* Calendars the user has disabled in the app. This screen is the only
* surface that lists them, so it both reads the set (to dim the rows) and
* toggles it. Every other surface simply excludes these ids.
*/
val disabledCalendarIds: StateFlow<Set<Long>> =
prefs.disabledCalendarIds
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
/** Automatic-backup settings + last-run status, for the Backup section UI. */
val autoBackup: StateFlow<AutoBackupUiState> = combine(
settingsPrefs.autoBackupEnabled,
settingsPrefs.autoBackupIntervalMinutes,
settingsPrefs.autoBackupFolderUri,
settingsPrefs.autoBackupStatus,
) { enabled, interval, folder, status ->
AutoBackupUiState(enabled, interval, folder, status)
}
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AutoBackupUiState(),
)
private val _error = MutableStateFlow(false) private val _error = MutableStateFlow(false)
val error: StateFlow<Boolean> = _error.asStateFlow() val error: StateFlow<Boolean> = _error.asStateFlow()
@@ -93,6 +135,76 @@ class CalendarsViewModel @Inject constructor(
repository.deleteCalendar(id) repository.deleteCalendar(id)
} }
/**
* Enable or disable a calendar app-side. Disabling removes it from every
* surface but Settings → Calendars (and hides its events) without touching
* provider data — purely a reversible Calendula-local view choice.
*/
fun setDisabled(id: Long, disabled: Boolean) {
viewModelScope.launch {
val current = prefs.disabledCalendarIds.first()
val next = if (disabled) current + id else current - id
if (next != current) prefs.setDisabledCalendarIds(next)
}
}
/**
* Enable or disable every calendar of one account in a single write — the
* "toggle all" affordance on an account header. Done as one set update so the
* per-calendar [setDisabled] calls can't race each other.
*/
fun setAccountDisabled(ids: Collection<Long>, disabled: Boolean) {
viewModelScope.launch {
val current = prefs.disabledCalendarIds.first()
val next = if (disabled) current + ids else current - ids.toSet()
if (next != current) prefs.setDisabledCalendarIds(next)
}
}
// --- Automatic backup (issue #8) ------------------------------------
fun setAutoBackupEnabled(enabled: Boolean) {
viewModelScope.launch {
settingsPrefs.setAutoBackupEnabled(enabled)
reschedule()
// Give immediate feedback when turning it on with a folder already set.
if (enabled && settingsPrefs.autoBackupFolderUri.first() != null) {
BackupScheduler.runNow(context)
}
}
}
fun setAutoBackupIntervalMinutes(minutes: Long) {
viewModelScope.launch {
settingsPrefs.setAutoBackupIntervalMinutes(minutes)
reschedule()
}
}
/** Persist the chosen destination folder (taking a durable write grant) and run once. */
fun setAutoBackupFolder(uri: Uri) {
viewModelScope.launch {
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
settingsPrefs.setAutoBackupFolderUri(uri.toString())
reschedule()
if (settingsPrefs.autoBackupEnabled.first()) BackupScheduler.runNow(context)
}
}
private suspend fun reschedule() {
BackupScheduler.apply(
context = context,
enabled = settingsPrefs.autoBackupEnabled.first(),
intervalMinutes = settingsPrefs.autoBackupIntervalMinutes.first(),
hasFolder = settingsPrefs.autoBackupFolderUri.first() != null,
)
}
private inline fun write(crossinline block: suspend () -> Unit) { private inline fun write(crossinline block: suspend () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
try { try {
@@ -106,6 +218,14 @@ class CalendarsViewModel @Inject constructor(
} }
} }
/** Automatic-backup settings + last-run status for the Backup section. */
data class AutoBackupUiState(
val enabled: Boolean = false,
val intervalMinutes: Long = SettingsPrefs.DEFAULT_BACKUP_INTERVAL,
val folderUri: String? = null,
val status: BackupStatus = BackupStatus(lastRun = 0L, lastSuccess = true, consecutiveFailures = 0),
)
/** Outcome of a whole-calendar backup, surfaced once to the screen. */ /** Outcome of a whole-calendar backup, surfaced once to the screen. */
sealed interface BackupResult { sealed interface BackupResult {
data class Success(val eventCount: Int) : BackupResult data class Success(val eventCount: Int) : BackupResult

View File

@@ -1,14 +1,56 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import android.provider.Settings
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.ContentTransform import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.coroutines.cancellation.CancellationException
/**
* Whether the user has asked the system to remove animations (Settings →
* Accessibility → "Remove animations", which sets the global animator duration
* scale to 0). Compose animations do *not* honour this platform flag on their
* own, so the shared motion helpers in this file check it and fall back to a
* quick cross-fade — opacity only, no spatial movement — to respect the
* vestibular intent of the setting while keeping state changes legible.
*
* Read once at composition; the scale changes rarely and only takes full effect
* after a process restart anyway.
*/
@Composable
fun rememberReduceMotion(): Boolean {
val resolver = LocalContext.current.contentResolver
return remember(resolver) {
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
}
}
/** /**
* The M3 Expressive spatial spring used for the month/week slide: the *fast* * The M3 Expressive spatial spring used for the month/week slide: the *fast*
@@ -24,17 +66,160 @@ fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.fastSpatialSpec() MaterialTheme.motionScheme.fastSpatialSpec()
/** /**
* Horizontal slide for navigating between adjacent months/weeks. * The fast effects spec from the active motion scheme, for opacity (fade)
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
* for use in non-composable transition lambdas and as the reduced-motion fallback.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.fastEffectsSpec()
/**
* Horizontal slide for navigating between adjacent months/weeks/days.
* *
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump * @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction. * (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec]. * @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
* @param fadeSpec effects spec for the reduced-motion fade, typically
* [rememberCalendarFadeSpec].
* @param reduceMotion when true, swap the directional slide for a plain cross-fade.
*/ */
fun calendarSlideTransition( fun calendarSlideTransition(
slideDir: Int, slideDir: Int,
spec: FiniteAnimationSpec<IntOffset>, spec: FiniteAnimationSpec<IntOffset>,
fadeSpec: FiniteAnimationSpec<Float>,
reduceMotion: Boolean,
): ContentTransform { ): ContentTransform {
if (reduceMotion) {
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
}
val dir = if (slideDir == 0) 1 else slideDir val dir = if (slideDir == 0) 1 else slideDir
return slideInHorizontally(spec) { w -> dir * w } return slideInHorizontally(spec) { w -> dir * w }
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w }) .togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
} }
/**
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
* opacity, so it doubles as its own reduced-motion form.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarFadeThrough(): ContentTransform {
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
return fadeIn(fade).togetherWith(fadeOut(fade))
}
/**
* Enter transition for a vertically-revealed section (expandable rows, inline
* fields): height grows from the top while fading in. Under reduced motion the
* height growth is dropped, leaving a quick fade.
*
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
* originally inlined in the event edit form, so every expandable surface in the
* app reveals the same way.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
}
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
}
/**
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
* motion keeps the fade only.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) {
fade
} else {
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
}
}
/**
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
* app's motion scheme instead of Compose's default spring. Returns a bare
* [Modifier] under reduced motion so rows snap into place. Requires the list to
* supply stable item keys.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
if (reduceMotion) {
Modifier
} else {
Modifier.animateItem(
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
)
}
/**
* Standard Android predictive-back transform for a full-screen overlay: as the
* back gesture is dragged, the surface scales toward ~90%, shifts toward the
* swiped edge and rounds its corners, previewing what's behind. Completing the
* gesture invokes [onBack]; cancelling springs it back.
*
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
* once and apply the returned [Modifier] to that screen's root so the preview
* respects the same back semantics. Under reduced motion the visual preview is
* skipped (the back still works); on API < 34 the system delivers no progress,
* so it degrades to a plain back.
*/
@Composable
fun Modifier.predictiveBack(
onBack: () -> Unit,
enabled: Boolean = true,
reduceMotion: Boolean = rememberReduceMotion(),
): Modifier {
val progress = remember { Animatable(0f) }
var fromLeftEdge by remember { mutableStateOf(true) }
PredictiveBackHandler(enabled = enabled) { events ->
try {
events.collect { event ->
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
}
onBack()
progress.snapTo(0f)
} catch (_: CancellationException) {
progress.animateTo(0f)
}
}
if (reduceMotion) return this
return this.graphicsLayer {
val p = progress.value
val scale = 1f - 0.1f * p
scaleX = scale
scaleY = scale
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
shape = RoundedCornerShape(32.dp.toPx() * p)
clip = p > 0f
}
}

View File

@@ -49,3 +49,31 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
if (i < 0) return available.firstOrNull() ?: CalendarView.Month if (i < 0) return available.firstOrNull() ?: CalendarView.Month
return available[(i + 1) % available.size] return available[(i + 1) % available.size]
} }
/**
* 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
* remains, then the system exits the app. Three operations move it:
*
* - [selectView] — a lateral switch (the pill or the drawer). It keeps a visit
* history so back retraces it: switching to a not-yet-visited view pushes it on
* top; switching to one already in the stack pops back to it (collapsing the
* loop, so the depth stays bounded by the number of distinct views). Selecting
* the home view collapses to just `[default]`.
* - [viewBaseStack] — the stack a widget launch resets to: just `[default]` when
* the widget's view is the home view, else `[default, source]`. A widget entry
* starts a fresh context rather than extending in-app history.
* - [drillToDay] — pushes the day view on top (a date tap from the month grid or
* a widget), unless the day view is already current.
*/
fun List<CalendarView>.selectView(target: CalendarView): List<CalendarView> {
val existing = indexOf(target)
return if (existing >= 0) take(existing + 1) else this + target
}
fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarView> =
if (source == default) listOf(default) else listOf(default, source)
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
fun List<CalendarView>.drillToDay(): List<CalendarView> =
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day

View File

@@ -0,0 +1,44 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.ui.graphics.vector.ImageVector
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventFormField
/**
* The shared label and icon for each optional [EventFormField]. Both the event
* editor (which shows these fields) and the settings screen (which toggles their
* defaults) draw from this one mapping, so a field reads the same wherever it
* appears.
*/
@StringRes
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
EventFormField.Reminders -> R.string.event_detail_reminders
EventFormField.Recurrence -> R.string.event_detail_recurrence
EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility
EventFormField.Color -> R.string.event_edit_color
EventFormField.Attendees -> R.string.event_edit_attendees
}
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
EventFormField.Location -> Icons.Default.Place
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
EventFormField.Reminders -> Icons.Default.Notifications
EventFormField.Recurrence -> Icons.Default.Repeat
EventFormField.Availability -> Icons.Default.EventAvailable
EventFormField.Visibility -> Icons.Default.Lock
EventFormField.Color -> Icons.Default.Palette
EventFormField.Attendees -> Icons.Default.People
}

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -73,11 +72,11 @@ fun CollapsingScaffold(
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
BackHandler(onBack = onBack)
val scrollBehavior = val scrollBehavior =
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
Scaffold( Scaffold(
modifier = modifier modifier = modifier
.predictiveBack(onBack = onBack)
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.nestedScroll(scrollBehavior.nestedScrollConnection), .nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -128,7 +127,9 @@ fun CollapsingScaffold(
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose * One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
* corner radii come from its [position] (so a run of rows reads as a single * corner radii come from its [position] (so a run of rows reads as a single
* rounded card). Corners round further on press. A null [onClick] makes the * rounded card). Corners round further on press. A null [onClick] makes the
* row non-interactive (e.g. read-only entries). * row non-interactive (e.g. read-only entries). [dimmed] fades the headline and
* summary to the M3 disabled emphasis while leaving the [trailing] control at
* full opacity — for rows that are present but switched off.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -138,6 +139,8 @@ fun GroupedRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
summary: String? = null, summary: String? = null,
selected: Boolean = false, selected: Boolean = false,
dimmed: Boolean = false,
container: Color? = null,
minHeight: Dp = 72.dp, minHeight: Dp = 72.dp,
leading: @Composable (() -> Unit)? = null, leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null,
@@ -169,6 +172,16 @@ fun GroupedRow(
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer, supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer, trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
) )
} else if (dimmed) {
// M3 disabled emphasis (0.38α) on the text/leading; the trailing control
// stays full-opacity so the toggle reads as live even on a faded row.
val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = muted,
leadingIconColor = muted,
supportingColor = muted,
)
} else { } else {
ListItemDefaults.colors(containerColor = Color.Transparent) ListItemDefaults.colors(containerColor = Color.Transparent)
} }
@@ -186,10 +199,10 @@ fun GroupedRow(
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.then(gap) .then(gap)
val containerColor = if (selected) { val containerColor = when {
MaterialTheme.colorScheme.secondaryContainer selected -> MaterialTheme.colorScheme.secondaryContainer
} else { container != null -> container
MaterialTheme.colorScheme.surfaceContainerHigh else -> MaterialTheme.colorScheme.surfaceContainerHigh
} }
if (onClick != null) { if (onClick != null) {
Surface( Surface(

View File

@@ -0,0 +1,38 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
/**
* Whether the week/day timeline draws an hour separator line, from the
* `showHourLines` preference. Provided once at the app root (like
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
* Defaults to off — the historical clean look.
*/
val LocalShowHourLines = staticCompositionLocalOf { false }
/**
* Draw a faint separator line at the top of each hour (1..23) when [show] is
* true. Applied to a day column's content so each line sits over the column's
* background but beneath the event blocks. [hourHeightPx] is one hour's pixel
* height; [color] is resolved by the caller from the theme.
*/
fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
if (!show) {
this
} else {
drawBehind {
for (hour in 1 until 24) {
val y = hour * hourHeightPx
drawLine(
color = color,
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = 1f,
)
}
}
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -30,6 +31,7 @@ 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.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
@@ -37,6 +39,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider import androidx.compose.ui.window.DialogWindowProvider
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
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
@@ -83,8 +86,10 @@ fun <T> OptionPicker(
label: @Composable (T) -> String, label: @Composable (T) -> String,
onSelect: (T) -> Unit, onSelect: (T) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
header: (@Composable ColumnScope.() -> Unit)? = null,
) { ) {
FullScreenPicker(title = title, onDismiss = onDismiss) { FullScreenPicker(title = title, onDismiss = onDismiss) {
header?.invoke(this)
options.forEachIndexed { index, option -> options.forEachIndexed { index, option ->
val isSelected = option == selected val isSelected = option == selected
GroupedRow( GroupedRow(
@@ -173,7 +178,11 @@ fun ReminderDefaultPicker(
}, },
onClick = { customExpanded = !customExpanded }, onClick = { customExpanded = !customExpanded },
) )
AnimatedVisibility(visible = customExpanded) { AnimatedVisibility(
visible = customExpanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
CustomReminderEditor( CustomReminderEditor(
amountText = amountText, amountText = amountText,
onAmountChange = { amountText = it }, onAmountChange = { amountText = it },
@@ -256,6 +265,17 @@ private fun CustomReminderEditor(
} }
} }
/** A short explanatory paragraph shown under a picker's title, above the rows. */
@Composable
private fun PickerDescription(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
@Composable @Composable
private fun SelectedCheck() { private fun SelectedCheck() {
Icon( Icon(
@@ -265,6 +285,149 @@ private fun SelectedCheck() {
) )
} }
/**
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
* options (today / this week / this month) and the rolling windows (next 7 / 30
* days), with a "Custom" row that expands an inline day-count editor (1365).
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
*/
@Composable
fun AgendaRangePicker(
title: String,
description: String,
selected: AgendaRange,
onSelect: (AgendaRange) -> Unit,
onDismiss: () -> Unit,
) {
val calendarAligned = listOf(AgendaRange.Day, AgendaRange.ThisWeek, AgendaRange.ThisMonth)
val rolling = listOf(AgendaRange.Week, AgendaRange.Month)
val rollingRowCount = rolling.size + 1 // + the custom row
val customSelected = selected is AgendaRange.Custom
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable {
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
}
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
val isSelected = option == selected
GroupedRow(
title = agendaRangeLabel(option),
position = position,
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(option)
onDismiss()
},
)
}
FullScreenPicker(title = title, onDismiss = onDismiss) {
PickerDescription(description)
// Calendar-aligned windows.
calendarAligned.forEachIndexed { index, option ->
rangeRow(option, positionOf(index, calendarAligned.size))
}
Spacer(Modifier.height(12.dp))
// Rolling windows + custom.
rolling.forEachIndexed { index, option ->
rangeRow(option, positionOf(index, rollingRowCount))
}
GroupedRow(
title = if (customSelected) {
agendaRangeLabel(selected)
} else {
stringResource(R.string.agenda_range_custom)
},
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
selected = customSelected,
trailing = if (customSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
CustomDaysEditor(
amountText = amountText,
onAmountChange = { amountText = it },
onConfirm = { days ->
onSelect(AgendaRange.Custom(days))
onDismiss()
},
)
}
}
}
/** The expanded "Custom" day-count editor: an amount field (1365) and Set. */
@Composable
private fun CustomDaysEditor(
amountText: String,
onAmountChange: (String) -> Unit,
onConfirm: (Int) -> Unit,
) {
val days = amountText.toIntOrNull()
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
DialogAmountField(
value = amountText,
onValueChange = onAmountChange,
placeholder = "30",
)
Spacer(Modifier.width(16.dp))
Text(
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
?: stringResource(R.string.agenda_range_custom_hint),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
FilledTonalButton(
onClick = { days?.let(onConfirm) },
enabled = days != null,
) {
Text(stringResource(R.string.reminder_custom_set))
}
}
}
}
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
@Composable
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
AgendaRange.Day -> stringResource(R.string.agenda_range_day)
AgendaRange.ThisWeek -> stringResource(R.string.agenda_range_this_week)
AgendaRange.ThisMonth -> stringResource(R.string.agenda_range_this_month)
AgendaRange.Week -> stringResource(R.string.agenda_range_week)
AgendaRange.Month -> stringResource(R.string.agenda_range_month)
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)
}
@Composable @Composable
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) { private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default) CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)

View File

@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"),
* `false` = 12-hour ("2:00 PM"). Provided once at the app root from the
* `TimeFormatPref` preference resolved against the device's 24-hour system
* setting, so every time label reads it without per-screen plumbing. Defaults
* to 24-hour for previews and any composition that forgets to provide it.
*/
val LocalUse24HourFormat = staticCompositionLocalOf { true }
private const val PATTERN_24 = "HH:mm"
private const val PATTERN_12 = "h:mm a"
private const val HOUR_PATTERN_12 = "h a"
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
DateTimeFormatter.ofPattern(if (is24Hour) PATTERN_24 else PATTERN_12, locale)
/**
* Format a wall-clock time (hour 0..23, minute 0..59) honouring the resolved
* [is24Hour] convention and [locale]: 24h → "14:00", 12h → "2:00 PM". Pure, so
* it can be unit-tested and used off the main thread (widget, notifications).
*/
fun formatTimeOfDay(hour: Int, minute: Int, is24Hour: Boolean, locale: Locale): String =
LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59))
.format(timeOfDayFormatter(is24Hour, locale))
/**
* Format minutes-from-midnight (0..1440) as a time label. The end-of-day value
* 1440 renders as "24:00" in 24h (its established reading) and as midnight
* ("12:00 AM") in 12h, which has no 24:00 equivalent.
*/
fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String = when {
minutes >= MINUTES_PER_DAY && is24Hour -> "24:00"
minutes >= MINUTES_PER_DAY -> formatTimeOfDay(0, 0, is24Hour = false, locale)
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
}
/**
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
* the prior look); 12h → "1 PM".
*/
fun formatHourLabel(hour: Int, is24Hour: Boolean, locale: Locale): String =
if (is24Hour) {
"%02d".format(hour)
} else {
LocalTime.of(hour.coerceIn(0, 23), 0)
.format(DateTimeFormatter.ofPattern(HOUR_PATTERN_12, locale))
}
private const val MINUTES_PER_DAY = 1_440

View File

@@ -75,10 +75,17 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
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
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
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.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.week.TimedBlock import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -237,6 +244,8 @@ private fun DayContent(
val threshold = with(density) { 24.dp.toPx() } val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) } var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Hoisted above the per-day AnimatedContent so the vertical scroll position // Hoisted above the per-day AnimatedContent so the vertical scroll position
// survives day-to-day swipes. We only centre on noon once, on first entry // survives day-to-day swipes. We only centre on noon once, on first entry
@@ -287,7 +296,7 @@ private fun DayContent(
DayUiState.Loading -> "loading" DayUiState.Loading -> "loading"
} }
}, },
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) }, transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "day-transition", label = "day-transition",
) { s -> ) { s ->
when (s) { when (s) {
@@ -462,6 +471,8 @@ private fun Timeline(
) { ) {
val totalHeight = HOUR_HEIGHT * 24 val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// Gutter and day column are two scroll viewports that SHARE one scroll // Gutter and day column are two scroll viewports that SHARE one scroll
@@ -484,7 +495,7 @@ private fun Timeline(
) { ) {
if (h > 0) { if (h > 0) {
Text( Text(
text = "%02d".format(h), text = formatHourLabel(h, use24Hour, locale),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier modifier = Modifier
@@ -530,6 +541,8 @@ private fun DayColumnCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() } val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
Card( Card(
// Plain rectangular column — the soft corners come from the outer // Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges. // rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -542,6 +555,9 @@ private fun DayColumnCard(
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
// Faint hour separators sit over the column background but under
// the event blocks (drawBehind paints before the children).
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
// Tap an empty slot to create an event there. Taps on event // Tap an empty slot to create an event there. Taps on event
// blocks are consumed by their own click handler first, so this // blocks are consumed by their own click handler first, so this
// only fires on the column background. Snaps to the tapped hour. // only fires on the column background. Snaps to the tapped hour.
@@ -585,7 +601,10 @@ private fun EventBlock(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) } val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
val timeLabel = "${minToHm(block.startMin)}${minToHm(block.endMin)}" val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale)
val showTime = block.endMin - block.startMin >= 45 val showTime = block.endMin - block.startMin >= 45
Box( Box(
modifier = modifier modifier = modifier
@@ -631,8 +650,8 @@ private fun DayLoading() {
} }
} }
private fun minToHm(min: Int): String = private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60) formatMinuteOfDay(min, is24Hour, locale)
private fun formatDayTitle(date: LocalDate): String { private fun formatDayTitle(date: LocalDate): String {
val locale = Locale.getDefault() val locale = Locale.getDefault()

View File

@@ -6,7 +6,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -92,9 +91,12 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.OptionCard
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.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.recurrenceText import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
@@ -132,8 +134,6 @@ fun EventDetailScreen(
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle() val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
BackHandler(onBack = onBack)
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -214,6 +214,7 @@ fun EventDetailScreen(
} }
Scaffold( Scaffold(
modifier = Modifier.predictiveBack(onBack = onBack),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -782,7 +783,7 @@ private fun formatWhen(
val zid = ZoneId.of(zone.id) val zid = ZoneId.of(zone.id)
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale) val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
val timeShort = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) val timeShort = timeOfDayFormatter(LocalUse24HourFormat.current, locale)
val startLdt = instance.start.toJavaLocalDateTime(zid) val startLdt = instance.start.toJavaLocalDateTime(zid)
val allDayLabel = stringResource(R.string.event_detail_all_day) val allDayLabel = stringResource(R.string.event_detail_all_day)

View File

@@ -8,14 +8,9 @@ import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.provider.ContactsContract import android.provider.ContactsContract
import android.util.Patterns import android.util.Patterns
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -48,7 +43,6 @@ import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
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.People
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Place
@@ -63,7 +57,6 @@ import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -88,6 +81,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
@@ -121,11 +116,16 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.toRRule import de.jeanlucmakiola.calendula.domain.toRRule
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
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.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.eventFormFieldLabel
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
@@ -135,6 +135,8 @@ import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
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.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.pastelize
@@ -202,8 +204,6 @@ fun EventEditScreen(
viewModel.reset() viewModel.reset()
onClose() onClose()
} }
BackHandler(onBack = close)
// The event vanished between the detail screen and the edit tap — fall // The event vanished between the detail screen and the edit tap — fall
// back to the detail screen, which shows its own failure state. // back to the detail screen, which shows its own failure state.
LaunchedEffect(loadFailed) { LaunchedEffect(loadFailed) {
@@ -253,6 +253,7 @@ fun EventEditScreen(
} }
Scaffold( Scaffold(
modifier = Modifier.predictiveBack(onBack = close),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -410,7 +411,6 @@ private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime }
* spring open with the expressive spatial spec instead of popping in. * spring open with the expressive spatial spec instead of popping in.
* (Initially-visible sections render without animating.) * (Initially-visible sections render without animating.)
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun OptionalFormSection( private fun OptionalFormSection(
visible: Boolean, visible: Boolean,
@@ -418,10 +418,8 @@ private fun OptionalFormSection(
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + enter = calendarExpandEnter(),
fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()), exit = calendarCollapseExit(),
exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()),
) { ) {
Column(modifier = Modifier.fillMaxWidth(), content = content) Column(modifier = Modifier.fillMaxWidth(), content = content)
} }
@@ -483,6 +481,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
@@ -510,6 +519,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(
@@ -1024,9 +1037,9 @@ private fun FieldPickerDialog(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
hiddenFields.forEach { field -> hiddenFields.forEach { field ->
OptionCard( OptionCard(
label = stringResource(fieldLabel(field)), label = stringResource(eventFormFieldLabel(field)),
onClick = { onSelect(field) }, onClick = { onSelect(field) },
icon = fieldIcon(field), icon = eventFormFieldIcon(field),
) )
} }
} }
@@ -1643,28 +1656,6 @@ private fun readContactAddress(context: Context, uri: Uri): String? =
} }
private fun fieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
EventFormField.Reminders -> R.string.event_detail_reminders
EventFormField.Recurrence -> R.string.event_detail_recurrence
EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility
EventFormField.Color -> R.string.event_edit_color
EventFormField.Attendees -> R.string.event_edit_attendees
}
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
EventFormField.Location -> Icons.Default.Place
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
EventFormField.Reminders -> Icons.Default.Notifications
EventFormField.Recurrence -> Icons.Default.Repeat
EventFormField.Availability -> Icons.Default.EventAvailable
EventFormField.Visibility -> Icons.Default.Lock
EventFormField.Color -> Icons.Default.Palette
EventFormField.Attendees -> Icons.Default.People
}
/** /**
* Visibility selector: one card per level, each with its own icon; the * Visibility selector: one card per level, each with its own icon; the
* current level is highlighted. Tap picks and closes. * current level is highlighted. Tap picks and closes.
@@ -1898,8 +1889,9 @@ private fun ScheduleRow(
val dateFormat = remember(locale) { val dateFormat = remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
} }
val timeFormat = remember(locale) { val use24Hour = LocalUse24HourFormat.current
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) val timeFormat = remember(locale, use24Hour) {
timeOfDayFormatter(use24Hour, locale)
} }
// Tappable values read as links (primary), like the location on the // Tappable values read as links (primary), like the location on the
// detail screen; errors flip them to the error colour. // detail screen; errors flip them to the error colour.

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

@@ -119,12 +119,21 @@ 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,
) )
/** Writable calendars — the only valid event targets. */ /**
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars() * Writable calendars — the only valid event targets. Disabled calendars are
.map { calendars -> calendars.filter { it.canModifyContents } } * excluded, so you can't create into a calendar you've removed from the app;
.catch { emit(emptyList()) } * a last-used preselect landing on a now-disabled calendar falls back to the
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
*/
private val writableCalendars: Flow<List<CalendarSource>> = combine(
repository.calendars(),
prefs.disabledCalendarIds,
) { calendars, disabled ->
calendars.filter { it.canModifyContents && it.id !in disabled }
}.catch { emit(emptyList()) }
/** The target calendar id, resolved exactly as the form shows it. */ /** The target calendar id, resolved exactly as the form shows it. */
private val resolvedCalendarId: Flow<Long?> = combine( private val resolvedCalendarId: Flow<Long?> = combine(
@@ -152,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,
@@ -170,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 &&

View File

@@ -30,11 +30,16 @@ class FilterViewModel @Inject constructor(
combine( combine(
repository.calendars(), repository.calendars(),
prefs.hiddenCalendarIds, prefs.hiddenCalendarIds,
) { calendars, hidden -> prefs.disabledCalendarIds,
if (calendars.isEmpty()) { ) { calendars, hidden, disabled ->
// Disabled calendars are gone from the app entirely — they don't
// belong in the drawer's hide/show list (you can't hide what's
// already disabled). They live only in Settings → Calendars.
val enabled = calendars.filterNot { it.id in disabled }
if (enabled.isEmpty()) {
FilterUiState.Failure(FailureReason.NoCalendarsConfigured) FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
} else { } else {
FilterUiState.Success(groupByAccount(calendars, hidden)) FilterUiState.Success(groupByAccount(enabled, hidden))
} }
} }
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) } .catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }

View File

@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.imports package de.jeanlucmakiola.calendula.ui.imports
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -39,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.OptionCard
/** /**
@@ -57,7 +57,6 @@ fun ImportScreen(
) { ) {
LaunchedEffect(uri) { viewModel.load(uri) } LaunchedEffect(uri) { viewModel.load(uri) }
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
BackHandler(onBack = onClose)
// A single event isn't shown here — it opens the create form for review. // A single event isn't shown here — it opens the create form for review.
LaunchedEffect(state) { LaunchedEffect(state) {
@@ -65,7 +64,9 @@ fun ImportScreen(
} }
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier
.predictiveBack(onBack = onClose)
.fillMaxSize(),
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.import_title)) }, title = { Text(stringResource(R.string.import_title)) },

View File

@@ -7,6 +7,7 @@ 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.ics.IcsImporter import de.jeanlucmakiola.calendula.data.ics.IcsImporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
@@ -56,6 +57,7 @@ sealed interface ImportUiState {
class ImportViewModel @Inject constructor( class ImportViewModel @Inject constructor(
private val repository: CalendarRepository, private val repository: CalendarRepository,
private val importer: IcsImporter, private val importer: IcsImporter,
private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
@@ -79,14 +81,20 @@ class ImportViewModel @Inject constructor(
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()), form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
warnings = parsed.warnings, warnings = parsed.warnings,
) )
else -> ImportUiState.Many( else -> {
// A disabled calendar is removed from the app, so it can't be
// an import target — exclude it alongside the read-only ones.
val disabled = prefs.disabledCalendarIds.first()
ImportUiState.Many(
events = parsed.events, events = parsed.events,
warnings = parsed.warnings, warnings = parsed.warnings,
calendars = repository.calendars().first().filter { it.canModifyContents }, calendars = repository.calendars().first()
.filter { it.canModifyContents && it.id !in disabled },
) )
} }
} }
} }
}
/** Bulk-import the parsed events into [targetCalendarId]; result → [ImportUiState.Done]. */ /** Bulk-import the parsed events into [targetCalendarId]; result → [ImportUiState.Done]. */
fun import(targetCalendarId: Long) { fun import(targetCalendarId: Long) {

View File

@@ -66,6 +66,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.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.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
@@ -217,6 +219,8 @@ private fun MonthContent(
val threshold = with(density) { 6.dp.toPx() } val threshold = with(density) { 6.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) } var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
val swipeModifier = Modifier.pointerInput(Unit) { val swipeModifier = Modifier.pointerInput(Unit) {
detectHorizontalDragGestures( detectHorizontalDragGestures(
@@ -243,7 +247,7 @@ private fun MonthContent(
MonthUiState.Loading -> "loading" MonthUiState.Loading -> "loading"
} }
}, },
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) }, transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "month-transition", label = "month-transition",
) { s -> ) { s ->
when (s) { when (s) {

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.search package de.jeanlucmakiola.calendula.ui.search
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -49,10 +48,14 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position import de.jeanlucmakiola.calendula.ui.common.Position
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.timeOfDayFormatter
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 java.time.Instant as JavaInstant import java.time.Instant as JavaInstant
@@ -88,10 +91,8 @@ fun SearchScreen(
focusRequester.requestFocus() focusRequester.requestFocus()
keyboard?.show() keyboard?.show()
} }
BackHandler(onBack = onBack)
Scaffold( Scaffold(
modifier = modifier, modifier = modifier.predictiveBack(onBack = onBack),
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -174,6 +175,7 @@ private fun SearchResults(
SearchResultRow( SearchResultRow(
event = event, event = event,
position = positionOf(index, events.size), position = positionOf(index, events.size),
modifier = calendarAnimateItem(),
onClick = { onEventClick(event) }, onClick = { onEventClick(event) },
) )
} }
@@ -184,10 +186,12 @@ private fun SearchResults(
private fun SearchResultRow( private fun SearchResultRow(
event: EventInstance, event: EventInstance,
position: Position, position: Position,
modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
GroupedRow( GroupedRow(
modifier = modifier,
title = event.title, title = event.title,
summary = searchSummary(event), summary = searchSummary(event),
position = position, position = position,
@@ -215,12 +219,11 @@ private fun searchSummary(event: EventInstance): String {
val dateText = remember(locale) { val dateText = remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
}.format(start) }.format(start)
val use24Hour = LocalUse24HourFormat.current
val timeText = if (event.isAllDay) { val timeText = if (event.isAllDay) {
stringResource(R.string.event_detail_all_day) stringResource(R.string.event_detail_all_day)
} else { } else {
remember(locale) { remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
}.format(start)
} }
val base = "$dateText · $timeText" val base = "$dateText · $timeText"
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base

View File

@@ -39,14 +39,19 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport 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.Check import androidx.compose.material.icons.filled.Check
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.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.Tune import androidx.compose.material.icons.filled.Tune
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
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
@@ -82,14 +87,21 @@ 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.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
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.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.qs.NewEventTileService 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.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
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.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
import de.jeanlucmakiola.calendula.ui.common.Position import de.jeanlucmakiola.calendula.ui.common.Position
@@ -99,7 +111,12 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.calendula.ui.common.positionOf import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
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. */
@@ -208,6 +225,12 @@ private fun SettingsHub(
onClick = onManageCalendars, onClick = onManageCalendars,
) )
LanguageRow(position = Position.Middle) LanguageRow(position = Position.Middle)
// One-tap add of the "New event" Quick Settings tile. The system prompt
// is API 33+; on older versions the tile is still addable manually from
// the QS editor, so the row simply doesn't appear here.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
QuickSettingsTileRow(position = Position.Middle)
}
ReportProblemRow(position = Position.Bottom) ReportProblemRow(position = Position.Bottom)
AppVersionText() AppVersionText()
@@ -249,6 +272,24 @@ private fun ReportProblemRow(position: Position) {
} }
} }
/**
* Asks the system to add the "New event" Quick Settings tile (API 33+). A
* top-level action row, like [ReportProblemRow] — the tile is a system-surface
* shortcut, not part of any one settings category.
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
private fun QuickSettingsTileRow(position: Position) {
val context = LocalContext.current
GroupedRow(
title = stringResource(R.string.settings_qs_tile),
summary = stringResource(R.string.settings_qs_tile_hint),
position = position,
leading = { CategoryIcon(Icons.Default.Dashboard, ChipAccent.Neutral) },
onClick = { requestAddQsTile(context) },
)
}
@Composable @Composable
private fun LanguageRow(position: Position) { private fun LanguageRow(position: Position) {
val context = LocalContext.current val context = LocalContext.current
@@ -279,6 +320,18 @@ private fun LanguageRow(position: Position) {
AppLanguage.apply(it) AppLanguage.apply(it)
}, },
onDismiss = { showDialog = false }, onDismiss = { showDialog = false },
// Invite contributions right where users pick their language.
header = {
val translateUrl = stringResource(R.string.about_translate_url)
GroupedRow(
title = stringResource(R.string.settings_translate),
summary = stringResource(R.string.settings_translate_hint),
position = Position.Alone,
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
onClick = { openUrl(context, translateUrl) },
)
Spacer(Modifier.height(16.dp))
},
) )
} }
} }
@@ -288,6 +341,7 @@ private fun AboutCard() {
val context = LocalContext.current val context = LocalContext.current
val sourceUrl = stringResource(R.string.about_source_url) val sourceUrl = stringResource(R.string.about_source_url)
val licenseUrl = stringResource(R.string.about_license_url) val licenseUrl = stringResource(R.string.about_license_url)
val supportUrl = stringResource(R.string.about_support_url)
Surface( Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh, color = MaterialTheme.colorScheme.surfaceContainerHigh,
@@ -346,6 +400,19 @@ private fun AboutCard() {
Text(stringResource(R.string.settings_license)) Text(stringResource(R.string.settings_license))
} }
} }
Spacer(Modifier.height(8.dp))
FilledTonalButton(
onClick = { openUrl(context, supportUrl) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(
Icons.Default.Favorite,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.settings_about_support))
}
} }
} }
} }
@@ -404,11 +471,16 @@ private fun AppearanceScreen(
) { ) {
var showTheme by remember { mutableStateOf(false) } var showTheme by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) } var showWeekStart by remember { mutableStateOf(false) }
var showTimeFormat by remember { mutableStateOf(false) }
var showDefaultView by remember { mutableStateOf(false) }
var showAgendaScreenRange by remember { mutableStateOf(false) }
var showAgendaWidgetRange by remember { mutableStateOf(false) }
CollapsingScaffold( CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance), title = stringResource(R.string.settings_section_appearance),
onBack = onBack, onBack = onBack,
) { ) {
// Theme & colour
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_theme), title = stringResource(R.string.settings_theme),
summary = themeLabel(state.themeMode), summary = themeLabel(state.themeMode),
@@ -422,7 +494,7 @@ private fun AppearanceScreen(
} else { } else {
stringResource(R.string.settings_dynamic_color_unavailable) stringResource(R.string.settings_dynamic_color_unavailable)
}, },
position = Position.Middle, position = Position.Bottom,
trailing = { trailing = {
Switch( Switch(
checked = state.dynamicColor, checked = state.dynamicColor,
@@ -436,12 +508,68 @@ private fun AppearanceScreen(
null null
}, },
) )
Spacer(Modifier.height(16.dp))
// Calendar — view, week, and timeline formatting
GroupedRow(
title = stringResource(R.string.settings_default_view),
summary = stringResource(state.defaultView.labelRes),
position = Position.Top,
onClick = { showDefaultView = true },
)
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_week_start), title = stringResource(R.string.settings_week_start),
summary = weekStartLabel(state.weekStart), summary = weekStartLabel(state.weekStart),
position = Position.Bottom, position = Position.Middle,
onClick = { showWeekStart = true }, onClick = { showWeekStart = true },
) )
GroupedRow(
title = stringResource(R.string.settings_time_format),
summary = timeFormatLabel(state.timeFormat),
position = Position.Middle,
onClick = { showTimeFormat = true },
)
GroupedRow(
title = stringResource(R.string.settings_hour_lines),
summary = stringResource(R.string.settings_hour_lines_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.showHourLines,
onCheckedChange = viewModel::setShowHourLines,
)
},
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
)
Spacer(Modifier.height(16.dp))
// Agenda
GroupedRow(
title = stringResource(R.string.settings_agenda_range),
summary = agendaRangeLabel(state.agendaScreenRange),
position = Position.Top,
onClick = { showAgendaScreenRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_widget_range),
summary = agendaRangeLabel(state.agendaWidgetRange),
position = Position.Middle,
onClick = { showAgendaWidgetRange = true },
)
GroupedRow(
title = stringResource(R.string.settings_agenda_range_bar),
summary = stringResource(R.string.settings_agenda_range_bar_hint),
position = Position.Bottom,
trailing = {
Switch(
checked = state.agendaShowRangeBar,
onCheckedChange = viewModel::setAgendaShowRangeBar,
)
},
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
)
} }
if (showTheme) { if (showTheme) {
@@ -457,13 +585,51 @@ private fun AppearanceScreen(
if (showWeekStart) { if (showWeekStart) {
OptionPicker( OptionPicker(
title = stringResource(R.string.settings_week_start), title = stringResource(R.string.settings_week_start),
options = WeekStartPref.entries, options = WEEK_START_OPTIONS,
selected = state.weekStart, selected = state.weekStart,
label = { weekStartLabel(it) }, label = { weekStartLabel(it) },
onSelect = viewModel::setWeekStart, onSelect = viewModel::setWeekStart,
onDismiss = { showWeekStart = false }, onDismiss = { showWeekStart = false },
) )
} }
if (showAgendaScreenRange) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_range),
description = stringResource(R.string.settings_agenda_range_hint),
selected = state.agendaScreenRange,
onSelect = viewModel::setAgendaScreenRange,
onDismiss = { showAgendaScreenRange = false },
)
}
if (showAgendaWidgetRange) {
AgendaRangePicker(
title = stringResource(R.string.settings_agenda_widget_range),
description = stringResource(R.string.settings_agenda_widget_range_hint),
selected = state.agendaWidgetRange,
onSelect = viewModel::setAgendaWidgetRange,
onDismiss = { showAgendaWidgetRange = false },
)
}
if (showTimeFormat) {
OptionPicker(
title = stringResource(R.string.settings_time_format),
options = TimeFormatPref.entries,
selected = state.timeFormat,
label = { timeFormatLabel(it) },
onSelect = viewModel::setTimeFormat,
onDismiss = { showTimeFormat = false },
)
}
if (showDefaultView) {
OptionPicker(
title = stringResource(R.string.settings_default_view),
options = IMPLEMENTED_VIEWS,
selected = state.defaultView,
label = { stringResource(it.labelRes) },
onSelect = viewModel::setDefaultView,
onDismiss = { showDefaultView = false },
)
}
} }
@Composable @Composable
@@ -487,8 +653,17 @@ private fun EventFormScreen(
fields.forEachIndexed { index, field -> fields.forEachIndexed { index, field ->
val checked = field in state.defaultFormFields val checked = field in state.defaultFormFields
GroupedRow( GroupedRow(
title = stringResource(formFieldLabel(field)), title = stringResource(eventFormFieldLabel(field)),
position = positionOf(index, fields.size), position = positionOf(index, fields.size),
// Same icon the field carries in the new-event form, so a toggle
// is easy to match to the field it controls.
leading = {
Icon(
imageVector = eventFormFieldIcon(field),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = { trailing = {
Switch( Switch(
checked = checked, checked = checked,
@@ -499,6 +674,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.
@@ -519,20 +718,6 @@ private fun EventFormScreen(
) )
}, },
) )
// One-tap add of the "New event" Quick Settings tile. The system prompt
// is API 33+; on older versions the tile is still addable manually from
// the QS editor, so the row simply doesn't appear there.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Spacer(Modifier.height(24.dp))
val context = LocalContext.current
GroupedRow(
title = stringResource(R.string.settings_qs_tile),
summary = stringResource(R.string.settings_qs_tile_hint),
position = Position.Alone,
onClick = { requestAddQsTile(context) },
)
}
} }
} }
@@ -584,6 +769,7 @@ private fun NotificationsScreen(
var showAllDayReminderTime by remember { mutableStateOf(false) } var showAllDayReminderTime by remember { mutableStateOf(false) }
var showSnooze by remember { mutableStateOf(false) } var showSnooze by remember { mutableStateOf(false) }
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) } var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
var calendarSectionExpanded by remember { mutableStateOf(false) }
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) } var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
CollapsingScaffold( CollapsingScaffold(
@@ -621,21 +807,73 @@ private fun NotificationsScreen(
onClick = { showAllDayReminderTime = true }, onClick = { showAllDayReminderTime = true },
) )
// Per-calendar overrides: each writable calendar may keep, drop, or // Delivery reliability + snooze: both are global reminder-delivery
// replace the global default — separately for timed and all-day events. // settings, so they sit with the defaults above rather than below the
// long per-calendar list. Reliability is a soft, optional battery-
// optimisation exemption (system-settings deep-link, no special
// permission); shown as live status, reversible at any time.
Spacer(Modifier.height(24.dp))
val batteryExempt = rememberBatteryOptimizationExempt()
GroupedRow(
title = stringResource(R.string.settings_reliable_delivery),
summary = if (batteryExempt) {
stringResource(R.string.settings_reliable_delivery_exempt)
} else {
stringResource(R.string.settings_reliable_delivery_hint)
},
position = Position.Top,
trailing = if (batteryExempt) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
onClick = { openBatteryOptimizationSettings(context) },
)
// Snooze: how long the notification's "Snooze" action defers a reminder.
GroupedRow(
title = stringResource(R.string.settings_snooze_duration),
summary = snoozeDurationLabel(state.snoozeMinutes),
position = Position.Bottom,
onClick = { showSnooze = true },
)
// Per-calendar overrides: the whole section folds behind one header to
// keep the screen tidy. Expanded, each writable calendar gets its own
// expandable card that may keep, drop, or replace the global default —
// separately for timed and all-day events.
if (state.writableCalendars.isNotEmpty()) { if (state.writableCalendars.isNotEmpty()) {
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Text( GroupedRow(
text = stringResource(R.string.settings_calendar_reminders_hint), title = stringResource(R.string.settings_calendar_reminders_title),
style = MaterialTheme.typography.bodyMedium, summary = stringResource(R.string.settings_calendar_reminders_hint),
color = MaterialTheme.colorScheme.onSurfaceVariant, position = Position.Alone,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), trailing = {
Icon(
imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
},
onClick = { calendarSectionExpanded = !calendarSectionExpanded },
)
AnimatedVisibility(
visible = calendarSectionExpanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
Column {
state.writableCalendars.forEach { calendar -> state.writableCalendars.forEach { calendar ->
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
val expanded = calendar.id in expandedCalendars val expanded = calendar.id in expandedCalendars
// Calendar card; tapping expands it into a grouped list of three // Calendar card; tapping expands it into a grouped list
// (the card + the timed and all-day override rows). // of three (the card + the timed and all-day rows).
GroupedRow( GroupedRow(
title = calendar.displayName, title = calendar.displayName,
position = if (expanded) Position.Top else Position.Alone, position = if (expanded) Position.Top else Position.Alone,
@@ -655,7 +893,11 @@ private fun NotificationsScreen(
} }
}, },
) )
AnimatedVisibility(visible = expanded) { AnimatedVisibility(
visible = expanded,
enter = calendarExpandEnter(),
exit = calendarCollapseExit(),
) {
Column { Column {
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id) val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
GroupedRow( GroupedRow(
@@ -675,43 +917,8 @@ private fun NotificationsScreen(
} }
} }
} }
// Delivery reliability: Android's battery optimisation can delay or drop
// the calendar provider's reminder broadcast. A soft, optional exemption
// (system-settings deep-link, no special permission) improves on-time
// delivery; shown as live status, reversible by the user at any time.
Spacer(Modifier.height(24.dp))
val batteryExempt = rememberBatteryOptimizationExempt()
GroupedRow(
title = stringResource(R.string.settings_reliable_delivery),
summary = if (batteryExempt) {
stringResource(R.string.settings_reliable_delivery_exempt)
} else {
stringResource(R.string.settings_reliable_delivery_hint)
},
position = Position.Alone,
trailing = if (batteryExempt) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
} }
} else { }
null
},
onClick = { openBatteryOptimizationSettings(context) },
)
// Snooze: how long the notification's "Snooze" action defers a reminder.
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_snooze_duration),
summary = snoozeDurationLabel(state.snoozeMinutes),
position = Position.Alone,
onClick = { showSnooze = true },
)
} }
if (showSnooze) { if (showSnooze) {
@@ -932,17 +1139,6 @@ private fun openUrl(context: Context, url: String) {
runCatching { context.startActivity(intent) } runCatching { context.startActivity(intent) }
} }
private fun formFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
EventFormField.Reminders -> R.string.event_detail_reminders
EventFormField.Recurrence -> R.string.event_detail_recurrence
EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility
EventFormField.Color -> R.string.event_edit_color
EventFormField.Attendees -> R.string.event_edit_attendees
}
@Composable @Composable
private fun themeLabel(mode: ThemeMode): String = stringResource( private fun themeLabel(mode: ThemeMode): String = stringResource(
when (mode) { when (mode) {
@@ -952,12 +1148,25 @@ private fun themeLabel(mode: ThemeMode): String = stringResource(
}, },
) )
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
private val WEEK_START_OPTIONS: List<WeekStartPref> =
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
@Composable @Composable
private fun weekStartLabel(pref: WeekStartPref): String = stringResource( private fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
// Localised full weekday name, so any of the seven days reads naturally
// without a per-day string resource.
is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1)
.getDisplayName(JavaTextStyle.FULL, currentLocale())
}
@Composable
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
when (pref) { when (pref) {
WeekStartPref.AUTO -> R.string.settings_week_start_auto TimeFormatPref.AUTO -> R.string.settings_time_format_auto
WeekStartPref.MONDAY -> R.string.settings_week_start_monday TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h
WeekStartPref.SUNDAY -> R.string.settings_week_start_sunday TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h
}, },
) )

View File

@@ -2,9 +2,12 @@ package de.jeanlucmakiola.calendula.ui.settings
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.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.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
/** /**
* Settings screen state (M4). Persisted preferences are instant to read, so * Settings screen state (M4). Persisted preferences are instant to read, so
@@ -16,9 +19,23 @@ data class SettingsUiState(
val themeMode: ThemeMode = ThemeMode.SYSTEM, val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true, val dynamicColor: Boolean = true,
val dynamicColorAvailable: Boolean = true, val dynamicColorAvailable: Boolean = true,
val weekStart: WeekStartPref = WeekStartPref.AUTO, val weekStart: WeekStartPref = WeekStartPref.Auto,
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
/** Whether the week/day timeline draws an hour separator line (v2.11). */
val showHourLines: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
val agendaShowRangeBar: Boolean = true,
/** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week,
/** 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,
/** /**

View File

@@ -1,16 +1,28 @@
package de.jeanlucmakiola.calendula.ui.settings package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import android.os.Build import android.os.Build
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.appwidget.updateAll
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
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.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.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -19,12 +31,15 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
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,
@ApplicationContext private val appContext: Context,
) : ViewModel() { ) : ViewModel() {
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
@@ -72,8 +87,29 @@ class SettingsViewModel @Inject constructor(
) { overrides, allDayOverrides, calendars -> ) { overrides, allDayOverrides, calendars ->
ReminderOverrides(overrides, allDayOverrides, calendars) ReminderOverrides(overrides, allDayOverrides, calendars)
}, },
) { base, defaults, overrides -> combine(
prefs.defaultView,
prefs.agendaScreenRange,
prefs.agendaWidgetRange,
prefs.timeFormat,
prefs.showHourLines,
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
},
combine(
prefs.agendaShowRangeBar,
prefs.autofocusEventTitle,
::MiscSettings,
),
) { base, defaults, overrides, views, misc ->
base.copy( base.copy(
defaultView = views.defaultView,
agendaScreenRange = views.agendaScreenRange,
agendaWidgetRange = views.agendaWidgetRange,
timeFormat = views.timeFormat,
showHourLines = views.showHourLines,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
allowColorOnUnsupportedCalendars = defaults.allowColor, allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder, defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder, defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -89,6 +125,12 @@ class SettingsViewModel @Inject constructor(
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable), initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
) )
// 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
// and can leave the widget on the intermediate value. Held sequentially,
// the last call reads the final committed pref and renders it.
private val widgetRefreshMutex = Mutex()
private data class ReminderDefaults( private data class ReminderDefaults(
val allowColor: Boolean, val allowColor: Boolean,
val defaultReminder: Int?, val defaultReminder: Int?,
@@ -103,6 +145,19 @@ class SettingsViewModel @Inject constructor(
val calendars: List<CalendarSource>, val calendars: List<CalendarSource>,
) )
private data class ViewSettings(
val defaultView: CalendarView,
val agendaScreenRange: AgendaRange,
val agendaWidgetRange: AgendaRange,
val timeFormat: TimeFormatPref,
val showHourLines: Boolean,
)
private data class MiscSettings(
val showRangeBar: Boolean,
val autofocusEventTitle: Boolean,
)
fun setThemeMode(mode: ThemeMode) { fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch { prefs.setThemeMode(mode) } viewModelScope.launch { prefs.setThemeMode(mode) }
} }
@@ -112,7 +167,52 @@ class SettingsViewModel @Inject constructor(
} }
fun setWeekStart(pref: WeekStartPref) { fun setWeekStart(pref: WeekStartPref) {
viewModelScope.launch { prefs.setWeekStart(pref) } viewModelScope.launch {
prefs.setWeekStart(pref)
// Both widgets depend on the week start (month weekday header; the
// agenda widget's "this week" range).
widgetRefreshMutex.withLock {
AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext)
}
}
}
fun setAgendaScreenRange(range: AgendaRange) {
viewModelScope.launch { prefs.setAgendaScreenRange(range) }
}
fun setAgendaWidgetRange(range: AgendaRange) {
viewModelScope.launch {
prefs.setAgendaWidgetRange(range)
widgetRefreshMutex.withLock {
// Push the range into each instance's Glance state and recompose.
// The widget reads it via currentState, so this reflects reliably
// even when updateAll only recomposes a live session (it does not
// re-run provideGlance's data preamble).
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setAgendaShowRangeBar(enabled: Boolean) {
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
}
fun setTimeFormat(pref: TimeFormatPref) {
viewModelScope.launch { prefs.setTimeFormat(pref) }
}
fun setShowHourLines(enabled: Boolean) {
viewModelScope.launch { prefs.setShowHourLines(enabled) }
}
fun setDefaultView(view: CalendarView) {
viewModelScope.launch { prefs.setDefaultView(view) }
} }
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) { fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
@@ -123,6 +223,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setRemindersEnabled(enabled) } viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
} }
fun setAutofocusEventTitle(enabled: Boolean) {
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
}
fun setDefaultReminderMinutes(minutes: Int?) { fun setDefaultReminderMinutes(minutes: Int?) {
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) } viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
} }

View File

@@ -80,7 +80,14 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
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
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
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.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.pastelize
@@ -247,6 +254,8 @@ private fun WeekContent(
val threshold = with(density) { 24.dp.toPx() } val threshold = with(density) { 24.dp.toPx() }
var dragAccum by remember { mutableFloatStateOf(0f) } var dragAccum by remember { mutableFloatStateOf(0f) }
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Hoisted above the per-week AnimatedContent so the vertical scroll position // Hoisted above the per-week AnimatedContent so the vertical scroll position
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on // survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
@@ -300,7 +309,7 @@ private fun WeekContent(
WeekUiState.Loading -> "loading" WeekUiState.Loading -> "loading"
} }
}, },
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) }, transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "week-transition", label = "week-transition",
) { s -> ) { s ->
when (s) { when (s) {
@@ -568,6 +577,8 @@ private fun Timeline(
) { ) {
val totalHeight = HOUR_HEIGHT * 24 val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// Gutter and day columns are two scroll viewports that SHARE one scroll // Gutter and day columns are two scroll viewports that SHARE one scroll
@@ -591,7 +602,7 @@ private fun Timeline(
) { ) {
if (h > 0) { if (h > 0) {
Text( Text(
text = "%02d".format(h), text = formatHourLabel(h, use24Hour, locale),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier modifier = Modifier
@@ -646,6 +657,8 @@ private fun DayColumnCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() } val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
Card( Card(
// Plain rectangular columns — the soft corners come from the outer // Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges. // rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -658,6 +671,9 @@ private fun DayColumnCard(
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
// Faint hour separators sit over the column background but under
// the event blocks (drawBehind paints before the children).
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
// Tap an empty slot to create an event there; taps on event // Tap an empty slot to create an event there; taps on event
// blocks are consumed by their own handler first. Snaps to hour. // blocks are consumed by their own handler first. Snaps to hour.
.pointerInput(date) { .pointerInput(date) {
@@ -700,7 +716,10 @@ private fun EventBlock(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) } val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
val timeLabel = "${minToHm(block.startMin)}${minToHm(block.endMin)}" val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale)
val showTime = block.endMin - block.startMin >= 45 val showTime = block.endMin - block.startMin >= 45
Box( Box(
modifier = modifier modifier = modifier
@@ -769,8 +788,8 @@ private fun WeekLoading() {
} }
} }
private fun minToHm(min: Int): String = private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60) formatMinuteOfDay(min, is24Hour, locale)
private fun formatWeekRange(weekStart: LocalDate): String { private fun formatWeekRange(weekStart: LocalDate): String {
val locale = Locale.getDefault() val locale = Locale.getDefault()

View File

@@ -4,9 +4,11 @@ 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.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
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -23,9 +25,6 @@ import kotlinx.datetime.toLocalDateTime
import java.util.Locale import java.util.Locale
import kotlin.time.Clock import kotlin.time.Clock
/** How far ahead the agenda widget loads (a month of upcoming events). */
private const val AGENDA_WIDGET_DAYS = 30
/** /**
* 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
* is chosen reactively in the composition, so one wide read covers ~13 months of * is chosen reactively in the composition, so one wide read covers ~13 months of
@@ -46,7 +45,22 @@ internal fun Context.hasCalendarPermission(): Boolean =
sealed interface AgendaWidgetData { sealed interface AgendaWidgetData {
/** Calendar permission not granted — the widget can't read events. */ /** Calendar permission not granted — the widget can't read events. */
data object NeedsPermission : AgendaWidgetData data object NeedsPermission : AgendaWidgetData
data class Ready(val today: LocalDate, val days: List<AgendaDay>) : AgendaWidgetData data class Ready(
val today: LocalDate,
/**
* Days with events across the **widest** selectable window. The displayed
* range is sliced from this in the composition (read reactively from
* Glance state), so a range change is pure recomposition rather than a
* flaky widget session restart — the same approach the month widget uses.
*/
val days: List<AgendaDay>,
/** Resolved clock convention for event time labels (the time-format pref). */
val is24Hour: Boolean,
/** First day of the week, for the calendar-aligned "this week" range. */
val weekStart: DayOfWeek,
/** Saved range pref — the fallback when an instance has no Glance state yet. */
val savedRange: AgendaRange,
) : AgendaWidgetData
} }
/** /**
@@ -91,9 +105,24 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
val zone = systemZone() val zone = systemZone()
val anchor = today(zone) val anchor = today(zone)
val repo = widgetEntryPoint().calendarRepository() val ep = widgetEntryPoint()
val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first() val prefs = ep.settingsPrefs()
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone)) val savedRange = prefs.agendaWidgetRange.first()
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
// Load the widest selectable window once; the displayed range is sliced in
// the composition from Glance state, so changing the range is a plain
// recomposition and never depends on the widget session restarting.
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
val instances = ep.calendarRepository().instances(window).first()
val is24Hour = prefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
return AgendaWidgetData.Ready(
today = anchor,
days = groupAgendaDays(anchor, instances, zone),
is24Hour = is24Hour,
weekStart = weekStart,
savedRange = savedRange,
)
} }
/** One-shot wide read backing the month widget's grid for any nearby month. */ /** One-shot wide read backing the month widget's grid for any nearby month. */

View File

@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter import androidx.glance.ColorFilter
import androidx.glance.GlanceId import androidx.glance.GlanceId
import androidx.glance.GlanceModifier import androidx.glance.GlanceModifier
@@ -24,6 +25,8 @@ import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.updateAll import androidx.glance.appwidget.updateAll
import androidx.glance.background import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.layout.Alignment import androidx.glance.layout.Alignment
import androidx.glance.layout.Box import androidx.glance.layout.Box
import androidx.glance.layout.Column import androidx.glance.layout.Column
@@ -41,6 +44,11 @@ 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.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.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
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
@@ -61,8 +69,20 @@ import java.util.Locale
* of events grouped under day headers (the Google "Schedule" widget model). * of events grouped under day headers (the Google "Schedule" widget model).
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda. * Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
*/ */
/**
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
* The range is read reactively in the composition ([currentState]) so a settings
* change is reflected by plain recomposition — `updateAll` does NOT reliably
* re-run the `provideGlance` preamble for a live widget session, so loading the
* range there and slicing it would silently keep the stale value (mirrors the
* month widget's MONTH_INDEX_KEY approach).
*/
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
class AgendaWidget : GlanceAppWidget() { class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = context.loadAgendaWidgetData() val data = context.loadAgendaWidgetData()
val dark = (context.resources.configuration.uiMode and val dark = (context.resources.configuration.uiMode and
@@ -100,12 +120,21 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
Spacer(GlanceModifier.height(4.dp)) Spacer(GlanceModifier.height(4.dp))
when (data) { when (data) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission) AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
is AgendaWidgetData.Ready -> is AgendaWidgetData.Ready -> {
if (data.days.isEmpty()) { // Range read reactively from per-instance Glance state (falls back
// to the saved pref for a freshly placed widget), then the wide
// pre-loaded window is sliced to it — pure recomposition.
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
val rangeEnd = data.today.plus(
range.dayCount(data.today, data.weekStart) - 1,
DateTimeUnit.DAY,
)
val visibleDays = data.days.filter { it.date <= rangeEnd }
if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title) WidgetMessage(R.string.agenda_empty_title)
} else { } else {
val rows = buildList { val rows = buildList {
data.days.forEach { day -> visibleDays.forEach { day ->
add(AgendaRow.Header(day.date, data.today)) add(AgendaRow.Header(day.date, data.today))
day.events.forEach { add(AgendaRow.Event(it)) } day.events.forEach { add(AgendaRow.Event(it)) }
} }
@@ -114,7 +143,8 @@ 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) is AgendaRow.Event -> EventRow(row.event, dark, data.is24Hour)
}
} }
} }
} }
@@ -185,12 +215,16 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp) .padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date))), .clickable(
actionStartActivity(
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
),
),
) )
} }
@Composable @Composable
private fun EventRow(event: EventInstance, dark: Boolean) { private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: 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) }
Row( Row(
@@ -199,11 +233,12 @@ private fun EventRow(event: EventInstance, dark: Boolean) {
.padding(horizontal = 4.dp, vertical = 4.dp) .padding(horizontal = 4.dp, vertical = 4.dp)
.clickable( .clickable(
actionStartActivity( actionStartActivity(
MainActivity.eventDetailIntent( MainActivity.openEventIntent(
context = context, context = context,
eventId = event.eventId, eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(), beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(), endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Agenda,
), ),
), ),
), ),
@@ -224,7 +259,7 @@ private fun EventRow(event: EventInstance, dark: Boolean) {
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp), style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
) )
Text( Text(
text = eventTimeSummary(context, event), text = eventTimeSummary(context, event, is24Hour),
maxLines = 1, maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
) )
@@ -263,17 +298,17 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
return if (relative != null) "$relative · $formatted" else formatted return if (relative != null) "$relative · $formatted" else formatted
} }
private fun eventTimeSummary(context: Context, event: EventInstance): String { private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String {
val time = if (event.isAllDay) { val time = if (event.isAllDay) {
context.getString(R.string.event_detail_all_day) context.getString(R.string.event_detail_all_day)
} else { } else {
"${formatTime(event.start)} ${formatTime(event.end)}" "${formatTime(event.start, is24Hour)} ${formatTime(event.end, is24Hour)}"
} }
val location = event.location?.takeIf { it.isNotBlank() } val location = event.location?.takeIf { it.isNotBlank() }
return if (location != null) "$time · $location" else time return if (location != null) "$time · $location" else time
} }
private fun formatTime(instant: Instant): String { private fun formatTime(instant: Instant, is24Hour: Boolean): String {
val t = instant.toLocalDateTime(zone()).time val t = instant.toLocalDateTime(zone()).time
return "%02d:%02d".format(t.hour, t.minute) return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
} }

View File

@@ -49,6 +49,7 @@ import androidx.glance.unit.ColorProvider
import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.month.MonthWeek import de.jeanlucmakiola.calendula.ui.month.MonthWeek
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
@@ -209,7 +210,9 @@ private fun MonthHeader(label: String) {
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(
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))), actionStartActivity(
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
),
), ),
) )
HeaderIcon( HeaderIcon(
@@ -300,8 +303,16 @@ private fun WeekRow(
@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
Box( Box(
modifier = GlanceModifier.width(colW).height(DAY_NUMBER_HEIGHT), modifier = GlanceModifier
.width(colW)
.height(DAY_NUMBER_HEIGHT)
// Tap a day number to open that day, rooted in the month view so back
// returns to the month grid.
.clickable(
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)),
),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Box( Box(
@@ -353,7 +364,24 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
@Composable @Composable
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) { private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
val context = LocalContext.current val context = LocalContext.current
Box(modifier = GlanceModifier.width(width).height(LANE_HEIGHT).padding(horizontal = 1.dp)) { Box(
modifier = GlanceModifier
.width(width)
.height(LANE_HEIGHT)
.padding(horizontal = 1.dp)
// Tap an event bar to open its detail, rooted in the month view.
.clickable(
actionStartActivity(
MainActivity.openEventIntent(
context = context,
eventId = event.eventId,
beginMillis = event.start.toEpochMilliseconds(),
endMillis = event.end.toEpochMilliseconds(),
source = CalendarView.Month,
),
),
),
) {
Box( Box(
modifier = GlanceModifier modifier = GlanceModifier
.fillMaxSize() .fillMaxSize()

View File

@@ -230,8 +230,7 @@
<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">Nichts geplant</string> <string name="agenda_empty_title">Alles erledigt</string>
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
<!-- Terminsuche --> <!-- Terminsuche -->
<string name="search_action">Suchen</string> <string name="search_action">Suchen</string>
@@ -276,10 +275,35 @@
<string name="settings_dynamic_color_unavailable">Erfordert Android 12 oder neuer</string> <string name="settings_dynamic_color_unavailable">Erfordert Android 12 oder neuer</string>
<string name="settings_week_start">Wochenstart</string> <string name="settings_week_start">Wochenstart</string>
<string name="settings_week_start_auto">Automatisch</string> <string name="settings_week_start_auto">Automatisch</string>
<string name="settings_week_start_monday">Montag</string> <string name="settings_time_format">Zeitformat</string>
<string name="settings_week_start_sunday">Sonntag</string> <string name="settings_time_format_auto">Automatisch</string>
<string name="settings_time_format_12h">12-Stunden (2:00 PM)</string>
<string name="settings_time_format_24h">24-Stunden (14:00)</string>
<string name="settings_hour_lines">Stundenlinien</string>
<string name="settings_hour_lines_summary">Trennlinie zu jeder vollen Stunde in Wochen- und Tagesansicht anzeigen</string>
<string name="settings_agenda_range">Agenda-Zeitraum</string>
<string name="settings_agenda_range_hint">Wie weit im Voraus die Agenda-Ansicht Termine anzeigt.</string>
<string name="settings_agenda_widget_range">Agenda-Widget-Zeitraum</string>
<string name="settings_agenda_widget_range_hint">Wie weit im Voraus das Agenda-Widget auf dem Startbildschirm Termine anzeigt.</string>
<string name="settings_agenda_range_bar">Zeitraum-Leiste</string>
<string name="settings_agenda_range_bar_hint">Eine Leiste oben in der Agenda anzeigen, die den dargestellten Zeitraum benennt, mit einer Schaltfläche zum Wechseln des Zeitraums für die Sitzung</string>
<string name="agenda_range_day">Heute</string>
<string name="agenda_range_this_week">Diese Woche</string>
<string name="agenda_range_this_month">Dieser Monat</string>
<string name="agenda_range_week">Nächste 7 Tage</string>
<string name="agenda_range_month">Nächste 30 Tage</string>
<string name="agenda_range_custom">Benutzerdefiniert…</string>
<string name="agenda_range_custom_hint">Tage</string>
<string name="agenda_range_override_hint">Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt.</string>
<string name="agenda_range_showing_label">Alle anstehenden Termine für</string>
<plurals name="agenda_range_days">
<item quantity="one">%d Tag</item>
<item quantity="other">%d Tage</item>
</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>
@@ -315,6 +339,7 @@
<string name="settings_license_value">MIT</string> <string name="settings_license_value">MIT</string>
<string name="settings_about_author">von Jean-Luc Makiola</string> <string name="settings_about_author">von Jean-Luc Makiola</string>
<string name="settings_about_source">Quellcode</string> <string name="settings_about_source">Quellcode</string>
<string name="settings_about_support">Entwicklung unterstützen</string>
<string name="settings_about_version">Version %1$s</string> <string name="settings_about_version">Version %1$s</string>
<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>

View File

@@ -197,6 +197,14 @@
<item quantity="one">%d hour</item> <item quantity="one">%d hour</item>
<item quantity="other">%d hours</item> <item quantity="other">%d hours</item>
</plurals> </plurals>
<plurals name="duration_days">
<item quantity="one">%d day</item>
<item quantity="other">%d days</item>
</plurals>
<plurals name="duration_weeks">
<item quantity="one">%d week</item>
<item quantity="other">%d weeks</item>
</plurals>
<!-- Shared event strings --> <!-- Shared event strings -->
<string name="event_untitled">(No title)</string> <string name="event_untitled">(No title)</string>
@@ -231,8 +239,7 @@
<string name="agenda_today_action">Today</string> <string name="agenda_today_action">Today</string>
<string name="agenda_header_today">Today</string> <string name="agenda_header_today">Today</string>
<string name="agenda_header_tomorrow">Tomorrow</string> <string name="agenda_header_tomorrow">Tomorrow</string>
<string name="agenda_empty_title">Nothing scheduled</string> <string name="agenda_empty_title">You\'re all caught up</string>
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
<!-- Event search --> <!-- Event search -->
<string name="search_action">Search</string> <string name="search_action">Search</string>
@@ -264,14 +271,40 @@
<string name="settings_theme_system">System</string> <string name="settings_theme_system">System</string>
<string name="settings_theme_light">Light</string> <string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string> <string name="settings_theme_dark">Dark</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_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_week_start_monday">Monday</string> <string name="settings_time_format">Time format</string>
<string name="settings_week_start_sunday">Sunday</string> <string name="settings_time_format_auto">Automatic</string>
<string name="settings_time_format_12h">12-hour (2:00 PM)</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_summary">Show a separator line at each hour in week and day view</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_widget_range">Agenda widget range</string>
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
<string name="settings_agenda_range_bar">Range bar</string>
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
<string name="agenda_range_day">Today</string>
<string name="agenda_range_this_week">This week</string>
<string name="agenda_range_this_month">This month</string>
<string name="agenda_range_week">Next 7 days</string>
<string name="agenda_range_month">Next 30 days</string>
<string name="agenda_range_custom">Custom…</string>
<string name="agenda_range_custom_hint">Days</string>
<string name="agenda_range_override_hint">Just for now — resets to your saved range when you reopen Calendula.</string>
<string name="agenda_range_showing_label">Showing all upcoming events for</string>
<plurals name="agenda_range_days">
<item quantity="one">%d day</item>
<item quantity="other">%d days</item>
</plurals>
<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>
@@ -286,6 +319,7 @@
<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_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_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>
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string> <string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
<string name="settings_reliable_delivery">Reliable delivery</string> <string name="settings_reliable_delivery">Reliable delivery</string>
@@ -298,8 +332,10 @@
<string name="settings_section_language">Language</string> <string name="settings_section_language">Language</string>
<string name="settings_language">App language</string> <string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string> <string name="settings_language_auto">System default</string>
<string name="settings_translate">Help translate</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, dynamic colour, week start</string> <string name="settings_appearance_subtitle">Theme, default view, week start</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>
@@ -307,6 +343,7 @@
<string name="settings_license_value">MIT</string> <string name="settings_license_value">MIT</string>
<string name="settings_about_author">by Jean-Luc Makiola</string> <string name="settings_about_author">by Jean-Luc Makiola</string>
<string name="settings_about_source">Source</string> <string name="settings_about_source">Source</string>
<string name="settings_about_support">Support development</string>
<string name="settings_about_version">Version %1$s</string> <string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Calendula app icon</string> <string name="settings_about_logo_desc">Calendula app icon</string>
<string name="settings_report_problem">Report a problem</string> <string name="settings_report_problem">Report a problem</string>
@@ -317,9 +354,14 @@
<string name="calendars_local_header">Your calendars</string> <string name="calendars_local_header">Your calendars</string>
<string name="calendars_local_empty">No local calendars yet. Create one to keep events on this device only.</string> <string name="calendars_local_empty">No local calendars yet. Create one to keep events on this device only.</string>
<string name="calendars_add">Add calendar</string> <string name="calendars_add">Add calendar</string>
<string name="calendars_disable_hint">Turn a calendar off to remove it from the app — its events, filters and pickers. Nothing is deleted, and you can turn it back on here anytime.</string>
<string name="calendars_show_in_app_a11y">Show \"%1$s\" in the app</string>
<string name="calendars_synced_header">Synced calendars</string> <string name="calendars_synced_header">Synced calendars</string>
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string> <string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
<string name="calendars_manage_in_app">Manage</string> <string name="calendars_manage_in_app">Manage in app</string>
<string name="calendars_account_menu_a11y">More options for %1$s</string>
<string name="calendars_enable_all">Enable all</string>
<string name="calendars_disable_all">Disable all</string>
<string name="calendars_add_account">Add account</string> <string name="calendars_add_account">Add account</string>
<string name="calendars_new_title">New calendar</string> <string name="calendars_new_title">New calendar</string>
<string name="calendars_edit_title">Edit calendar</string> <string name="calendars_edit_title">Edit calendar</string>
@@ -333,6 +375,26 @@
<string name="calendars_backup_header">Backup</string> <string name="calendars_backup_header">Backup</string>
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string> <string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
<string name="calendars_backup_action">Export as .ics file</string> <string name="calendars_backup_action">Export as .ics file</string>
<string name="calendars_auto_backup">Automatic backup</string>
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
<string name="calendars_auto_backup_folder">Backup folder</string>
<string name="calendars_auto_backup_folder_unset">Tap to choose a folder</string>
<string name="calendars_auto_backup_interval">Interval</string>
<string name="calendars_auto_backup_every">Every %1$s</string>
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
<string name="calendars_auto_backup_status_never">No automatic backup yet</string>
<string name="calendars_auto_backup_status_ok">Last backup: %1$s</string>
<string name="calendars_auto_backup_status_failed">Last backup failed: %1$s</string>
<string name="backup_channel_name">Backup</string>
<string name="backup_channel_description">Warns if automatic backups fail repeatedly.</string>
<string name="backup_failed_title">Automatic backup failed</string>
<string name="backup_failed_text">Calendula couldn\'t write the backup file. Check the backup folder in Settings.</string>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
<string name="calendars_backup_failed">Couldn\'t export the backup.</string> <string name="calendars_backup_failed">Couldn\'t export the backup.</string>
<plurals name="calendars_backup_done"> <plurals name="calendars_backup_done">
<item quantity="one">Exported %d event.</item> <item quantity="one">Exported %d event.</item>
@@ -377,6 +439,8 @@
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string> <string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string> <string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/calendula/</string>
<!-- Crash reporting: a captured report the user can submit, by hand, as a <!-- Crash reporting: a captured report the user can submit, by hand, as a
Gitea issue (the app sends nothing automatically). --> Gitea issue (the app sends nothing automatically). -->

View File

@@ -170,6 +170,74 @@ class CalendarRepositoryImplTest {
} }
} }
@Test
fun `instances drops events whose calendar the user disabled`(@TempDir tempDir: Path) = runTest {
val prefs = newPrefs(tempDir)
prefs.setDisabledCalendarIds(setOf(2L))
val fake = FakeCalendarDataSource().apply {
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "Enabled", calendarId = 1L),
makeEvent(11L, "Disabled", calendarId = 2L),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("Enabled")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `instances applies the union of hidden and disabled sets`(@TempDir tempDir: Path) = runTest {
val prefs = newPrefs(tempDir)
prefs.setHiddenCalendarIds(setOf(2L))
prefs.setDisabledCalendarIds(setOf(3L))
val fake = FakeCalendarDataSource().apply {
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "Shown", calendarId = 1L),
makeEvent(11L, "Hidden", calendarId = 2L),
makeEvent(12L, "Disabled", calendarId = 3L),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("Shown")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `instances re-emits when the disabled set changes`(@TempDir tempDir: Path) = runTest {
val prefs = newPrefs(tempDir)
val fake = FakeCalendarDataSource().apply {
instancesResult = { _, _ ->
listOf(
makeEvent(10L, "A", calendarId = 1L),
makeEvent(11L, "B", calendarId = 2L),
)
}
}
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
repo.instances(range).test {
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
prefs.setDisabledCalendarIds(setOf(2L))
assertThat(awaitItem().map { it.title }).containsExactly("A")
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest { fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L } val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }

View File

@@ -50,4 +50,34 @@ class CalendarPrefsTest {
} }
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L)) assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L))
} }
@Test
fun `disabledCalendarIds defaults to empty when unset`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
assertThat(prefs.disabledCalendarIds.first()).isEmpty()
}
@Test
fun `setDisabledCalendarIds round-trips through DataStore`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setDisabledCalendarIds(setOf(1L, 42L, 7L))
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(1L, 42L, 7L))
}
@Test
fun `setting empty disabled set clears storage`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setDisabledCalendarIds(setOf(1L))
prefs.setDisabledCalendarIds(emptySet())
assertThat(prefs.disabledCalendarIds.first()).isEmpty()
}
@Test
fun `hidden and disabled sets are stored independently`(@TempDir tempDir: Path) = runTest {
val prefs = CalendarPrefs(newDataStore(tempDir))
prefs.setHiddenCalendarIds(setOf(1L))
prefs.setDisabledCalendarIds(setOf(2L))
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L))
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(2L))
}
} }

View File

@@ -5,6 +5,7 @@ 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.ui.agenda.AgendaRange
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
@@ -25,7 +26,7 @@ class SettingsPrefsTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM) assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
assertThat(prefs.dynamicColor.first()).isTrue() assertThat(prefs.dynamicColor.first()).isTrue()
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.AUTO) assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto)
} }
@Test @Test
@@ -43,10 +44,70 @@ class SettingsPrefsTest {
} }
@Test @Test
fun `week start round-trips`(@TempDir tempDir: Path) = runTest { fun `week start round-trips any day`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setWeekStart(WeekStartPref.SUNDAY) prefs.setWeekStart(WeekStartPref.Day(DayOfWeek.SATURDAY))
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY) assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Day(DayOfWeek.SATURDAY))
}
@Test
fun `legacy MONDAY week-start value migrates to Day`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.WEEK_START_KEY] = "MONDAY"
m
}
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Day(DayOfWeek.MONDAY))
}
@Test
fun `garbage week-start value falls back to Auto`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.WEEK_START_KEY] = "FUNDAY"
m
}
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto)
}
@Test
fun `time format defaults to auto and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.timeFormat.first()).isEqualTo(TimeFormatPref.AUTO)
prefs.setTimeFormat(TimeFormatPref.TWELVE_HOUR)
assertThat(prefs.timeFormat.first()).isEqualTo(TimeFormatPref.TWELVE_HOUR)
}
@Test
fun `hour lines default off and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.showHourLines.first()).isFalse()
prefs.setShowHourLines(true)
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
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month)
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month)
prefs.setAgendaScreenRange(AgendaRange.Custom(14))
prefs.setAgendaWidgetRange(AgendaRange.Week)
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14))
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week)
} }
@Test @Test
@@ -252,13 +313,15 @@ class SettingsPrefsTest {
@Test @Test
fun `explicit week-start prefs resolve regardless of locale`() { fun `explicit week-start prefs resolve regardless of locale`() {
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY) assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US))
assertThat(WeekStartPref.SUNDAY.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.SUNDAY) .isEqualTo(DayOfWeek.MONDAY)
assertThat(WeekStartPref.Day(DayOfWeek.SATURDAY).resolveFirstDay(Locale.GERMANY))
.isEqualTo(DayOfWeek.SATURDAY)
} }
@Test @Test
fun `auto week start follows the locale convention`() { fun `auto week start follows the locale convention`() {
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY) assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY) assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
} }
} }

View File

@@ -26,10 +26,24 @@ class ReminderTimeTextTest {
isAllDay = false, isAllDay = false,
zone = berlin, zone = berlin,
locale = Locale.GERMANY, locale = Locale.GERMANY,
is24Hour = true,
) )
assertThat(text).isEqualTo("09:30 10:00") assertThat(text).isEqualTo("09:30 10:00")
} }
@Test
fun `12-hour preference renders an am-pm time range`() {
val text = reminderTimeText(
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin),
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin),
isAllDay = false,
zone = berlin,
locale = Locale.US,
is24Hour = false,
)
assertThat(text).isEqualTo("2:00 PM 3:00 PM")
}
@Test @Test
fun `timed event crossing midnight includes both dates`() { fun `timed event crossing midnight includes both dates`() {
val text = reminderTimeText( val text = reminderTimeText(
@@ -38,6 +52,7 @@ class ReminderTimeTextTest {
isAllDay = false, isAllDay = false,
zone = berlin, zone = berlin,
locale = Locale.GERMANY, locale = Locale.GERMANY,
is24Hour = true,
) )
assertThat(text).contains("11.06.2026") assertThat(text).contains("11.06.2026")
assertThat(text).contains("12.06.2026") assertThat(text).contains("12.06.2026")
@@ -54,6 +69,7 @@ class ReminderTimeTextTest {
// 02:00 in Berlin — naive local reading would shift the day. // 02:00 in Berlin — naive local reading would shift the day.
zone = berlin, zone = berlin,
locale = Locale.GERMANY, locale = Locale.GERMANY,
is24Hour = true,
) )
assertThat(text).isEqualTo("11.06.2026") assertThat(text).isEqualTo("11.06.2026")
} }
@@ -66,6 +82,7 @@ class ReminderTimeTextTest {
isAllDay = true, isAllDay = true,
zone = berlin, zone = berlin,
locale = Locale.GERMANY, locale = Locale.GERMANY,
is24Hour = true,
) )
assertThat(text).isEqualTo("11.06.2026 12.06.2026") assertThat(text).isEqualTo("11.06.2026 12.06.2026")
} }
@@ -79,6 +96,7 @@ class ReminderTimeTextTest {
isAllDay = true, isAllDay = true,
zone = berlin, zone = berlin,
locale = Locale.GERMANY, locale = Locale.GERMANY,
is24Hour = true,
) )
assertThat(text).isEqualTo("11.06.2026") assertThat(text).isEqualTo("11.06.2026")
} }

View File

@@ -0,0 +1,83 @@
package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class AgendaRangeTest {
// A reference anchor: 2026-06-17 is a Wednesday.
private val wednesday = LocalDate(2026, 6, 17)
@Test
fun `rolling ranges have fixed 1, 7, 30 day counts regardless of anchor`() {
assertThat(AgendaRange.Day.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(1)
assertThat(AgendaRange.Week.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(7)
assertThat(AgendaRange.Month.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(30)
}
@Test
fun `this week runs through the day before the week repeats`() {
// Mon-start week: Wed → Wed,Thu,Fri,Sat,Sun = 5 days (everything before next Monday).
assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(5)
// On the first day of the week the whole 7 days remain.
val monday = LocalDate(2026, 6, 15)
assertThat(AgendaRange.ThisWeek.dayCount(monday, DayOfWeek.MONDAY)).isEqualTo(7)
// A Sunday-start week shifts the boundary: Wed → 4 days left (through Saturday).
assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.SUNDAY)).isEqualTo(4)
}
@Test
fun `this month runs through the last day of the month`() {
// June has 30 days: the 17th leaves 14 days (17..30 inclusive).
assertThat(AgendaRange.ThisMonth.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(14)
// February 2026 (non-leap) has 28 days.
assertThat(AgendaRange.ThisMonth.dayCount(LocalDate(2026, 2, 20), DayOfWeek.MONDAY))
.isEqualTo(9)
}
@Test
fun `custom day count is clamped to bounds`() {
assertThat(AgendaRange.Custom(45).dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(45)
assertThat(AgendaRange.Custom(0).dayCount(wednesday, DayOfWeek.MONDAY))
.isEqualTo(AgendaRange.MIN_CUSTOM_DAYS)
assertThat(AgendaRange.Custom(9_999).dayCount(wednesday, DayOfWeek.MONDAY))
.isEqualTo(AgendaRange.MAX_CUSTOM_DAYS)
}
@Test
fun `fixed ranges round-trip through storage`() {
listOf(
AgendaRange.Day,
AgendaRange.Week,
AgendaRange.Month,
AgendaRange.ThisWeek,
AgendaRange.ThisMonth,
).forEach { range ->
assertThat(parseAgendaRange(range.storageValue(), default = AgendaRange.Day))
.isEqualTo(range)
}
}
@Test
fun `custom range round-trips through storage`() {
val range = AgendaRange.Custom(14)
assertThat(range.storageValue()).isEqualTo("CUSTOM:14")
assertThat(parseAgendaRange("CUSTOM:14", default = AgendaRange.Day))
.isEqualTo(AgendaRange.Custom(14))
}
@Test
fun `garbage and null fall back to the default`() {
assertThat(parseAgendaRange(null, default = AgendaRange.Month)).isEqualTo(AgendaRange.Month)
assertThat(parseAgendaRange("YEAR", default = AgendaRange.Week)).isEqualTo(AgendaRange.Week)
assertThat(parseAgendaRange("CUSTOM:abc", default = AgendaRange.Day)).isEqualTo(AgendaRange.Day)
}
@Test
fun `out-of-range custom value clamps on parse`() {
assertThat(parseAgendaRange("CUSTOM:9999", default = AgendaRange.Day))
.isEqualTo(AgendaRange.Custom(AgendaRange.MAX_CUSTOM_DAYS))
}
}

View File

@@ -0,0 +1,37 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.util.Locale
class TimeFormatTest {
@Test
fun `24-hour format is zero-padded HH mm`() {
assertThat(formatTimeOfDay(9, 5, is24Hour = true, Locale.US)).isEqualTo("09:05")
assertThat(formatTimeOfDay(14, 0, is24Hour = true, Locale.US)).isEqualTo("14:00")
assertThat(formatTimeOfDay(0, 0, is24Hour = true, Locale.US)).isEqualTo("00:00")
}
@Test
fun `12-hour format adds a localised am-pm marker`() {
assertThat(formatTimeOfDay(14, 0, is24Hour = false, Locale.US)).isEqualTo("2:00 PM")
assertThat(formatTimeOfDay(0, 30, is24Hour = false, Locale.US)).isEqualTo("12:30 AM")
assertThat(formatTimeOfDay(12, 0, is24Hour = false, Locale.US)).isEqualTo("12:00 PM")
}
@Test
fun `minute-of-day end-of-day renders 24 00 in 24h and midnight in 12h`() {
assertThat(formatMinuteOfDay(1_440, is24Hour = true, Locale.US)).isEqualTo("24:00")
assertThat(formatMinuteOfDay(1_440, is24Hour = false, Locale.US)).isEqualTo("12:00 AM")
assertThat(formatMinuteOfDay(13 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("13:15")
}
@Test
fun `hour label is zero-padded in 24h and compact am-pm in 12h`() {
assertThat(formatHourLabel(13, is24Hour = true, Locale.US)).isEqualTo("13")
assertThat(formatHourLabel(13, is24Hour = false, Locale.US)).isEqualTo("1 PM")
assertThat(formatHourLabel(1, is24Hour = false, Locale.US)).isEqualTo("1 AM")
assertThat(formatHourLabel(0, is24Hour = false, Locale.US)).isEqualTo("12 AM")
}
}

View File

@@ -0,0 +1,94 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The pure back-stack rules behind [CalendarHost]'s top-level navigation:
* the home view is always the bottom, a lateral move keeps the stack one deep,
* and a date tap drills the day view on top. Verifies the back sequences the
* widget-UX fix promises (e.g. agenda widget → day → back → agenda → back → home).
*/
class ViewBackStackTest {
@Test
fun `a widget whose view is the home view resets to just the home`() {
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Week))
.containsExactly(CalendarView.Week)
}
@Test
fun `a widget launch resets to its view over the home`() {
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Month))
.containsExactly(CalendarView.Week, CalendarView.Month)
.inOrder()
}
@Test
fun `pill switches build a visit history that back retraces`() {
// Agenda → Day → Week → Month, each a new view, stacks the full history.
val stack = listOf(CalendarView.Agenda)
.selectView(CalendarView.Day)
.selectView(CalendarView.Week)
.selectView(CalendarView.Month)
assertThat(stack).containsExactly(
CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month,
).inOrder()
// Back walks the views in reverse, not straight to the home view.
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Week)
}
@Test
fun `selecting an already-visited view collapses the loop back to it`() {
val stack = listOf(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
.selectView(CalendarView.Agenda)
assertThat(stack).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder()
}
@Test
fun `selecting the home view collapses to just the home`() {
val stack = listOf(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
.selectView(CalendarView.Week)
assertThat(stack).containsExactly(CalendarView.Week)
}
@Test
fun `drilling pushes the day view on top`() {
assertThat(listOf(CalendarView.Week, CalendarView.Month).drillToDay())
.containsExactly(CalendarView.Week, CalendarView.Month, CalendarView.Day)
.inOrder()
}
@Test
fun `drilling is a no-op when the day view is already current`() {
val stack = listOf(CalendarView.Week, CalendarView.Day)
assertThat(stack.drillToDay()).isEqualTo(stack)
}
@Test
fun `agenda widget date tap backs out agenda then home`() {
// openDate(source = Agenda): root in Agenda over the Week home, then drill.
val stack = viewBaseStack(CalendarView.Week, CalendarView.Agenda).drillToDay()
assertThat(stack)
.containsExactly(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
.inOrder()
// Pressing back pops one level at a time down to the home view.
assertThat(stack.dropLast(1)).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder()
assertThat(stack.dropLast(2)).containsExactly(CalendarView.Week)
}
@Test
fun `month widget date tap backs out month then home`() {
val stack = viewBaseStack(CalendarView.Week, CalendarView.Month).drillToDay()
assertThat(stack.last()).isEqualTo(CalendarView.Day)
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Month)
assertThat(stack.first()).isEqualTo(CalendarView.Week)
}
@Test
fun `a widget whose view is the home view does not duplicate the home`() {
// Default = Agenda, agenda widget event tap → just the home, no extra layer.
assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda))
.containsExactly(CalendarView.Agenda)
}
}

24
docs/BUILDING.md Normal file
View File

@@ -0,0 +1,24 @@
# Building from source
Calendula builds with the standard Android Gradle toolchain — no extra setup
beyond the SDK and a JDK.
## Requirements
- **Android SDK 36+**
- **JDK 17** — if your default JDK is not 17, set `JAVA_HOME` explicitly.
The Gradle wrapper is checked in, so you don't need a system Gradle.
## Common tasks
```bash
./gradlew assembleDebug # debug APK → app/build/outputs/apk/debug/
./gradlew test # JVM unit tests
./gradlew lint # Android lint
```
## Release builds
Release signing and the publishing pipeline are documented separately in
[RELEASING.md](RELEASING.md).

View File

@@ -21,6 +21,7 @@ AuthorName: Jean-Luc Makiola
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
Donate: https://ko-fi.com/jeanlucmakiola
AutoName: Calendula AutoName: Calendula
@@ -47,6 +48,19 @@ Builds:
# No NDK / no flavors. The release buildType applies a signingConfig only # No NDK / no flavors. The release buildType applies a signingConfig only
# when key.properties exists; on the buildserver it does not, so this # when key.properties exists; on the buildserver it does not, so this
# produces the unsigned APK F-Droid compares against our binary. # produces the unsigned APK F-Droid compares against our binary.
#
# DO NOT add `submodules: true` here ahead of need. fdroidserver's
# initsubmodules() RAISES NoSubmodulesException ("No git submodules
# available") on any build entry whose commit has no `.gitmodules`, so
# `submodules: true` is a HARD BUILD FAILURE — not a no-op — on every
# release before the floret-kit submodule lands. (Verified 2026-06-28: it
# broke the fdroid build / checkupdates / check-source-code jobs on
# fdroiddata MR !41553; confirmed locally with the fdroidserver-master
# fetchsrclibs path.) Add `submodules: true` ONLY in the recipe entry that
# targets the FIRST release actually shipping the `floret-kit` git submodule
# (composite build via `includeBuild("floret-kit")`, core-time refactor on
# branch `chore/floret-kit-core-time`) — and only there. From that entry on
# it becomes the AutoUpdateMode template and propagates to future builds.
# SHA-256 of our app signing certificate (public; embedded in every published # SHA-256 of our app signing certificate (public; embedded in every published
# APK). Locks F-Droid to publish only binaries signed with our key. # APK). Locks F-Droid to publish only binaries signed with our key.

View File

@@ -0,0 +1,33 @@
### Added
- Choose the view Calendula opens on. A new **Default view** setting (Settings →
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
time you start the app, instead of always opening on Week. Thanks to
@devinside for the suggestion ([#1]).
- The month widget is now interactive: tap any day to open it, or tap an event to
open its details. Previously only the month's prev/next/today controls
responded. Thanks to @devinside for spotting this ([#2]).
- Predictive back. Swiping back from a screen — an event, the editor, search or
settings — now follows your finger, shrinking the screen to preview what's
behind so you can see where Back will take you before you let go; slide it back
to cancel. The preview needs Android 14 or newer; on older versions Back works
as before.
- Respect for "Remove animations". If you've turned animations off in your
device's Accessibility settings, Calendula now honours it everywhere: motion
collapses to a quick fade and the back preview is skipped.
### Changed
- Smoother, more consistent motion throughout. Expanding sections, list updates
in search and agenda, and the onboarding screens now animate the same way
across the app, instead of some places sliding or growing while others popped
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
snapping, while paging within a view keeps its slide.
### Fixed
- Home-screen widgets now back out the way you came in. A day or event opened
from the Agenda widget keeps you in the agenda context, and from the Month
widget in the month context — pressing Back returns there instead of dropping
you on the week view. More broadly, the app now keeps a real view history:
switching views and drilling into a day are retraced by Back one step at a
time, down to your default view before the app exits. Thanks to @devinside for
reporting ([#2]).

View File

@@ -0,0 +1,14 @@
### Added
- Turn a calendar off across the whole app. Settings → Calendars now has a
switch on every calendar — both your own and synced ones. Switching one off
removes it everywhere: its events disappear from all views and search, and it
drops out of the drawer's hide/show filter, the event-form calendar picker and
the import target picker. Unlike hiding (the quick per-view checkbox in the
drawer), a disabled calendar leaves the app entirely until you turn it back on.
Nothing is deleted and no other app is affected — it's a Calendula-only view
choice — so you can re-enable it any time from the same screen, where disabled
calendars stay listed but dimmed.
- An optional way to support development. Settings → About now has a "Support
development" button that opens Ko-fi in your browser. It's a plain donation
link with no perks attached, and it needs no new permissions — Calendula still
has no internet access of its own and just hands the link to your browser.

View File

@@ -0,0 +1,50 @@
### Added
- Start the week on any day. The **Week starts on** setting (Settings →
Appearance) now offers every weekday — not just Monday or Sunday — alongside
the automatic, locale-based default. Thanks to @zmaherdev for the suggestion
([#3]).
- Choose a 12- or 24-hour clock. A new **Time format** setting (Settings →
Appearance) lets you force a 12-hour (2:00 PM) or 24-hour (14:00) clock, or
follow the system setting automatically. It applies everywhere times appear —
the week and day timelines, agenda, event details, search and reminders — so
the format is now consistent across the whole app. Thanks to @zmaherdev for
the suggestion ([#6]).
- Optional hour lines in the timeline. A new **Hour lines** switch (Settings →
Appearance) draws a faint separator at each hour in the week and day views,
making it easier to see when events start and end. Off by default. Thanks to
@zmaherdev for the suggestion ([#5]).
- Limit how far ahead the agenda looks, and switch it on the fly. New **Agenda
range** and **Agenda widget range** settings (Settings → Appearance) let the
agenda screen and its home-screen widget each show just today, the rest of this
week, the rest of this month, a rolling 7 or 30 days, or a custom number of
days — "This week" follows your week-start. The agenda also gains a bar at the
top naming the exact dates in view, with a button to switch the range just for
the current session (it resets when you reopen the app); the bar can be turned
off. Thanks to @zmaherdev for the suggestion ([#4]).
- Automatic backup of local calendars. A new **Automatic backup** option
(Settings → Calendars → Backup) periodically exports your local calendars to an
`.ics` file in a folder you choose, on an interval you set (from 30 minutes up).
Useful for file-based syncing such as Syncthing, or simply as a safety net — it
is a one-way export and never touches your synced accounts. Thanks to @shield
for the discussion that inspired it ([#7], [#8]).
- Field icons in the event-form settings. Each optional-field toggle (Settings →
Event form) now shows the same icon the field uses in the new-event form, so
the list is easier to scan.
- Help translate Calendula. A **Help translate** link at the top of **Settings →
App language** opens the project's Weblate, where you can add or improve a
language in your browser — no coding needed. Contributions in any language are
welcome.
### Changed
- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,
agenda), and the agenda's empty state now reads "You're all caught up".
- Reworked **Settings → Calendars**. Local and synced calendars are now grouped
into collapsible, source-branded cards — each account shows its app's icon —
with a per-account menu to enable or disable all of its calendars at once or
open the account in its source app.
- Reorganised **Settings → Notifications**: reliable-delivery and snooze settings
moved up with the other global reminder options, and the per-calendar reminder
overrides now fold into a single expandable section.
- Moved **Add Quick Settings tile** out of the event-form section to its own
top-level entry in Settings.

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

@@ -8,3 +8,4 @@ Categories:
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
Donate: https://ko-fi.com/jeanlucmakiola

View File

@@ -26,10 +26,14 @@ lifecycleCompose = "2.10.0"
androidxTestRules = "1.7.0" androidxTestRules = "1.7.0"
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha). # Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
glance = "1.1.1" glance = "1.1.1"
work = "2.10.1"
documentfile = "1.0.1"
[libraries] [libraries]
# AndroidX core # AndroidX core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }