Compare commits

...

98 Commits

Author SHA1 Message Date
4e125e58d5 feat(contacts): show source year instead of age in titles
A FREQ=YEARLY event has one static title, so a per-occurrence age is impossible
without heavy per-year exception rows. Replace {age} with {year} — the birth
year (or an anniversary's start year) — which is static and correct on every
occurrence, and shows everywhere (widgets, other apps, exports).

- renderSpecialDateTitle substitutes {year}; drop the age snapshot computation
  and the sync `today` parameter.
- Default templates become "{name}'s birthday ({year})" / "…anniversary ({year})".
- Rename the setting to "Show year" (prefs specialDatesShowYear) and remove the
  now-unneeded {age} snapshot disclaimer in the template editor.
- Update tests, CHANGELOG and the design-doc note.

lint + unit tests + assembleDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:19:04 +02:00
52816b327a feat(contacts): per-type title format in the special-dates section
The "Title format" row was only editable for Birthdays; move it into each
per-type card so Anniversaries and Other dates get their own editable template
too (the dialog was already keyed by type). Each enabled type card now reads
toggle → title format → reminders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:54:11 +02:00
cbb241bc4d feat(contacts): calendar-wide reminders for special-dates calendars
Managed calendars now treat reminders as a calendar-level setting instead of the
seed-once-per-event model, matching how these homogeneous birthday/anniversary
calendars are actually used.

- New per-type "Reminders" control in the Contact special dates section. Changing
  it persists the per-calendar all-day override (so new events match) AND
  re-applies the set to *all existing events* in that calendar
  (CalendarDataSource.applyManagedCalendarReminders → SpecialDatesSyncEngine
  .applyReminders), encoding each event's all-day offset from its own date.
- Settings → Notifications: the contact-date calendars no longer offer a
  per-calendar override row; they show a link that jumps to the Contact special
  dates section (managedCalendarIds now in SettingsUiState).

Tests cover applyReminders (override persisted + bulk-apply invoked, and None
clears). lint + unit tests + assembleDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:41:52 +02:00
0fd579b19b feat(contacts): drop {age} from default titles, disclaim it when added
A FREQ=YEARLY event has one static title, so {age} is only a sync-time snapshot
and can look wrong on far-future occurrences. Keep it out of the default
templates ({name}'s birthday / anniversary), and when a user does add {age} in
the title-format editor, show a short disclaimer about the snapshot behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:51:42 +02:00
030c3e6ce1 docs(design): mark contact special-dates implemented + note deviations
Records the UID_2445 (vs SYNC_DATA1) identity decision, the sync-time age
snapshot, and the seeded reminder default; checks off the #14/#15 task lists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:17:23 +02:00
e67960d352 docs(contacts): translations, store copy + changelog for special dates
- German translations for the special-dates sub-page, the managed-field editor
  hint, and the calendar/title strings.
- Store listing (en/de): a paragraph on the optional, on-device, one-way contact
  special-dates feature.
- Soften the calendar-permission rationale ("all it asks for up front") now that
  an optional contacts permission exists, without weakening the privacy promise.
- CHANGELOG entry for #15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:16:11 +02:00
22bbfef3e2 feat(contacts): settings sub-page for contact special dates
A dedicated sub-page under Settings drives the mirror:

- master enable toggle — requests READ_CONTACTS contextually (only here, never at
  startup), runs an immediate sync on enable, and on disable confirms then tears
  the managed calendars down;
- per-type toggles (Birthdays / Anniversaries / Other), each confirm-guarded
  since turning one off deletes its calendar;
- an editable title template ({name}/{age}) and a Show-age toggle;
- a "Sync now" row with the last-run time, a paused/permission banner with a
  re-grant button, and a pointer to per-calendar colour/visibility/reminders.

Exposed via a separate SpecialDatesUiState flow (the main settings combine is
already at capacity); the ViewModel gained the engine/scheduler wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:12:08 +02:00
4da9abe05b feat(contacts): lock managed fields in the event editor
When editing an event in a special-dates calendar, the title, all-day/date and
recurrence are locked (the sync overwrites them) while reminders, location, notes
and colour stay editable — the field-level "managed" contract from the design.

- EventEditUiState.isManaged (calendar id ∈ managedCalendarIds while editing),
  wired through the ViewModel's state combine.
- InlineTextField/InlineField gain an `enabled` flag (dims + goes read-only);
  ScheduleRow dims and disables its date/time taps; the all-day switch and the
  recurrence card are disabled.
- A one-line "Managed by …" hint under the title explains what's editable.

The calendar picker needs no change — it's already create-only, and managed
events are always edits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:06:00 +02:00
4540faf2a0 feat(contacts): schedule + lifecycle for the special-dates mirror
Mirrors the auto-backup infrastructure. A daily periodic WorkManager job plus an
immediate run on enable/"Sync now" and a debounced foreground run keep the mirror
fresh without a ContentObserver.

- SpecialDatesScheduler.apply/runNow + SpecialDatesSyncWorker (EntryPoint):
  respects the toggle, debounces foreground runs (4h), and parks the feature in a
  PermissionRevoked stalled state (recorded for the settings banner) instead of
  retrying forever when READ_CONTACTS is gone.
- CalendulaApp.onCreate reconciles the schedule on every launch.
- RootScreen ON_RESUME enqueues a debounced foreground sync, gated on the
  READ_CONTACTS grant so opted-out users never enqueue it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:01:29 +02:00
3aecb19a0c feat(contacts): idempotent special-dates sync engine
The engine reconciles device contact dates into the per-type local calendars.
Each run is an idempotent diff keyed on the deterministic UID_2445:

- new contacts inserted (seeding reminders via resolveDefaultReminder, plus a
  useful per-calendar all-day default of on-the-day + a week before so birthdays
  get lead time out of the box);
- changed contacts get a targeted managed-column update (title/dtstart/rrule
  only) — reminders/location/notes are never re-touched, so user edits survive;
- removed contacts deleted.

Managed calendars are created/adopted/removed per enabled type (reconcileCalendars,
self-healing against a stored-id that no longer exists), all-day FREQ=YEARLY
events anchored at the birth year (or a leap anchor when year-less). Pure helpers
(uid, anchor, age snapshot, title templating) and the diff are extracted for
unit testing; a stateful fake exercises full-run idempotency and user-data
preservation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:58:26 +02:00
f7c0b8a957 feat(contacts): special-dates preferences + managed-calendar identity
Adds the SettingsPrefs surface the mirror is configured and reconciled through:
master enable, per-type toggles (default all three on), per-type managed calendar
id (source of truth for the editor's managedCalendarIds and the sync targets),
editable per-type title template, show-age, and a run/stalled status plus a
foreground-sync debounce timestamp.

Calendar ids are stored per type under their own long key (no serialization/
escaping); templates likewise per type. SpecialDatesStatus/StalledReason model
the paused (permission-revoked) state for the settings banner.

Extends the test FakeCalendarDataSource with a stateful managed-event surface
(inserts reflected in later queries) so the sync engine can be exercised for
idempotency. Round-trip tests for every new pref.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:27:36 +02:00
9a8d9b2177 feat(contacts): add managed-event write surface to CalendarDataSource
The special-dates mirror needs a durable, user-invisible per-event key so a
re-sync diffs instead of duplicating. Rather than Events.SYNC_DATA1 (which the
provider drops on a LOCAL calendar unless written via a sync-adapter URI), the
key is a deterministic UID_2445 (contact-<type>:<lookupKey>@calendula), written
and read through the normal event URIs the app already uses.

- createManagedCalendar: a normal local calendar plus a CAL_SYNC2 marker, so the
  mirror can re-adopt its calendars after a prefs wipe (findManagedCalendars).
- queryManagedEvents: reads back _ID/UID_2445/TITLE/DTSTART/RRULE (DELETED=0) as
  the existing side of the diff.
- insertManagedEvent: writes the deterministic UID and seeds reminder rows once.
- updateManagedFields: a bare, targeted column update — never reconciles
  reminders/attendees, so user-owned event data survives every sync.

Reuses toWriteTimes/toRfc2445Duration (all-day UTC-midnight DTSTART + P1D
DURATION for the FREQ=YEARLY row) and the existing deleteEvent for removals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:22:28 +02:00
93ca38d974 feat(contacts): declare READ_CONTACTS + read contact special-dates
Codeberg #15, foundation for the contact special-dates calendars. Declares the
optional, feature-gated READ_CONTACTS permission (never requested at startup)
and adds the offline, read-only contacts reader.

- AndroidManifest: READ_CONTACTS with a comment documenting the opt-in/offline
  one-way-mirror contract.
- domain/contacts: SpecialDateType + ContactSpecialDate model and a pure
  parseContactEventDate covering full (yyyy-MM-dd), year-less (--MM-dd) and
  compact (yyyyMMdd) shapes, with Feb-29 handling via a leap anchor.
- data/contacts: ContactSpecialDatesDataSource querying ContactsContract.Data
  Event rows, split by TYPE, deduped per (contact, type); returns empty without
  the permission so sync can degrade to a stalled state. Hilt-bound.
- Unit tests for the date parser (full/year-less/compact/Feb-29/malformed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:19:42 +02:00
a38093cae3 Merge branch 'feat/per-calendar-multi-reminders' into feat/contact-special-dates 2026-07-01 10:14:29 +02:00
225f4c3491 test(reminders): cover disabled-calendar alert filtering
Extract the disabled-calendar filtering from EventReminderReceiver into a
pure postableAlerts() function and unit-test it, since the receiver flow
itself is not reachable from a plain JUnit test.

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

Closes #17

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:40:46 +02:00
a89560953d Merge remote-tracking branch 'origin/release/v2.13.0' into feat/per-calendar-multi-reminders 2026-06-30 16:28:39 +02:00
4e498de051 Merge fix/edit-recurring-this-event into release/v2.13.0
Codeberg #16: 'only this event' recurrence edits now write a DURATION-based
exception (DTEND is rejected by the provider). Includes debug-build markers.

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

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

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

    IllegalArgumentException: Exceptions can't overwrite dtend

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:19:44 +02:00
25ff7ce7ca docs(design): contact special-dates calendars + per-calendar multiple reminders
Capture the full design for surfacing contact birthdays/anniversaries/custom
dates as auto-updating local calendars (Codeberg #15), plus the #14
prerequisite (per-calendar multiple default reminders). One local calendar per
type to reuse existing per-calendar color/visibility/reminder infra; one-way
mirror with field-level managed events keyed on contact LOOKUP_KEY.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Bumps to 2.11.2.

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

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

Bumps to 2.11.1.

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

View File

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

View File

@@ -3,7 +3,7 @@ name: Feature request
about: Suggest an idea or improvement
title: ""
labels:
- enhancement
- feat
---
### 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)**
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)*
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).
**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"
bucket, owner says high-importance 2026-06-22)* — attendees are already
*read* (queried, mapped, shown on the detail screen since v0.6) but the
event form can't *write* them, the last big read-only gap in the event
model. Add an attendees section to `EventEditScreen` / `EventForm`: add by
typed email (contact-picker entry can follow later), edit/remove rows, set
role (required / optional) — writing `CalendarContract.Attendees` rows on
insert + dirty-checked update, mirroring the reminders-diff pattern.
**Caveat to resolve first:** writing `Attendees` rows triggers sync-adapter
*invitation* behavior that differs by backend (Google auto-emails invites;
DAVx5/CalDAV writes the `ATTENDEE` property and lets the server decide) —
decide and document what Calendula promises before building. Full sketch in
"Attendee editing" under Locations & People below.
12. **Attendee editing** *(shipped — merged into release/v2.8.0, `feat/attendee-editing`
MR !29 / commit `b0f34ff`, 2026-06-22)* — closed the last big read-only gap in
the event model: attendees were already *read* (queried, mapped, shown on the
detail screen since v0.6) and are now *writable* from the form. An attendees
section on `EventEditScreen` / `EventForm` adds by typed email **or** the
no-permission contact picker, edits/removes rows, and sets role (required /
optional) — writing `CalendarContract.Attendees` rows on insert + dirty-checked
update, mirroring the reminders-diff pattern. The sync-adapter *invitation*
caveat was **settled record-only** (Calendula never sends invites; the backend
decides delivery) — full sketch + decision in "Attendee editing" under Locations
& People below.
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
- 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)
**Bottom — deprioritised, not important**
- Duplicate event (detail action → prefilled create form) — moved here
2026-06-17; cheap but low value, pick up only if asked
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
full-text search. Pulled in opportunistically, not sequenced.
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts.
Pulled in opportunistically, not sequenced.
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).
**Next committed work is Tier 5 #12 — attendee editing** (the last read-only gap
in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
(quick-settings tile, now-line, week numbers in month, full-text search).
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected),
and Tier 5 #12 — attendee editing — shipped in release/v2.8.0, closing the last
read-only gap in the event model. v2.8.0 also cleared most Tier 2/3 leftovers
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
@@ -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
natural data source for a future widget)
- Jump to date — drawer date picker (un-cut from V1)
- Current-time "now" line in day/week — standard in every calendar, cheap,
currently absent. Daily-driver polish.
- Week numbers in the **month** grid — week view already shows the badge
(`WeekNumberBadge`, `WeekScreen.kt`); extend to month for ISO/European users.
- ~~Current-time "now" line in day/week~~ **shipped v2.8.0**
- Week numbers in the **month** grid — **rejected** (owner decision): clutters
the view and shrinks the day cells; the badge stays week-view-only.
- Pinch-to-zoom time scale in day/week
- Tablet / foldable layouts *(was v3.0)*
- Full-text search *(was v3.0)* — promote out of "fill-in": for a daily driver
with real event history, finding an event is core completeness, not optional.
- ~~Full-text search~~ **shipped v2.8.0** for a daily driver with real event
history, finding an event is core completeness, not optional.
## Event editing & creation
- Duplicate event (detail action → prefilled create form)
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form)
*(next)* — chosen to follow the in-progress tap-to-create + calendar
management work: reuses the color-picker component and palette plumbing
being built for local calendar management, and finishes the create/edit
theme. `EVENT_COLOR` / `EVENT_COLOR_KEY` from the calendar's color list
(`Colors` table, `TYPE_EVENT`); falls back to the calendar color when unset.
- ~~**Per-event color**~~ *(shipped v2.4.0)*`EVENT_COLOR` / `EVENT_COLOR_KEY`
from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard
picker in the form, falling back to the calendar color when unset. Reused the
color-picker component and palette plumbing from local calendar management and
finished the create/edit theme.
## 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
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
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
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
(reliability vs simplicity + battery cost).
2. All-day reminder representation (minutes-before vs absolute time-of-day).
3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs
a list inside the Notifications sub-page).
1. **Provider broadcast kept** — did not self-schedule via `AlarmManager`; the
battery-optimisation exemption is the reliability lever (simplicity + battery
cost won over the exact-alarm subsystem).
2. **All-day reminders = minutes-before-midnight** (day-scale presets); absolute
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
exact-alarm / WorkManager decision) — Tier 4 #13.
- ~~Snooze + dismiss actions on the notification~~ *(shipped in release/v2.8.0,
Tier 4 #11)* — snooze re-fires a snooze-only exact alarm.
## Sharing & interop
@@ -438,8 +495,8 @@ it directly undermines the feature's premise, so it rides in here.
## Platform & launchers
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
optional quick-settings tile still open
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
- ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
## 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
in detail yet:
- **Contact address picker** for the location field via the system picker
(`ACTION_PICK` on postal addresses) — one-shot, needs no READ_CONTACTS,
fits the privacy story. Same mechanism later for picking emails.
- ~~**Contact address picker** for the location field via the system picker
(`ACTION_PICK`)~~ **shipped v2.8.0** — one-shot, needs no READ_CONTACTS, fits
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
Tor" → tap suggestion → resolved address inserted). Backend would be
Photon (Nominatim's public policy forbids autocomplete). **Requires the

View File

@@ -5,6 +5,148 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- See your contacts' birthdays and anniversaries in your calendar. A new,
**optional** feature (Settings → Contact special dates) mirrors your contacts'
birthdays, anniversaries and other dates into local "Birthdays",
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
change. Each is a normal local calendar, so you set its colour, visibility and
reminders the usual way — new birthdays even start with a reminder a week before
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
This is the first feature to use the contacts permission: it is requested only
when you turn the feature on, everything stays on your device (Calendula has no
internet access), and your contacts are only ever read, never changed. Thanks
to @moonj for the suggestion ([#15]).
- Set more than one default reminder per calendar. A calendar's default
reminders — and the global defaults under Settings → Notifications — can now
hold several lead times instead of just one, so new events can start with, say,
a reminder a week before *and* one on the day. The reminder pickers are now
multi-select; per-calendar overrides can still inherit the global default or
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
### Fixed
- Disabled calendars no longer notify. Reminders for events in a calendar you
have disabled (Settings → Calendars) are now suppressed instead of still
popping up — matching how a disabled calendar's events already stay hidden
everywhere else in the app ([#17]).
- Editing a single occurrence of a recurring event works again. Choosing **Only
this event** and saving a change to one event in a repeating series silently
did nothing — the change was rejected and the edit form simply reappeared with
nothing applied. The edited occurrence is now stored correctly, so the change
lands on just that one event and leaves the rest of the series untouched
([#16]).
## [2.12.0] — 2026-06-28
### Added
- See past events your way. Two new settings change how events that have already
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
usual, dim them, or hide them from the list entirely. **Dim completed events**
(Settings → Appearance) separately fades finished events in the month and week
views. Both are off by default, an event only counts as finished once it has
actually ended (events still in progress are never dimmed), and the lists
update on their own as the day goes on. Thanks to @ptab for the suggestion
([#12]).
### Changed
- Readable titles on overlapping events. In the week and day views, events that
overlap split a day into slim columns where the title used to be clipped to a
character or two. The title now wraps across as many lines as the block can
fit, and the time is hidden on those narrow blocks so the name can fill the
space — so you can tell events apart without opening each one. Thanks to @ptab
for the suggestion ([#13]).
## [2.11.2] — 2026-06-28
### Added
- Jump straight to typing a title. When you start a new event, Calendula now puts
the cursor in the title field and opens the keyboard right away, so you can type
the name without an extra tap. It's on by default and only affects creating an
event — editing never grabs focus — and a new **Focus title on new event**
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
suggestion ([#10]).
## [2.11.1] — 2026-06-28
### Fixed
- Calendula can now be set as your default calendar app. It registers the
calendar-app intent filters the system uses, so it appears in the chooser when
you tap a date in a launcher or clock — and opening one takes you straight to
that day. Android has no way for an app to make itself the default, so you pick
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
## [2.11.0] — 2026-06-27
### 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
@@ -610,3 +752,17 @@ automatically, with zero telemetry and no internet permission.
[#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
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
[#15]: https://codeberg.org/jlmakiola/calendula/issues/15
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17

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>
</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>
<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;
@@ -62,19 +68,27 @@ database, no sync stack reinvented.
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
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
never leaves the device
## 📦 Install
Calendula ships through a self-hosted F-Droid repository; every version tag
is built, signed, and published there automatically.
### F-Droid (recommended)
1. Install an F-Droid client ([F-Droid](https://f-droid.org), Droid-ify, Neo
Store, …).
2. Add the repository — open this link on your phone, or paste it under
*Settings → Repositories → Add*:
Calendula is on the **official [F-Droid](https://f-droid.org) repository**
just search for **Calendula** in any F-Droid client, or
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
### 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
@@ -84,36 +98,27 @@ is built, signed, and published there automatically.
fingerprint (SHA-256):
`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
other F-Droid app.
2. Refresh, search for **Calendula**, install.
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
./gradlew assembleDebug # debug APK
./gradlew test # JVM unit tests
./gradlew lint # Android lint
```
## 🌍 Translations
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 →
CalendarContract`, observer-driven refresh, JVM-first tests. The full tour —
including the recurring-write and reminder pipelines — lives in
[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).
No coding needed — register on the Weblate server, pick (or request) a language,
and translate the strings in your browser. You can also reach this link in the
app from the top of **Settings → App language**.
## 📜 License

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,15 @@
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--
Optional and feature-gated: only the "Contact special dates" feature reads
contacts, and only after the user enables it and grants this at runtime
(never requested at startup). Everything stays offline — birthdays and
other contact dates are mirrored one-way into local calendars; contacts
are never written and nothing leaves the device (the app has no INTERNET
permission). See docs/design/contact-special-dates.md.
-->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<!--
Lets the "Reliable delivery" setting open the direct system dialog to
exempt Calendula from battery optimisation (so reminder broadcasts aren't
@@ -59,6 +68,35 @@
<category android:name="android.intent.category.LAUNCHER" />
</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). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />

View File

@@ -1,8 +1,18 @@
package de.jeanlucmakiola.calendula
import android.app.Application
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
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"
@@ -16,5 +26,42 @@ class CalendulaApp : Application() {
// Install first thing so startup crashes are captured too (privacy-
// respecting, on-device; the user submits the report by hand).
CrashReporter.install(this)
reconcileAutoBackup()
reconcileSpecialDates()
}
/**
* 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,
)
}
}
/**
* Re-arm (or cancel) the daily special-dates reconcile from the saved
* settings on every launch — like [reconcileAutoBackup], this cancels
* orphaned work after the feature is turned off and re-schedules after a
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
* refresh, so no immediate run is needed here.
*/
private fun reconcileSpecialDates() {
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
SpecialDatesScheduler.apply(
context = this@CalendulaApp,
enabled = deps.settingsPrefs().specialDatesEnabled.first(),
)
}
}
}

View File

@@ -8,11 +8,15 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
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.platform.LocalContext
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
@@ -20,15 +24,22 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
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.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@@ -81,19 +92,36 @@ class MainActivity : AppCompatActivity() {
ThemeMode.LIGHT -> false
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(
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
) {
RootScreen(
modifier = Modifier.fillMaxSize(),
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = { requestedDetailKey = null },
widgetNavRequest = requestedNav,
onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
)
Box(modifier = Modifier.fillMaxSize()) {
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalShowHourLines provides settings.showHourLines,
) {
RootScreen(
modifier = Modifier.fillMaxSize(),
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = { requestedDetailKey = null },
widgetNavRequest = requestedNav,
onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
)
}
// A persistent corner marker so a debug build is never
// mistaken for the production app; compiled out of release.
if (BuildConfig.DEBUG) DebugRibbon()
}
pendingCrashReport?.let { report ->
CrashReportDialog(
report = report,
@@ -138,10 +166,33 @@ class MainActivity : AppCompatActivity() {
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
else -> 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" }
}
/**
* 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 {
@@ -183,6 +234,10 @@ class MainActivity : AppCompatActivity() {
}
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_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"

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

@@ -24,10 +24,12 @@ import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import kotlinx.datetime.toJavaLocalDate
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID
@@ -97,6 +99,54 @@ interface CalendarDataSource {
/** Permanently delete a local calendar the app owns, with all its events. */
fun deleteCalendar(id: Long)
/**
* Create a local calendar tagged as the special-dates mirror for [type]
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
* local calendar, so it inherits per-calendar colour/visibility/reminders.
*/
fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long
/**
* The app's special-dates calendars, found by their `CAL_SYNC2` marker.
* Lets the mirror re-adopt its calendars after the stored ids are lost, and
* detect when the user deleted one in system settings.
*/
fun findManagedCalendars(): List<ManagedCalendarRow>
/**
* The managed events in [calendarId] (those carrying a `UID_2445`, not
* soft-deleted) as [ManagedEventRow]s — the existing side of the sync diff.
*/
fun queryManagedEvents(calendarId: Long): List<ManagedEventRow>
/**
* Insert a managed event carrying the deterministic [uid] in `UID_2445`;
* seeds its reminder rows from [form] (never touched again by sync). Returns
* the new `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
*/
fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long
/**
* Overwrite exactly the given managed columns on [eventId] (keys are
* `CalendarContract.Events` columns; a null value clears the column). A
* bare, targeted update — it never reconciles reminders or attendees, so
* user-owned data on the event survives every sync. No-ops on an empty map.
*/
fun updateManagedFields(eventId: Long, values: Map<String, Any?>)
/**
* Replace the reminders on **every** event in [calendarId] with [minutes]
* (all-day lead times), each encoded from its own date so it fires at
* [allDayReminderTimeMinutes]. Used by the special-dates section so a reminder
* change applies to existing events too, not just future ones — the managed
* calendars treat reminders as a calendar-level setting.
*/
fun applyManagedCalendarReminders(
calendarId: Long,
allDayReminderTimeMinutes: Int,
minutes: List<Int>,
)
/**
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
* (minutes from local midnight) is the wall-clock time all-day reminders
@@ -292,6 +342,160 @@ class AndroidCalendarDataSource @Inject constructor(
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
}
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.NAME, name)
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
put(
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
CalendarContract.Calendars.CAL_ACCESS_OWNER,
)
put(CalendarContract.Calendars.VISIBLE, 1)
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
// The marker that identifies this as our special-dates calendar,
// independent of the (user-editable) display name. CAL_SYNC1 already
// holds the description, so the marker lives in CAL_SYNC2.
put(MANAGED_MARKER_COLUMN, markerFor(type))
}
val uri = resolver.insert(localCalendarsUri(), values)
?: throw WriteFailedException("create managed calendar")
return ContentUris.parseId(uri)
}
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
CalendarContract.Calendars.CONTENT_URI,
arrayOf(CalendarContract.Calendars._ID, MANAGED_MARKER_COLUMN),
"${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " +
"${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND " +
"$MANAGED_MARKER_COLUMN IS NOT NULL",
arrayOf(CalendarContract.ACCOUNT_TYPE_LOCAL, LOCAL_ACCOUNT_NAME),
null,
)?.use { c ->
buildList {
while (c.moveToNext()) {
val type = typeForMarker(c.getString(1)) ?: continue
add(ManagedCalendarRow(id = c.getLong(0), type = type))
}
}
} ?: emptyList()
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> = resolver.query(
CalendarContract.Events.CONTENT_URI,
arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.UID_2445,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.RRULE,
),
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.UID_2445} IS NOT NULL",
arrayOf(calendarId.toString()),
null,
)?.use { c ->
buildList {
while (c.moveToNext()) {
val uid = c.getString(1)?.takeIf { it.isNotEmpty() } ?: continue
add(
ManagedEventRow(
eventId = c.getLong(0),
uid = uid,
title = c.getString(2).orEmpty(),
dtStartMillis = c.getLong(3),
rrule = c.getString(4)?.takeIf { it.isNotEmpty() },
),
)
}
}
} ?: emptyList()
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
val times = form.toWriteTimes(ZoneId.systemDefault())
val values = ContentValues().apply {
put(
CalendarContract.Events.CALENDAR_ID,
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
)
// Deterministic UID = the reconciliation key. A re-sync reads it back
// (queryManagedEvents) and diffs on it, so a contact never doubles.
put(CalendarContract.Events.UID_2445, uid)
put(CalendarContract.Events.TITLE, form.title.trim())
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
if (form.rrule == null) {
put(CalendarContract.Events.DTEND, times.dtEndMillis)
} else {
put(CalendarContract.Events.RRULE, form.rrule)
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
}
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
form.location.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
form.description.trim().takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
}
val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?.let(ContentUris::parseId)
?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}")
// Seeded once, then never re-touched by sync — so a user who moves a
// birthday reminder keeps their change.
encodedReminders(form, allDayReminderTimeMinutes).forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to managed event $eventId")
}
}
return eventId
}
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
if (values.isEmpty()) return
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(),
null, null,
)
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
}
override fun applyManagedCalendarReminders(
calendarId: Long,
allDayReminderTimeMinutes: Int,
minutes: List<Int>,
) {
val zone = ZoneId.systemDefault()
resolver.query(
CalendarContract.Events.CONTENT_URI,
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
"${CalendarContract.Events.CALENDAR_ID} = ? AND ${CalendarContract.Events.DELETED} = 0",
arrayOf(calendarId.toString()),
null,
)?.use { c ->
while (c.moveToNext()) {
val eventId = c.getLong(0)
// Managed events are all-day: DTSTART is a UTC midnight.
val startDate = Instant.ofEpochMilli(c.getLong(1))
.atZone(ZoneOffset.UTC).toLocalDate()
val encoded = minutes
.map { toProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes) }
.distinct()
reconcileReminders(eventId, encoded)
}
}
}
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
ensureObserversRegistered()
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
@@ -1038,6 +1242,17 @@ class AndroidCalendarDataSource @Inject constructor(
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
}
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
private fun markerFor(type: SpecialDateType): String = when (type) {
SpecialDateType.Birthday -> "$MANAGED_MARKER_PREFIX:birthday"
SpecialDateType.Anniversary -> "$MANAGED_MARKER_PREFIX:anniversary"
SpecialDateType.Custom -> "$MANAGED_MARKER_PREFIX:custom"
}
/** Inverse of [markerFor]; null for a value that isn't one of ours. */
private fun typeForMarker(marker: String?): SpecialDateType? =
SpecialDateType.entries.firstOrNull { markerFor(it) == marker }
private companion object {
const val TAG = "CalendarDataSource"
@@ -1047,6 +1262,13 @@ class AndroidCalendarDataSource @Inject constructor(
*/
const val LOCAL_ACCOUNT_NAME = "Calendula"
/**
* Column and namespace for the special-dates marker. CAL_SYNC1 already
* holds the local-calendar description, so the marker uses CAL_SYNC2.
*/
val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
/**
* How far ahead/behind a search looks for a recurring event's nearest
* occurrence (~2 years). Wide enough for everyday series; a series that

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
@@ -55,11 +56,12 @@ class CalendarRepositoryImpl @Inject constructor(
.reQuery { dataSource.calendars() }
.flowOn(io)
// Instances are filtered by the app-side hidden-calendar set (M3): an event
// is dropped whenever the user has hidden its calendar. Re-runs when the
// provider ticks *or* the hidden set changes — toggling a calendar in the
// filter sheet updates every view immediately. [calendars] stays unfiltered
// so the filter sheet can list and re-enable hidden calendars.
// Instances are filtered by the app-side hidden disabled calendar sets
// (M3): an event is dropped whenever the user has hidden *or* disabled its
// calendar. Re-runs when the provider ticks *or* either set changes —
// toggling a calendar in the filter sheet or the calendar manager updates
// 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>> =
combine(
ticks
@@ -71,10 +73,17 @@ class CalendarRepositoryImpl @Inject constructor(
)
},
prefs.hiddenCalendarIds,
) { instances, hidden ->
if (hidden.isEmpty()) instances
else instances.filterNot { it.calendarId in hidden }
}.flowOn(io)
prefs.disabledCalendarIds,
) { instances, hidden, disabled ->
val excluded = hidden + disabled
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) {
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
@@ -82,9 +91,9 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
if (query.isBlank()) return@withContext emptyList()
val hidden = prefs.hiddenCalendarIds.first()
val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
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> =

View File

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

View File

@@ -0,0 +1,24 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
/**
* One of the app's special-dates calendars, discovered by the marker stamped in
* its `CAL_SYNC2` column — so the mirror can re-adopt its calendars even if the
* stored ids in preferences were lost (e.g. an app-data wipe).
*/
data class ManagedCalendarRow(val id: Long, val type: SpecialDateType)
/**
* A managed event as read back for the sync diff. [uid] is the deterministic
* `Events.UID_2445` (`contact-<type>:<lookupKey>@calendula`) the mirror keys on;
* [title], [dtStartMillis] and [rrule] are the managed columns compared against
* the desired state to decide whether a targeted update is needed.
*/
data class ManagedEventRow(
val eventId: Long,
val uid: String,
val title: String,
val dtStartMillis: Long,
val rrule: String?,
)

View File

@@ -0,0 +1,41 @@
package de.jeanlucmakiola.calendula.data.contacts
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import javax.inject.Inject
import javax.inject.Singleton
/**
* Resolves the localized calendar names/templates and per-type colours the
* mirror creates its calendars with. The colours are picked from the shared
* palette so managed calendars look native alongside user calendars.
*/
@Singleton
class AndroidSpecialDatesCalendarSpec @Inject constructor(
@ApplicationContext private val context: Context,
) : SpecialDatesCalendarSpec {
override fun displayName(type: SpecialDateType): String = context.getString(
when (type) {
SpecialDateType.Birthday -> R.string.special_dates_calendar_birthday
SpecialDateType.Anniversary -> R.string.special_dates_calendar_anniversary
SpecialDateType.Custom -> R.string.special_dates_calendar_custom
},
)
override fun defaultTitleTemplate(type: SpecialDateType): String = context.getString(
when (type) {
SpecialDateType.Birthday -> R.string.special_dates_default_title_birthday
SpecialDateType.Anniversary -> R.string.special_dates_default_title_anniversary
SpecialDateType.Custom -> R.string.special_dates_default_title_custom
},
)
override fun color(type: SpecialDateType): Int = when (type) {
SpecialDateType.Birthday -> 0xFF8E24AA.toInt() // purple
SpecialDateType.Anniversary -> 0xFFD50000.toInt() // red
SpecialDateType.Custom -> 0xFF039BE5.toInt() // blue
}
}

View File

@@ -0,0 +1,98 @@
package de.jeanlucmakiola.calendula.data.contacts
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.Event
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reads the dated `Event` rows (birthdays, anniversaries, custom dates) of the
* device's contacts. Read-only and offline — the one-way source for the
* special-dates mirror. Requires `READ_CONTACTS`; returns an empty list when
* the permission is absent so the sync can degrade to a stalled state rather
* than crash.
*/
interface ContactSpecialDatesDataSource {
fun hasPermission(): Boolean
/** All usable contact special-dates, deduplicated per contact and type. */
fun readSpecialDates(): List<ContactSpecialDate>
}
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday
Event.TYPE_ANNIVERSARY -> SpecialDateType.Anniversary
else -> SpecialDateType.Custom
}
@Singleton
class AndroidContactSpecialDatesDataSource @Inject constructor(
@ApplicationContext private val context: Context,
) : ContactSpecialDatesDataSource {
override fun hasPermission(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
override fun readSpecialDates(): List<ContactSpecialDate> {
if (!hasPermission()) return emptyList()
val resolver = context.contentResolver
// A contact can carry the same date more than once (multiple raw
// contacts under one aggregate); keep the first per (contact, type).
val seen = HashSet<Pair<String, SpecialDateType>>()
val result = ArrayList<ContactSpecialDate>()
runCatching {
resolver.query(
ContactsContract.Data.CONTENT_URI,
PROJECTION,
"${ContactsContract.Data.MIMETYPE} = ?",
arrayOf(Event.CONTENT_ITEM_TYPE),
null,
)?.use { c ->
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
val idxLabel = c.getColumnIndexOrThrow(Event.LABEL)
val idxLookup = c.getColumnIndexOrThrow(ContactsContract.Data.LOOKUP_KEY)
val idxName = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME_PRIMARY)
while (c.moveToNext()) {
val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue
val parts = parseContactEventDate(c.getString(idxDate)) ?: continue
val type = specialDateTypeForRawEventType(c.getInt(idxType))
if (!seen.add(lookup to type)) continue
result += ContactSpecialDate(
lookupKey = lookup,
displayName = c.getString(idxName)?.trim().orEmpty(),
type = type,
month = parts.month,
day = parts.day,
year = parts.year,
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
)
}
}
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }
return result
}
private companion object {
const val TAG = "ContactSpecialDates"
val PROJECTION = arrayOf(
ContactsContract.Data.LOOKUP_KEY,
ContactsContract.Data.DISPLAY_NAME_PRIMARY,
Event.START_DATE,
Event.TYPE,
Event.LABEL,
)
}
}

View File

@@ -0,0 +1,119 @@
package de.jeanlucmakiola.calendula.data.contacts
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.Data
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.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
import kotlinx.coroutines.flow.first
import java.util.concurrent.TimeUnit
/**
* Schedules the contact special-dates mirror. Birthdays change rarely, so this
* is deliberately cheap: a **daily** periodic reconcile, plus an immediate run
* on enable / "Sync now" and a debounced one when the app is foregrounded. No
* ContentObserver — it would need the process alive and buys almost nothing for
* once-a-year events. Everything stays offline (local contacts → local calendar).
*/
object SpecialDatesScheduler {
private const val WORK_NAME = "special-dates-sync"
private const val WORK_NAME_NOW = "special-dates-sync-now"
private const val KEY_FOREGROUND = "foreground"
/** Enqueue (or cancel) the daily periodic reconcile to match [enabled]. */
fun apply(context: Context, enabled: Boolean) {
val workManager = WorkManager.getInstance(context)
if (!enabled) {
workManager.cancelUniqueWork(WORK_NAME)
workManager.cancelUniqueWork(WORK_NAME_NOW)
return
}
val request = PeriodicWorkRequestBuilder<SpecialDatesSyncWorker>(1, TimeUnit.DAYS)
// Delay the first periodic run so it never overlaps an immediate run.
.setInitialDelay(1, TimeUnit.DAYS)
.build()
workManager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
}
/**
* Run one reconcile immediately. [foreground] runs are debounced (the app can
* resume often); enable/"Sync now" runs ([foreground] false) always sync.
*/
fun runNow(context: Context, foreground: Boolean = false) {
val request = OneTimeWorkRequestBuilder<SpecialDatesSyncWorker>()
.setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build())
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(WORK_NAME_NOW, ExistingWorkPolicy.REPLACE, request)
}
internal const val INPUT_FOREGROUND = KEY_FOREGROUND
}
/**
* Runs the [SpecialDatesSyncEngine]. Pulls its collaborators through a Hilt
* [EntryPoint] so it works under WorkManager's default worker factory. Records
* the run for the settings status line; a missing permission parks the feature
* in a stalled state (surfaced in settings) rather than retrying forever.
*/
class SpecialDatesSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun settingsPrefs(): SettingsPrefs
fun syncEngine(): SpecialDatesSyncEngine
}
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; never revive a
// disabled feature.
if (!prefs.specialDatesEnabled.first()) return Result.success()
val now = System.currentTimeMillis()
// A foreground resume can fire often — skip if we synced recently.
val foreground = inputData.getBoolean(SpecialDatesScheduler.INPUT_FOREGROUND, false)
if (foreground && now - prefs.specialDatesLastForegroundSync.first() < FOREGROUND_DEBOUNCE_MILLIS) {
return Result.success()
}
return try {
val result = deps.syncEngine().sync()
val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) {
SpecialDatesStalledReason.PermissionRevoked
} else {
null
}
prefs.recordSpecialDatesRun(now, stalled)
if (foreground) prefs.setSpecialDatesLastForegroundSync(now)
Result.success()
} catch (e: Exception) {
Log.w(TAG, "Special-dates sync failed", e)
Result.retry()
}
}
companion object {
private const val TAG = "SpecialDatesSync"
/** Skip a foreground-triggered sync if one ran within this window (4h). */
private const val FOREGROUND_DEBOUNCE_MILLIS = 4L * 60 * 60 * 1000
}
}

View File

@@ -0,0 +1,294 @@
package de.jeanlucmakiola.calendula.data.contacts
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.contacts.anchorDate
import de.jeanlucmakiola.calendula.domain.contacts.managedEventUid
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
import de.jeanlucmakiola.calendula.domain.toRRule
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
/** Outcome of one mirror reconcile, reported back so the worker can record status. */
enum class SpecialDatesSyncResult { Success, Disabled, PermissionMissing }
/**
* Localized, per-type presentation the engine needs but can't derive purely
* (calendar display name, colour, and the default title template). Kept behind
* an interface so the engine's diff stays unit-testable without Android
* resources.
*/
interface SpecialDatesCalendarSpec {
fun displayName(type: SpecialDateType): String
fun color(type: SpecialDateType): Int
fun defaultTitleTemplate(type: SpecialDateType): String
}
/**
* Mirrors contact birthdays/anniversaries/custom dates into local calendars,
* one per type. Every reconcile is an idempotent diff keyed on the deterministic
* `UID_2445` ([managedEventUid]): new contacts are inserted (seeding user-owned
* fields once), changed contacts get a *targeted* managed-column update, and
* removed contacts are deleted — so a user's own edits (reminders, location,
* notes) are never clobbered. See docs/design/contact-special-dates.md.
*/
@Singleton
class SpecialDatesSyncEngine @Inject constructor(
private val contacts: ContactSpecialDatesDataSource,
private val calendars: CalendarDataSource,
private val prefs: SettingsPrefs,
private val spec: SpecialDatesCalendarSpec,
) {
/**
* Reconcile every enabled type against the device's contacts. Returns why it
* stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success].
*/
suspend fun sync(): SpecialDatesSyncResult {
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
val enabledTypes = prefs.specialDatesTypes.first()
val calendarByType = reconcileCalendars(enabledTypes)
val desiredByType = contacts.readSpecialDates().groupBy { it.type }
val reminderCtx = readReminderContext()
val templates = prefs.specialDatesTitleTemplates.first()
val showYear = prefs.specialDatesShowYear.first()
calendarByType.forEach { (type, calendarId) ->
val template = templates[type]?.takeIf { it.isNotBlank() }
?: spec.defaultTitleTemplate(type)
syncType(
calendarId = calendarId,
type = type,
contactsOfType = desiredByType[type].orEmpty(),
template = template,
showYear = showYear,
reminderCtx = reminderCtx,
)
}
return SpecialDatesSyncResult.Success
}
/**
* Set the reminder default for a type's managed calendar and apply it to
* **all** its existing events too (not just future ones) — for managed
* calendars the reminder is a calendar-level setting. Persists the per-calendar
* all-day override so new events keep matching. No-op if the calendar for
* [type] doesn't exist yet.
*/
suspend fun applyReminders(type: SpecialDateType, override: CalendarReminderOverride) {
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
prefs.setCalendarAllDayReminderOverride(calendarId, override)
val minutes = when (override) {
CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
CalendarReminderOverride.None -> emptyList()
is CalendarReminderOverride.Minutes -> override.minutes
}
calendars.applyManagedCalendarReminders(
calendarId = calendarId,
allDayReminderTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
minutes = minutes,
)
}
/** Delete every managed calendar and forget its id — used when the feature is turned off. */
suspend fun teardown() {
calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) }
SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) }
}
/**
* Ensure each enabled type has exactly one managed calendar (adopting an
* existing one, or the stored id if it still exists, else creating one) and
* that disabled types have none. Returns the calendar id per enabled type.
*/
private suspend fun reconcileCalendars(enabledTypes: Set<SpecialDateType>): Map<SpecialDateType, Long> {
val foundByType = calendars.findManagedCalendars()
.groupBy({ it.type }, { it.id })
.mapValues { it.value.first() }
val stored = prefs.specialDatesCalendars.first()
val result = LinkedHashMap<SpecialDateType, Long>()
for (type in SpecialDateType.entries) {
// A stored id counts only if the calendar still exists (the user may
// have deleted it in system settings); otherwise adopt a found one.
val existingId = stored[type]?.takeIf { id -> foundByType.containsValue(id) }
?: foundByType[type]
if (type in enabledTypes) {
val id = existingId ?: createCalendar(type)
if (stored[type] != id) prefs.setSpecialDatesCalendarId(type, id)
result[type] = id
} else {
if (existingId != null) calendars.deleteCalendar(existingId)
if (stored.containsKey(type)) prefs.setSpecialDatesCalendarId(type, null)
}
}
return result
}
private suspend fun createCalendar(type: SpecialDateType): Long {
val id = calendars.createManagedCalendar(spec.displayName(type), spec.color(type), type)
prefs.setSpecialDatesCalendarId(type, id)
// Seed a useful all-day reminder default (on the day + a week before), so
// birthdays get lead time out of the box. Per-calendar, user-adjustable —
// only set when the user hasn't already configured this calendar.
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
prefs.setCalendarAllDayReminderOverride(
id,
CalendarReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
)
}
return id
}
private suspend fun syncType(
calendarId: Long,
type: SpecialDateType,
contactsOfType: List<ContactSpecialDate>,
template: String,
showYear: Boolean,
reminderCtx: ReminderContext,
) {
val built = contactsOfType.map { sd ->
buildManagedEvent(calendarId, type, sd, template, showYear, reminderCtx)
}
val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId))
val formByUid = built.associate { it.desired.uid to it.form }
diff.insertUids.forEach { uid ->
calendars.insertManagedEvent(formByUid.getValue(uid), uid, reminderCtx.allDayTimeMinutes)
}
diff.updates.forEach { calendars.updateManagedFields(it.eventId, it.columns) }
diff.deleteEventIds.forEach { calendars.deleteEvent(it) }
}
private fun buildManagedEvent(
calendarId: Long,
type: SpecialDateType,
sd: ContactSpecialDate,
template: String,
showYear: Boolean,
reminderCtx: ReminderContext,
): BuiltManagedEvent {
val anchor = sd.anchorDate()
val start = LocalDateTime(anchor, LocalTime(0, 0))
// The source year is static and correct on every occurrence (unlike age).
val year = if (showYear) sd.year else null
val title = renderSpecialDateTitle(template, sd.displayName, year)
val form = EventForm(
calendarId = calendarId,
title = title,
isAllDay = true,
start = start,
end = start,
reminders = reminderCtx.resolveAllDay(calendarId),
availability = Availability.Free,
rrule = YEARLY_RRULE,
)
val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis
return BuiltManagedEvent(
desired = ManagedEventDesired(
uid = managedEventUid(type, sd.lookupKey),
title = title,
dtStartMillis = dtStartMillis,
rrule = YEARLY_RRULE,
),
form = form,
)
}
private suspend fun readReminderContext(): ReminderContext = ReminderContext(
timedGlobal = prefs.defaultReminderMinutes.first(),
allDayGlobal = prefs.defaultAllDayReminderMinutes.first(),
timedOverrides = prefs.perCalendarReminderOverride.first(),
allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(),
allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
)
private data class ReminderContext(
val timedGlobal: List<Int>,
val allDayGlobal: List<Int>,
val timedOverrides: Map<Long, List<Int>>,
val allDayOverrides: Map<Long, List<Int>>,
val allDayTimeMinutes: Int,
) {
fun resolveAllDay(calendarId: Long): List<Int> = resolveDefaultReminder(
timedGlobal = timedGlobal,
allDayGlobal = allDayGlobal,
timedOverrides = timedOverrides,
allDayOverrides = allDayOverrides,
calendarId = calendarId,
isAllDay = true,
)
}
private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm)
private companion object {
/** "On the day" (0) + one week before (7 days), as all-day lead minutes. */
val DEFAULT_REMINDER_MINUTES = listOf(0, 7 * 24 * 60)
val YEARLY_RRULE = SimpleRecurrence(freq = RecurrenceFreq.Yearly).toRRule()
}
}
/** The managed columns of a desired event, compared against the existing row. */
internal data class ManagedEventDesired(
val uid: String,
val title: String,
val dtStartMillis: Long,
val rrule: String?,
)
internal data class ManagedFieldUpdate(val eventId: Long, val columns: Map<String, Any?>)
internal data class ManagedDiff(
val insertUids: List<String>,
val updates: List<ManagedFieldUpdate>,
val deleteEventIds: List<Long>,
)
/**
* The idempotent diff at the heart of the mirror, keyed on `UID_2445`:
* desired-not-existing → insert, existing-not-desired → delete, and for events
* in both only the *changed* managed columns (title/dtstart/rrule) are emitted —
* so a re-run with no contact changes produces nothing, and a managed update
* never touches user-owned columns or reminder rows. Pure, so it's unit-tested.
*/
internal fun diffManagedEvents(
desired: List<ManagedEventDesired>,
existing: List<ManagedEventRow>,
): ManagedDiff {
val desiredByUid = desired.associateBy { it.uid }
val existingByUid = existing.associateBy { it.uid }
val insertUids = desired.filter { it.uid !in existingByUid }.map { it.uid }
val deleteEventIds = existing.filter { it.uid !in desiredByUid }.map { it.eventId }
val updates = buildList {
for (d in desired) {
val row = existingByUid[d.uid] ?: continue
val columns = buildMap<String, Any?> {
if (row.title != d.title) put(CalendarContract.Events.TITLE, d.title)
if (row.dtStartMillis != d.dtStartMillis) {
put(CalendarContract.Events.DTSTART, d.dtStartMillis)
}
if (row.rrule != d.rrule) put(CalendarContract.Events.RRULE, d.rrule)
}
if (columns.isNotEmpty()) add(ManagedFieldUpdate(row.eventId, columns))
}
}
return ManagedDiff(insertUids, updates, deleteEventIds)
}

View File

@@ -14,6 +14,10 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
import kotlinx.coroutines.CoroutineDispatcher
@@ -45,6 +49,18 @@ abstract class DataBindModule {
abstract fun bindReminderAlertStore(
impl: AndroidReminderAlertStore,
): ReminderAlertStore
@Binds
@Singleton
abstract fun bindContactSpecialDatesDataSource(
impl: AndroidContactSpecialDatesDataSource,
): ContactSpecialDatesDataSource
@Binds
@Singleton
abstract fun bindSpecialDatesCalendarSpec(
impl: AndroidSpecialDatesCalendarSpec,
): SpecialDatesCalendarSpec
}
@Module

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.ics
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.documentfile.provider.DocumentFile
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.io.IOException
@@ -28,6 +29,36 @@ class IcsExporter @Inject constructor(
} ?: 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
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
@@ -42,5 +73,6 @@ class IcsExporter @Inject constructor(
private companion object {
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
* event form. Null until the first event is created.
@@ -53,6 +78,7 @@ class CalendarPrefs @Inject constructor(
companion object {
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")
}
}

View File

@@ -5,8 +5,13 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
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.map
@@ -19,18 +24,52 @@ import javax.inject.Singleton
/** Light/dark override. SYSTEM follows the device setting. */
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
* locale's convention (e.g. Monday in DE, Sunday in en-US).
* Clock convention for time-of-day labels. AUTO follows the device's 24-hour
* system setting; the others force a 12- or 24-hour clock app-wide.
*/
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
/**
* How the Agenda screen treats events that have already finished today: [SHOW]
* leaves them as-is (the historical behaviour), [DIM] fades them, [HIDE] drops
* them from the list entirely.
*/
enum class PastEventDisplay { SHOW, DIM, HIDE }
/** Parse a stored [PastEventDisplay.name]; unknown/null falls back to [default]. */
fun parsePastEventDisplay(
stored: String?,
default: PastEventDisplay = PastEventDisplay.SHOW,
): PastEventDisplay = PastEventDisplay.entries.firstOrNull { it.name == stored } ?: default
/**
* Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the
* 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) {
WeekStartPref.MONDAY -> DayOfWeek.MONDAY
WeekStartPref.SUNDAY -> DayOfWeek.SUNDAY
is WeekStartPref.Day -> day
// 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)
}
/**
@@ -55,7 +94,7 @@ class SettingsPrefs @Inject constructor(
}
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) {
@@ -67,7 +106,87 @@ class SettingsPrefs @Inject constructor(
}
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 the Agenda screen treats events that already ended today. Defaults to
* [PastEventDisplay.SHOW] — the historical behaviour; users opt into dimming
* or hiding.
*/
val pastEventDisplay: Flow<PastEventDisplay> = store.data.map { prefs ->
prefs[PAST_EVENT_DISPLAY_KEY].toEnum(PastEventDisplay.SHOW)
}
suspend fun setPastEventDisplay(mode: PastEventDisplay) {
store.edit { it[PAST_EVENT_DISPLAY_KEY] = mode.name }
}
/**
* Whether the month/week grids fade events that have already finished.
* Defaults to OFF — independent of the Agenda's [pastEventDisplay].
*/
val dimCompletedEvents: Flow<Boolean> = store.data.map { prefs ->
prefs[DIM_COMPLETED_EVENTS_KEY] ?: false
}
suspend fun setDimCompletedEvents(enabled: Boolean) {
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
}
/**
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
* [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 }
}
/**
@@ -102,6 +221,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 —
* for users whose only calendar app this is, reminders are essential; the
@@ -144,35 +277,36 @@ class SettingsPrefs @Inject constructor(
}
/**
* The default reminder lead time (minutes before start) prefilled on new
* **timed** events. `null` = no default reminder — the prior behaviour, kept
* as the factory default so existing users aren't surprised by reminders they
* never asked for. Stored as a string so "none" is distinct from a numeric
* value (and from an unset key, which is also "none"). Per-calendar overrides
* in [perCalendarReminderOverride] take precedence; all-day events instead use
* The default reminder lead times (minutes before start) prefilled on new
* **timed** events. The empty list = no default reminder — the prior
* behaviour, kept as the factory default so existing users aren't surprised by
* reminders they never asked for. Stored as a comma-joined list of minutes, or
* "none"/empty for no reminder (also the unset state). A legacy single value
* ("30") parses transparently to a one-element list. Per-calendar overrides in
* [perCalendarReminderOverride] take precedence; all-day events instead use
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
*/
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
val defaultReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
prefs[DEFAULT_REMINDER_KEY].toReminderList()
}
suspend fun setDefaultReminderMinutes(minutes: Int?) {
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
suspend fun setDefaultReminderMinutes(minutes: List<Int>) {
store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() }
}
/**
* The default reminder lead time prefilled on new **all-day** events, in
* The default reminder lead times prefilled on new **all-day** events, in
* minutes before the start of the day. All-day events want day-scale lead
* times ("1 day before"), so they have their own default rather than reusing
* the timed one. `null` = no default. Per-calendar overrides do **not** apply
* to all-day events — they always use this global value.
* the timed one. Empty list = no default. Per-calendar overrides do **not**
* apply to all-day events — they always use this global value.
*/
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
val defaultAllDayReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList()
}
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() }
}
/**
@@ -209,13 +343,13 @@ class SettingsPrefs @Inject constructor(
/**
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
* keyed by calendar id. A calendar **present** in the map overrides the global
* timed default for its new events: a `null` value means "no reminder", an int
* means that lead time. A calendar **absent** from the map inherits the global
* default. Serialised as `id=value;id=value`, with `none` for an explicit
* no-reminder override. (All-day events ignore this and use
* [defaultAllDayReminderMinutes].)
* timed default for its new events: an **empty** list means "no reminder", a
* non-empty list means those lead times. A calendar **absent** from the map
* inherits the global default. Serialised as `id=value;id=value`, where a value
* is a comma-joined minute list or `none` for an explicit no-reminder override.
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
*/
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
}
@@ -232,7 +366,7 @@ class SettingsPrefs @Inject constructor(
* events, with the same semantics as [perCalendarReminderOverride] (absent =
* inherit the global all-day default; present null = no reminder).
*/
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
}
@@ -248,6 +382,141 @@ 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
}
}
// --- Contact special dates (issue #15) ------------------------------
/** Master switch for the contact special-dates mirror. Default OFF (opt-in). */
val specialDatesEnabled: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_ENABLED_KEY] ?: false }
suspend fun setSpecialDatesEnabled(enabled: Boolean) {
store.edit { it[SPECIAL_DATES_ENABLED_KEY] = enabled }
}
/** The date types the user wants mirrored (default: all three). */
val specialDatesTypes: Flow<Set<SpecialDateType>> = store.data.map { prefs ->
SpecialDateType.entries.filterTo(mutableSetOf()) { prefs[typeEnabledKey(it)] ?: true }
}
suspend fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
store.edit { it[typeEnabledKey(type)] = enabled }
}
/** The managed calendar id per type, for the ones that currently exist. */
val specialDatesCalendars: Flow<Map<SpecialDateType, Long>> = store.data.map { prefs ->
SpecialDateType.entries.mapNotNull { type ->
prefs[calendarIdKey(type)]?.let { type to it }
}.toMap()
}
/** Every managed calendar id — the editor locks title/date/recurrence for their events. */
val managedCalendarIds: Flow<Set<Long>> = specialDatesCalendars.map { it.values.toSet() }
suspend fun setSpecialDatesCalendarId(type: SpecialDateType, calendarId: Long?) {
store.edit { prefs ->
if (calendarId == null) prefs.remove(calendarIdKey(type)) else prefs[calendarIdKey(type)] = calendarId
}
}
/**
* The title template per type ("{name}'s birthday"); an empty string means
* unset, so the settings screen can seed the localized default. `{name}` is
* the contact name, `{age}` the age at the upcoming date (empty when the
* birth year is unknown or [specialDatesShowAge] is off).
*/
val specialDatesTitleTemplates: Flow<Map<SpecialDateType, String>> = store.data.map { prefs ->
SpecialDateType.entries.associateWith { prefs[titleTemplateKey(it)].orEmpty() }
}
suspend fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
store.edit { it[titleTemplateKey(type)] = template }
}
/** Whether `{year}` resolves in the title (only meaningful when the year is known). Default ON. */
val specialDatesShowYear: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_SHOW_YEAR_KEY] ?: true }
suspend fun setSpecialDatesShowYear(enabled: Boolean) {
store.edit { it[SPECIAL_DATES_SHOW_YEAR_KEY] = enabled }
}
/** Outcome of the last mirror sync, for the settings status line. */
val specialDatesStatus: Flow<SpecialDatesStatus> = store.data.map { prefs ->
SpecialDatesStatus(
lastRun = prefs[SPECIAL_DATES_LAST_RUN_KEY] ?: 0L,
stalled = prefs[SPECIAL_DATES_STALLED_KEY]
?.let { name -> SpecialDatesStalledReason.entries.firstOrNull { it.name == name } },
)
}
/** Record a sync outcome; [stalled] non-null marks the feature paused (e.g. permission revoked). */
suspend fun recordSpecialDatesRun(atMillis: Long, stalled: SpecialDatesStalledReason?) {
store.edit { prefs ->
prefs[SPECIAL_DATES_LAST_RUN_KEY] = atMillis
if (stalled == null) prefs.remove(SPECIAL_DATES_STALLED_KEY) else prefs[SPECIAL_DATES_STALLED_KEY] = stalled.name
}
}
/** Epoch millis of the last foreground-triggered sync, to debounce ON_RESUME. */
val specialDatesLastForegroundSync: Flow<Long> =
store.data.map { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] ?: 0L }
suspend fun setSpecialDatesLastForegroundSync(atMillis: Long) {
store.edit { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] = atMillis }
}
private fun typeEnabledKey(type: SpecialDateType) =
booleanPreferencesKey("special_dates_type_${type.name}")
private fun calendarIdKey(type: SpecialDateType) =
longPreferencesKey("special_dates_calendar_${type.name}")
private fun titleTemplateKey(type: SpecialDateType) =
stringPreferencesKey("special_dates_title_${type.name}")
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
null -> DEFAULT_FORM_FIELDS
else -> stored.split(',')
@@ -259,8 +528,16 @@ class SettingsPrefs @Inject constructor(
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
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 PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val 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 REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
@@ -282,76 +559,149 @@ class SettingsPrefs @Inject constructor(
stringPreferencesKey("per_calendar_allday_reminder_override")
internal val DEFAULT_FORM_FIELDS =
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
internal val SPECIAL_DATES_ENABLED_KEY = booleanPreferencesKey("special_dates_enabled")
internal val SPECIAL_DATES_SHOW_YEAR_KEY = booleanPreferencesKey("special_dates_show_year")
internal val SPECIAL_DATES_LAST_RUN_KEY = longPreferencesKey("special_dates_last_run")
internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason")
internal val SPECIAL_DATES_LAST_FG_SYNC_KEY =
longPreferencesKey("special_dates_last_foreground_sync")
}
}
/** Snapshot of the special-dates mirror's last outcome (see [SettingsPrefs.specialDatesStatus]). */
data class SpecialDatesStatus(
/** Epoch millis of the last sync, or 0 if it has never run. */
val lastRun: Long,
/** Non-null when the mirror is paused and why; null when healthy. */
val stalled: SpecialDatesStalledReason?,
)
/** Why the special-dates mirror is paused. */
enum class SpecialDatesStalledReason {
/** READ_CONTACTS was revoked in system settings after the feature was enabled. */
PermissionRevoked,
}
/** 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]). */
sealed interface CalendarReminderOverride {
/** No override — the calendar uses the global default. */
data object Inherit : CalendarReminderOverride
/** Explicit "no reminder" for this calendar, regardless of the global default. */
data object None : CalendarReminderOverride
/** A specific lead time in minutes before the event start. */
data class Minutes(val minutes: Int) : CalendarReminderOverride
/** Specific lead times in minutes before the event start (non-empty). */
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
}
/**
* The lead time to prefill on a new event: the matching per-calendar override
* The lead times to prefill on a new event: the matching per-calendar override
* if [calendarId] has one for this event kind, otherwise the global default for
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
* it can be unit-tested.
* events consult [timedOverrides] / [timedGlobal]. The empty list = no reminder.
* Pure so it can be unit-tested.
*/
fun resolveDefaultReminder(
timedGlobal: Int?,
allDayGlobal: Int?,
timedOverrides: Map<Long, Int?>,
allDayOverrides: Map<Long, Int?>,
timedGlobal: List<Int>,
allDayGlobal: List<Int>,
timedOverrides: Map<Long, List<Int>>,
allDayOverrides: Map<Long, List<Int>>,
calendarId: Long?,
isAllDay: Boolean,
): Int? {
): List<Int> {
val overrides = if (isAllDay) allDayOverrides else timedOverrides
val global = if (isAllDay) allDayGlobal else timedGlobal
return if (calendarId != null && overrides.containsKey(calendarId)) {
overrides[calendarId]
overrides.getValue(calendarId)
} else {
global
}
}
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
private fun MutableMap<Long, Int?>.applyOverride(
/**
* Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the
* key; [None] and an empty [Minutes] both store the empty list).
*/
private fun MutableMap<Long, List<Int>>.applyOverride(
calendarId: Long,
override: CalendarReminderOverride,
) {
when (override) {
CalendarReminderOverride.Inherit -> remove(calendarId)
CalendarReminderOverride.None -> put(calendarId, null)
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
CalendarReminderOverride.None -> put(calendarId, emptyList())
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
}
}
/** 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 ENTRY_SEP = ";"
private const val KEY_VALUE_SEP = "="
private const val LIST_SEP = ","
private fun String?.toReminderMinutes(): Int? = when (this) {
null, "", NONE -> null
else -> toIntOrNull()
/** Distinct, ascending lead times — the canonical form stored and resolved. */
private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
/**
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
* list; a comma-joined list → its minutes; a legacy single value ("30") → a
* one-element list. Non-numeric parts are dropped defensively.
*/
private fun String?.toReminderList(): List<Int> = when {
this == null || isEmpty() || this == NONE -> emptyList()
else -> split(LIST_SEP).mapNotNull { it.trim().toIntOrNull() }.normalizeReminders()
}
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
/** Serialise lead times for storage: "none" when empty, else comma-joined. */
private fun List<Int>.toStoredReminders(): String =
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
if (stored.isNullOrBlank()) return emptyMap()
return stored.split(ENTRY_SEP).mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
id to value
id to parts[1].toReminderList()
}.toMap()
}
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default

