Extract CalendarPickerGroups into ui/common: the calendar-manager screen's
grouped-card system (device chip for local calendars, the owning app's
launcher icon per synced account, colour chip + check per calendar) as a
single reusable picker. Use it in both the event editor and the .ics import
screen so all 'which calendar' lists match.
Moves LeadingAvatar/SourceLogo/curatedSourcePackage out of CalendarsScreen
into common as the shared source of truth. Drops the redundant 'Add to
calendar' caption from the import picker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 'Add to calendar' picker rendered bare OptionCards with no calendar
colour and no account grouping. Reuse the same account-grouped GroupedRow
layout as the event editor's calendar picker — coloured chip per calendar,
account sub-headers, a check on the selected row — so it matches the rest
of the app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a 'Restore from .ics file' row to the Calendars backup section, next
to Export. It opens a SAF document picker and routes the picked Uri into
the existing import flow (parse, dedup by UID, target-calendar picker,
summary) via CalendarHost's importUri — the same path an externally
opened .ics already takes, so no new import machinery is needed.
Closes #32.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Patch release bundling the fixes for #34 (pre-1970 recurring events could
not be opened) and #27 (time-picker dial ignored the 24h setting), plus
#30 (create events from external ACTION_INSERT launches). Bumps
versionName to 2.13.1 (versionCode 21301) and cuts the changelog.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
toEventDetailCore returned null when a present DTEND preceded DTSTART, the
only remaining false-drop that surfaces as the generic "Something went
wrong." error screen — the same un-openable trap as the pre-1970 DTSTART
bug, and worse because the user can't even open the malformed event to fix
it. Clamp the end to DTSTART (a zero-length event) instead, matching how
SearchMapper already coerces its end. After this the detail mapper drops a
row only when DTSTART is genuinely absent (unrenderable).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Register an intent-filter for ACTION_INSERT on the events dir mime type
(vnd.android.cursor.dir/event), the way the AOSP calendar accepts one, so
other apps and widgets (e.g. the Todo Agenda widget) can launch Calendula
to create a new event.
MainActivity.insertFormOrNull parses the standard CalendarContract extras
(EXTRA_EVENT_BEGIN_TIME/END_TIME/ALL_DAY, Events.TITLE/DESCRIPTION/
EVENT_LOCATION/RRULE) into a prefilled EventForm via the pure, unit-tested
buildInsertEventForm — omitted fields fall back to the same defaults the
in-app "new event" uses (next full hour, +1h). The form is routed through
the existing single-event prefill channel (RootScreen → CalendarHost →
the create form for review), with calendarId left null so it resolves to
the last-used / first-writable calendar. No new permission is needed
(WRITE_CALENDAR is already held), and the user still explicitly saves.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The event-form (and Settings) time picker seeded is24Hour from the
system TIME_12_24 override / device locale, ignoring the app's own
TimeFormatPref. So with the app set to 24h under an English locale the
dial still showed AM/PM, while every time label (which reads
LocalUse24HourFormat) showed 24h.
Seed the picker from LocalUse24HourFormat — the app-wide clock
convention already resolved once at the root from TimeFormatPref — so the
dial matches the labels. Drops the now-unused deviceUses24HourClock
helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DTSTART is stored as UTC epoch millis, so a recurring series anchored
before 1970-01-01 (common for yearly birthdays/anniversaries synced over
CalDAV) has a legitimately negative DTSTART. The detail and search
mappers dropped any row with dtstart < 0, and since the detail query
reads the series-master DTSTART (the ancient anchor), every occurrence of
such a series became un-openable — surfacing as the generic
"Something went wrong." error screen — and the events vanished from
search too.
Relax the guard to reject only an *absent* DTSTART (isNull), which is the
malformed case it was meant to catch; negative epoch millis flow through
correctly (Instant/formatting and the all-day reminder decode are all
Long-based). Add regression tests for both mappers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the accumulated [Unreleased] section under [2.13.0] — 2026-07-03,
adding the entries that were still missing: Spanish + Italian community
translations (thanks kikerw and corrent via Weblate), custom fonts (#19),
configurable quick-switch cycle + drawer order (#24), and the scrollable
event-editor calendar picker (#29). Regenerate fastlane/.../changelogs/
21300.txt via scripts/sync_changelog_to_fastlane.sh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The source key was removed on this branch; the Weblate translations
merged from main still carried it, tripping check_translations.py and
fatal ExtraTranslation lint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Community translations for es and it reached ~85% coverage (Weblate
PR #52 into main). Adding their locale entries makes them selectable in
the in-app picker and Android 13+ per-app language settings once that
PR is merged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Weblate owns all translation files including values-de; devs only touch
the English base. Reverts the hand-added special-dates strings and the
permission_rationale_body rewording to main's version (texts recoverable
from 1ff4115, to be re-added through the Weblate API). Keeps the removal
of the stale reminder_custom_with_value key, whose source string was
deleted on this branch (ExtraTranslation is fatal).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full-screen selection pickers opened with the LargeTopAppBar expanded, so the
title started below the bar and only settled into it after a scroll — a tall
empty header above a short option list. Give CollapsingScaffold a largeTopBar
flag and have FullScreenPicker use a pinned single-line TopAppBar, so every
picker (calendar, theme, week start, language, reminder, agenda range) shows
its title in the bar from the start. Settings and the calendar manager keep
the large collapsing header.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The calendar picker was an AlertDialog holding a non-scrolling Column, so
accounts with many calendars overflowed the dialog's fixed height and the
entries past ~9 were unreachable (Codeberg #29).
Replace it with the app's FullScreenPicker (collapsing scaffold + scrollable
content), rendering calendars as connected GroupedRows grouped under their
owning account — the same grouping the visibility filter and calendar manager
use — with a colour chip per row and a check on the current selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The periodic job and the immediate 'Sync now'/foreground runs had no mutual
exclusion, so two overlapping reconciles could each see no managed calendar
and both create one (duplicate 'Birthdays', doubled events); likewise a
disable-teardown racing an in-flight sync got its calendars recreated right
after deletion. A lifecycle Mutex now makes sync() and teardown() atomic, and
sync() re-reads the enabled flag inside the lock so a teardown always wins.
- The foreground resume trigger shared a unique work name with enable/'Sync
now' under ExistingWorkPolicy.REPLACE, so a debounced foreground enqueue could
cancel-and-swallow a pending enable sync (feature on, no calendars for up to a
day). It now uses its own work name.
- doWork() mapped every exception to retry(), so a revoked calendar permission
retried with backoff forever and never surfaced. A SecurityException now parks
the feature in the stalled state instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>