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