View File

@@ -8,6 +8,7 @@ import android.content.pm.PackageManager
import android.provider.CalendarContract
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -16,6 +17,17 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The due alerts that should actually surface as notifications: everything
* except alerts whose calendar the user has disabled in-app (mirroring the
* event filtering in CalendarRepositoryImpl). The caller still marks the full
* due set fired, so suppressed alerts are not re-broadcast by the provider.
*/
internal fun postableAlerts(
due: List<ReminderAlert>,
disabledCalendarIds: Set<Long>,
): List<ReminderAlert> = due.filterNot { it.calendarId in disabledCalendarIds }
/**
* Becomes the app that turns the calendar provider's reminder alarms into
* visible notifications (the Etar model — the provider broadcasts
@@ -32,6 +44,7 @@ class EventReminderReceiver : BroadcastReceiver() {
@Inject lateinit var alertStore: ReminderAlertStore
@Inject lateinit var notifier: ReminderNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs
@Inject lateinit var calendarPrefs: CalendarPrefs
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
@@ -46,7 +59,11 @@ class EventReminderReceiver : BroadcastReceiver() {
if (settingsPrefs.remindersEnabled.first()) {
val now = System.currentTimeMillis()
val due = alertStore.dueAlerts(now)
due.forEach(notifier::post)
val disabled = calendarPrefs.disabledCalendarIds.first()
// Suppress reminders for disabled calendars, but still mark
// every due alert fired so the provider stops re-broadcasting
// the suppressed ones.
postableAlerts(due, disabled).forEach { notifier.post(it) }
alertStore.markFired(due.map { it.alertId }, now)
}
} finally {

View File

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

View File

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

View File

@@ -14,6 +14,9 @@ import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity
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.util.Locale
import javax.inject.Inject
@@ -29,6 +32,7 @@ import javax.inject.Singleton
@Singleton
class ReminderNotifier @Inject constructor(
@ApplicationContext private val context: Context,
private val settingsPrefs: SettingsPrefs,
) {
/** 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()
}
fun post(alert: ReminderAlert) {
suspend fun post(alert: ReminderAlert) {
ensureChannel()
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(
beginMillis = alert.beginMillis,
endMillis = alert.endMillis,
isAllDay = alert.isAllDay,
zone = ZoneId.systemDefault(),
locale = Locale.getDefault(),
is24Hour = is24Hour,
)
val text = listOfNotNull(time, alert.location).joinToString(" · ")
val notification = NotificationCompat.Builder(context, CHANNEL_ID)

View File

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

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

View File

@@ -0,0 +1,122 @@
package de.jeanlucmakiola.calendula.domain.contacts
import java.time.LocalDate
/**
* The kinds of contact "special date" Calendula mirrors into local calendars.
* Each kind gets its own local calendar, so it inherits per-calendar colour,
* visibility and reminder defaults for free. See
* docs/design/contact-special-dates.md.
*/
enum class SpecialDateType {
Birthday,
Anniversary,
/** Everything else — a contact "Event" that is neither birthday nor anniversary. */
Custom,
}
/**
* One dated event read from a device contact (a `ContactsContract` `Event`
* row). The [lookupKey] is the stable contact identity used to reconcile the
* mirror without duplicating; [year] is null when the contact stored the date
* without one (`--MM-dd`), in which case age can't be shown.
*/
data class ContactSpecialDate(
val lookupKey: String,
val displayName: String,
val type: SpecialDateType,
val month: Int,
val day: Int,
val year: Int?,
/** The contact's custom label for a [SpecialDateType.Custom] date, if any. */
val label: String? = null,
)
/** The parsed month/day (+ optional year) of a contact date. */
data class ContactDateParts(val year: Int?, val month: Int, val day: Int)
/**
* A fixed leap year to validate and anchor year-less dates against, so that a
* `--02-29` birthday is representable (and, once anchored, only recurs in leap
* years — matching how the calendar provider expands a yearly Feb-29 series).
*/
const val YEARLESS_ANCHOR_YEAR = 1972
/**
* Parse a `ContactsContract.CommonDataKinds.Event.START_DATE` value into its
* calendar parts, or null if it isn't a usable date. Handles the three shapes
* seen in the wild:
* - full `yyyy-MM-dd` (year known),
* - year-less `--MM-dd` (year null),
* - compact `yyyyMMdd`.
*
* A date that names an impossible day (e.g. `1999-02-29`) is rejected. Pure, so
* it's unit-tested without a device.
*/
fun parseContactEventDate(raw: String?): ContactDateParts? {
val s = raw?.trim().orEmpty()
if (s.isEmpty()) return null
// Year-less: "--MM-dd" or "--MMdd".
if (s.startsWith("--")) {
val (m, d) = parseMonthDay(s.substring(2)) ?: return null
return validated(null, m, d)
}
if (s.contains('-')) {
val parts = s.split('-').filter { it.isNotEmpty() }
return when (parts.size) {
// "yyyy-MM-dd" — but a leading '-' would have dropped the empty
// first part, so require the first token to look like a year.
3 -> if (s.startsWith('-')) null else validated(
year = parts[0].toIntOrNull() ?: return null,
month = parts[1].toIntOrNull() ?: return null,
day = parts[2].toIntOrNull() ?: return null,
)
// Year-less "MM-dd" without the "--" prefix.
2 -> validated(
year = null,
month = parts[0].toIntOrNull() ?: return null,
day = parts[1].toIntOrNull() ?: return null,
)
else -> null
}
}
// Compact "yyyyMMdd".
if (s.length == 8 && s.all { it.isDigit() }) {
return validated(
year = s.substring(0, 4).toInt(),
month = s.substring(4, 6).toInt(),
day = s.substring(6, 8).toInt(),
)
}
return null
}
/** "MM-dd" or compact "MMdd". */
private fun parseMonthDay(rest: String): Pair<Int, Int>? {
if (rest.contains('-')) {
val p = rest.split('-').filter { it.isNotEmpty() }
if (p.size != 2) return null
return (p[0].toIntOrNull() ?: return null) to (p[1].toIntOrNull() ?: return null)
}
if (rest.length == 4 && rest.all { it.isDigit() }) {
return rest.substring(0, 2).toInt() to rest.substring(2, 4).toInt()
}
return null
}
/**
* Confirm month/day form a real calendar date (validated against the actual
* year when known, otherwise the leap anchor so `02-29` survives).
*/
private fun validated(year: Int?, month: Int, day: Int): ContactDateParts? {
if (month !in 1..12 || day !in 1..31) return null
val checkYear = year ?: YEARLESS_ANCHOR_YEAR
return runCatching { LocalDate.of(checkYear, month, day) }
.map { ContactDateParts(year, month, day) }
.getOrNull()
}

