Attendees were read-only (shown on the detail screen since v0.6) but the
form couldn't write them — the last read-only gap in the event model.
Add a Guests section to the create/edit form:
- Inline grouped list: each guest is a tonal card (avatar, name/email, a
tappable Required/Optional role chip, remove); the trailing card is an
inline email field — type an address, press Done, it commits. No dialog,
matching the app's inline-field input idiom. Manual adds are email-only
(names come from sync); the InlineTextField gains IME-action support.
- Reminders restyled to the same grouped-list pattern (shared
GroupedItemCard / AddActionCard), replacing the single-card blob.
Persistence (CalendarDataSource): new guests are written as plain
RELATIONSHIP_ATTENDEE / STATUS_INVITED rows — no fabricated organizer.
On edit, a dirty-checked reconcileAttendees diffs by email: drops removed
guests, inserts new ones, updates only the required/optional flag on kept
rows (preserving response status). Organizer, resources and no-email rows
are never touched. toEditForm carries only editable guests, so attendees
now ride in the edit snapshot and an external guest change trips the
conflict check.
Per the settled invitation decision: Calendula has no INTERNET and never
sends an invitation — it only writes the rows; the backend decides
delivery. The section shows honest, calendar-aware copy ("your account
may email guests when it syncs" on synced calendars, "no one is notified"
on local).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add "Snooze" and "Dismiss" action buttons to reminder notifications.
The app is otherwise pure provider-broadcast (the Etar model): the calendar
provider fires EVENT_REMINDER and we post the notification, then mark the
CalendarAlerts row fired. A snoozed reminder has no provider backing — its
row is already fired — so snooze self-schedules an exact alarm to re-show
the same notification, while primary delivery is left unchanged.
- ReminderActionReceiver (not exported): SNOOZE cancels + schedules a
re-show, DISMISS cancels, SHOW (the alarm) re-posts so it can be snoozed
or dismissed again.
- ReminderSnoozeScheduler: setExactAndAllowWhileIdle, with an inexact
allow-while-idle fallback if exact alarms are revoked (API 31-32).
- ReminderNotifier: two actions + cancel().
- snoozeMinutes pref (default 10) in Settings -> Notifications, OptionPicker
presets 5/10/15/30/60.
- Manifest: USE_EXACT_ALARM + SCHEDULE_EXACT_ALARM (maxSdk 32) + receiver.
- New ic_notification_snooze/_dismiss drawables, duration plurals, en+de
strings, snooze-pref tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture uncaught exceptions on-device and let the user submit them, by
hand, as a Gitea issue — no network access, no auto-upload (the app holds
no INTERNET permission). Closes prod-readiness item 10; the issue
templates also close item 7.
- CrashReporter: uncaught-exception handler installed first in
CalendulaApp.onCreate so startup crashes are caught too. Persists an
allowlist-only report (app/Android/device version, locale, time, stack
trace — nothing else) to filesDir/crash, then chains to the previous
handler so the process still dies normally. Crash-loop detection +
markHealthy reset.
- buildCrashReport is pure/testable; CrashReportBuilderTest asserts the
header is exactly the allowlisted lines (guards against PII creep).
- Surfacing: next-launch dialog showing the full report verbatim (the
privacy backstop) with a dismissed-marker so it doesn't nag; a Settings
"Report a problem" row; and a minimal standalone CrashReportActivity
that MainActivity routes to on a startup crash-loop, kept clear of the
Hilt graph / DataStore theme.
- submitCrashReport copies the report to the clipboard and opens the
prefilled Gitea issues/new URL (long traces fall back to paste).
- .gitea/ISSUE_TEMPLATE: crash_report, bug_report, feature_request.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v2.7 Branch 2 (core, no UI yet). The read side of the .ics engine:
- domain/ics: IcsParser (inverse of IcsWriter) — unfold/unescape/param
parsing, VALUE=DATE / UTC-Z / TZID date handling resolved against the OS
tz db, VEVENT walk → ParsedIcsEvent + typed warnings. Liberal-in/
strict-out: a malformed VEVENT is skipped, RECURRENCE-ID overrides /
attendees / unresolved TZIDs are reported, not silently dropped.
- Promoted parseRfc2445DurationMillis into domain/ics (shared by writer-
side mapper and parser); IcsDuration + test.
- Datasource existingUids()/insertImportedEvent(); repository
importEvents() with UID dedup (skip known UIDs → idempotent restore) →
IcsImportSummary. IcsImporter reads a Uri's text.
- ParsedIcsEvent.toEventForm() for the single-event "open into the create
form" path.
Parser round-trips against IcsWriter; dedup + form-adapter unit-tested.
Intent filter, routing and import UI land in the next commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All-day events live at UTC midnights with an exclusive end, but coversDay
sliced each day in the device timezone. East of UTC the exclusive end
landed a few hours into the next local day, so a one-day all-day event
(e.g. a birthday) rendered on two days in the day/week/month views — while
the detail and edit screens, which work in UTC, showed it correctly.
Compare all-day coverage in UTC and step the exclusive end back to the
last covered day, mirroring the detail/edit views.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Branch 1 of 2 for v2.7 (the .ics topic). Adds the write side of a
hand-rolled RFC 5545 engine (zero deps, stays on kotlinx-datetime):
- domain/ics: IcsText (escape + 75-octet folding), IcsEvent model,
IcsWriter.writeCalendar. Timezone rule: all-day VALUE=DATE, one-off
timed UTC Z, recurring timed TZID-labelled from EVENT_TIMEZONE (no
VTIMEZONE — import resolves TZID against the OS tz db).
- Single-event share from the detail screen (FileProvider + ACTION_SEND).
- Whole-calendar backup of the writable local calendars to a SAF file
(Settings -> Calendars -> Export as .ics), one combined VCALENDAR.
- insertEvent now writes Events.UID_2445; legacy rows fall back to a
stable synthesised UID at export time so a later restore won't dupe.
- EXDATE / RECURRENCE-ID overrides are deliberately skipped this pass
(documented v1 limit; import will skip them too).
Engine + mapper unit-tested. Import (Branch 2, feat/ics-import) ships in
the same v2.7 release; no tag until both land + on-device review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All-day events live at UTC midnight, so a raw "1 day before" reminder
fires at an off hour (02:00 local in CEST) rather than the morning. Add a
global "all-day reminder time" setting (default 09:00) and encode it into
the provider MINUTES offset so the reminder lands at the chosen wall-clock
time the day before instead.
- AllDayReminderEncoding: pure to/from provider-minutes helpers, keeping
the form/UI/diff in whole-day "semantic" minutes and converting only at
the Reminders read/write boundary (insertEvent, reconcileReminders,
EventDetailMapper). Covers DST, negative offsets, and pre-existing rows.
- SettingsPrefs.allDayReminderTimeMinutes (default 540) threaded from the
repository into the data-source write paths.
- Settings: a time-picker row, plus a shared TimePickerAlert lifted from
the event editor.
- Fix the time picker's 12/24-hour detection: honour an explicit system
override, else fall back to the device locale rather than the app's
per-app language, so it matches the rest of the device.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Optional per-event color in the event form. The read/render path already
resolved EVENT_COLOR with a calendar fallback; this adds the write side and
the picker.
- Palette-backed calendars (Google, some CalDAV) pick from the account's
Colors (TYPE_EVENT) and write EVENT_COLOR_KEY, so the color round-trips
through sync; local calendars write a raw EVENT_COLOR from the shared
CALENDAR_COLOR_PALETTE. Never writes a raw color to a palette calendar.
- Swatch row + palette extracted to ui/common/ColorSwatchRow.kt (shared with
the calendar editor). Switching calendars resets the choice (keys are
account-scoped); a "Reset" action returns to the calendar color.
- New "Allow colors on unsupported calendars" setting (off by default)
extends the raw path to no-palette synced calendars, with an honest
"may not survive sync" warning on the picker and in Settings.
- Color flows through insert / dirty-checked update / occurrence-exception;
mapper, form, and repository tests added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Day/week: tap an empty slot to open the create form prefilled with that
day and the tapped hour (snapped to the hour, 1 h long). Threaded a start
time through CalendarHost → EventEditScreen → openNew; the FAB keeps its
default.
Local calendars: a full-screen editor from Settings → Calendars to
create/rename/recolor/delete device-only calendars (ACCOUNT_TYPE_LOCAL,
sync-adapter insert) with name, pastel-previewed colour, and a description
(stored in CAL_SYNC1). Synced calendars are listed read-only grouped by
account, each with a "manage in source app" deep-link resolved from the
account's own authenticator (DAVx5/ICSx5/…), plus an add-account shortcut;
a <queries> block makes the source apps launchable. Extracted a shared
InlineTextField into ui.common so the event form and calendar editor share
one borderless input style.
Tests: repository delegation + write-failure, mapper isLocal/description,
fake data source extended. Version bumped to 2.2.0 / 20200.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
No locking (plan 03, decision 5): openForEdit keeps an EditSnapshot — the
prefilled form plus the raw Events-row times, which the form itself can't
see (it derives its times from the tapped occurrence, so an externally
moved event would otherwise stay invisible). Right before writing,
performSave re-reads the event and compares snapshots: a mismatch parks
the save in SaveUiState.AwaitingConflict carrying the already-chosen
recurring scope, and the dialog offers overwrite / discard / cancel
(OptionCard style). Overwrite still writes only dirty fields, so external
changes to untouched fields survive either way. A deleted event lands in
SaveUiState.Gone — an informational dialog that closes form and detail.
Fields the form can't write (attendees, status, self response, reminder
methods) are excluded from the comparison so sync noise can't fake a
conflict. The load-time zone is pinned in the EditTarget so a device
timezone change mid-edit can't either.
Store metadata: F-Droid descriptions (DE+EN) and the README stop claiming
read-only and now describe write support and reminder delivery. New
fastlane phoneScreenshots (6 per locale: week/month/day/detail/form/
reminder onboarding), captured on-device against demo-only calendars.
Tests: EditSnapshot equality (unchanged event, field change, row-time move
the form can't see, non-writable changes stay quiet).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Calendula now posts event reminders itself (the Etar model): the provider
schedules the alarms and broadcasts EVENT_REMINDER, but a calendar app must
turn them into visible notifications — essential for users whose only
calendar app this is. A manifest-registered, exported receiver (data scheme
content://com.android.calendar) wakes us at reminder time; no foreground
service, no own alarm scheduling.
Delivery path (data/reminders/): EventReminderReceiver (Hilt, goAsync) →
ReminderAlertStore queries CalendarAlerts for STATE_SCHEDULED rows with
ALARM_TIME <= now → ReminderNotifier posts one notification per alert on a
dedicated high-importance channel, then best-effort marks rows FIRED
(needs WRITE_CALENDAR; without it a re-broadcast silently replaces — tag
per alert + setOnlyAlertOnce). Swiped notifications never return: FIRED
rows are never re-queried, so no dismiss-intent machinery. Research
(AOSP CalendarAlarmManager): the provider creates alert rows only for
METHOD_ALERT reminders, so the email-reminder filter happens upstream.
Tapping opens the event's detail screen: MainActivity is singleTop now,
parses eventId/begin/end extras (onCreate + onNewIntent) into Compose
state, and CalendarHost consumes the key exactly like an event tap.
Onboarding gained a one-time second step after the calendar grant (shared
OnboardingScaffold extracted from PermissionScreen): explains delivery,
warns that a second calendar app with notifications on duplicates
reminders, requests POST_NOTIFICATIONS (dialog on API 33+ only; minSdk 29).
"Not now" turns the feature off; reminders default ON. Settings mirrors
the toggle in a new Notifications section with the duplicate hint, and
re-requests the permission when enabling. Strings DE+EN.
Deliberately deferred (roadmap): snooze/dismiss actions, BOOT_COMPLETED /
exact-alarm scheduling, battery-exemption prompts.
Tests: reminderTimeText (all-day UTC-midnight reading, exclusive end day,
midnight-crossing ranges), reminders/onboarding pref round-trips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The create form (v1.2) now edits: a pencil on the detail screen (writable
calendars only, contextual WRITE upgrade like delete) opens it prefilled via
EventDetail.toEditForm; populated sections always show, the calendar is
fixed, and a dirty-check writes only changed columns (pristine saves are
no-ops). Saving a dirty recurring event parks in SaveUiState.AwaitingScope
and asks how far the change reaches (Google model): "only this event" =
modified-occurrence exception via CONTENT_EXCEPTION_URI (empty optionals as
explicit NULLs since the provider clones the parent row), "this and all
following" = series split (insert new event first, then truncate), "all
events" = series-row update with the time delta applied to the series
DTSTART. A changed rule drops the exception option. Delete gained the same
middle scope.
Recurrence: EventForm.rrule + SimpleRecurrence (FREQ/INTERVAL/UNTIL/COUNT +
weekly BYDAY with locale-ordered weekday toggles) behind a picker on create
and edit; unrepresentable rules render humanized (shared ui/common
RecurrenceText) and survive verbatim. UNTIL validation flags rules ending
before the event starts.
Provider lessons baked in (verified on-device via adb probes): instance
caches regenerate only from an update's own values, so truncation sends the
full time-column set (truncateSeries) — RRULE-only updates left a stale
duplicate occurrence on the split day; UNTIL is written as the local end of
day in UTC (toRRule(zone), previousLocalDayEndUtcMillis) so UTC+x zones
can't leak an extra day. Reminder edits reconcile against actual provider
rows, keeping untouched rows' methods.
Tests: RecurrenceTest (parse/render/round-trip, truncation), update/exception
mapper paths, repository pass-throughs, prefill + populatedFields, raw-title
mapper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-v1.2.0 design iteration on the event form, reviewed slice by slice
on-device:
- Form rebuilt on the detail screen's card system: tonal EditCards with
gutter icons (centred on the first row, top-aligned for multiline),
borderless inline fields (placeholders at half opacity), calendar-coloured
title accent, no dividers, bare top bar
- Optional sections (location, description, reminders, availability,
visibility) with per-user defaults in Settings ("New event form" toggles);
hidden ones unfold via a "More fields" picker dialog
- Reminders: stacked rows + full-width borderless add; two-step picker
(one-tap presets, then custom amount + minutes/hours/days/weeks dropdown);
written as METHOD_ALERT Reminders rows. Availability busy/free segmented
toggle; visibility selector with per-level icons
- OptionCard (ui/common) is now the app-wide selection-dialog standard;
calendar picker, visibility, more-fields, reminder presets and the
recurring-delete chooser all use it — radio-row dialogs removed
- MaterialExpressiveTheme with MotionScheme.standard() (expressive bounce
felt overdone); FAB stack + field reveals animate on theme springs;
jump-to-today slides toward today's actual direction
- IME: adjustResize + imePadding so the keyboard never pans the form
- Tests: form-field prefs round-trips, availability/access provider
mappings; DE+EN strings throughout
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Second slice of milestone 2 (write support):
- EventForm domain model + problems() validation (end-before-start,
no-calendar; blank titles and instant events stay legal)
- Full-screen EventEditScreen: title, all-day switch, M3 date/time pickers
(moving the start preserves the duration), calendar picker limited to
writable calendars, location, description. Save validates, requests the
WRITE upgrade contextually, and closes on success
- Calendar preselection: explicit pick > last-used (CalendarPrefs) > first
writable calendar
- insertEvent in the data source; EventWriteMapper (JVM-tested) normalises
all-day events to UTC midnights with exclusive DTEND, timed events to the
device zone
- CalendarFabColumn shared by month/week/day: persistent "+" FAB anchored on
the visible day, jump-to-today pill stacked above it
- Tests: EventForm validation, write-time mapping (incl. DST-safe epoch
check), repository createEvent delegation/error propagation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First slice of milestone 2 (write support), per the new plan in
docs/superpowers/plans/2026-06-11-03-write-support.md:
- Delete from the event detail screen with confirmation; recurring events
choose "only this event" (cancelled exception via CONTENT_EXCEPTION_URI,
series survives) or "all events in the series" (Events-row delete)
- WRITE_CALENDAR in the manifest; onboarding requests read+write in one
system dialog but only read gates the app — declining write keeps it
usable read-only. v1.0 installs get a contextual write request on their
first delete
- CALENDAR_ACCESS_LEVEL is read into CalendarSource.canModifyContents;
read-only calendars (WebCal, birthdays, …) show no write actions. The
no-op placeholder Edit button is removed until edit ships (v1.3)
- Onboarding copy drops the now-false "read-only" claim (DE+EN)
- Tests: repository delete delegation/error propagation, access-level
mapping; FakeCalendarDataSource grows write ops
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round out the read-only model so the detail view shows everything
CalendarContract actually stores, ahead of write support.
Data layer:
- New domain types: Reminder, EventStatus, Availability, AccessLevel,
AttendeeRelationship, AttendeeType; EventDetail gains reminders, status,
availability, accessLevel, eventTimezone, selfStatus and Attendee gains
relationship + type (all defaulted so existing callers compile)
- EventDetailProjection reads STATUS / AVAILABILITY / ACCESS_LEVEL /
EVENT_TIMEZONE / SELF_ATTENDEE_STATUS; AttendeeProjection reads
RELATIONSHIP + TYPE; new ReminderProjection queries CalendarContract.Reminders
- Mappers translate each provider integer code, guarding STATUS's null-vs-0
ambiguity (0 == TENTATIVE) so an absent status reads as Confirmed
- Mapper unit tests cover every new column's codes
Detail UI:
- Status / availability / access chips under the title; cancelled also strikes
the title through
- Reminders card with humanised lead times (plurals, DE + EN)
- Foreign-timezone card, shown only for timed events in a non-device zone
- Attendee role badges + the user's own "Your response: …" line
- http(s) URLs in the description are now tappable
URL field cut: CalendarContract exposes no Events.URL column (only the
CUSTOM_APP_URI app deep-link), so URLs are surfaced by linkifying the
description instead. Recorded in ROADMAP/CHANGELOG.
Version bumped to 0.6.0 / 6.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M3 — calendar filter: the navigation drawer now hosts the calendar list
inline (grouped by account, colour swatch + checkbox per calendar). Hidden
calendars are persisted app-side and filtered centrally in the repository,
so month/week/day re-filter live the moment a checkbox flips. Drawer trimmed
to Today, the calendar filter, and Settings, with leading icons and a clear
title/section type scale; the stubbed jump-to-date entry (M2) was removed.
M4 — settings: full-screen destination with appearance (theme System/Light/
Dark, Material You dynamic colour auto-disabled < API 31, week start Auto/Mon/
Sun), language (per-app locales via AppCompat, persisted to API 29), and an
about section (version, licence, source link). Theme is driven by one
activity-scoped settings source so changes apply app-wide at once. Week start
now drives the month grid and week view; Auto follows the locale.
Also:
- default view switched from month to week
- Settings screen handles system back (was closing the app)
- fix pre-existing NonObservableLocale/LocalContextConfigurationRead lint
errors in EventDetailScreen so CI lint is green again
- versionName/versionCode bumped to 0.5.0 / 5
Tests: repository hidden-filter (incl. live re-emit), SettingsPrefs round-trip
+ week-start resolution, filter grouping. lint + unit tests + assembleDebug green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the throwaway debug screen with the first real calendar UI and a
functional Month <-> Week switcher, on Material 3 Expressive.
Month view (S1):
- Material 3 Expressive card-per-day grid; only the current month's weeks
render (neighbouring days left blank)
- per-day event dots with "+N" overflow, today via primaryContainer
- spring-based press feedback from the active motion scheme
- swipe + drawer navigation, Loading/Failure/Success states
Week view (S2):
- vertical time schedule with overlap-resolved lanes (per-day clipping,
midnight spanning, instant events)
- all-day / multi-day events as connected horizontal spans
- single scroll container (gutter + day columns stay aligned), columns
bundled in a rounded container, noon-centred on load
- top section colour-shifts with the app bar on scroll; swipe navigation,
three states
Shared / infra:
- CalendarHost holds the active view; RootScreen renders it post-permission
- ui/common building blocks: CalendarDrawer, CalendarFailure,
ViewSwitcherPill, pastelize, observable locale, M3 Expressive slide
transition (motionScheme fastSpatialSpec)
- unit tests for the week layout (lanes, clipping, all-day spans)
- build: compileSdk 37, material3 pinned to 1.5.0-alpha21 for Expressive
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deviation from Plan 02: the JVM mockable-android.jar stubs every Cursor
method even with isReturnDefaultValues=true (returns null/0 regardless of
the underlying MatrixCursor backing). Introduce an internal ColumnReader
interface so mappers stay pure-Kotlin and JVM-testable via MapColumnReader,
while production reads through CursorColumnReader.
CalendulaSeed (0xFF5C6B7A) anchors the design palette. Theme picks
Dynamic Color on API 31+ when enabled (default true) and falls back
to a hand-tuned Light/Dark scheme otherwise. CalendulaTypography is
the M3 Expressive default for V1 - custom type scale lands in a
later UI-design iteration.
ColorSchemeTest pins the seed value (3 unit tests, all pass).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>