The mirror keyed each event on (contact, type) only, and the read deduped on
that same pair from an unordered query. So a contact with two custom dates
('Wedding', 'Graduation') mirrored just one — and, the winner being
order-dependent, the single event could ping-pong between the two dates across
syncs. A Custom date's UID now carries a discriminator (its label, else its
month-day), the read dedups on the full UID, and the query is ordered by
Data._ID so a genuine duplicate resolves the same way every time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two provider-level bugs in the managed-event data path:
- queryManagedEvents and applyManagedCalendarReminders matched every event in
the calendar (UID_2445 IS NOT NULL / no filter). A stray user event there
(e.g. an .ics import) was treated as 'existing but not desired' and deleted,
or had its own reminders wiped and all-day-re-encoded. Both now match only
our own mirror events (the 'contact-' UID prefix).
- All-day reminder offsets were sampled at the event's DTSTART, which for a
year-less birthday is the 1972 leap anchor — a year whose timezone offset
(pre-DST) differs from today's, skewing every modern occurrence by up to an
hour. The offset is now sampled at the upcoming occurrence (nextYearlyOccurrence),
leaving only the inherent ±1h DST drift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Managed special-dates calendars were only recognised by an id cached in
preferences, and were offered as ordinary write targets — so a user could
create an event in 'Birthdays' (which the next sync then deleted), and after
a backup restore wiped the prefs the editor lock silently disappeared.
CalendarSource now carries isManaged, read from the durable CAL_SYNC2 marker
the data layer already stamps, so identity survives a restore. With it:
- the new-event calendar picker excludes managed calendars (no user events
land in a calendar the sync owns);
- the editor lock keys off the marker, not a stored id;
- a managed event's save is forced to the whole series instead of parking in
the scope dialog (a 'this/following' split creates rows the sync reverts);
- the detail sheet hides Delete for managed events (the sync would just
resurrect them — a contact date is removed at its source).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four fixes across the settings/prefs layer:
- Quick-switch toggles and reorders were read-modify-write against the
async-echoed UI snapshot, so two rapid interactions reverted each
other. Writes now go through SettingsPrefs.updateQuickSwitch, an
atomic transform over the currently-stored value, via intent-level
ViewModel ops; the MIN_ENABLED floor is re-checked inside the
transform since the screen's guard reads the stale snapshot.
- parseReminderOverrides treated any unparseable stored value as an
explicit empty override (no reminder). Only the deliberate 'none'
sentinel means that now; garbage drops the entry so the calendar
inherits the global default. Partially-valid values salvage their
valid minutes.
- Replacing an already-active custom font never refreshed typography:
the unchanged 'custom' token made AppFontSettings value-equal, so the
StateFlow never re-emitted. A per-role import stamp now breaks
equality on re-import (missing key = 0, backward compatible).
- The FontPicker custom preview resolved the font file unmemoized on
every recomposition (disk stat + fresh FontFamily defeating the
typeface cache); it's now remembered, keyed on the import stamp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The order mutation and onReorder call sat after the 160 ms settle
animation inside the same cancellable coroutine; grabbing another row
within that window cancelled the job between animation and commit,
silently reverting a finished reorder. The commit now runs
synchronously in onDragEnd — the settle is purely visual (the live
offset is re-based onto the new slot in the same frame, so nothing
jumps) and safe to interrupt.
Also derive the hovered target slot via derivedStateOf so a drag only
recomposes the column when a slot boundary is crossed, not on every
pixel (the held row's own translation is already draw-phase).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three defects in the v2.13.0 multi-select reminder picker:
- Unchecking the last time on a per-calendar picker silently persisted
an explicit no-reminder override; an accidental toggle-undo wiped the
calendar's default. Empty-by-unchecking now reverts to 'Use default'
(Inherit); deliberate no-reminder is its own exclusive 'None' row
(reusing reminder_none) on both pickers, so the empty state is
visible and reachable instead of implicit.
- A custom (non-preset) lead time's row vanished the moment it was
unchecked, stranding the hand-entered value. Custom values seen this
session keep their row (unchecked) until the picker closes.
- The optimistic selection seeded once from a possibly-not-yet-loaded
settings state (initialValue emptyList behind a CalendarProvider-
gated combine), so a quick first toggle after process-death restore
overwrote the stored default. The local state now re-syncs from the
incoming selection until the user first interacts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two holes in the v2.13.0 'disabled calendars no longer notify' fix:
- A snoozed reminder bypassed the filter: ReminderActionReceiver's
ACTION_SHOW posted unconditionally. The gate now lives once in
ReminderNotifier.post — the single choke point both receivers use —
built on a shared ReminderAlert.isForDisabledCalendar predicate that
never treats calendarId 0L (pre-upgrade snooze intents without
EXTRA_CALENDAR_ID) as disabled.
- A reminder firing while its calendar was disabled was lost forever:
the receiver marks the full due set STATE_FIRED (deliberately, to
stop provider re-broadcasts) and nothing re-scans. Suppressed alerts
are now stashed in a DataStore-backed SuppressedReminderStore and
re-posted when the calendar is re-enabled, while the event hasn't
ended yet; expired entries are purged opportunistically.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The worker no longer pre-checks specialDatesEnabled: the engine already
gates on it, and the worker now consumes the previously-unreachable
Disabled result instead (without recording a run).
- ReminderContext drops the timed defaults it could never use — managed
events are always all-day — saving two DataStore reads per sync.
- createCalendar no longer persists the calendar id itself;
reconcileCalendars is the single place ids are recorded (it always
followed up with the same write).
- The title-template fallback uses the shared resolveTitleTemplate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- choiceFor moves next to CalendarReminderOverride in SettingsPrefs and
replaces the hand-rolled null/empty/else decoder in SettingsViewModel,
so the picker and the special-dates rows can't drift apart.
- The special-dates template dialog used the codebase's only
OutlinedTextField; it now follows the documented dialog convention
(InlineTextField over a tonal surface, see DialogControls).
- reminderChoiceLabel drops the manual loop (and its wrong comment —
map is inline, so composable calls are fine in its lambda).
- The blank-template fallback is shared with the sync engine via
resolveTitleTemplate instead of being copy-pasted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The READ_CONTACTS check was written out three times (data source, RootScreen
resume trigger, settings screen). A single extension in the contacts package
mirrors the existing Context.hasCalendarPermission() precedent; the settings
call site follows in the settings cleanup commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AndroidSpecialDatesCalendarSpec hardcoded three ARGB literals that its KDoc
claimed were 'picked from the shared palette' — a silent-drift hazard, and
referencing ui.common from data would invert layering. The palette now lives
in data/calendar as CalendarColorPalette with named hues; the swatch pickers
use CalendarColorPalette.all and the spec references Purple/Red/Blue.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
insertManagedEvent duplicated ~45 lines of insertEvent (ContentValues build
and the reminder-seeding loop). Both now go through buildEventInsertValues
in EventWriteMapper and a shared seedReminders, differing only in the UID
they stamp and the colour/attendee columns insertEvent layers on top.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
German translations must flow through Weblate; strings committed from
code conflict with Weblate's repo state on its next merge. The nine
font-picker strings from the custom-fonts feature fall back to English
until translated in Weblate (MissingTranslation is informational).
The stale-key removal (reminder_custom_with_value) stays: ExtraTranslation
is a fatal lint error, so deletions of retired source strings must
accompany the code change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restrict the "Choose file…" document picker to font MIME types
(font/ttf, font/otf, font/sfnt, font/collection plus legacy
application/* font aliases) instead of */*, so only fonts are
selectable. CustomFontStore's Font.Builder validation still guards
anything a provider mislabels.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a per-role font picker in Appearance settings. The two Material
typeface roles can be set independently: brand (display/headline) and
plain (title/body/label). Each can be the system default, a bundled
font (Atkinson Hyperlegible, Lora, JetBrains Mono), or a .ttf/.otf the
user loads from the device. The choice rebuilds the app-wide typography
in MainActivity; a null/"system" choice leaves the Material default.
Per the issue discussion the font-size and font-colour parts were
dropped — Android's accessibility font scaling and the dynamic-colour
theme already cover them.
Custom files are copied into app-private storage and validated with
android.graphics.fonts.Font.Builder before replacing the previous one,
so a bad pick can't wedge global text rendering; a missing/unreadable
file degrades to the system font. Each bundled font is previewed in its
own face in the picker. OFL licences + attribution vendored under
licenses/fonts/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework ReorderableColumn: the first cut measured each row, but grouped
cards have position-dependent padding, so slot heights varied and rows
snapped to the wrong places with no animation. Now rows are a fixed
uniform pitch, the target slot is the whole-pitches dragged, neighbours
slide aside with a spring, and the held row settles onto its slot before
the order commits. Add a lift (scale + shadow) on the held row and
cancel an in-flight settle if a new drag pre-empts it.
GroupedRow gains gapBelow so the reorderable list can own uniform
inter-row spacing.
On-device verified on Pixel 10.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
Previously only the small day-number was a tap target, so tapping the
blank area of a day cell (or its "+N" overflow) did nothing. Make every
non-event part of a day column open that day — the day number, the empty
lane cells, and the overflow row — via a shared openDayAction, so a tap
anywhere on a day opens it, matching the in-app month grid. Event bars
keep opting out to open their own detail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Views" settings section that lets users pick which views the
top-bar quick-switch button cycles through and drag to reorder them,
plus an independent drag-to-reorder for the navigation drawer's view
list. Two separate configs: a view turned off in the quick-switch
cycle is still reachable from the drawer, which always lists every view.
- QuickSwitchConfig (order + enabled set) and a drawer order persisted
in SettingsPrefs (comma-joined enum names; "!" marks a disabled view).
Missing views append enabled and unknown names drop, so a future view
defaults into both lists.
- The pill cycles through the configured enabled views in order; the
drawer renders CalendarDrawer from the drawer order. Both threaded
from CalendarHost via CalendarHostViewModel.
- New ReorderableColumn: dependency-free, measurement-driven drag
reordering for the short grouped-card settings lists (no LazyColumn,
since settings are a single verticalScroll column). Commits one write
per gesture.
- The switch needs two targets, so the last two enabled views can't be
turned off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping a widget header now opens the app on a sensible top-level view,
via a shared WidgetNavRequest.OpenView(view?) — a concrete view roots
there over the default home, a null view resolves to the default home:
- Month widget: the month/year title opens the month view (#18). Its
"today" button now snaps the grid back to the current month in place
(the reset that used to sit on the title), so paging + jump-to-today
are both reachable and the title is free to open the app.
- Agenda widget: the "Upcoming" title opens the default view (#20), so
users whose home view is Week/Month can reach it in one tap instead of
drilling through a day or event.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The month widget's prev/next/today controls and the agenda widget's
refresh run through Glance `actionRunCallback<T>()`, which persists the
callback's class name into the click PendingIntent and instantiates it
reflectively when tapped. Under R8 full mode (AGP 9 default) those
callback classes — only ever referenced reflectively — were renamed and
lost their no-arg constructor, so the lookup failed silently and the
arrows did nothing on release builds (while actionStartActivity taps,
which need no reflection, kept working).
Keep every ActionCallback's name and constructor. Verified against the
release mapping: ShiftMonthAction/ResetMonthAction/RefreshAgendaAction
now retain their original fully-qualified names.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>