View File

@@ -0,0 +1,37 @@
package de.jeanlucmakiola.calendula.domain.contacts
import kotlinx.datetime.LocalDate
/**
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
* contact date. Stable across syncs (the reconciliation key), and namespaced by
* type so the same contact's birthday and anniversary never collide.
*/
fun managedEventUid(type: SpecialDateType, lookupKey: String): String =
"contact-${type.name.lowercase()}:$lookupKey@calendula"
/**
* The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real
* date when the year is known (so age can be derived), otherwise the month/day
* on a fixed leap anchor year so a `--02-29` date stays representable and the
* anchor never drifts between syncs.
*/
fun ContactSpecialDate.anchorDate(): LocalDate =
LocalDate(year ?: YEARLESS_ANCHOR_YEAR, month, day)
/**
* Render a title [template] for a contact, substituting `{name}` and `{year}`
* (the source year — a birthday's birth year or an anniversary's start year;
* empty when [year] is null). Unlike an age, the year is static and correct on
* every occurrence of the yearly event. Collapses the whitespace an empty
* `{year}` may leave behind, so "{name}'s birthday ({year})" degrades cleanly to
* "Jane's birthday" when no year is known.
*/
fun renderSpecialDateTitle(template: String, name: String, year: Int?): String =
template
.replace("{name}", name)
.replace("{year}", year?.toString().orEmpty())
// Drop an empty "()" left by an unresolved {year}, then tidy spacing.
.replace(Regex("""\(\s*\)"""), "")
.replace(Regex("""\s+"""), " ")
.trim()

