Commit Graph

589 Commits

Author SHA1 Message Date
d16c21f15a feat(calendars): say what is different about a calendar (#76)
The calendar manager knew four things about a calendar and told the user
one of them (off, via the switch). Read-only calendars — a subscription, a
share you can only view — looked identical to writable ones while being
silently absent from every picker, and a calendar whose account isn't
syncing events to this device looked entirely normal while being empty by
construction.

Both now carry a supporting line on their row, not a badge: a row can hold
several of these at once next to a live switch, which is what M3 supporting
text composes and a row of static chips doesn't.

The non-syncing case also loses its switch, sorts to the bottom of its
account and dims. Visibility can't reveal what isn't on the device, so the
control would do nothing — and it no longer counts towards "the whole
account is off" or moves with toggle-all.

sync_events is read as "not synced" for account-backed calendars only.
Nothing syncs a device-local calendar by definition, so the flag says
nothing there, and another app's local calendar can hold real events at
sync_events=0 — the same unsoundness that made the first #75 migration
guard wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:24:50 +02:00
7aef01d95e docs(architecture): record what the second review pass changed
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 10m57s
The visibility section claimed the reminder side needed no per-calendar
handling. It does on the one path where VISIBLE was never written: an alert the
notifier silences keeps its SCHEDULED state while its event is still ahead, and
switching the calendar back on re-posts it, so the provider's own table is the
stash the deleted SuppressedReminderStore used to be.

Also rewraps the paragraph and separates it from the one that follows, which it
had been running into since the section was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:38:34 +02:00
ce4d6bc4d1 fix(calendars): keep an event's own calendar when it is switched off
Excluding switched-off calendars from the event form's picker is right for
*targets*, but it also dropped the calendar an event already lives in. Editing
an event of a writable calendar switched off on this device (from a widget, a
deep link, another app's ACTION_EDIT) rendered the calendar row as the red "no
calendar" error, with the picker still enabled — so any pick turned the save
into a calendar move nobody asked for. The event's own calendar is added back
whenever it isn't among the targets, the way the managed special-dates case
already did; a calendar the app may not write to is still no target.

Settings → Notifications had missed the same predicate swap: it kept offering
per-calendar reminder overrides for switched-off calendars, where the provider
schedules no alarms and the setting could never fire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:34:24 +02:00
4ad805e747 fix(calendars): don't flash a flushed calendar's events back on
The reconciler writes VISIBLE=0 and then releases the id from the pending set,
but the ContentObserver that invalidates the repository's cached calendar
snapshot is dispatched through the main looper and arrives later. Until it did,
the released set was read against the snapshot from before the write: the
calendar reported as on again and instances re-admitted exactly the events the
migration was hiding — on a cold start, for as long as the busy main thread took.

The snapshot cache is keyed on the pending set as well as the tick now, so any
read that sees a changed set re-queries the provider; a change to the set also
re-runs the flows, and the pending ids are read in the same pass as the
calendars rather than combined in from a live flow. Both id sets are deduped at
the prefs seam (the store is shared with SettingsPrefs, so every unrelated write
re-emitted them) and calendars() collapses identical lists, which keeps those
re-queries as rare as they should be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:34:24 +02:00
bb6e3ad336 fix(calendars): only tell upgrades about the visibility change, and reconcile on every grant
Two holes in the reconciler, both found by review.

The one-time notice armed on any device holding a calendar at VISIBLE=0, which
is the norm on a fresh install: a second account's calendars, "Holidays in X", a
subscribed calendar. A brand-new user got a changelog dialog about a migration
they never experienced, right after onboarding. It is gated on
firstInstallTime != lastUpdateTime now, and a fresh install retires the notice
unshown ahead of the permission check — so an update installed before the first
grant can't make it look like an upgrade afterwards.

The catch-up run hung off PermissionViewModel.onGranted, which only fires for the
in-app request. Granting from Android's app-settings screen comes back through
RootScreen's ON_RESUME, so an upgrading user who took that route kept their
inherited switch-offs unflushed — their events filtered app-side while the
provider went on scheduling the reminders they asked to stop. The trigger sits on
RootScreen showing the app instead, which covers both routes. To keep that cheap,
a settled run now returns after two DataStore reads instead of querying every
calendar first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:34:24 +02:00
edbeadfa30 fix(reminders): stop marking a silenced reminder handled and losing it
"With VISIBLE=0 the provider creates no alert rows" justified deleting the
suppression stash, but it only holds where the flag was actually written. Where
the switch lives in pendingDisabledCalendarIds — a read-only install, or an
upgrade whose flush hasn't landed — the provider still holds VISIBLE=1 and keeps
creating and broadcasting rows. The receiver silenced those in
ReminderNotifier.post and then marked the whole due batch STATE_FIRED, and
dueAlerts only ever returns STATE_SCHEDULED, so switching the calendar back on
before the event could no longer surface the reminder: it was gone.

post() now reports whether it put a notification up, and the receiver marks what
it posted plus what it silenced for an event already over (handledAlertIds). A
silenced alert for an event still ahead stays scheduled, which makes the
provider's own table the stash SuppressedReminderStore used to be — no local
mirror, no serialization. ReminderRecovery re-posts those rows when the calendar
is switched back on, so recovery doesn't wait for the next unrelated broadcast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:33:58 +02:00
ef48717e2c fix(calendars): hide-only visibility reconcile, and keep it working read-only
Code review of the one-visibility-model fix (#75) found the reconciliation
reaching further than it should and the read-only case falling through it.

The migration switched calendars *on* to keep the upgrade invisible, but
"not disabled in Calendula" is the default for every calendar, including ones
the user deliberately hid in Google Calendar, Etar or DAVx5 — those would
reappear there and start firing reminders from a switch the user never touched.
Its sync_events guard didn't hold either: an ACCOUNT_TYPE_LOCAL calendar another
app created can sit at sync_events=0 while holding real device-local events. The
reconcile now only hides, and a one-time notice explains that visibility follows
the device and where to change it, instead of quietly rewriting other apps'
state.

Only READ_CALENDAR gates the app, so a read-only install could not write the
flag at all: every calendar it had switched off came back with its events and
its reminders, and the switch couldn't undo it. Those switch-offs are kept
app-side now (the retired disabled-set key, re-read under a new name), folded
into the visibility every consumer reads, and drained into the provider entry by
entry once WRITE_CALENDAR arrives — which also makes a part-applied run resumable
without re-applying a switch the user has since flipped by hand.

Also: restore the ReminderNotifier.post gate, the one path a snooze re-shown
from our own alarm passes; move the whole reconcile inside its try/catch, so a
damaged preferences file can't crash the process at launch; share one Calendars
query per provider tick across the flows that need it; and give the reworded
Settings hint new keys, so five locales stop rendering the retired app-only
wording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 13:16:55 +02:00
41593a0e9d fix(calendars): make the Settings toggle the one visibility model (#75)
Reminders never fired for a calendar hidden at system level and nothing hinted
at it: the provider only schedules reminder alarms for Calendars.VISIBLE=1,
while Calendula filtered with its own disabledCalendarIds pref and parsed
isVisibleInSystem without ever using it — two models that could disagree
indefinitely.

Settings → Calendars now writes Calendars.VISIBLE, one calendar per update
(CalendarProvider2 skips its own checkNextAlarm() reschedule for any selection
that isn't _id=), and every display predicate reads isVisibleInSystem. The
drawer's filter sheet stays a purely in-app declutter and still leaves reminders
alone.

With VISIBLE=0 the provider creates no alert rows, so there is nothing left to
suppress: the disabled-calendar gates, SuppressedReminderStore and the re-enable
recovery are gone. A one-shot migration reconciles the retired set with the app's
state winning — enabled in-app and syncing gets shown, disabled gets hidden,
everything else untouched — so the upgrade changes nothing the user sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 12:44:56 +02:00
4c1bfc052e Merge pull request 'ci(release): retry the Codeberg release create past the tag-settle 500' (!96) from fix/codeberg-release-retry into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Reviewed-on: #96
2026-07-25 07:09:53 +00:00
bba536394f ci(release): retry the Codeberg release create past the tag-settle 500
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 4m23s
The Codeberg mirror step pushes the tag, then immediately POSTs the release
for it — but Codeberg 500s when the release request outruns its indexing of
the just-pushed ref, and with only one attempt that single 500 skipped the
mirror every release (the same POST succeeds seconds later, as a manual retry
confirmed for v2.16.0). Wrap the create/update in a backoff retry loop that
PATCHes in place if a release already exists, so a transient 5xx no longer
loses the mirror. Step stays best-effort (continue-on-error).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 22:22:52 +02:00
bb34973047 Merge pull request 'Release 2.16.0' (!95) from release/v2.16.0 into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 12m59s
Reviewed-on: #95
v2.16.0
2026-07-24 20:00:13 +00:00
108a1890be docs(changelog): cut 2.16.0
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 10m49s
Consolidate the release under a single 2.16.0 heading dated today and fold
in the work that had accumulated in Unreleased. Add the entries the log had
been missing as PRs merged: app-name toggle (#44), the Arabic/French/Italian
translations, the custom recurrence picker redesign + end-date fix (#42),
multi-day agenda events (#83), and dark-theme picker bar icons (#70).
Regenerate the fastlane per-version changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 21:47:44 +02:00
521c0ffd78 i18n: offer Arabic in the language picker
The Weblate merge added a values-ar translation (~50% complete, RTL —
the app already declares supportsRtl). Add the matching <locale> line so
Arabic is selectable in both the in-app picker and the system per-app
language settings. Russian and Portuguese stay out for now: at 12% and
<1% they're below the usable bar (the lowest currently-offered language,
zh-CN, sits at ~24%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:20:07 +02:00
33a37d4105 Merge remote-tracking branch 'origin/main' into release/v2.16.0 2026-07-24 18:17:23 +02:00
6ccc0cbed2 Merge pull request 'Translations update from Weblate' (!91) from weblate-bot/calendula:weblate-calendula-strings into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
2026-07-24 16:15:27 +00:00
Weblate
6e6ffce271 Update translation files
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 4m28s
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-07-24 16:09:17 +00:00
Sup99999
23386bb998 Translated using Weblate (Arabic)
Currently translated at 52.4% (229 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-07-24 16:09:17 +00:00
Xavier
28b1568486 Translated using Weblate (French)
Currently translated at 91.0% (398 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/fr/
2026-07-24 16:09:17 +00:00
Sup99999
5127971a37 Translated using Weblate (Arabic)
Currently translated at 32.7% (143 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/ar/
2026-07-24 16:09:17 +00:00
Weblate
0562af8d66 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/
2026-07-24 16:09:17 +00:00
Sup99999
e3d3728bbd Added translation using Weblate (Arabic) 2026-07-24 16:09:17 +00:00
Weblate
8cc9d075fd Translated using Weblate (Italian)
Currently translated at 100.0% (437 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-24 16:09:17 +00:00
Sam Gamgee
ede6d967c1 Translated using Weblate (Italian)
Currently translated at 100.0% (437 of 437 strings)

Translation: Calendula/Strings
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/it/
2026-07-24 16:09:17 +00:00
c30f6ab318 Merge pull request 'build(lint): don't fail the build on partial community plurals' (!94) from fix/lint-partial-plurals into main
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
2026-07-24 16:09:12 +00:00
dd2b96b5fd build(lint): don't fail the build on partial community plurals
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m49s
A Weblate translation of a <plurals> may not fill every CLDR quantity
form its locale defines — e.g. Arabic's import_reminder_prompt_body_existing
omits the "zero" form — which lint flags fatally as MissingQuantity and
aborts the CI build. The missing form falls back to "other" at runtime, so
this is the same partial-translation case we already accept for
MissingTranslation; downgrade MissingQuantity to informational alongside it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:57:51 +02:00
c22658db4c Merge pull request 'fix(intents): show Calendula under "Add to calendar" from other apps (#74)' (!93) from fix/insert-event-intent-filters into release/v2.16.0
Reviewed-on: #93
2026-07-24 15:46:13 +00:00
7b3893ddc6 feat(intents): open .ics/.vcs handed over as application/octet-stream (#74)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 10m43s
Mail clients, browsers and file managers frequently label a calendar
attachment as a generic download (application/octet-stream) rather than
text/calendar, so the MIME-typed VIEW filter missed them. Add a dedicated
filter that matches those by .ics/.vcs extension via pathPattern — kept
separate so the path constraint can't narrow the reliable MIME-typed filter.
The import handler already ignores the declared MIME, so a let-through file
imports normally. Best-effort: reliable for file:// and content:// whose path
carries the name; nameless content:// URIs still fall back to the MIME filter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:40:27 +02:00
9e28fa4981 fix(intents): accept item-typed INSERT / INSERT_OR_EDIT for "Add to calendar" (#74)
Other apps' "Add to calendar" / "Save to calendar" actions commonly fire the
canonical insert intent — ACTION_INSERT with setType("vnd.android.cursor.item/
event") — the singular *item* MIME type (Android's own docs example; used by
e.g. DB Navigator). Calendula advertised INSERT only on the *dir* MIME type, so
it never matched: absent from the chooser, and a silent no-op when it was the
only calendar app installed.

Add the item-typed INSERT to the events filter, plus ACTION_INSERT_OR_EDIT (the
third "add to calendar" action AOSP and Google Calendar register). The runtime
parser already treats any ACTION_INSERT as create; teach insertFormOrNull /
editEventKeyOrNull about INSERT_OR_EDIT so an id-less one is a create and an
id-carrying one opens the edit form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:37:59 +02:00
7549c8fe04 Merge pull request 'fix(pickers): keep full-screen picker bar icons theme-matched (#70)' (!92) from fix/picker-status-bar-icons into release/v2.16.0
Reviewed-on: #92
2026-07-24 15:26:24 +00:00
75f699b714 chore(floret-kit): re-point submodule to merged main (#70)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 2m32s
floret-kit#3 merged to main; move the pointer from the fix branch commit
to the main merge commit. No code change — same tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:24:57 +02:00
3e821c9092 fix(pickers): keep full-screen picker bar icons theme-matched (#70)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 9m26s
Bump floret-kit to the fix that drives each picker Dialog window's
status/navigation-bar icon appearance from the runtime theme. The picker
Dialog owns its own Window, seeded from the XML theme rather than the
activity's runtime edge-to-edge state, so in dark theme the status-bar
icons went black and near-invisible on the dark picker (Codeberg #70).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:12:29 +02:00
7602f158a3 Merge pull request 'feat(month): month view style picker + Split pull-to-expand (#38, #53)' (!90) from feat/month-view-style into release/v2.16.0
Reviewed-on: #90
2026-07-20 21:25:49 +00:00
052335e842 fix(month): carry the scroll position across a style change (#38)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m54s
The scrolling styles navigate by scroll position and never set the paged
month, so it sat wherever paged navigation last left it — today's month
on a fresh open. Switching to a paged style, or reseeding the other
scrolling style's list state (both read off it), snapped back there
instead of holding the month on screen.

Track the paged month to the visible month while a scrolling style is
up, realigning the split selection alongside it so landing on Split
shows a live day in the month you were viewing rather than a stale,
off-month one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:20:34 +02:00
28fb7404d8 fix(month): point the split slide the way the month moves (#53)
Tapping a greyed leading/trailing day in the split grid follows the
selection to that day's own month, but selectDate never set the slide
direction — so the incoming page reused whatever the last swipe left in
slideDir and could travel backwards while navigation moved forwards.

Wrap selectDate so a tap that crosses months points slideDir at the
month it is actually heading to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:20:21 +02:00
6ab235e1d8 refactor(month): make the style picker read by looking, not comparing (#38)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m35s
The chooser showed a per-style blurb on every row and a generic feature
summary under the preview. Move the selected style's own blurb under its
preview instead, drop the generic summary, and reduce the rows to a single
line — the preview is now bigger (280dp, wider) in the space that frees up.

The blurbs now stand alone: Dense's copy referenced Continuous ("the same
endless scroll…"), which made no sense once each shows in isolation. Rename
the two vertical styles to say what they do — Continuous → "Scrolling months",
Dense → "Seamless weeks".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:55:40 +02:00
fb90c94e37 fix(month): let the selection outline travel with its cell (#53)
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 5m44s
The outline was the last piece with nothing to morph against, so it could
only fade in place while everything around it moved. The expanded grid
draws no outline by design — once the cells carry real event bars it is one
mark too many — which left it partnerless.

Same answer as the multi-day dots: give it an invisible stand-in over the
same cell in the expanded grid. It draws nothing and exists only to be the
other end of the morph, so the outline now rides its cell between the two
layouts.

Tagged only while it is the selected day. Tagged unconditionally it would
put a key on all 42 cells against the single partner on offer, and the
other 41 would fly in from the layout origin — the failure the dots just
had. The stand-in carries the same padding as the outline's own bounds, or
it would arrive at the wrong size, and it sits in its own layer rather than
inside the cell pill, which is itself tagged and would make it travel twice.

MonthGrid takes `selected` again, but for an anchor rather than a mark; the
other styles pass nothing and compose no extra layer at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:19:19 +02:00
7a05fc3ac1 fix(month): give every covered day's dot a place inside the bar (#53)
A multi-day event has a dot on every day it covers but only one bar, so the
dots away from its start had no counterpart. Untagged they stood still and
faded; tagged without a partner they flew in from the top of the grid.
Neither is an animation — the last commit swapped one for the other.

The expanded grid now places an invisible slice of the bar in each further
column it spans, keyed to that column's day. Every dot has a real place to
come out of and go back into, at its own column, so it drops out of the bar
above it instead of appearing from nowhere.

That makes the seating's anchored flag pointless again — every dot has a
partner now — so it and its tests come back out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:06:22 +02:00
bf38dac5d7 fix(month): only tag the dot that has a bar to become (#53)
Dots on the middle of a multi-day run flew in from the top of the grid
instead of fading where they stand.

They were meant to be left unmatched — a bar is drawn once per row, from
the column it starts in, so only that day's dot has a counterpart. But
"unmatched" was implemented as tagged-and-unmatched, which is a different
thing: a shared element entering with no partner has no bounds to start
from, so it animates in from the layout origin. The top.

Seating now says which day a bar is actually drawn from, and only that dot
carries a tag. The rest are plain content and fade in place, which is what
the comment claimed they did all along.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:51:45 +02:00
1e9581528e fix(month): stop the row clip swallowing marks in mid-flight (#53)
Frame-by-frame, the marks were not fading in late — they were invisible
for the first half of their journey and then appearing already near the
destination, row by row from the top down.

A mark's two homes are in different rows: the dot for the 20th sits a third
of the way down the compact grid, its bar most of the way down the expanded
one. The events box clips to its own row, so a mark arriving there is out
of bounds — and undrawn — until it crosses in. The lower the row, the
further it travels and the later it showed, which is exactly the order the
capture shows them appearing in.

Rendering in place rather than in an overlay is what subjects marks to that
clip, and that is worth keeping — it is what puts the whole grid in one
z-order. So the clip yields instead, and only while pieces are in flight;
at rest it still stops a bar spilling into the row below.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:40:46 +02:00
279872aa3b fix(month): morph in place instead of over the top of the grid (#53)
Travelling pieces were painted into an overlay above the entire tree —
Compose's default, and right for a thumbnail flying across a screen, wrong
for a grid changing shape in place. Everything tagged left the tree's
z-order, so every untagged neighbour spent the transition buried under it.

Lifting each of those out in turn — the selection outline, then both "+N"
markers — fixed the burying and bought something worse: they floated over
the grid on a layer of their own, out of step with what they belong to.
Three pieces needing the same escape hatch was the tell that the overlay
itself was wrong here.

Nothing renders in the overlay now. One z-order, one clip, so the grid
reads as a single surface changing shape rather than a stack of pieces
sliding past each other. The outline goes back to being plain content that
sits above its own pill the ordinary way, and morphOverlay is gone.

The "+N" markers become a matched pair instead of an orphan. The compact
grid writes the count as text and the expanded one as a row of dots, but
they stand for the same events on exactly the same days — both seat three
and overflow the rest — so they were always two halves of one marker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:09:25 +02:00
ff6c4b6a4d fix(month): let the event marks actually grow, and lift the "+N" out (#53)
Two reasons a mark arrived a beat late instead of animating.

The dots and bars carried morphBounds *inside* their own size modifiers, so
the shared bounds had nothing to drive: the dot stayed pinned at 5dp however
far its bar had travelled, and the bar sat at full width from the first
frame, each snapping into place only once the transition ended. The growth
was being clamped away. morphBounds now sits outside the width/height on
both sides. A bar's offset stays outside it — that is where the bar sits,
not how big it is.

The "+N" overflow markers were untagged, so they spent every transition
painted over by the overlay the tagged pieces draw into, exactly as the
selection outline did. Both the compact grid's and the expanded grid's are
now lifted into it.

The outline itself moves to the fast effects spec, as asked: it is a small
mark that only ever appears or disappears, and at the shared pace it
lingered after the thing it marks had already moved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:55:30 +02:00
7fb01ab8d1 fix(month): fade the selection outline with the page it belongs to (#53)
Sending the outline to the shared-transition overlay stopped it hiding
behind its own cell, but it also lifted it out of the layer AnimatedContent
fades the outgoing page with — so it stopped fading at all. It stood at
full strength for the whole expand or collapse and then blinked out at the
end, which is the opposite half of the same bug.

The fade is reapplied on the lifted content's own layer, so it travels with
it. That belongs in morphOverlay rather than at the call site: anything
sent to the overlay loses the parent's fade the same way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:43:21 +02:00
bdb16d069e fix(month): drag from the handle, and stop the outline hiding behind its cell (#53)
The handle advertised a gesture it didn't answer to — the drag lived on the
grid alone, so the one part of the screen that says "pull me" was the one
part that ignored being pulled. Grid and handle now drag as a single
surface; the pane stays outside it, since it scrolls and is full of
tappable rows. Tapping the handle still toggles: a parent drag and a child
clickable coexist the same way they do in any scrollable list.

The selection outline was still flickering through an expand or collapse,
and being untagged was not enough to explain it. Tagged pieces paint into
an overlay above the whole regular tree, so the outline — correctly left in
that tree, to keep the collapsed cell's bounds — spent every transition
hidden behind its own morphing pill and reappeared the moment it ended.
It now renders into that same overlay while keeping its own bounds, which
is what the untagged layout was for in the first place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:34:56 +02:00
beb8536e8a feat(calendar): commit page swipes on the threshold, not on release (#53)
The split style's pull-to-expand fires the moment the drag clears its
threshold, and that turned out to be the better feel — so paging now does
the same everywhere: month, week and day.

Waiting for the lift meant the page sat still under a finger that had
already travelled far enough to ask for it, and the answer only arrived
once you let go, which reads as the view being slow rather than as a
deliberate commit. The trade is that a drag can no longer be taken back by
dragging the other way; once you have moved 24dp deliberately you meant it,
and the page you left is one swipe back.

The three views had three copies of the same detector, so this extracts one
rememberCalendarPageSwipe and the threshold constant beside it. It stays
horizontal-only on purpose: the week and day timelines scroll vertically
underneath, and a two-dimensional detector would claim those drags before
the inner scroll saw them. The month keeps its own axis-locking detector
because the split style needs the vertical axis, and now shares the
threshold.

Also fixes the selection outline shivering rather than fading. Its animated
alpha was read while composing the cell, so every frame of the fade
recomposed all 42 cells at once — the fade was driving the whole grid
through recomposition to change one colour. It is now held as a State and
read inside a drawBehind, which keeps it in the draw phase where it touches
nothing but pixels. That means drawing the rounded rect by hand instead of
Modifier.border, so the corner radius is named alongside CELL_SHAPE to stop
the two drifting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:25:18 +02:00
63ba69729e fix(month): keep the selection outline off the morphing cell (#53)
Drawn on the cell's morphing pill, the outline rode its shared bounds — so
collapsing painted it at the *expanded* cell's size and shrank it down, a
full-height outline flashing over a grid that no longer had full-height
cells. The same shape of mistake as the marker that used to cross the
outgoing month during a page turn.

It gets its own untagged layer, laid out where the collapsed cell actually
is, and fades in there. That is the only size it is ever true at.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:57:29 +02:00
01a6c8bab8 revert(month): put the morph back on the calendar's own motion specs (#53)
The trigger was what felt slow, and firing on the threshold instead of on
release already fixed that. Speeding up the animation on top of it was a
second change to the same complaint, and not one that was asked for — the
morph goes back to the shared calendar slide and fade specs, so it moves at
the same pace as every other transition in the app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:51:57 +02:00
f70b33b864 fix(month): drop the expanded grid's outline and make the pull fire sooner (#53)
Three things from the first on-device look.

The selection outline is gone from the expanded grid. Once the cells carry
real event bars it was one mark too many, and it had nothing to say —
every day there is one tap from being selected, and tapping is what closes
the view. MonthGrid/MonthWeekRow lose the parameter again.

Expand and collapse now fire the instant the drag clears the threshold
instead of on release. Waiting for the lift left the screen inert under a
finger that had already asked for the thing, which read as the gesture
being slow to take rather than as a deliberate commit. Paging still waits,
because there the gesture is genuinely undecided until you let go — you can
drag back and settle on a different month. Between expanded and collapsed
there is nothing to change your mind about.

The morph itself runs on the motion scheme's *fast* specs rather than the
page slide's default ones. Paging is a spring under one whole page and
wants room to settle; this answers a drag already committed to, and with a
hundred-odd pieces moving at once a long settle reads as lag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:48:03 +02:00
0df420e551 feat(month): collapse the expanded month on back (#53)
Expanding replaces the whole screen, so it is a place you can be, and every
other place in the app can be backed out of. Without this, back left the
Month view entirely and coming back found it still expanded.

Declared deeper than CalendarHost's view-stack handler, which is what makes
it take precedence while it is enabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:37:44 +02:00
488de490f9 feat(month): morph the split style's dots into the expanded grid (#53)
The expand cross-faded two unrelated grids, so a day's cell jumped from a
46dp row near the top of the screen to a full-height one halfway down and
the dots simply vanished where bars appeared. It read as a swap, not as the
month opening up.

Tag the pieces that mean the same thing on both sides and let Compose carry
them: each cell pill grows, its number rides along, and every dot travels
out to the bar it stood for — which only works because the dots are now
seated by lane, so each one has exactly one bar to become.

The tags travel as a composition local with a null default, so the paged,
continuous, and dense styles — which share these same row and cell
composables — provide nothing and pay nothing. Reduced motion provides
nothing either, leaving the plain cross-fade underneath.

A multi-day bar is anchored on the day it starts in its row; the dots on
the days it merely covers are left unmatched and fade where they stand
while the bar sweeps out over them. Only one of them could become the bar
without the others teleporting into it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:36:13 +02:00
f15fffa799 feat(month): let the split style pull open to the full month (#53)
Drag the compact grid down and the day pane gives way to the month drawn
the paged style's way — real bars and pills, not dots; drag up to bring the
pane back. Split could show you which days were busy, or one day in full,
but never the month's events at once without a trip to Settings.

The month swipe grows a second axis to do it, so the two are locked to one
decision per gesture: independent horizontal and vertical detectors would
each see their own component of a diagonal drag and both fire. Paging keeps
ties, so an ambiguous drag still reads as it did.

A tap in the expanded grid picks the day and drops back, which makes it a
chooser you dip into rather than a mode you can get stranded in — so the
grid needs the selection outline too, which MonthGrid now takes optionally.

Expansion is state, not a preference: persisted, someone would expand it
once and later find their Split style changed with nothing to explain why.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:30:37 +02:00