The continuous style streamed weeks with no boundaries at all, which left it
hard to tell where one month ended: the only marker was a "Jul 1" label on the
1st, and every boundary week mixed two months' days into one row.
It is now a vertical stack of self-contained month blocks. Each block shows only
its own days — the boundary week keeps its seven columns so nothing shifts
sideways, but the neighbour month's cells are blank rather than filled with
duplicates of days shown again a block later — under a sticky month header with
whitespace either side. Scrolling stays continuous; only the reading changes.
- The coordinate space moves from absolute week index to absolute month index
(two LazyColumn items per month: header, then block). Unlike week indices, it
doesn't depend on the week-start preference, so changing that reflows the rows
inside a block without moving the block or losing the scroll position.
- The sliding data window now loads months rather than weeks, widened to whole
grid weeks at both ends so a bar reaching into a block from a clipped-off day
still renders.
- `clipWeekToMonth` is the pure seam: it drops the neighbour month's pills and
counts and cuts spanning bars back to the month's own columns, keeping a flat
cap on the cut side so a bar reads as continuing past the block.
- The top bar carries the year in this style — the block's own header names the
month, so repeating it two lines up was pure duplication.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds MonthViewStyle.Split: the month compressed to day numbers and event dots
over a list of the selected day's events.
- The app's first selected-day concept. Every other view drills straight into a
date; here a tap selects, because the pane below is already the answer to
"what's on this day" and opening a whole screen would defeat the layout. The
full Day view stays one tap away on the pane's date header, matching the Week
and Agenda headers (#37).
- The pane reuses the agenda's row vocabulary (extracted in the groundwork
commit) rather than growing a parallel set, so the two surfaces read as one
app. It needs no extra provider query: instancesByDay is already covered by
the month grid range.
- Selection follows the month — today when the new month holds it, else the 1st
— so the pane never lists a day the grid isn't showing. Tapping a leading or
trailing day follows it to its own month.
- Selection and today are different signals (tinted, outlined cell vs. the
filled circle the other views use for today), so both read when they land on
the same day.
- The swipe stays on the grid alone; the pane scrolls and is full of tappable
rows.
MonthScreen gains onEventClick, wired in CalendarHost alongside the other three
views, and MonthUiState.Success now carries its zone for the same reason the
agenda's does.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds MonthViewStyle.Continuous: one uninterrupted vertical stream of weeks
instead of month pages.
The layout unit is the ISO week, not the month. A vertical stack of month grids
would still repeat a boundary week at the end of one month and the start of the
next, which is precisely the duplication the issue asks to be rid of. Streaming
weeks means every date appears exactly once.
- Weeks are addressed by an absolute index (epoch 1900, so indices stay
non-negative and map 1:1 onto LazyColumn item indices), giving the list one
stable, gap-free coordinate space to scroll and key by.
- The view model loads a sliding window of weeks around the visible range.
nextLoadWindow() holds the hysteresis: the window only moves once the visible
range comes within four weeks of a loaded edge, so scrolling re-queries the
provider occasionally rather than every frame. Unloaded rows render a
same-height skeleton, so nothing jumps when the window catches up.
- No dimmed "other month" days — every day in the stream belongs to a month
equally. Instead the 1st names its month, which is the only marker needed to
tell one month from the next, and the top bar title tracks the month the
viewport mostly sits in.
- The horizontal swipe detector and the paged AnimatedContent are both off in
this style; vertical scrolling owns the gesture. Today and drawer jump-to-date
animate the list instead of swapping months. The view opens positioned on the
current month.
MonthWeekRow now takes inMonth as a predicate rather than a YearMonth, which is
what lets the same row serve a stream that has no enclosing month.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces MonthViewStyle{Paged, Continuous, Split} and the setting that
chooses it. Only Paged is wired up so far — the other two land next; this
commit is the pref, the state plumbing and the chooser.
One picker rather than two switches ("vertical scrolling" + "month with
agenda"): independent toggles would multiply into four combinations, most of
which nobody asked for. Split does not disable the Agenda view — that stays a
forward multi-day window, while the split pane lists one selected day.
The chooser lives on the Views settings screen (per-view layout belongs with
the other view configuration) and is a hand-rolled FullScreenPicker rather than
OptionPicker, which cannot render previews: the three options differ in shape,
which a word like "Continuous" does not convey. Each card carries a schematic
drawn from theme tokens, and selection reads three ways over — border weight,
container tint and a check — so it never rests on colour alone. The cards are a
selectableGroup with Role.RadioButton for screen readers.
Folded into the ViewCustomization holder, which had spare arity; the outer
settings combine is still at its five-flow limit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Groundwork for the month view style setting (#38, #53); no behaviour change.
- Split layoutCalendarWeek out of layoutMonthWeeks so the continuous style,
which streams weeks and has no enclosing YearMonth to slice by, lays rows
out identically. layoutMonthWeeks (also used by the month widget) keeps its
signature and becomes a loop over it.
- Add MonthUiState.Success.instancesByDay: the grid's events keyed by date and
uncapped, so the split style's day pane can list a date without a second
provider query — the month grid range already covers it.
- Move AgendaDayHeader / AgendaEmptyDayRow / AgendaEventRow and their label
helpers into AgendaRows.kt as internal, so the split pane reuses the agenda's
row vocabulary instead of growing a parallel one. AgendaEmptyDayRow takes its
text as a parameter now that it serves more than "nothing left today".
- Add the month package's first JVM tests, covering week counts, span
continuation across row boundaries, lane stacking and instancesByDay.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-ups to the picker redesign, plus the bugs it exposed.
The live read-out now renders customResult itself — the exact string OK
would save — instead of rebuilding a parallel rule with `interval ?: 1`
and `end ?: Never` fallbacks, which let it confidently describe a rule
that differed from the selected controls whenever a field was invalid.
Shrink the invalid space behind it: a blank amount field reads as its
visible placeholder (1 / 10) rather than as an error, and backing out of
the date picker falls back to "never" instead of stranding a dateless
"on a date". Only an out-of-range 0 remains invalid, and that now says so
rather than greying out OK with no cause.
UNTIL displayed the day after the one picked for zones behind UTC:
toRRule deliberately writes the end of the chosen *local* day expressed
in UTC (the provider applies UNTIL coarsely), so the read side must
convert back before taking the date. Fixes the detail screen too, and
untilLocalDate is extracted so it can be tested.
Also: hoist a remember() out of a conditional (a slot that appears and
disappears breaks positional memoisation), match GroupedSurface's 22dp
corners instead of a drifted local 20dp copy, move the cards onto
GroupedSurface, drop the segmented row's icon slot so longer unit labels
fit, and reserve two lines so the stack stops shifting as the phrase
grows with each weekday.
Extract SettingsPrefs.firstDayOfWeek(scope), replacing three copies that
had drifted onto different initialValues.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to 30fcbfa, fixing eight issues found in review:
- Cap the agenda row list at 100. SizeMode.Exact asks Glance for one
RemoteViews per host size where SizeMode.Single produced exactly one,
roughly doubling the payload; with the range reaching
AgendaRange.MAX_CUSTOM_DAYS (365) an uncapped list could push past the
binder transaction limit and the host would show "Problem loading
widget". A trailing day header stranded by the cut is dropped.
- Loosen the height cap so it only catches genuinely squashed widgets.
It previously required ~320dp of height for LARGE, so widening a widget
without also making it unusually tall — the exact resize #51 reports —
stayed REGULAR or COMPACT and the feature was near a no-op for it.
Thresholds now work on height minus header chrome.
- Lock the event stripe to the system font scale. It is a Dp beside sp
text, so at large accessibility settings the text outgrew it and it
under-ran the row it marks.
- Route the day-header and placeholder padding through the metrics table
so vertical rhythm holds at the larger tiers, and derive the text
indent from the row constants instead of a hardcoded 19dp.
- Share the bucketing as widget/WidgetScale.kt so MonthWidget (already
SizeMode.Exact) can adopt one rule rather than growing a parallel copy.
- Anchor the type ramp to Material 3 type-scale roles per CLAUDE.md, with
the two off-scale values marked and justified inline. COMPACT is
unchanged, so a default-sized widget still looks exactly as before.
- Tie the "default size unchanged" test to the provider XML's declared
3-cell band rather than one measured 222dp point.
- Hang the metrics off an ordinal-indexed table so lookup allocates
nothing per recomposition.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correctness:
- Recurring writes: move the series DTSTART by the *wall-clock* shift applied
to the edited occurrence and re-resolve it in the event's zone, instead of by
a millisecond delta. The old delta baked in whichever UTC offset applied on
the edited occurrence's date, so pinning a recurring event to another zone —
or editing an occurrence on the far side of a DST boundary from the series
anchor — shifted the whole series by an hour. Also snaps the anchor to a UTC
midnight when the event becomes all-day.
- Detail card and edit form now resolve a pinned zone's abbreviation/offset at
the *event's* instant, not at "now", so a July event no longer reads
"CET · 10:00 AM" when opened in January.
- Agenda: the zone used to label multi-day rows now travels on
AgendaUiState.Success rather than a process-lifetime file-level constant, so
labelling can't disagree with the grouping after a device time-zone change.
- Week title: spell out the year when the week straddles New Year, via a new
forceYear flag on formatCalendarTitle.
Performance:
- Build the ~600-entry zone catalogue off the main thread (produceState +
Dispatchers.Default); resolve the device row's summary on its own so it still
renders complete on the first frame.
- Pre-normalize each TimeZoneOption's search keys at construction, turning
~2400 NFD normalizations per keystroke into plain prefix/substring checks.
Hoist the combining-mark Regex out of the hot path.
- Key the edit form's local-time line on the fields it reads instead of
recomputing it on every keystroke.
- Move LauncherNameManager's PackageManager binder calls off the main thread.
Cleanup:
- Drop a duplicate Public icon import and the unused
event_edit_timezone_clear string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Upcoming" agenda widget used Glance's default SizeMode.Single: it was
composed once at the minimum size and the launcher stretched that single
RemoteViews when enlarged, so the text stayed small-widget-sized no matter how
big the widget grew. Reported as a "font size" request (#51), but it's really a
missing size-response.
Switch to SizeMode.Exact (like MonthWidget) and read LocalSize.current to pick
one of four tiers (COMPACT/REGULAR/LARGE/XLARGE), scaling type and row metrics.
Exact over Responsive so the ~30-day LazyColumn isn't replicated per tier.
Width picks the tier, height can only lower it. Width governs how much of a
title fits on a row, so it's what should drive type size; height only decides
how many rows are visible, so a tall narrow widget shows more events rather than
bigger text. Height does act as a cap, though, or a squashed widget would keep
the large type its width earned in a sliver of space. Thresholds are spread over
the width range a phone actually produces (measured on a Pixel/Nova: a compact
widget is 222dp wide, a large one 378dp) rather than a theoretical range, so the
tiers are reachable in practice; XLARGE is reserved for tablets/foldables.
COMPACT reproduces the original constants verbatim, so an existing widget is
visually unchanged. The tier logic lives in a pure, Glance-free AgendaScale.kt
(compose.ui.unit only) and is JVM-tested: the COMPACT baseline, the width
buckets, the height cap stepping a squashed widget down, and that height never
raises the tier.
No new setting: the widget follows the size the launcher/user already chose.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Settings → Appearance toggle that switches the app's launcher label
between "Calendula" and "Calendar", for users on launchers that can't rename
apps themselves.
The launcher entry moves off MainActivity onto two <activity-alias> components
(DefaultNameAlias / CalendarNameAlias); exactly one is enabled at a time via
PackageManager.setComponentEnabledSetting. MainActivity keeps every other intent
filter; the android.app.shortcuts meta-data moves onto both aliases so the
long-press shortcut still publishes. Component-enabled state is the single source
of truth — no persisted preference.
The ComponentName uses the applicationId for the package (carrying the
.debug/.releasetest suffix) and the namespace for the class, since manifest
".Alias" names resolve against the namespace; switching to Calendar enables the
target alias before disabling the other to avoid a zero-entry launcher transient.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agenda window starts at the anchor's local midnight, which east of UTC
is the previous day's 22:00 UTC. All-day events live at UTC midnights with an
exclusive next-midnight end, so yesterday's all-day event (a birthday, say)
still overlaps today's window start and is returned by the provider.
groupAgendaDays then clamped its first day up to the anchor and, via a trailing
coerceAtLeast(firstDay) on the last day, pulled it onto the anchor's "today"
section — the multi-day fix only stopped the forward leak on interior days.
Drop instances whose true last day (resolved in UTC for all-day events) falls
before the anchor, or whose first day falls past the window end: they occupy no
visible day and must not be clamped onto an edge. Adds eastern-zone regression
tests for both edges.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code-review follow-up.
- The multi-day expansion in groupAgendaDays is shared with the agenda
widget, but only the screen's summary was made day-aware — so the
widget rendered the raw "start – end" on every spanned day, the very
bug the screen fix cured. Hoist a pure agendaTimeLabel(event, day,
zone) into the shared agenda layer and resolve strings from it in both
the screen and the widget, so they label identically. (findings 1, 2)
- groupAgendaDays could silently drop an instance whose clamped span was
empty (firstDay > lastDay); floor lastDay at firstDay so a returned
instance always surfaces on at least its first visible day. (finding 3)
- agendaTimeLabel resolves the span days once instead of the summary
recomputing them 2–3× per row. (finding 5)
Finding 4 (within-day sort) needs no change: sorting by absolute start
already places a still-running multi-day event at the top of each day it
continues into, which is chronologically correct (it is ongoing from
that day's midnight), and at its real start time on its first day.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-up. spanFirstDay/spanLastDay resolved every event in the
device zone, but all-day events live at UTC midnights with an exclusive
end — east of UTC (e.g. Europe/Berlin) that pushed spanLastDay onto the
next day, so a single-day all-day event reported spansMultipleDays and
leaked onto a second agenda day. Resolve all-day dates in UTC, matching
the Week view and detail card. Adds eastern-zone regression tests that
the prior UTC-only tests could not catch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Search matched city, id, and the long localized name but not the
abbreviation, so typing "CEST" found nothing. Match it too: an exact
abbreviation hit ranks just under a city prefix, so typing an abbreviation
gathers every zone that shows it (all the CEST zones at once). It matches
the region-resolved abbreviation — i.e. exactly what the row displays.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
groupAgendaDays keyed each instance by its start day alone, so a
multi-day event surfaced only on its first day and vanished from the
rest of its span in both the Agenda screen and the agenda widget.
Expand each instance across every day from its start (clamped to the
anchor for ongoing events) through its last occupied day, bounded by the
visible window end. An event ending exactly at midnight — including the
exclusive next-midnight all-day events end at — does not reach that
boundary day.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Central European Time (Europe/Berlin)" is too wide — it made the zone
field wrap and stretch. Lead with the id ("Europe/Berlin") and follow it
with the abbreviation instead: "CEST · GMT+02:00" in the picker and edit
card, "CET · 8:00 AM – 9:00 AM" on the detail card (abbreviation + the
event's own-zone time).
The abbreviation is resolved DST-aware at the same instant as the offset
(CET vs CEST) via "zzz"; zones with no named abbreviation fall back to a
"GMT+05:30" form, and zoneDescriptor drops the separate offset in that
case so it isn't stated twice. The long localized name is kept on the
option for search only — typing "pacific" still works — but no longer
shown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two review fixes.
The zone picker's search box was a raw Material OutlinedTextField — the
only one left in the app, and against the convention DialogControls
states outright ("the family's InlineTextField over a tonal surface, not
Material's outlined field"). Rebuild it on InlineTextField over a tonal
surface, with the clear button inside the surface since the picker's top
bar is the title rather than a search field.
Showing a pinned event only in its own zone answered "what was it set
to?" while dropping "when is it for me?" — the user had to do the offset
arithmetic. Show both whenever they differ:
- the edit form keeps editing the event in its own zone (that's the time
it was set at) and captions it with the local equivalent;
- the detail screen keeps local times primary and now leads the zone card
with the original ("8:00 AM – 9:00 AM in New York") instead of naming
the zone and nothing else.
EventForm.timesIn is pure, so the conversion — including crossing the
date line and each zone's own DST, which don't move together — is a
plain JUnit test rather than something only reviewable on a phone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every write sampled ZoneId.systemDefault() and stamped it into
EVENT_TIMEZONE, so the column was real but only ever held the device's
zone: an event synced from elsewhere could be read in its zone, never
authored in one.
Give EventForm a nullable `timezone`, where null keeps meaning "the
device zone at save time" — so every existing call site behaves exactly
as before — and a non-null value pins the event to a zone it then tracks
across DST. toWriteTimes resolves the form's zone ahead of the device's;
toEditForm pins only when the stored zone differs from the device's, and
prefills such an event in its own zone so the form shows the wall-clock
the event actually means.
Two provider-contract bugs fall out of this:
- Editing the time of a foreign-zone event rewrote EVENT_TIMEZONE to the
device's. The instants stayed right, so nothing looked wrong, but the
event silently stopped tracking its zone and would drift an hour at the
next DST boundary. Only the timesChanged gate spared title-only edits.
- A zone change with an untouched wall-clock is still a time change (the
same 09:00 elsewhere is a different instant), so it now trips
timesChanged and rewrites DTSTART instead of being dropped.
All-day events keep carrying no zone at all: they're date-anchored, and
the UTC midnights they normalise to are an anchor rather than a location.
TimeZoneCatalog is pure JVM so the search ranking and DST-aware offsets
stay plain JUnit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an "Always show today" setting (Settings → Agenda, on by default) that
keeps today as the first entry in both the Agenda screen and its home-screen
widget even once nothing is left today. Under today's normal header a small
"No more events today" card appears — the coffee-cup empty-state motif in the
app, a rounded surface in the widget — so the first rows you see are clearly
today's rather than a future day's.
The anchor is a pure, JVM-tested helper (anchorTodayIfMissing) applied after
past-event filtering; in-app it only kicks in when the window starts on today,
never on a jumped-to date. The widget reads the pref reactively via per-instance
Glance state, mirroring the range/past-event settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A reminder fired ahead of an event on a different day showed only the
event's time (e.g. "09:30 – 10:00"), making it look like it was
happening today. reminderTimeText now prefixes timed events with a
relative day: "Tomorrow"/"Yesterday", the short weekday for another day
this week, or the exact date for anything further out.
The this-week boundary honours the user's "week starts on" setting: the
resolved first day of week is threaded through from ReminderNotifier, so
e.g. a Sunday reads as next week under a Sunday-start locale. All-day
events keep their explicit date (never ambiguous), and cross-midnight
timed events keep both explicit dates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The calendar row in the editor is now tappable when editing an existing
event: picking a different calendar moves the event there on save, rather
than forcing a delete-and-recreate.
CALENDAR_ID is sync-adapter-owned and can't be updated in place, so the
move is copy+delete: the master row is re-inserted on the target calendar
(preserving UID_2445 so backup dedup and sync identity survive), its
reminders and editable guests are copied, and — for a recurring series —
every exception is replayed against the new master (modified occurrences
via CONTENT_EXCEPTION_URI, cancellations as STATUS_CANCELED). The user's
field edits are then applied with the normal series update. Everything on
the new side is built before the source is deleted (post-before-delete),
with a rollback of the copy on any failure, so a move is all-or-nothing.
A calendar change forces whole-series scope, so it skips the recurring
scope dialog. Managed special-dates calendars stay locked. Colour is not
carried across (a raw/keyed colour may be invalid on the target account),
matching the existing calendar-switch behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cancelled-exception fix works on synced calendars but not local ones. A
cancelled exception only attaches to its parent through ORIGINAL_SYNC_ID; a
local event has no _sync_id, so the link never forms and the provider's
expansion of the *parent* collapses — every other occurrence disappears, which
is the original #47 corruption, just on a different calendar type. Verified
on-device both ways: a DAVx5 series survives a single-occurrence delete, the
same series on a LOCAL calendar vanishes entirely.
deleteOccurrence now branches on _sync_id. Synced events keep the (verified)
exception path. Events without one — local calendars, and synced events not yet
pushed — add the occurrence to the master's EXDATE, which needs no parent link
and is the canonical iCalendar way to drop one; a sync adapter carries it
upstream unchanged if the calendar later syncs.
Two provider quirks shape the write (both observed on a Pixel):
- An EXDATE-only update is not treated as a recurrence change: the expanded
Instances rows are left alone, so the occurrence stays visible. The
time/recurrence set has to ride along to force re-expansion.
- DTSTART alone is worse — the provider then recomputes lastDate as if the event
were a single instance and collapses the series to its first occurrence.
DTSTART + DURATION + RRULE + zone together re-expand it correctly.
This path is reached in normal use: Calendula's own contact special-date
calendars are local and hold all-day yearly series, so deleting one birthday
occurrence went through it. All-day series take the VALUE=DATE EXDATE form.
Adds pure buildOccurrenceExdateValues + JVM tests (timed, append, duplicate
fold, all-day).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A recurring series row carries DURATION, not DTEND, so EventDetailMapper's
end == begin fallback rendered it zero-length. That was harmless while every
caller supplied per-occurrence times from Instances, but the bare
content://…/events/<id> VIEW intent added in #48 names no occurrence and
keeps the row's own times — so a series opened from a third-party widget
without begin/end extras showed as "10:00 – 10:00".
Read DURATION in the detail projection and derive the end from it, the same
way SearchMapper and IcsExportMapper already do.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CalDAV sync adapters (DAVx5) publish all ~147 CSS3 named colours into
CalendarContract.Colors, so the event-colour picker showed a full screen
of alphabetically-scrambled, partly duplicated swatches.
Curation now runs in the space the picker actually paints — every swatch
is softened through pastelize, which pins lightness and caps saturation,
so the raw palette's lightness axis is invisible on screen. Judging
distinctness there: colours that paint identically collapse to one
(folding aliases, dark/light shades of a hue, and the neutrals together),
oversized palettes drop washed-out neutral-origin tints and thin by CIE76
ΔE in painted Lab, and survivors sort continuously by painted hue with the
wheel cut at its single widest gap. The CSS3 dump lands at ~33 distinct,
rainbow-ordered swatches; small hand-picked palettes (Google's) pass
through untouched. Every surviving swatch keeps its provider colour key so
picks still round-trip through sync.
This revives work stranded on fix/caldav-color-picker (never merged) and
adapts it to the floret-kit extraction of pastelize: the curation's
painted-space transform now lives self-contained in domain/pastelArgb as a
mirror of floret's pastelize shaping, rather than the two sharing one
function.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Delete only this event" on a recurring series wrote a cancelled
exception carrying just ORIGINAL_INSTANCE_TIME + STATUS_CANCELED. Without
DTSTART + DURATION the provider clones the master *with its RRULE intact*
and cancels the whole clone, so every other occurrence vanished, the
target survived as a "cancelled" ghost, and re-deleting toggled the
series back — exactly the reported corruption.
Anchor the exception as a single instance (DTSTART + DURATION + zone +
all-day, read from the series row) so the provider clears the inherited
RRULE and cancels only that occurrence — the same discipline the edit
path already documents (Codeberg #16). Also filter STATUS_CANCELED out of
the instances grid query so the cancelled occurrence disappears instead
of lingering as a tappable ghost (NULL status is kept — a normal event).
Extracts the exception ContentValues into a pure buildOccurrenceCancelValues
helper with JVM tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the 2.14.0 feature set (Month week numbers, .ics restore +
per-calendar export picker, Day view on date-header tap, wrapped long
titles, Simplified Chinese) onto the floret-kit migration without
resurrecting the inline component copies the kit now owns.
Conflict resolutions (both were import-block collisions where the
migration repointed to de.jeanlucmakiola.floret.* while release kept the
old app-local imports):
- CalendarsScreen.kt: keep the kit imports (CollapsingScaffold,
DialogAmountField/DialogUnitDropdown, collapseExit/expandEnter,
predictiveBack) and repoint FullScreenPicker + positionOf to the kit
(both moved out of ui.common). Keep the genuinely app-local
LeadingAvatar/SourceLogo/curatedSourcePackage; drop the renamed
calendarCollapseExit/calendarExpandEnter (0 uses).
- ImportScreen.kt: keep the app-local CalendarPickerGroups, use the kit's
predictiveBack, drop the now-unused OptionCard import.
Semantic fixup: CalendarPickerGroups.kt (new in 2.14.0) relied on
same-package resolution of GroupedRow/Position, which the migration moved
to the kit — added the explicit floret.components imports.
Verified: :app:compileDebugKotlin, ./gradlew test (app + kit), and
scripts/check_translations.py all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Supersedes the stale chore/floret-kit-core-time branch: re-applies the
floret-kit migration on top of current main (122 commits ahead of the old
branch's base), pinning the kit at the multi-value-reminders + pinned-picker HEAD.
- Submodule + composite build (includeBuild), 6 module deps, CI submodules:
recursive, reproducible-release scan extended to the kit, F-Droid recipe.
- Deletes the inline copies now owned by the kit (GroupedList, Picker scaffolds,
InlineTextField, OptionCard, DialogControls, CrashReporter + dialog/submit,
OnboardingScaffold, AppLanguage, TimeBridge, ReorderableColumn, DebugRibbon)
and redraws them from components/identity/core-crash/core-locale/core-time.
- Reminder overrides drawn from core-reminders (multi-value ReminderOverride +
codec); Calendula keeps its app-specific bits (all-day resolution, labels,
presets, the multi-select ReminderDefaultPicker, its own CrashReportActivity).
- Theme draws FloretExpressiveTheme while keeping the user-typography param.
Build pending (deferred): run ./gradlew :app:compileDebugKotlin with ANDROID_HOME
(or floret-kit/local.properties) set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an opt-in left gutter to the Month grid showing the ISO calendar-week
number, gated by a new "Week numbers" display setting (default off). The
number is computed on each row's first day — the same basis as the Week
view's badge — so the two views agree, and rendered as a low-emphasis
onSurfaceVariant label so it recedes across all six rows rather than
competing with the event bars. The weekday header reserves a matching
gutter so the day columns stay aligned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fake now records the calendarIds it receives; two repository tests
assert exportEvents forwards a chosen subset and defaults to null (all
eligible calendars), closing the coverage gap for the per-calendar
export selector.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tapping Export with more than one exportable calendar now opens a picker
to choose which local calendars to include (all selected by default); a
single calendar exports straight through as before. Threads an optional
calendarIds filter through exportEvents/exportableEvents (null = all
eligible), so the auto-backup path is unaffected. The backup section is
now gated on there being at least one exportable (non-managed) calendar.
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>
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>
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>
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>
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>
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>
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>
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>
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>