View File

@@ -196,7 +196,9 @@ fun CalendarHost(
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = req.dateIso
viewStack = viewBaseStack(defaultView, req.source).drillToDay()
// No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits.
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
onWidgetNavConsumed()
}
is WidgetNavRequest.OpenEvent -> {

View File

@@ -19,6 +19,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
@@ -49,6 +50,15 @@ fun RootScreen(
hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR
) == PackageManager.PERMISSION_GRANTED
// Refresh the contact special-dates mirror on foreground (the
// worker is debounced and no-ops when the feature is off). Gated
// on the permission so users without the opt-in never enqueue it.
if (ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CONTACTS
) == PackageManager.PERMISSION_GRANTED
) {
SpecialDatesScheduler.runNow(context, foreground = true)
}
}
}
lifecycle.addObserver(obs)

View File

@@ -13,8 +13,13 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
* detail-key channel and leave the base view untouched.)
*/
sealed interface WidgetNavRequest {
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
data class OpenDate(val dateIso: String, val source: CalendarView) : WidgetNavRequest
/**
* Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over
* [source]. A null [source] means the request came from outside the app (a
* launcher/clock date tap, issue #9) rather than a widget, so it roots over
* the default home view instead of a widget's view.
*/
data class OpenDate(val dateIso: String, val source: CalendarView?) : WidgetNavRequest
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
data class OpenEvent(

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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
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.Search
import androidx.compose.material3.DrawerValue
@@ -35,9 +39,13 @@ import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
@@ -46,18 +54,27 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.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.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import kotlinx.coroutines.launch
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
@@ -84,15 +101,18 @@ fun AgendaScreen(
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) }
val isOnToday = when (val s = state) {
is AgendaUiState.Success -> s.anchor == s.today
else -> true
}
val successState = state as? AgendaUiState.Success
ModalNavigationDrawer(
drawerState = drawerState,
@@ -135,21 +155,134 @@ fun AgendaScreen(
)
},
) { innerPadding ->
AgendaContent(
state = state,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
.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(
state = state,
pastDisplay = pastDisplay,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
modifier = Modifier
.weight(1f)
.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
private fun AgendaContent(
state: AgendaUiState,
pastDisplay: PastEventDisplay,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
@@ -159,19 +292,42 @@ private fun AgendaContent(
is AgendaUiState.Failure -> Box(modifier) {
CalendarFailure(reason = state.reason, onRetry = onRetry)
}
is AgendaUiState.Success ->
if (state.days.isEmpty()) {
is AgendaUiState.Success -> {
val now by rememberCurrentMinute()
// Hiding drops finished events — and any day they leave empty; dimming
// keeps them but fades the row. Recomputed each minute so events fall
// away (or fade) as they end while the screen stays open.
val days = if (pastDisplay == PastEventDisplay.HIDE) {
state.days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(now) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
}
} else {
state.days
}
if (days.isEmpty()) {
AgendaEmpty(modifier)
} else {
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
AgendaList(
days = days,
today = state.today,
dimPast = pastDisplay == PastEventDisplay.DIM,
now = now,
onEventClick = onEventClick,
modifier = modifier,
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AgendaList(
state: AgendaUiState.Success,
days: List<AgendaDay>,
today: LocalDate,
dimPast: Boolean,
now: Instant,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -180,9 +336,9 @@ private fun AgendaList(
// Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) {
state.days.forEach { day ->
days.forEach { day ->
stickyHeader(key = "header-${day.date}") {
AgendaDayHeader(date = day.date, today = state.today)
AgendaDayHeader(date = day.date, today = today)
}
itemsIndexed(
items = day.events,
@@ -191,6 +347,7 @@ private fun AgendaList(
AgendaEventRow(
event = event,
position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now),
modifier = calendarAnimateItem(),
onClick = { onEventClick(event) },
)
@@ -223,13 +380,14 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
private fun AgendaEventRow(
event: EventInstance,
position: Position,
dimmed: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow(
modifier = modifier,
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
title = title,
summary = agendaTimeSummary(event),
position = position,
@@ -254,7 +412,7 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Filled.EventAvailable,
imageVector = Icons.Filled.Coffee,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp),
@@ -265,13 +423,6 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
style = MaterialTheme.typography.titleMedium,
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,
)
}
}
@@ -338,15 +489,17 @@ private fun agendaTimeSummary(event: EventInstance): String {
val time = if (event.isAllDay) {
stringResource(R.string.event_detail_all_day)
} 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() }
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
return "%02d:%02d".format(t.hour, t.minute)
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
}
private fun formatAgendaDate(date: LocalDate): String {

View File

@@ -53,5 +53,13 @@ sealed interface AgendaUiState {
val anchor: LocalDate,
val today: LocalDate,
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
}

View File

@@ -5,6 +5,9 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
@@ -17,10 +20,13 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import java.util.Locale
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
@@ -30,16 +36,36 @@ import kotlin.time.Clock
import kotlin.time.Instant
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)
@HiltViewModel
class AgendaViewModel @Inject constructor(
private val repository: CalendarRepository,
settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : 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()) }
/**
* How to treat events that already ended today (show / dim / hide). A display
* concern only, so it rides alongside the data state rather than re-querying;
* the screen combines it with a per-minute "now" to fade or drop past rows.
*/
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = PastEventDisplay.SHOW,
)
private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate
@@ -48,14 +74,32 @@ class AgendaViewModel @Inject constructor(
private val _anchor = MutableStateFlow(todayDate)
val anchor: StateFlow<LocalDate> = _anchor
val state: StateFlow<AgendaUiState> = _anchor
.flatMapLatest { anchor ->
val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone)
// A transient, in-view override of the saved agenda range. Held in memory
// (not persisted), so it survives view switches and rotation within the
// 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(
repository.calendars(),
repository.instances(range),
repository.instances(window),
) { calendars, instances ->
buildState(anchor, calendars, instances)
buildState(params, calendars, instances)
}
}
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
@@ -75,16 +119,47 @@ class AgendaViewModel @Inject constructor(
_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(
anchor: LocalDate,
params: AgendaParams,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): AgendaUiState {
if (calendars.isEmpty()) {
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val anchor = params.anchor
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,13 +4,17 @@ import android.accounts.AccountManager
import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult
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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -26,25 +30,33 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.CalendarMonth
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.Edit
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.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.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -58,20 +70,36 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
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.layout.ContentScale
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.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.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.lifecycle.compose.collectAsStateWithLifecycle
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.ui.common.CALENDAR_COLOR_PALETTE
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.ColorSwatchRow
@@ -79,7 +107,6 @@ import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.ui.common.positionOf
import java.time.LocalDate
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
@@ -98,8 +125,10 @@ fun CalendarsScreen(
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
val error by viewModel.error.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.
// [editorSession] bumps on every open so the editor's field state resets for
@@ -134,14 +163,21 @@ fun CalendarsScreen(
CalendarsList(
local = calendars.filter { it.isLocal },
synced = calendars.filterNot { it.isLocal },
disabledIds = disabledIds,
error = error,
onConsumeError = viewModel::consumeError,
backupResult = backupResult,
onExportBackup = viewModel::exportBackup,
onConsumeBackupResult = viewModel::consumeBackupResult,
autoBackup = autoBackup,
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
onBack = onBack,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id },
onSetDisabled = viewModel::setDisabled,
onSetAccountDisabled = viewModel::setAccountDisabled,
)
}
}
@@ -150,17 +186,28 @@ fun CalendarsScreen(
private fun CalendarsList(
local: List<CalendarSource>,
synced: List<CalendarSource>,
disabledIds: Set<Long>,
error: Boolean,
onConsumeError: () -> Unit,
backupResult: BackupResult?,
onExportBackup: (android.net.Uri) -> Unit,
onConsumeBackupResult: () -> Unit,
autoBackup: AutoBackupUiState,
onSetAutoBackupEnabled: (Boolean) -> Unit,
onSetAutoBackupInterval: (Long) -> Unit,
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
onBack: () -> Unit,
onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit,
onSetDisabled: (Long, Boolean) -> Unit,
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit,
) {
val context = LocalContext.current
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)
LaunchedEffect(error) {
@@ -176,6 +223,13 @@ private fun CalendarsList(
contract = ActivityResultContracts.CreateDocument("text/calendar"),
) { 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)
LaunchedEffect(backupResult) {
when (val r = backupResult) {
@@ -200,36 +254,51 @@ private fun CalendarsList(
onBack = onBack,
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
// Local (device-only) calendars — the calendars the app owns. The
// "Add calendar" entry closes the group as its final row.
SectionHeader(stringResource(R.string.calendars_local_header))
if (local.isEmpty()) {
HintText(stringResource(R.string.calendars_local_empty))
}
val localCount = local.size + 1
local.forEachIndexed { index, calendar ->
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
position = positionOf(index, localCount),
leading = { CalendarColorChip(calendar.color) },
trailing = {
Icon(
Icons.Default.Edit,
contentDescription = stringResource(R.string.calendars_edit_title),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
// What the per-calendar / per-account switches below actually do.
HintText(stringResource(R.string.calendars_disable_hint))
// 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()) {
HintText(stringResource(R.string.calendars_local_empty))
} else {
local.forEachIndexed { index, calendar ->
val disabled = calendar.id in disabledIds
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
EnableSwitch(
calendarName = calendar.displayName,
enabled = !disabled,
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
)
},
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
// safety net. Offered only when there is something to back up.
@@ -237,42 +306,106 @@ private fun CalendarsList(
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_backup_header))
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(
title = stringResource(R.string.calendars_backup_action),
position = Position.Alone,
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
onClick = {
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))
// Synced calendars — read-only, grouped by account, each with a
// per-account "manage in source app" link.
// Synced calendars — read-only, grouped by account. Each account is a
// 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))
HintText(stringResource(R.string.calendars_synced_hint))
synced
.groupBy { it.accountName.ifBlank { it.accountType } }
.forEach { (account, cals) ->
AccountHeader(account = account, accountType = cals.first().accountType)
cals.forEachIndexed { index, calendar ->
GroupedRow(
title = calendar.displayName,
position = positionOf(index, cals.size),
leading = { CalendarColorChip(calendar.color) },
)
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 ->
val disabled = calendar.id in disabledIds
GroupedRow(
title = calendar.displayName,
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
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,
leading = { AddAvatar() },
onClick = {
runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) }
},
}
if (showInterval) {
BackupIntervalDialog(
currentMinutes = autoBackup.intervalMinutes,
onConfirm = onSetAutoBackupInterval,
onDismiss = { showInterval = false },
)
}
}
@@ -424,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). */
@Composable
private fun EditorCard(
@@ -455,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
private fun AccountHeader(account: String, accountType: String) {
val context = LocalContext.current
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
private fun CalendarGroup(
title: String,
expanded: Boolean,
bodyHasRows: Boolean,
headerDisabled: Boolean,
leading: @Composable () -> Unit,
manageIcon: ImageVector,
manageLabel: String,
onManage: () -> Unit,
onToggleExpand: () -> Unit,
showToggleAll: Boolean,
allEnabled: Boolean,
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(),
) {
Text(
text = account,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
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))
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()
},
)
}
}
}
/** 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
private fun LeadingAvatar(icon: ImageVector) {
Box(
@@ -499,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
private fun SectionHeader(text: String) {
Text(
@@ -538,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
* own authenticator package (resolved from [AccountManager], no permission

View File

@@ -1,12 +1,19 @@
package de.jeanlucmakiola.calendula.ui.calendars
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.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.ics.IcsWriter
import kotlinx.coroutines.CoroutineDispatcher
@@ -15,6 +22,8 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -31,8 +40,11 @@ import javax.inject.Inject
*/
@HiltViewModel
class CalendarsViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -46,6 +58,36 @@ class CalendarsViewModel @Inject constructor(
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)
val error: StateFlow<Boolean> = _error.asStateFlow()
@@ -93,6 +135,76 @@ class CalendarsViewModel @Inject constructor(
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) {
viewModelScope.launch {
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. */
sealed interface BackupResult {
data class Success(val eventCount: Int) : BackupResult

View File

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

View File

@@ -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

@@ -127,7 +127,9 @@ fun CollapsingScaffold(
* 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
* 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)
@Composable
@@ -137,6 +139,8 @@ fun GroupedRow(
modifier: Modifier = Modifier,
summary: String? = null,
selected: Boolean = false,
dimmed: Boolean = false,
container: Color? = null,
minHeight: Dp = 72.dp,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
@@ -168,6 +172,16 @@ fun GroupedRow(
supportingColor = 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 {
ListItemDefaults.colors(containerColor = Color.Transparent)
}
@@ -185,10 +199,10 @@ fun GroupedRow(
.fillMaxWidth()
.padding(horizontal = 16.dp)
.then(gap)
val containerColor = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
val containerColor = when {
selected -> MaterialTheme.colorScheme.secondaryContainer
container != null -> container
else -> MaterialTheme.colorScheme.surfaceContainerHigh
}
if (onClick != null) {
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

@@ -36,6 +36,7 @@ fun InlineTextField(
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
singleLine: Boolean = true,
minLines: Int = 1,
enabled: Boolean = true,
keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
imeAction: ImeAction = ImeAction.Default,
@@ -43,15 +44,17 @@ fun InlineTextField(
onImeAction: (() -> Unit)? = null,
) {
val resolvedStyle = textStyle.copy(
color = if (textStyle.color.isSpecified) {
textStyle.color
} else {
MaterialTheme.colorScheme.onSurface
color = when {
// A disabled field reads as dimmed, like a locked value.
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
textStyle.color.isSpecified -> textStyle.color
else -> MaterialTheme.colorScheme.onSurface
},
)
BasicTextField(
value = value,
onValueChange = onValueChange,
enabled = enabled,
textStyle = resolvedStyle,
singleLine = singleLine,
minLines = minLines,

View File

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

View File

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

View File

@@ -7,11 +7,13 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -25,11 +27,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -37,6 +41,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import de.jeanlucmakiola.calendula.R
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
@@ -83,8 +88,10 @@ fun <T> OptionPicker(
label: @Composable (T) -> String,
onSelect: (T) -> Unit,
onDismiss: () -> Unit,
header: (@Composable ColumnScope.() -> Unit)? = null,
) {
FullScreenPicker(title = title, onDismiss = onDismiss) {
header?.invoke(this)
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
@@ -106,10 +113,15 @@ fun <T> OptionPicker(
}
/**
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
* a "Custom" row that expands an inline number field plus a segmented unit
* selector. Returns the choice as a [CalendarReminderOverride].
* Reminder-default picker, full-screen and **multi-select**: each [presets]
* lead time (plus any chosen custom value) is a checkbox row that toggles
* independently, so a default can carry several reminders ("1 week before" *and*
* "on the day"). A per-calendar picker ([allowInherit]) gains an exclusive "Use
* default reminder" row above the list; choosing it defers to the global default
* and clears the checked times. With nothing checked and inherit off, the choice
* is [CalendarReminderOverride.None] (an explicit no-reminder). A "Custom" row
* expands an inline number field plus a unit selector to add an arbitrary lead
* time to the set. Changes apply live via [onSelect]; the user leaves via back.
*/
@Composable
fun ReminderDefaultPicker(
@@ -120,57 +132,74 @@ fun ReminderDefaultPicker(
onSelect: (CalendarReminderOverride) -> Unit,
onDismiss: () -> Unit,
) {
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
val customSelected = selectedMinutes != null && selectedMinutes !in presets
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
// Optimistic local state: the chosen override is authoritative while the
// picker is open, so quick successive toggles compose on each other instead
// of racing the round-trip through the settings flow (which would drop a
// toggle made before the previous write echoes back). Seeded once on open;
// the picker leaves composition on dismiss, so reopening re-reads [selected].
var current by remember { mutableStateOf(selected) }
val inherits = current is CalendarReminderOverride.Inherit
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
// Presets plus any selected custom (non-preset) values, each as its own row.
val rows = presets + selectedMinutes.filter { it !in presets }.sorted()
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf(seed.first) }
var unit by rememberSaveable { mutableStateOf(seed.second) }
var amountText by rememberSaveable { mutableStateOf("") }
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
val options = buildList {
if (allowInherit) add(CalendarReminderOverride.Inherit)
add(CalendarReminderOverride.None)
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
fun apply(override: CalendarReminderOverride) {
current = override
onSelect(override)
}
val rowCount = options.size + 1 // + the custom row
// Emit the override for a new set of minutes: empty collapses to None, which
// (with inherit unchosen) reads as an explicit "no reminder".
fun emit(minutes: List<Int>) {
val norm = minutes.distinct().sorted()
apply(
if (norm.isEmpty()) {
CalendarReminderOverride.None
} else {
CalendarReminderOverride.Minutes(norm)
},
)
}
fun toggle(minute: Int) =
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
FullScreenPicker(title = title, onDismiss = onDismiss) {
options.forEachIndexed { index, option ->
val isSelected = option == selected
// Exclusive "use default" choice (per-calendar only): its own group above
// the multi-select list, since inheriting is mutually exclusive with
// picking specific times.
if (allowInherit) {
GroupedRow(
title = reminderOverrideLabel(option),
position = positionOf(index, rowCount),
selected = isSelected,
trailing = if (isSelected) {
title = stringResource(R.string.reminder_use_default),
position = Position.Alone,
selected = inherits,
trailing = if (inherits) {
{ SelectedCheck() }
} else {
null
},
onClick = {
onSelect(option)
onDismiss()
},
onClick = { apply(CalendarReminderOverride.Inherit) },
)
Spacer(Modifier.height(24.dp))
}
val rowCount = rows.size + 1 // + the custom row
rows.forEachIndexed { index, minute ->
val checked = minute in selectedMinutes
GroupedRow(
title = reminderLeadTimeLabel(minute),
position = positionOf(index, rowCount),
selected = checked,
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
onClick = { toggle(minute) },
)
}
// When expanded, the Custom row connects downward into the editor card
// so the two read as one grouped container (the per-calendar pattern).
GroupedRow(
title = if (customSelected) {
stringResource(
R.string.reminder_custom_with_value,
reminderLeadTimeLabel(selectedMinutes!!),
)
} else {
stringResource(R.string.event_edit_reminder_custom)
},
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
selected = customSelected,
trailing = if (customSelected) {
{ SelectedCheck() }
} else {
null
},
title = stringResource(R.string.event_edit_reminder_custom),
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
@@ -184,8 +213,9 @@ fun ReminderDefaultPicker(
unit = unit,
onUnitChange = { unit = it },
onConfirm = { minutes ->
onSelect(CalendarReminderOverride.Minutes(minutes))
onDismiss()
emit(selectedMinutes + minutes)
amountText = ""
customExpanded = false
},
)
}
@@ -260,6 +290,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
private fun SelectedCheck() {
Icon(
@@ -269,18 +310,146 @@ 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
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
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()
},
)
}
}
}
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
minutes == null -> "" to ReminderUnit.Minutes
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
else -> minutes.toString() to ReminderUnit.Minutes
/** 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)
}

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

@@ -80,7 +80,12 @@ import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
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 kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -466,6 +471,8 @@ private fun Timeline(
) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
Box(modifier = Modifier.fillMaxSize()) {
// Gutter and day column are two scroll viewports that SHARE one scroll
@@ -488,7 +495,7 @@ private fun Timeline(
) {
if (h > 0) {
Text(
text = "%02d".format(h),
text = formatHourLabel(h, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
@@ -534,6 +541,8 @@ private fun DayColumnCard(
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
Card(
// Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -546,6 +555,9 @@ private fun DayColumnCard(
BoxWithConstraints(
modifier = Modifier
.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
// blocks are consumed by their own click handler first, so this
// only fires on the column background. Snaps to the tapped hour.
@@ -589,7 +601,10 @@ private fun EventBlock(
modifier: Modifier = Modifier,
) {
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
Box(
modifier = modifier
@@ -635,8 +650,8 @@ private fun DayLoading() {
}
}
private fun minToHm(min: Int): String =
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
formatMinuteOfDay(min, is24Hour, locale)
private fun formatDayTitle(date: LocalDate): String {
val locale = Locale.getDefault()

View File

@@ -95,6 +95,8 @@ import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.OptionCard
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.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
@@ -781,7 +783,7 @@ private fun formatWhen(
val zid = ZoneId.of(zone.id)
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).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 allDayLabel = stringResource(R.string.event_detail_all_day)

View File

@@ -43,7 +43,6 @@ 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.Person
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Place
@@ -82,6 +81,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified
@@ -123,6 +124,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
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.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.OptionCard
@@ -132,6 +135,8 @@ import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
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.reminderUnitLabel
import de.jeanlucmakiola.calendula.ui.common.pastelize
@@ -430,6 +435,10 @@ private fun EventEditContent(
val form = state.form
val locale = currentLocale()
val dark = isSystemInDarkTheme()
// The title, date and recurrence are managed by the special-dates sync, so
// they're locked here; everything else (reminders, location, notes) is the
// user's to edit.
val locked = state.isManaged
var picker by remember { mutableStateOf<PickerTarget?>(null) }
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
@@ -476,6 +485,17 @@ private fun EventEditContent(
?: MaterialTheme.colorScheme.primary
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:
// - palette calendars (Google, …) and local calendars always support it;
// - synced calendars with no palette only when the user opted in, and even
@@ -503,6 +523,11 @@ private fun EventEditContent(
placeholder = stringResource(R.string.event_edit_title_hint),
textStyle = MaterialTheme.typography.headlineMedium
.copy(fontWeight = FontWeight.SemiBold),
enabled = !locked,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.focusRequester(titleFocusRequester),
)
Spacer(Modifier.height(10.dp))
Box(
@@ -511,6 +536,17 @@ private fun EventEditContent(
.height(3.dp)
.background(accent, RoundedCornerShape(2.dp)),
)
if (locked) {
Spacer(Modifier.height(10.dp))
Text(
text = stringResource(
R.string.event_edit_managed_hint,
selectedCalendar?.displayName.orEmpty(),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(20.dp))
@@ -529,7 +565,11 @@ private fun EventEditContent(
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay)
Switch(
checked = form.isAllDay,
onCheckedChange = viewModel::setAllDay,
enabled = !locked,
)
}
},
) {
@@ -541,6 +581,7 @@ private fun EventEditContent(
locale = locale,
onDateClick = { picker = PickerTarget.StartDate },
onTimeClick = { picker = PickerTarget.StartTime },
enabled = !locked,
)
ScheduleRow(
label = stringResource(R.string.event_edit_ends),
@@ -550,6 +591,7 @@ private fun EventEditContent(
onDateClick = { picker = PickerTarget.EndDate },
onTimeClick = { picker = PickerTarget.EndTime },
isError = EventFormProblem.EndBeforeStart in state.problems,
enabled = !locked,
)
if (EventFormProblem.EndBeforeStart in state.problems) {
Spacer(Modifier.height(2.dp))
@@ -680,7 +722,7 @@ private fun EventEditContent(
EditCard(
icon = Icons.Default.Repeat,
iconContentDescription = null,
onClick = { showRecurrencePicker = true },
onClick = if (locked) null else ({ showRecurrencePicker = true }),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1017,9 +1059,9 @@ private fun FieldPickerDialog(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
hiddenFields.forEach { field ->
OptionCard(
label = stringResource(fieldLabel(field)),
label = stringResource(eventFormFieldLabel(field)),
onClick = { onSelect(field) },
icon = fieldIcon(field),
icon = eventFormFieldIcon(field),
)
}
}
@@ -1636,28 +1678,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
* current level is highlighted. Tap picks and closes.
@@ -1860,6 +1880,7 @@ private fun InlineField(
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
singleLine: Boolean = true,
minLines: Int = 1,
enabled: Boolean = true,
keyboardType: KeyboardType = KeyboardType.Text,
modifier: Modifier = Modifier
.fillMaxWidth()
@@ -1873,6 +1894,7 @@ private fun InlineField(
textStyle = textStyle,
singleLine = singleLine,
minLines = minLines,
enabled = enabled,
keyboardType = keyboardType,
)
}
@@ -1887,19 +1909,22 @@ private fun ScheduleRow(
onDateClick: () -> Unit,
onTimeClick: () -> Unit,
isError: Boolean = false,
enabled: Boolean = true,
) {
val dateFormat = remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
}
val timeFormat = remember(locale) {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
val use24Hour = LocalUse24HourFormat.current
val timeFormat = remember(locale, use24Hour) {
timeOfDayFormatter(use24Hour, locale)
}
// Tappable values read as links (primary), like the location on the
// detail screen; errors flip them to the error colour.
val valueColor = if (isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.primary
// detail screen; errors flip them to the error colour; a locked (managed)
// row dims to a plain, non-tappable value.
val valueColor = when {
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
isError -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
}
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -1916,7 +1941,7 @@ private fun ScheduleRow(
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier
.clickable(onClick = onDateClick)
.clickable(enabled = enabled, onClick = onDateClick)
.padding(vertical = 8.dp, horizontal = 6.dp),
)
if (!isAllDay) {
@@ -1925,7 +1950,7 @@ private fun ScheduleRow(
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier
.clickable(onClick = onTimeClick)
.clickable(enabled = enabled, onClick = onTimeClick)
.padding(vertical = 8.dp, horizontal = 6.dp),
)
}

View File

@@ -29,6 +29,18 @@ data class EventEditUiState(
val hiddenFields: List<EventFormField> = emptyList(),
/** True while editing an existing event (the calendar is then fixed). */
val isEditing: Boolean = false,
/**
* True when the event's calendar is a contact special-dates calendar the app
* manages. While editing such an event the title, date and recurrence are
* locked (sync overwrites them); reminders, location and notes stay editable.
*/
val isManaged: 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
* then drops "only this event" (an exception row can't carry a rule).

View File

@@ -108,10 +108,10 @@ class EventEditViewModel @Inject constructor(
)
private data class ReminderDefaults(
val timed: Int?,
val allDay: Int?,
val timedOverrides: Map<Long, Int?>,
val allDayOverrides: Map<Long, Int?>,
val timed: List<Int>,
val allDay: List<Int>,
val timedOverrides: Map<Long, List<Int>>,
val allDayOverrides: Map<Long, List<Int>>,
)
private data class ExternalInputs(
@@ -119,12 +119,21 @@ class EventEditViewModel @Inject constructor(
val lastUsed: Long?,
val defaultFields: Set<EventFormField>,
val allowColorOnUnsupported: Boolean,
val autofocusTitle: Boolean,
)
/** Writable calendars — the only valid event targets. */
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents } }
.catch { emit(emptyList()) }
/**
* Writable calendars — the only valid event targets. Disabled calendars are
* excluded, so you can't create into a calendar you've removed from the app;
* 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. */
private val resolvedCalendarId: Flow<Long?> = combine(
@@ -152,10 +161,12 @@ class EventEditViewModel @Inject constructor(
prefs.lastUsedCalendarId,
settingsPrefs.defaultFormFields,
settingsPrefs.allowColorOnUnsupportedCalendars,
settingsPrefs.autofocusEventTitle,
::ExternalInputs,
).flowOn(io),
colorPalette,
) { local, external, palette ->
settingsPrefs.managedCalendarIds,
) { local, external, palette, managedIds ->
val form = local.form ?: return@combine null
val resolvedId = form.calendarId
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
@@ -170,6 +181,9 @@ class EventEditViewModel @Inject constructor(
visibleFields = visibleFields,
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
isEditing = local.editTarget != null,
// Editing an event in a managed calendar locks its synced fields.
isManaged = local.editTarget != null && resolvedId != null && resolvedId in managedIds,
autofocusTitle = external.autofocusTitle,
// A modified-occurrence exception can't carry its own rule, so
// the scope dialog drops "only this event" after a rule change.
recurrenceChanged = local.editTarget != null &&
@@ -249,7 +263,7 @@ class EventEditViewModel @Inject constructor(
// Re-check after suspending: bail if the form closed or the user edited.
val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch
val default = resolveDefaultReminder(
val reminders = resolveDefaultReminder(
timedGlobal = defaults.timed,
allDayGlobal = defaults.allDay,
timedOverrides = defaults.timedOverrides,
@@ -257,7 +271,6 @@ class EventEditViewModel @Inject constructor(
calendarId = targetId,
isAllDay = form.isAllDay,
)
val reminders = listOfNotNull(default)
_form.value = form.copy(reminders = reminders)
// Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field.

View File

@@ -30,11 +30,16 @@ class FilterViewModel @Inject constructor(
combine(
repository.calendars(),
prefs.hiddenCalendarIds,
) { calendars, hidden ->
if (calendars.isEmpty()) {
prefs.disabledCalendarIds,
) { 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)
} else {
FilterUiState.Success(groupByAccount(calendars, hidden))
FilterUiState.Success(groupByAccount(enabled, hidden))
}
}
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }

View File

@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
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.EventForm
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
@@ -56,6 +57,7 @@ sealed interface ImportUiState {
class ImportViewModel @Inject constructor(
private val repository: CalendarRepository,
private val importer: IcsImporter,
private val prefs: CalendarPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -79,11 +81,17 @@ class ImportViewModel @Inject constructor(
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
warnings = parsed.warnings,
)
else -> ImportUiState.Many(
events = parsed.events,
warnings = parsed.warnings,
calendars = repository.calendars().first().filter { it.canModifyContents },
)
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,
warnings = parsed.warnings,
calendars = repository.calendars().first()
.filter { it.canModifyContents && it.id !in disabled },
)
}
}
}
}

View File

@@ -36,6 +36,8 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
@@ -44,6 +46,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
@@ -60,10 +63,14 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
@@ -97,6 +104,14 @@ fun MonthScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val month by viewModel.month.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick).
val nowState = rememberCurrentMinute()
val dimCutoff by remember(dimCompleted) {
derivedStateOf { if (dimCompleted) nowState.value else null }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -193,14 +208,16 @@ fun MonthScreen(
.fillMaxSize(),
) {
WeekdayHeader(weekStart = weekStart)
MonthContent(
state = state,
slideDir = slideDir,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
)
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
MonthContent(
state = state,
slideDir = slideDir,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onOpenDay = onOpenDay,
)
}
}
}
}
@@ -558,6 +575,8 @@ private fun MonthBar(
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp,
@@ -565,7 +584,7 @@ private fun MonthBar(
bottomEnd = if (continuesRight) 0.dp else 4.dp,
)
Box(
modifier = modifier
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(event.color, dark), shape)
.padding(horizontal = 4.dp)
.semantics { contentDescription = title },

View File

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

View File

@@ -54,6 +54,8 @@ import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.Position
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.positionOf
import java.time.Instant as JavaInstant
@@ -217,12 +219,11 @@ private fun searchSummary(event: EventInstance): String {
val dateText = remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
}.format(start)
val use24Hour = LocalUse24HourFormat.current
val timeText = if (event.isAllDay) {
stringResource(R.string.event_detail_all_day)
} else {
remember(locale) {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
}.format(start)
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
}
val base = "$dateText · $timeText"
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base

View File

@@ -1,10 +1,15 @@
package de.jeanlucmakiola.calendula.ui.settings
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
/**
@@ -17,32 +22,48 @@ data class SettingsUiState(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: 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 the Agenda screen treats events that already ended today. */
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
/** Whether the month/week grids fade events that have already finished. */
val dimCompletedEvents: Boolean = false,
/** How far ahead the in-app Agenda screen shows events (v2.11). */
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"). */
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). */
val remindersEnabled: Boolean = true,
/**
* The default reminder lead time (minutes) prefilled on new timed events;
* null = no default reminder. Per-calendar overrides take precedence.
* The default reminder lead times (minutes) prefilled on new timed events;
* empty = no default reminder. Per-calendar overrides take precedence.
*/
val defaultReminderMinutes: Int? = null,
/** The default reminder lead time prefilled on new all-day events; null = none. */
val defaultAllDayReminderMinutes: Int? = null,
val defaultReminderMinutes: List<Int> = emptyList(),
/** The default reminder lead times prefilled on new all-day events; empty = none. */
val defaultAllDayReminderMinutes: List<Int> = emptyList(),
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
/** How long the notification "Snooze" action defers a reminder; default 10 min. */
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
/**
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
* calendar present in the map overrides the global default (null value = no
* calendar present in the map overrides the global default (empty value = no
* reminder); absent = inherit the global default.
*/
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Writable calendars, shown as per-calendar reminder-override rows. */
val writableCalendars: List<CalendarSource> = emptyList(),
/**
@@ -50,4 +71,30 @@ data class SettingsUiState(
* colour palette (the colour may then not survive their next sync).
*/
val allowColorOnUnsupportedCalendars: Boolean = false,
/**
* Ids of the contact special-dates calendars. Their reminders are owned by
* the Contact special dates section, so the per-calendar override list links
* out to it instead of offering an override here.
*/
val managedCalendarIds: Set<Long> = emptySet(),
)
/**
* State for the contact special-dates sub-page (issue #15). Exposed as its own
* flow rather than folded into [SettingsUiState] (whose combine is already full).
* [titleTemplates] is always populated — a blank stored template resolves to the
* localized default so the editor never shows an empty field.
*/
data class SpecialDatesUiState(
val enabled: Boolean = false,
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
/** The reminder choice per type's managed calendar (applied to all its events). */
val reminderChoices: Map<SpecialDateType, CalendarReminderOverride> = emptyMap(),
/** Whether {year} resolves in titles (the source year is static and always correct). */
val showYear: Boolean = true,
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
val stalledPermission: Boolean = false,
/** Epoch millis of the last sync, or 0 if it has never run. */
val lastRun: Long = 0L,
)

View File

@@ -1,17 +1,38 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
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.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
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.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
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_PAST_DISPLAY_KEY
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.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -20,12 +41,18 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs,
repository: CalendarRepository,
private val specialDatesEngine: SpecialDatesSyncEngine,
specialDatesSpec: SpecialDatesCalendarSpec,
@IoDispatcher private val io: CoroutineDispatcher,
@ApplicationContext private val appContext: Context,
) : ViewModel() {
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
@@ -70,13 +97,37 @@ class SettingsViewModel @Inject constructor(
prefs.perCalendarReminderOverride,
prefs.perCalendarAllDayReminderOverride,
writableCalendars,
) { overrides, allDayOverrides, calendars ->
ReminderOverrides(overrides, allDayOverrides, calendars)
prefs.managedCalendarIds,
) { overrides, allDayOverrides, calendars, managedIds ->
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
},
prefs.defaultView,
) { base, defaults, overrides, defaultView ->
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,
prefs.pastEventDisplay,
prefs.dimCompletedEvents,
::MiscSettings,
),
) { base, defaults, overrides, views, misc ->
base.copy(
defaultView = defaultView,
defaultView = views.defaultView,
agendaScreenRange = views.agendaScreenRange,
agendaWidgetRange = views.agendaWidgetRange,
timeFormat = views.timeFormat,
showHourLines = views.showHourLines,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
dimCompletedEvents = misc.dimCompletedEvents,
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -85,6 +136,7 @@ class SettingsViewModel @Inject constructor(
perCalendarReminderOverride = overrides.timed,
perCalendarAllDayReminderOverride = overrides.allDay,
writableCalendars = overrides.calendars,
managedCalendarIds = overrides.managedIds,
)
}.stateIn(
scope = viewModelScope,
@@ -92,20 +144,136 @@ class SettingsViewModel @Inject constructor(
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(
val allowColor: Boolean,
val defaultReminder: Int?,
val allDayReminder: Int?,
val defaultReminder: List<Int>,
val allDayReminder: List<Int>,
val allDayReminderTime: Int,
val snoozeMinutes: Int,
)
private data class ReminderOverrides(
val timed: Map<Long, Int?>,
val allDay: Map<Long, Int?>,
val timed: Map<Long, List<Int>>,
val allDay: Map<Long, List<Int>>,
val calendars: List<CalendarSource>,
val managedIds: Set<Long>,
)
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,
val pastEventDisplay: PastEventDisplay,
val dimCompletedEvents: Boolean,
)
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
val specialDatesState: StateFlow<SpecialDatesUiState> = combine(
combine(
prefs.specialDatesEnabled,
prefs.specialDatesTypes,
prefs.specialDatesTitleTemplates,
prefs.specialDatesShowYear,
prefs.specialDatesStatus,
) { enabled, types, templates, showYear, status ->
SpecialDatesUiState(
enabled = enabled,
types = types,
// A blank stored template resolves to the localized default so the
// editor always shows something sensible.
titleTemplates = SpecialDateType.entries.associateWith { type ->
templates[type]?.takeIf { it.isNotBlank() }
?: specialDatesSpec.defaultTitleTemplate(type)
},
showYear = showYear,
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
lastRun = status.lastRun,
)
},
prefs.specialDatesCalendars,
prefs.perCalendarAllDayReminderOverride,
) { base, calendars, allDayOverrides ->
base.copy(
reminderChoices = calendars.mapValues { (_, calendarId) ->
// An override is always seeded on creation; present-empty = None.
when (val minutes = allDayOverrides[calendarId]) {
null -> CalendarReminderOverride.Inherit
emptyList<Int>() -> CalendarReminderOverride.None
else -> CalendarReminderOverride.Minutes(minutes)
}
},
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = SpecialDatesUiState(),
)
/**
* Turn the mirror on or off. Enabling schedules the daily job and runs an
* immediate sync (which creates the calendars); disabling cancels the job and
* tears the managed calendars down. The caller must have READ_CONTACTS granted
* before enabling.
*/
fun setSpecialDatesEnabled(enabled: Boolean) {
viewModelScope.launch {
prefs.setSpecialDatesEnabled(enabled)
if (enabled) {
SpecialDatesScheduler.apply(appContext, enabled = true)
SpecialDatesScheduler.runNow(appContext)
} else {
SpecialDatesScheduler.apply(appContext, enabled = false)
withContext(io) { specialDatesEngine.teardown() }
}
}
}
fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
viewModelScope.launch {
prefs.setSpecialDateTypeEnabled(type, enabled)
// Reconcile now: create the new type's calendar, or delete a disabled one.
SpecialDatesScheduler.runNow(appContext)
}
}
fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
viewModelScope.launch {
prefs.setSpecialDatesTitleTemplate(type, template.trim())
SpecialDatesScheduler.runNow(appContext)
}
}
fun setSpecialDatesShowYear(enabled: Boolean) {
viewModelScope.launch {
prefs.setSpecialDatesShowYear(enabled)
SpecialDatesScheduler.runNow(appContext)
}
}
/**
* Set a type's managed-calendar reminder default and apply it to all its
* existing events (managed calendars own their reminders calendar-wide).
*/
fun setSpecialDatesReminders(type: SpecialDateType, override: CalendarReminderOverride) {
viewModelScope.launch { withContext(io) { specialDatesEngine.applyReminders(type, override) } }
}
/** Kick off an immediate reconcile (the "Sync now" action). */
fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext)
fun setThemeMode(mode: ThemeMode) {
viewModelScope.launch { prefs.setThemeMode(mode) }
}
@@ -115,7 +283,68 @@ class SettingsViewModel @Inject constructor(
}
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 setPastEventDisplay(mode: PastEventDisplay) {
viewModelScope.launch {
prefs.setPastEventDisplay(mode)
// The agenda widget honours this setting too, so push it into each
// instance's Glance state and recompose — same reliable-update path as
// setAgendaWidgetRange (updateAll alone won't re-run the data preamble).
widgetRefreshMutex.withLock {
val manager = GlanceAppWidgetManager(appContext)
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name }
}
AgendaWidget().updateAll(appContext)
}
}
}
fun setDimCompletedEvents(enabled: Boolean) {
viewModelScope.launch { prefs.setDimCompletedEvents(enabled) }
}
fun setDefaultView(view: CalendarView) {
@@ -130,11 +359,15 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
}
fun setDefaultReminderMinutes(minutes: Int?) {
fun setAutofocusEventTitle(enabled: Boolean) {
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
}
fun setDefaultReminderMinutes(minutes: List<Int>) {
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
}
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
}

View File

@@ -45,7 +45,9 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
@@ -55,6 +57,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
@@ -73,16 +76,25 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
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.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.next
import de.jeanlucmakiola.calendula.ui.common.pastelize
@@ -124,6 +136,14 @@ fun WeekScreen(
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
// The instant before which an event counts as completed, or null when dimming
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
// screen while the setting is off (it stays null regardless of the tick).
val nowState = rememberCurrentMinute()
val dimCutoff by remember(dimCompleted) {
derivedStateOf { if (dimCompleted) nowState.value else null }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
@@ -216,19 +236,21 @@ fun WeekScreen(
)
},
) { innerPadding ->
WeekContent(
state = state,
slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
WeekContent(
state = state,
slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
}
}
@@ -545,8 +567,10 @@ private fun AllDayBar(
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
Box(
modifier = modifier
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
@@ -572,6 +596,8 @@ private fun Timeline(
) {
val totalHeight = HOUR_HEIGHT * 24
val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
Box(modifier = Modifier.fillMaxSize()) {
// Gutter and day columns are two scroll viewports that SHARE one scroll
@@ -595,7 +621,7 @@ private fun Timeline(
) {
if (h > 0) {
Text(
text = "%02d".format(h),
text = formatHourLabel(h, use24Hour, locale),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
@@ -650,6 +676,8 @@ private fun DayColumnCard(
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -662,6 +690,9 @@ private fun DayColumnCard(
BoxWithConstraints(
modifier = Modifier
.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
// blocks are consumed by their own handler first. Snaps to hour.
.pointerInput(date) {
@@ -680,6 +711,7 @@ private fun DayColumnCard(
EventBlock(
block = block,
dark = dark,
height = height,
onClick = { onEventClick(block.event) },
modifier = Modifier
.offset(x = laneWidth * block.lane, y = top)
@@ -700,14 +732,35 @@ private fun DayColumnCard(
private fun EventBlock(
block: TimedBlock,
dark: Boolean,
height: Dp,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
val timeLabel = "${minToHm(block.startMin)}${minToHm(block.endMin)}"
val showTime = block.endMin - block.startMin >= 45
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale)
// Only full-width (non-overlapping) blocks that are tall enough show the time.
// On narrow overlapping columns we drop it so the title can wrap to fill the
// whole block, mirroring Google Calendar.
val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1
val density = LocalDensity.current
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// Wrap the title across as many lines as the block can fit (minus the 2.dp
// top/bottom padding and the reserved time line) instead of clipping it to a
// single character on slim, overlapping blocks.
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
Box(
modifier = modifier
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 2.dp)
@@ -717,18 +770,15 @@ private fun EventBlock(
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.85f),
)
if (showTime) {
// Narrow columns can't fit "13:0014:00" on one line, so let it
// wrap to a second line (after the dash) instead of clipping the
// end time.
Text(
text = timeLabel,
style = MaterialTheme.typography.labelSmall,
maxLines = 2,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.Black.copy(alpha = 0.6f),
)
@@ -773,8 +823,8 @@ private fun WeekLoading() {
}
}
private fun minToHm(min: Int): String =
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
formatMinuteOfDay(min, is24Hour, locale)
private fun formatWeekRange(weekStart: LocalDate): String {
val locale = Locale.getDefault()

View File

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

View File

@@ -4,9 +4,12 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.EventInstance
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.groupAgendaDays
import kotlinx.coroutines.flow.first
@@ -22,9 +25,7 @@ import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
import kotlin.time.Clock
/** How far ahead the agenda widget loads (a month of upcoming events). */
private const val AGENDA_WIDGET_DAYS = 30
import kotlin.time.Instant
/**
* How far either side of today the month widget pre-loads. The displayed month
@@ -46,7 +47,28 @@ internal fun Context.hasCalendarPermission(): Boolean =
sealed interface AgendaWidgetData {
/** Calendar permission not granted — the widget can't read events. */
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.
* Kept unfiltered: hiding/dimming finished events also happens reactively
* in the composition, against [now], for the same reliable-update reason.
*/
val days: List<AgendaDay>,
/** Resolved clock convention for event time labels (the time-format pref). */
val is24Hour: Boolean,
/** First day of the week, for the calendar-aligned "this week" range. */
val weekStart: DayOfWeek,
/** Saved range pref — the fallback when an instance has no Glance state yet. */
val savedRange: AgendaRange,
/** Saved past-event display pref — the fallback before Glance state is set. */
val savedPastDisplay: PastEventDisplay,
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
val now: Instant,
) : AgendaWidgetData
}
/**
@@ -91,9 +113,27 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
val zone = systemZone()
val anchor = today(zone)
val repo = widgetEntryPoint().calendarRepository()
val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
val ep = widgetEntryPoint()
val prefs = ep.settingsPrefs()
val savedRange = prefs.agendaWidgetRange.first()
val savedPastDisplay = prefs.pastEventDisplay.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,
savedPastDisplay = savedPastDisplay,
now = Clock.System.now(),
)
}
/** 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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.ColorFilter
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
@@ -24,6 +25,8 @@ import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.updateAll
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
@@ -40,8 +43,16 @@ import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.pastelize
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
@@ -62,8 +73,28 @@ import java.util.Locale
* of events grouped under day headers (the Google "Schedule" widget model).
* 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")
/**
* Per-instance Glance state key holding the past-event display mode (as
* [PastEventDisplay.name]). Read reactively in the composition for the same
* reason as [AGENDA_RANGE_KEY] — so toggling the setting reflects on the live
* widget without depending on the `provideGlance` preamble re-running.
*/
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
val data = context.loadAgendaWidgetData()
val dark = (context.resources.configuration.uiMode and
@@ -101,12 +132,36 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
Spacer(GlanceModifier.height(4.dp))
when (data) {
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
is AgendaWidgetData.Ready ->
if (data.days.isEmpty()) {
is AgendaWidgetData.Ready -> {
// 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 pastDisplay =
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
val rangeEnd = data.today.plus(
range.dayCount(data.today, data.weekStart) - 1,
DateTimeUnit.DAY,
)
// Slice to the chosen range, then drop finished events (and any day
// they leave empty) when the mode is Hide — so "Upcoming" really is.
val visibleDays = data.days
.filter { it.date <= rangeEnd }
.let { days ->
if (pastDisplay == PastEventDisplay.HIDE) {
days.mapNotNull { day ->
val remaining = day.events.filterNot { it.hasEnded(data.now) }
if (remaining.isEmpty()) null else day.copy(events = remaining)
}
} else {
days
}
}
if (visibleDays.isEmpty()) {
WidgetMessage(R.string.agenda_empty_title)
} else {
val rows = buildList {
data.days.forEach { day ->
visibleDays.forEach { day ->
add(AgendaRow.Header(day.date, data.today))
day.events.forEach { add(AgendaRow.Event(it)) }
}
@@ -115,11 +170,18 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
items(rows.size) { index ->
when (val row = rows[index]) {
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
is AgendaRow.Event -> EventRow(row.event, dark)
is AgendaRow.Event -> EventRow(
event = row.event,
dark = dark,
is24Hour = data.is24Hour,
dimmed = pastDisplay == PastEventDisplay.DIM &&
row.event.hasEnded(data.now),
)
}
}
}
}
}
}
}
}
@@ -195,9 +257,15 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
}
@Composable
private fun EventRow(event: EventInstance, dark: Boolean) {
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
val context = androidx.glance.LocalContext.current
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
// dropping both text lines to the lower-emphasis on-surface-variant tone.
val stripeColor = pastelize(event.color, dark).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it
}
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
Row(
modifier = GlanceModifier
.fillMaxWidth()
@@ -220,17 +288,17 @@ private fun EventRow(event: EventInstance, dark: Boolean) {
.width(5.dp)
.height(36.dp)
.cornerRadius(3.dp)
.background(pastelize(event.color, dark)),
.background(stripeColor),
) {}
Spacer(GlanceModifier.width(10.dp))
Column(modifier = GlanceModifier.defaultWeight()) {
Text(
text = title,
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
style = TextStyle(color = titleColor, fontSize = 14.sp),
)
Text(
text = eventTimeSummary(context, event),
text = eventTimeSummary(context, event, is24Hour),
maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
)
@@ -269,17 +337,17 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
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) {
context.getString(R.string.event_detail_all_day)
} else {
"${formatTime(event.start)} ${formatTime(event.end)}"
"${formatTime(event.start, is24Hour)} ${formatTime(event.end, is24Hour)}"
}
val location = event.location?.takeIf { it.isNotBlank() }
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
return "%02d:%02d".format(t.hour, t.minute)
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
}

View File

@@ -13,7 +13,7 @@
<!-- Permission-Flow (F1) -->
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App zu Beginn nicht — und nichts verlässt je dein Gerät.</string>
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
@@ -230,8 +230,7 @@
<string name="agenda_today_action">Heute</string>
<string name="agenda_header_today">Heute</string>
<string name="agenda_header_tomorrow">Morgen</string>
<string name="agenda_empty_title">Nichts geplant</string>
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
<string name="agenda_empty_title">Alles erledigt</string>
<!-- Terminsuche -->
<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_week_start">Wochenstart</string>
<string name="settings_week_start_auto">Automatisch</string>
<string name="settings_week_start_monday">Montag</string>
<string name="settings_week_start_sunday">Sonntag</string>
<string name="settings_time_format">Zeitformat</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_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_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>
@@ -292,7 +316,6 @@
<string name="reminder_none">Keine</string>
<string name="reminder_use_default">Standard-Erinnerung verwenden</string>
<string name="reminder_custom_amount">Anzahl</string>
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
<string name="reminder_custom_set">Übernehmen</string>
<string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string>
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>
@@ -315,6 +338,7 @@
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">von Jean-Luc Makiola</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_logo_desc">Calendula-App-Symbol</string>
<string name="settings_report_problem">Problem melden</string>
@@ -386,4 +410,38 @@
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
<string name="crash_report_body_template">Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n</string>
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
<!-- Contact special dates (issue #15) -->
<string name="special_dates_calendar_birthday">Geburtstage</string>
<string name="special_dates_calendar_anniversary">Jahrestage</string>
<string name="special_dates_calendar_custom">Besondere Tage</string>
<string name="special_dates_default_title_birthday">{name}s Geburtstag ({year})</string>
<string name="special_dates_default_title_anniversary">{name}s Jahrestag ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
<string name="event_edit_managed_hint">Verwaltet von „%1$s“ — Titel, Datum und Wiederholung werden aus deinen Kontakten synchron gehalten. Erinnerungen, Ort und Notizen kannst du selbst bearbeiten.</string>
<string name="settings_special_dates_subtitle">Geburtstage &amp; Jahrestage aus Kontakten</string>
<string name="settings_section_special_dates">Besondere Kontakttage</string>
<string name="settings_special_dates_enable">Kontakttage anzeigen</string>
<string name="settings_special_dates_enable_hint">Spiegelt Geburtstage und weitere Daten deiner Kontakte in lokale Kalender. Liest Kontakte nur auf diesem Gerät — nichts wird hochgeladen, und deine Kontakte werden nie verändert.</string>
<string name="settings_special_dates_type_birthday">Geburtstage</string>
<string name="settings_special_dates_type_anniversary">Jahrestage</string>
<string name="settings_special_dates_type_custom">Weitere Tage</string>
<string name="settings_special_dates_template">Titelformat</string>
<string name="settings_special_dates_template_hint">Verwende {name} für den Kontakt und {year} für das Jahr (Geburtsjahr bzw. Startjahr eines Jahrestags; ausgeblendet, wenn unbekannt).</string>
<string name="settings_special_dates_reminders">Erinnerungen</string>
<string name="settings_special_dates_show_year">Jahr anzeigen</string>
<string name="settings_special_dates_show_year_hint">{year} in Titeln anzeigen, wenn es bekannt ist</string>
<string name="settings_special_dates_sync_now">Jetzt synchronisieren</string>
<string name="settings_special_dates_never_synced">Noch nicht synchronisiert</string>
<string name="settings_special_dates_last_synced">Zuletzt synchronisiert %1$s</string>
<string name="settings_special_dates_calendar_hint">Farbe und Sichtbarkeit jedes Kalenders legst du in den Kalender-Einstellungen fest.</string>
<string name="settings_calendar_reminders_managed_hint">In Besondere Kontakttage festgelegt</string>
<string name="settings_special_dates_paused_title">Pausiert</string>
<string name="settings_special_dates_paused_hint">Calendula kann deine Kontakte nicht mehr lesen, daher werden diese Kalender nicht aktualisiert.</string>
<string name="settings_special_dates_grant">Zugriff erlauben</string>
<string name="settings_special_dates_disable_title">Kontakttage deaktivieren?</string>
<string name="settings_special_dates_disable_all_message">Dadurch werden die Kontakttage-Kalender und ihre Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren.</string>
<string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren.</string>
<string name="settings_special_dates_disable_confirm">Deaktivieren</string>
<string name="dialog_save">Speichern</string>
</resources>

View File

@@ -14,7 +14,7 @@
<!-- Permission flow (F1) -->
<string name="permission_rationale_title">See all your events, beautifully</string>
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for.</string>
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s all it asks for up front — and nothing ever leaves your device.</string>
<string name="permission_request_button">Grant calendar access</string>
<string name="permission_denied_title">Calendar access denied</string>
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
@@ -68,6 +68,8 @@
<string name="event_edit_close">Close</string>
<string name="event_edit_save">Save</string>
<string name="event_edit_title_hint">Add title</string>
<!-- %1$s is the managing calendar's name, e.g. "Birthdays". -->
<string name="event_edit_managed_hint">Managed by “%1$s” — the title, date and repeat are kept in sync from your contacts. Reminders, location and notes are yours to edit.</string>
<string name="event_edit_starts">Starts</string>
<string name="event_edit_ends">Ends</string>
<string name="event_edit_error_end_before_start">Ends before it starts</string>
@@ -197,6 +199,14 @@
<item quantity="one">%d hour</item>
<item quantity="other">%d hours</item>
</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 -->
<string name="event_untitled">(No title)</string>
@@ -231,8 +241,7 @@
<string name="agenda_today_action">Today</string>
<string name="agenda_header_today">Today</string>
<string name="agenda_header_tomorrow">Tomorrow</string>
<string name="agenda_empty_title">Nothing scheduled</string>
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
<string name="agenda_empty_title">You\'re all caught up</string>
<!-- Event search -->
<string name="search_action">Search</string>
@@ -269,10 +278,42 @@
<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_auto">Automatic</string>
<string name="settings_week_start_monday">Monday</string>
<string name="settings_week_start_sunday">Sunday</string>
<string name="settings_time_format">Time format</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_dim_completed">Dim completed events</string>
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
<string name="settings_past_events">Past events</string>
<string name="settings_past_events_show">Show</string>
<string name="settings_past_events_dim">Dim</string>
<string name="settings_past_events_hide">Hide</string>
<string name="settings_agenda_header">Agenda</string>
<string name="settings_agenda_range">Agenda range</string>
<string name="settings_agenda_range_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_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_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>
@@ -285,8 +326,8 @@
<string name="reminder_none">None</string>
<string name="reminder_use_default">Use default reminder</string>
<string name="reminder_custom_amount">Amount</string>
<string name="reminder_custom_with_value">Custom (%1$s)</string>
<string name="reminder_custom_set">Set</string>
<string name="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_reminder_inherits">Default (%1$s)</string>
<string name="settings_reliable_delivery">Reliable delivery</string>
@@ -299,15 +340,47 @@
<string name="settings_section_language">Language</string>
<string name="settings_language">App language</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 -->
<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_notifications_subtitle">Event reminders</string>
<string name="settings_special_dates_subtitle">Contact birthdays &amp; anniversaries</string>
<!-- Contact special dates (issue #15) -->
<string name="settings_section_special_dates">Contact special dates</string>
<string name="settings_special_dates_enable">Show contact dates</string>
<string name="settings_special_dates_enable_hint">Mirror your contacts\' birthdays and other dates into local calendars. Reads contacts on this device only — nothing is uploaded, and your contacts are never changed.</string>
<string name="settings_special_dates_type_birthday">Birthdays</string>
<string name="settings_special_dates_type_anniversary">Anniversaries</string>
<string name="settings_special_dates_type_custom">Other dates</string>
<string name="settings_special_dates_template">Title format</string>
<string name="settings_special_dates_template_hint">Use {name} for the contact and {year} for the year (the birth year, or an anniversary\'s start year; hidden when unknown).</string>
<string name="settings_special_dates_reminders">Reminders</string>
<string name="settings_special_dates_show_year">Show year</string>
<string name="settings_special_dates_show_year_hint">Include {year} in titles when it is known</string>
<string name="settings_special_dates_sync_now">Sync now</string>
<string name="settings_special_dates_never_synced">Not synced yet</string>
<!-- %1$s is a relative time, e.g. "5 minutes ago". -->
<string name="settings_special_dates_last_synced">Last synced %1$s</string>
<string name="settings_special_dates_calendar_hint">Set each calendar\'s colour and visibility in Calendars settings.</string>
<string name="settings_calendar_reminders_managed_hint">Set in Contact special dates</string>
<string name="settings_special_dates_paused_title">Paused</string>
<string name="settings_special_dates_paused_hint">Calendula can no longer read your contacts, so these calendars aren\'t updating.</string>
<string name="settings_special_dates_grant">Grant access</string>
<string name="settings_special_dates_disable_title">Turn off contact dates?</string>
<string name="settings_special_dates_disable_all_message">This deletes the contact-date calendars and their events. Any reminders or notes you added to them will be lost.</string>
<!-- %1$s is the date type, e.g. "Birthdays". -->
<string name="settings_special_dates_disable_type_message">This deletes the “%1$s” calendar and its events. Any reminders or notes you added to them will be lost.</string>
<string name="settings_special_dates_disable_confirm">Turn off</string>
<string name="dialog_save">Save</string>
<string name="settings_section_about">About</string>
<string name="settings_license">License</string>
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">by Jean-Luc Makiola</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_logo_desc">Calendula app icon</string>
<string name="settings_report_problem">Report a problem</string>
@@ -318,9 +391,14 @@
<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_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_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_new_title">New calendar</string>
<string name="calendars_edit_title">Edit calendar</string>
@@ -334,6 +412,26 @@
<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_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>
<plurals name="calendars_backup_done">
<item quantity="one">Exported %d event.</item>
@@ -378,6 +476,8 @@
<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_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
Gitea issue (the app sends nothing automatically). -->
@@ -393,4 +493,13 @@
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
<!-- Contact special dates (issue #15). {name} is the contact, {age} the age
at the upcoming date (dropped when the year is unknown or Show age is off). -->
<string name="special_dates_calendar_birthday">Birthdays</string>
<string name="special_dates_calendar_anniversary">Anniversaries</string>
<string name="special_dates_calendar_custom">Special dates</string>
<string name="special_dates_default_title_birthday">{name}\'s birthday ({year})</string>
<string name="special_dates_default_title_anniversary">{name}\'s anniversary ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
</resources>

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
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }

View File

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

View File

@@ -1,12 +1,15 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import java.time.ZoneId
/**
* Test-only fake. Tunable via the three `var` properties; `tick()` simulates
@@ -83,6 +86,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun deleteCalendar(id: Long) {
writeError?.let { throw it }
deletedCalendarIds += id
managedCalendarRows.removeAll { it.id == id }
managedEvents.remove(id)
}
/** All-day reminder fire-time minute-of-day passed into the last write. */
@@ -139,6 +144,80 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun deleteEvent(eventId: Long) {
writeError?.let { throw it }
deletedEventIds += eventId
managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } }
}
// --- Managed special-dates surface (stateful, so a re-sync sees inserts) ---
var nextManagedEventId: Long = 1_000L
private var managedCalendarSeq: Long = 700L
private val managedCalendarRows = mutableListOf<ManagedCalendarRow>()
private val managedEvents = mutableMapOf<Long, MutableList<ManagedEventRow>>()
data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType)
val createdManagedCalendars = mutableListOf<CreatedManagedCalendar>()
val insertedManagedEvents = mutableListOf<Pair<EventForm, String>>()
val updatedManagedFields = mutableListOf<Pair<Long, Map<String, Any?>>>()
/** Preset an orphan managed calendar (e.g. to test adoption after a prefs wipe). */
fun presetManagedCalendar(id: Long, type: SpecialDateType) {
managedCalendarRows += ManagedCalendarRow(id, type)
managedEvents.getOrPut(id) { mutableListOf() }
}
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
writeError?.let { throw it }
createdManagedCalendars += CreatedManagedCalendar(displayName, color, type)
val id = managedCalendarSeq++
managedCalendarRows += ManagedCalendarRow(id, type)
managedEvents.getOrPut(id) { mutableListOf() }
return id
}
override fun findManagedCalendars(): List<ManagedCalendarRow> = managedCalendarRows.toList()
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> =
managedEvents[calendarId]?.toList() ?: emptyList()
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
writeError?.let { throw it }
insertedManagedEvents += form to uid
allDayReminderTimes += allDayReminderTimeMinutes
val id = nextManagedEventId++
val times = form.toWriteTimes(ZoneId.systemDefault())
managedEvents.getOrPut(requireNotNull(form.calendarId)) { mutableListOf() } +=
ManagedEventRow(id, uid, form.title.trim(), times.dtStartMillis, form.rrule)
return id
}
/** (calendarId, allDayTime, minutes) recorded from applyManagedCalendarReminders. */
val appliedManagedReminders = mutableListOf<Triple<Long, Int, List<Int>>>()
override fun applyManagedCalendarReminders(
calendarId: Long,
allDayReminderTimeMinutes: Int,
minutes: List<Int>,
) {
writeError?.let { throw it }
appliedManagedReminders += Triple(calendarId, allDayReminderTimeMinutes, minutes)
}
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
writeError?.let { throw it }
if (values.isEmpty()) return
updatedManagedFields += eventId to values
managedEvents.values.forEach { rows ->
val idx = rows.indexOfFirst { it.eventId == eventId }
if (idx >= 0) {
var row = rows[idx]
(values[CalendarContract.Events.TITLE] as? String)?.let { row = row.copy(title = it) }
(values[CalendarContract.Events.DTSTART] as? Long)?.let { row = row.copy(dtStartMillis = it) }
if (values.containsKey(CalendarContract.Events.RRULE)) {
row = row.copy(rrule = values[CalendarContract.Events.RRULE] as? String)
}
rows[idx] = row
}
}
}
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {

View File

@@ -0,0 +1,226 @@
package de.jeanlucmakiola.calendula.data.contacts
import android.provider.CalendarContract
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class SpecialDatesSyncEngineTest {
private class FakeContacts(
var permission: Boolean = true,
var dates: List<ContactSpecialDate> = emptyList(),
) : ContactSpecialDatesDataSource {
override fun hasPermission() = permission
override fun readSpecialDates() = dates
}
private class FakeSpec : SpecialDatesCalendarSpec {
override fun displayName(type: SpecialDateType) = type.name
override fun color(type: SpecialDateType) = 0xFF112233.toInt()
override fun defaultTitleTemplate(type: SpecialDateType) = when (type) {
SpecialDateType.Birthday -> "{name}'s birthday ({year})"
SpecialDateType.Anniversary -> "{name}'s anniversary"
SpecialDateType.Custom -> "{name}"
}
}
private fun prefs(tempDir: Path): SettingsPrefs {
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() },
)
return SettingsPrefs(store)
}
private fun birthday(key: String, name: String, month: Int, day: Int, year: Int?) =
ContactSpecialDate(key, name, SpecialDateType.Birthday, month, day, year)
private suspend fun engineWith(
tempDir: Path,
contacts: FakeContacts,
calendars: FakeCalendarDataSource = FakeCalendarDataSource(),
enabled: Boolean = true,
): Pair<SpecialDatesSyncEngine, SettingsPrefs> {
val settings = prefs(tempDir)
if (enabled) settings.setSpecialDatesEnabled(true)
return SpecialDatesSyncEngine(contacts, calendars, settings, FakeSpec()) to settings
}
@Test
fun `disabled feature is a no-op`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val (engine, _) = engineWith(tempDir, FakeContacts(), calendars, enabled = false)
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.Disabled)
assertThat(calendars.createdManagedCalendars).isEmpty()
}
@Test
fun `missing permission reports stalled without touching calendars`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val (engine, _) = engineWith(tempDir, FakeContacts(permission = false), calendars)
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.PermissionMissing)
assertThat(calendars.createdManagedCalendars).isEmpty()
}
@Test
fun `first sync creates calendars and inserts one event per contact`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(
dates = listOf(
birthday("a", "Jane", 5, 14, 1990),
birthday("b", "Bob", 12, 25, null),
),
)
val (engine, settings) = engineWith(tempDir, contacts, calendars)
assertThat(engine.sync()).isEqualTo(SpecialDatesSyncResult.Success)
// All three type calendars exist; the birthday one holds both contacts.
assertThat(calendars.createdManagedCalendars.map { it.type })
.containsExactlyElementsIn(SpecialDateType.entries)
assertThat(calendars.insertedManagedEvents).hasSize(2)
assertThat(calendars.insertedManagedEvents.map { it.second })
.containsExactly("contact-birthday:a@calendula", "contact-birthday:b@calendula")
// Year-less contact drops the year; year-known keeps it.
val titles = calendars.insertedManagedEvents.map { it.first.title }
assertThat(titles).contains("Jane's birthday (1990)")
assertThat(titles).contains("Bob's birthday")
// Calendar ids were persisted for the editor / next sync.
assertThat(settings.specialDatesCalendars.first()).containsKey(SpecialDateType.Birthday)
}
@Test
fun `a second sync with no changes writes nothing`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, _) = engineWith(tempDir, contacts, calendars)
engine.sync()
val insertsAfterFirst = calendars.insertedManagedEvents.size
val calendarsAfterFirst = calendars.createdManagedCalendars.size
engine.sync()
assertThat(calendars.insertedManagedEvents).hasSize(insertsAfterFirst)
assertThat(calendars.createdManagedCalendars).hasSize(calendarsAfterFirst)
assertThat(calendars.updatedManagedFields).isEmpty()
assertThat(calendars.deletedEventIds).isEmpty()
}
@Test
fun `a renamed contact triggers a title-only managed update`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, _) = engineWith(tempDir, contacts, calendars)
engine.sync()
contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990))
engine.sync()
assertThat(calendars.updatedManagedFields).hasSize(1)
val (_, columns) = calendars.updatedManagedFields.single()
// Only managed columns are ever written — never reminders/location.
assertThat(columns.keys).containsExactly(CalendarContract.Events.TITLE)
assertThat(columns[CalendarContract.Events.TITLE]).isEqualTo("Janet's birthday (1990)")
}
@Test
fun `a removed contact deletes its event`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, _) = engineWith(tempDir, contacts, calendars)
engine.sync()
contacts.dates = emptyList()
engine.sync()
assertThat(calendars.deletedEventIds).hasSize(1)
}
@Test
fun `disabling a type deletes its calendar on the next sync`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
engine.sync()
val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary)
settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
engine.sync()
assertThat(calendars.deletedCalendarIds).contains(anniversaryId)
assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary)
}
@Test
fun `applyReminders persists the override and applies it to all events`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, settings) = engineWith(tempDir, contacts, calendars)
engine.sync()
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.Minutes(listOf(0, 1440)))
// Persisted as the per-calendar all-day override (so new events match)...
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal])
.isEqualTo(listOf(0, 1440))
// ...and applied to the existing events in that calendar.
assertThat(calendars.appliedManagedReminders).contains(
Triple(birthdayCal, settings.allDayReminderTimeMinutes.first(), listOf(0, 1440)),
)
}
@Test
fun `applyReminders with None clears the reminders`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
engine.sync()
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.None)
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal]).isEmpty()
assertThat(calendars.appliedManagedReminders.last().third).isEmpty()
}
// --- pure diff ---
private fun desired(uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
ManagedEventDesired(uid, title, dt, rrule)
private fun row(id: Long, uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
ManagedEventRow(id, uid, title, dt, rrule)
@Test
fun `diff inserts new, deletes gone, and leaves unchanged alone`() {
val diff = diffManagedEvents(
desired = listOf(desired("keep", "Jane", 100), desired("new", "Ada", 200)),
existing = listOf(row(1, "keep", "Jane", 100), row(2, "gone", "Old", 300)),
)
assertThat(diff.insertUids).containsExactly("new")
assertThat(diff.deleteEventIds).containsExactly(2L)
assertThat(diff.updates).isEmpty()
}
@Test
fun `diff emits only the changed managed columns`() {
val diff = diffManagedEvents(
desired = listOf(desired("a", "Janet", 150)),
existing = listOf(row(9, "a", "Jane", 100)),
)
val update = diff.updates.single()
assertThat(update.eventId).isEqualTo(9L)
assertThat(update.columns.keys)
.containsExactly(CalendarContract.Events.TITLE, CalendarContract.Events.DTSTART)
}
}

View File

@@ -50,4 +50,34 @@ class CalendarPrefsTest {
}
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,8 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.DayOfWeek
@@ -25,7 +27,7 @@ class SettingsPrefsTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
assertThat(prefs.dynamicColor.first()).isTrue()
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.AUTO)
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto)
}
@Test
@@ -43,10 +45,70 @@ class SettingsPrefsTest {
}
@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))
prefs.setWeekStart(WeekStartPref.SUNDAY)
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY)
prefs.setWeekStart(WeekStartPref.Day(DayOfWeek.SATURDAY))
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
@@ -122,22 +184,38 @@ class SettingsPrefsTest {
}
@Test
fun `default reminder is none until set`(@TempDir tempDir: Path) = runTest {
fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultReminderMinutes.first()).isNull()
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
}
@Test
fun `default reminder round-trips, including none`(@TempDir tempDir: Path) = runTest {
fun `default reminder round-trips none, single, and multiple lead times`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setDefaultReminderMinutes(30)
assertThat(prefs.defaultReminderMinutes.first()).isEqualTo(30)
prefs.setDefaultReminderMinutes(null)
assertThat(prefs.defaultReminderMinutes.first()).isNull()
prefs.setDefaultReminderMinutes(listOf(30))
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
// Several lead times persist, normalised to distinct ascending order.
prefs.setDefaultReminderMinutes(listOf(10_080, 0, 30, 30))
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(0, 30, 10_080).inOrder()
prefs.setDefaultReminderMinutes(emptyList())
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
}
@Test
fun `garbage stored default reminder reads as none`(@TempDir tempDir: Path) = runTest {
fun `legacy single-value default reminder parses as a one-element list`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
// The pre-multi-reminder format stored a bare integer string.
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "30"
m
}
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
}
@Test
fun `garbage stored default reminder reads as empty`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
store.updateData { p ->
@@ -145,7 +223,7 @@ class SettingsPrefsTest {
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
m
}
assertThat(prefs.defaultReminderMinutes.first()).isNull()
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
}
@Test
@@ -153,16 +231,16 @@ class SettingsPrefsTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15, 10_080)))
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
prefs.perCalendarReminderOverride.first().let { map ->
assertThat(map).containsExactly(7L, 15, 9L, null)
assertThat(map).containsExactly(7L, listOf(15, 10_080), 9L, emptyList<Int>())
}
// Inherit drops the override entirely (absent != null value).
// Inherit drops the override entirely (absent != an empty-list value).
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
prefs.perCalendarReminderOverride.first().let { map ->
assertThat(map).containsExactly(7L, 15)
assertThat(map).containsExactly(7L, listOf(15, 10_080))
assertThat(map.containsKey(9L)).isFalse()
}
}
@@ -170,48 +248,48 @@ class SettingsPrefsTest {
@Test
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
prefs.setDefaultAllDayReminderMinutes(1_440)
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEqualTo(1_440)
prefs.setDefaultAllDayReminderMinutes(null)
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
prefs.setDefaultAllDayReminderMinutes(listOf(1_440))
assertThat(prefs.defaultAllDayReminderMinutes.first()).containsExactly(1_440)
prefs.setDefaultAllDayReminderMinutes(emptyList())
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
}
@Test
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440))
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440)
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15)))
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(1_440)))
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(1_440))
// Clearing the all-day override leaves the timed one untouched.
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
}
@Test
fun `resolveDefaultReminder picks the kind-matching override or global`() {
val timed = mapOf(7L to 15, 9L to null)
val allDay = mapOf(7L to 2_880)
val timed = mapOf(7L to listOf(15, 10_080), 9L to emptyList<Int>())
val allDay = mapOf(7L to listOf(2_880))
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
timedGlobal = 30,
allDayGlobal = 1_440,
timedGlobal = listOf(30),
allDayGlobal = listOf(1_440),
timedOverrides = timed,
allDayOverrides = allDay,
calendarId = calendarId,
isAllDay = isAllDay,
)
// Timed: minutes override, explicit none, inherit global, no calendar.
assertThat(resolve(7L, isAllDay = false)).isEqualTo(15)
assertThat(resolve(9L, isAllDay = false)).isNull()
assertThat(resolve(5L, isAllDay = false)).isEqualTo(30)
assertThat(resolve(null, isAllDay = false)).isEqualTo(30)
// Timed: multi-value override, explicit none, inherit global, no calendar.
assertThat(resolve(7L, isAllDay = false)).containsExactly(15, 10_080).inOrder()
assertThat(resolve(9L, isAllDay = false)).isEmpty()
assertThat(resolve(5L, isAllDay = false)).containsExactly(30)
assertThat(resolve(null, isAllDay = false)).containsExactly(30)
// All-day: its own override wins; absent → all-day global; a timed-only
// override (cal 9) does not bleed into all-day.
assertThat(resolve(7L, isAllDay = true)).isEqualTo(2_880)
assertThat(resolve(9L, isAllDay = true)).isEqualTo(1_440)
assertThat(resolve(5L, isAllDay = true)).isEqualTo(1_440)
assertThat(resolve(7L, isAllDay = true)).containsExactly(2_880)
assertThat(resolve(9L, isAllDay = true)).containsExactly(1_440)
assertThat(resolve(5L, isAllDay = true)).containsExactly(1_440)
}
@Test
@@ -252,13 +330,70 @@ class SettingsPrefsTest {
@Test
fun `explicit week-start prefs resolve regardless of locale`() {
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
assertThat(WeekStartPref.SUNDAY.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.SUNDAY)
assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US))
.isEqualTo(DayOfWeek.MONDAY)
assertThat(WeekStartPref.Day(DayOfWeek.SATURDAY).resolveFirstDay(Locale.GERMANY))
.isEqualTo(DayOfWeek.SATURDAY)
}
@Test
fun `auto week start follows the locale convention`() {
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
}
@Test
fun `special dates feature is off with all types on by default`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.specialDatesEnabled.first()).isFalse()
assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet())
assertThat(prefs.specialDatesCalendars.first()).isEmpty()
assertThat(prefs.managedCalendarIds.first()).isEmpty()
assertThat(prefs.specialDatesShowYear.first()).isTrue()
}
@Test
fun `special date type toggles round-trip`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
assertThat(prefs.specialDatesTypes.first())
.isEqualTo(setOf(SpecialDateType.Birthday, SpecialDateType.Custom))
}
@Test
fun `managed calendar ids round-trip and drive managedCalendarIds`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, 7L)
prefs.setSpecialDatesCalendarId(SpecialDateType.Anniversary, 9L)
assertThat(prefs.specialDatesCalendars.first())
.isEqualTo(mapOf(SpecialDateType.Birthday to 7L, SpecialDateType.Anniversary to 9L))
assertThat(prefs.managedCalendarIds.first()).isEqualTo(setOf(7L, 9L))
prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, null)
assertThat(prefs.specialDatesCalendars.first())
.isEqualTo(mapOf(SpecialDateType.Anniversary to 9L))
}
@Test
fun `title template round-trips per type`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday]).isEmpty()
prefs.setSpecialDatesTitleTemplate(SpecialDateType.Birthday, "{name}'s birthday")
assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday])
.isEqualTo("{name}'s birthday")
}
@Test
fun `sync status records run and stalled reason`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.specialDatesStatus.first())
.isEqualTo(SpecialDatesStatus(lastRun = 0L, stalled = null))
prefs.recordSpecialDatesRun(1000L, SpecialDatesStalledReason.PermissionRevoked)
assertThat(prefs.specialDatesStatus.first())
.isEqualTo(SpecialDatesStatus(1000L, SpecialDatesStalledReason.PermissionRevoked))
prefs.recordSpecialDatesRun(2000L, null)
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
}
}

View File

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

View File

@@ -26,10 +26,24 @@ class ReminderTimeTextTest {
isAllDay = false,
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
)
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
fun `timed event crossing midnight includes both dates`() {
val text = reminderTimeText(
@@ -38,6 +52,7 @@ class ReminderTimeTextTest {
isAllDay = false,
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
)
assertThat(text).contains("11.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.
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
)
assertThat(text).isEqualTo("11.06.2026")
}
@@ -66,6 +82,7 @@ class ReminderTimeTextTest {
isAllDay = true,
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
)
assertThat(text).isEqualTo("11.06.2026 12.06.2026")
}
@@ -79,6 +96,7 @@ class ReminderTimeTextTest {
isAllDay = true,
zone = berlin,
locale = Locale.GERMANY,
is24Hour = true,
)
assertThat(text).isEqualTo("11.06.2026")
}

View File

@@ -0,0 +1,64 @@
package de.jeanlucmakiola.calendula.domain.contacts
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class ContactSpecialDateTest {
@Test
fun `parses a full yyyy-MM-dd date with year`() {
val parts = parseContactEventDate("1990-05-14")
assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
}
@Test
fun `parses a year-less --MM-dd date`() {
val parts = parseContactEventDate("--05-14")
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 5, day = 14))
}
@Test
fun `parses a compact yyyyMMdd date`() {
val parts = parseContactEventDate("19900514")
assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14))
}
@Test
fun `parses a year-less MM-dd date without the double-dash prefix`() {
val parts = parseContactEventDate("12-25")
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 12, day = 25))
}
@Test
fun `keeps a year-less Feb 29 by validating against a leap anchor`() {
val parts = parseContactEventDate("--02-29")
assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 2, day = 29))
}
@Test
fun `keeps Feb 29 in a real leap year`() {
assertThat(parseContactEventDate("2000-02-29"))
.isEqualTo(ContactDateParts(year = 2000, month = 2, day = 29))
}
@Test
fun `rejects Feb 29 in a non-leap year`() {
assertThat(parseContactEventDate("1999-02-29")).isNull()
}
@Test
fun `rejects an impossible month or day`() {
assertThat(parseContactEventDate("2020-13-01")).isNull()
assertThat(parseContactEventDate("2020-06-31")).isNull()
assertThat(parseContactEventDate("--00-10")).isNull()
}
@Test
fun `rejects blank, null and garbage input`() {
assertThat(parseContactEventDate(null)).isNull()
assertThat(parseContactEventDate("")).isNull()
assertThat(parseContactEventDate(" ")).isNull()
assertThat(parseContactEventDate("not-a-date")).isNull()
assertThat(parseContactEventDate("2020")).isNull()
}
}

View File

@@ -0,0 +1,47 @@
package de.jeanlucmakiola.calendula.domain.contacts
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class SpecialDateFormattingTest {
private fun birthday(month: Int, day: Int, year: Int?) = ContactSpecialDate(
lookupKey = "k",
displayName = "Jane",
type = SpecialDateType.Birthday,
month = month,
day = day,
year = year,
)
@Test
fun `uid is deterministic and namespaced by type`() {
assertThat(managedEventUid(SpecialDateType.Birthday, "abc"))
.isEqualTo("contact-birthday:abc@calendula")
assertThat(managedEventUid(SpecialDateType.Anniversary, "abc"))
.isEqualTo("contact-anniversary:abc@calendula")
}
@Test
fun `anchor date uses the known year`() {
assertThat(birthday(5, 14, 1990).anchorDate()).isEqualTo(LocalDate(1990, 5, 14))
}
@Test
fun `anchor date falls back to a leap year when year-less`() {
assertThat(birthday(2, 29, null).anchorDate()).isEqualTo(LocalDate(YEARLESS_ANCHOR_YEAR, 2, 29))
}
@Test
fun `title substitutes name and year`() {
assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", 1990))
.isEqualTo("Jane's birthday (1990)")
}
@Test
fun `title drops an empty year parenthesis and tidies spacing`() {
assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", null))
.isEqualTo("Jane's birthday")
}
}

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")
}
}

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

@@ -0,0 +1,248 @@
# Design: Contact special-dates calendars (+ per-calendar multiple reminders)
Status: **implemented** on `feat/contact-special-dates` (#14 prerequisite merged in)
Date: 2026-06-30 (implemented 2026-07-01)
Tracking: Codeberg #15 (feature), Codeberg #14 (prerequisite)
Implementation notes / deviations from this design:
- **Event identity key is a deterministic `Events.UID_2445`**
(`contact-<type>:<lookupKey>@calendula`), not `SYNC_DATA1`. On a LOCAL calendar
the provider drops `_sync` columns unless written through a sync-adapter URI;
`UID_2445` is written/read through the normal event URIs the app already uses
(and doubles as a stable iCal UID). The managed **calendar** still carries a
`CAL_SYNC2` marker for re-adoption (calendar-row writes already use the
sync-adapter URI).
- **Year, not age.** A `FREQ=YEARLY` row has one static title, so a per-occurrence
age is impossible without per-year exception rows (rejected as too heavy). The
template instead offers `{year}` — the birth year (or an anniversary's start
year) — which is static and correct on every occurrence. A "Show year" toggle
gates it; it's in the default templates ("{name}'s birthday ({year})").
- New managed calendars seed a per-calendar all-day reminder default of
on-the-day + one week before, so birthdays get lead time out of the box.
- Title template is user-editable per type from day one (`{name}`, `{year}`).
This document captures the full design for surfacing contact birthdays —
and, by extension, anniversaries and custom dates — as auto-updating local
calendars in Calendula. It also specifies #14 (per-calendar *multiple*
default reminders), which is a hard prerequisite and ships first.
The two pieces are deliberately split across **two branches / two
shipments**:
- `feat/per-calendar-multi-reminders`#14, ships first.
- `feat/contact-special-dates`#15, builds on #14.
They *may* release together depending on timing, but #14 is independently
useful and is the foundation the special-dates calendars lean on.
---
## Why this is worth doing carefully
Calendula's identity is privacy: **no INTERNET permission**, and historically
**no `READ_CONTACTS`** — guest-picking uses a one-shot `ACTION_PICK` so the app
never holds the contacts permission (`EventEditScreen.kt:446-460`).
This feature **declares `READ_CONTACTS`** for the first time. That is a
deliberate, owner-approved shift, mitigated by:
- The permission is **opt-in and feature-gated** — requested only when the user
enables the feature, never at startup.
- Everything stays **offline**. Contacts are read locally and mirrored into a
local calendar; nothing leaves the device.
- The mirror is **one-way** (contacts → calendar). Calendula never writes to
Contacts.
Store listing / About copy will need to explain the optional permission.
---
## Prerequisite — #14: per-calendar *multiple* default reminders
### Today
- Events already support **multiple reminders** — `EventForm.reminders:
List<Int>` (`EventForm.kt:24`), each written as a separate
`CalendarContract.Reminders` row (`CalendarDataSource.kt:620-630`), each
firing independently. The runtime/notification path is done.
- The **defaults** layer is single-valued, though:
- Global: `defaultReminderMinutes` / `defaultAllDayReminderMinutes` — single
`Int` (`SettingsPrefs.kt:287,302`).
- Per-calendar override: `perCalendarReminderOverride: Map<Long, Int?>` and
the all-day variant (`SettingsPrefs.kt:350-381`), resolved by
`resolveDefaultReminder()` (`SettingsPrefs.kt:509-524`).
- UI: expandable per-calendar override section
(`SettingsScreen.kt:879-953`); `ReminderDefaultPicker` (`:956+`).
### The change
Widen the **defaults**, not the runtime, from one reminder to a list:
1. Resolution model `Int` → `List<Int>` in global defaults + per-calendar
override + `resolveDefaultReminder()`. The DataStore string format already
parses `id=value;id=value` (`SettingsPrefs.kt:576-577`); `value` becomes a
comma-separated list. Keep the `Inherit / None / Minutes` override states
(`CalendarReminderOverride`, `SettingsPrefs.kt:493-500`) — `Minutes` carries
a list.
2. `ReminderDefaultPicker` → multi-select. Mirror the event-form multi-reminder
UI rather than inventing new interaction.
3. New events seed `EventForm.reminders` from the **resolved list** instead of
a single value.
No new permissions, no provider changes, no scheduling changes. Type widening
plus one picker upgrade.
### Why it gates #15
Each special-dates calendar is a normal local calendar, so it inherits the
**per-calendar reminder default** for free. A birthdays calendar is useless
without lead time (the reporter literally asked for "a week before *and* on the
day") — that requires *multiple* defaults, i.e. #14.
---
## #15: Contact special-dates calendars
### Core model: one-way mirror, one local calendar per type
Contacts → **separate local calendars per type**:
- "Birthdays"
- "Anniversaries"
- "Custom dates" (everything else / `TYPE_OTHER` / `TYPE_CUSTOM`)
Separate-per-type is the key architectural decision: each is a normal local
calendar (`createLocalCalendar()`, `CalendarDataSource.kt:238-260`), so the
**existing per-calendar infra applies for free**:
- **Color** → existing per-calendar color.
- **Show/hide** → existing calendar visibility.
- **Default reminders** → per-calendar override from #14.
This shrinks the new surface dramatically: anniversaries and custom dates are
near copy-paste of birthdays (same engine, different `ContactsContract` data
kind, one extra local calendar each), and the settings sub-page does **not**
need to reinvent color/visibility/reminders.
### Events: field-level managed, not read-only
Calendula manages a few fields; the user owns the rest.
- **Managed** (overwritten on every sync, disabled in the editor for these
events): title, start date, recurrence (`FREQ=YEARLY`), existence.
- **User-owned** (seeded once on insert, never touched again by sync):
location, reminders, notes, busy/free.
The rule that makes this safe: sync performs a **targeted column update**
(managed columns only), **never delete-and-reinsert**. Reminders live in
separate rows, so once seeded they are simply never re-touched. A user can move
a birthday reminder to "2 weeks before" or add a location and neither is
clobbered.
In the editor, when an event belongs to a managed calendar: disable the
title/date/recurrence fields; allow the rest.
### Reconciliation identity
Stamp each event with the contact's `LOOKUP_KEY` in `Events.SYNC_DATA1` (ours,
since it's a local calendar). Sync becomes an idempotent diff keyed on it:
- contact added → insert
- birthday/date changed → targeted update of managed columns
- contact or date removed → delete the event
Getting this wrong = duplicate birthdays on every sync, so the identity key is
non-negotiable.
### Data source
`ContactsContract.Data` rows of `Event.CONTENT_ITEM_TYPE`, split by
`Event.TYPE`:
- `TYPE_BIRTHDAY` → Birthdays calendar
- `TYPE_ANNIVERSARY` → Anniversaries calendar
- `TYPE_OTHER` / `TYPE_CUSTOM` (+ label) → Custom dates calendar
### Dates: year-less + age
Many contacts store `--MM-DD` (no year). Handle both:
- **Year present:** anchor `DTSTART` at that year; `FREQ=YEARLY`. We can show
age ("Jane turns 30") when the show-age option is on.
- **Year absent:** `FREQ=YEARLY` from an arbitrary anchor year; no age shown.
Recurrence support is already there: `FREQ=YEARLY` + `UNTIL`/`COUNT`/`INTERVAL`
(`Recurrence.kt:32-174`).
### Auto-update cadence
Birthdays change rarely, so nothing live/expensive:
- Sync **on enable**, **on app foreground**, and a **daily WorkManager** job
(catches new/edited/deleted contacts even if the app isn't opened).
- **No `ContentObserver`** — needs the process alive and buys almost nothing
for once-a-year events.
### Permission + lifecycle
- **Enable:** request `READ_CONTACTS` (feature-gated, contextual — mirror the
contextual `WRITE_CALENDAR` pattern at `EventEditScreen.kt:216-231`). On
grant, create the enabled calendars and run the first sync.
- **Disable:** delete the managed calendars + their events (with a confirm).
- **Permission revoked in system settings:** detect on next sync; surface that
the feature is stalled rather than failing silently.
### Configurable settings sub-page
Enabling the feature reveals a **dedicated sub-page** (not inline rows). Because
color/visibility/reminders already live in normal calendar settings, the
sub-page only carries the genuinely new knobs:
- Master enable toggle.
- Per-type toggles: Birthdays / Anniversaries / Custom dates.
- Title/display template, e.g. `"{name}'s birthday"` (new translatable string).
- Show-age toggle (only meaningful when the year is known).
- (Possibly) sync-cadence / "sync now".
- Pointer to the normal calendar settings for color, visibility, reminders.
The owner wants this broadly configurable — keep the sub-page the home for
display/behaviour options, and lean on existing per-calendar settings for the
rest.
---
## Open questions / deferred
- Title template: free-form vs a small set of presets. Start with one good
localized default; revisit configurability.
- Whether "Custom dates" is one bucket or split further by label. Start: one
bucket.
- Contact scope: all contacts vs per-account/group filtering. Start: all;
filtering is a later refinement.
## Task outline
### #14 — per-calendar multiple default reminders (`feat/per-calendar-multi-reminders`)
- [x] Widen global + per-calendar reminder defaults `Int` → `List<Int>`
(`SettingsPrefs.kt`), keep `Inherit/None/Minutes(list)`.
- [x] `resolveDefaultReminder()` returns a list.
- [x] `ReminderDefaultPicker` → multi-select (mirror event-form UI).
- [x] Seed new events' `EventForm.reminders` from the resolved list.
- [x] Migration / parse compatibility for existing single-value stored prefs.
- [x] Tests: resolution, parse round-trip, all-day variant.
### #15 — contact special-dates (`feat/contact-special-dates`)
- [x] Declare `READ_CONTACTS`; feature-gated contextual request on enable.
- [x] Per-type local calendars (create on enable, delete on disable+confirm).
- [x] Contacts reader: `Event` rows by `TYPE`, year-less handling.
- [x] Sync engine: idempotent diff keyed on the deterministic `UID_2445`
(see notes; not `SYNC_DATA1`); targeted managed-column updates; seed
user-owned fields on insert only.
- [x] `FREQ=YEARLY` event generation + `{year}` in titles (static, always correct).
- [x] Auto-update: on enable, on foreground, daily WorkManager.
- [x] Editor: disable managed fields for managed-calendar events.
- [x] Revoked-permission detection + stalled-state surface.
- [x] Settings sub-page: enable, per-type toggles, title template, show-age.
- [x] Translatable strings (titles, sub-page).
- [x] About / store copy for the optional `READ_CONTACTS` permission.

View File

@@ -21,6 +21,7 @@ AuthorName: Jean-Luc Makiola
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
Donate: https://ko-fi.com/jeanlucmakiola
AutoName: Calendula

View File

@@ -10,6 +10,12 @@ Serie) und einem einfachen Wiederholungs-Picker. Erinnerungen stellt
Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den
Termin.
Optional lassen sich Geburtstage, Jahrestage und weitere besondere Tage
deiner Kontakte im Kalender anzeigen. Du schaltest das selbst ein; Kontakte
werden nur auf deinem Gerät gelesen, nie hochgeladen und nie verändert —
Calendula spiegelt die Daten lediglich in lokale Kalender, die du voll
kontrollierst.
Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend,
mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen.

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

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

View File

@@ -8,6 +8,11 @@ changes (only this event / this and all following / the whole series) and a
simple repeat picker. Calendula also delivers your event reminders as
notifications — tap one and you're on the event.
Optionally, show your contacts' birthdays, anniversaries and other special
dates in your calendar. You turn this on yourself; contacts are read only on
your device, are never uploaded, and are never changed — Calendula only
mirrors the dates into local calendars you fully control.
The differentiator is the design: real Material 3 Expressive throughout,
with dynamic color, expressive motion, and expressive shapes.

View File

@@ -8,3 +8,4 @@ Categories:
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
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"
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
glance = "1.1.1"
work = "2.10.1"
documentfile = "1.0.1"
[libraries]
# AndroidX core
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-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" }