Commit Graph

333 Commits

Author SHA1 Message Date
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
1499b5803d fix(month): seat the split style's dots by lane (#53)
The dots gathered a day's *distinct colours*, which meant dot i stood for
no particular event and two events sharing a calendar collapsed into one —
undercounting the day.

Seat them off the same lane assignment the paged grid draws from, so a dot
and the bar in that lane are the same event. That fixes the count, and it
is what will let one morph into the other when the grid expands.

The cap is now MAX_EVENT_ROWS rather than a constant of its own: the two
have to agree or a dot would have no bar to become.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:26:57 +02:00
39cc70be95 fix(month): keep the split selection inside the month that owns it (#53)
The marker could still be caught fading in on the outgoing page. Paging changes
the selection immediately but the state only once the new month has loaded, so
until then the content key — the state's month — is unchanged and AnimatedContent
updates the page *in place*: the old grid really was told the next month's 1st
was selected, and it shows that date among its trailing days.

A page now marks only the days its own month owns. Selection always lands inside
the month it belongs to, so nothing legitimate is lost, and the phantom goes
without timing the fade against the page transition — a delay would have moved
it rather than removed it, and slowed selecting a day within a page besides.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:10:08 +02:00
f9619e74d9 style(month): settle the split style's selection and day pane (#53)
Three things still moved badly while paging.

The selection marker was thrown across the outgoing grid. `selected` was read
from outside the AnimatedContent, so both pages rendered the *incoming*
selection — and the new month's 1st is a day the old grid still shows among its
trailing days, so the outline jumped there (bottom right) before the new page
slid in and put it where it belonged (top left). The selection now travels with
the state, so each page keeps its own and the marker only ever appears where its
month has it.

Moving the selection within a month also jumped, outline blinking out of one
cell and into another. Each cell now fades its own outline, so it reads as one
mark crossing the grid. Snapped under reduced motion.

And the day pane swapped its rows outright, popping one list out and another in.
It now cross-fades, the incoming list rising as it arrives — the same entrance
the rest of the family uses for resolved content — with the skeleton handing
over the same way. Rows within a day also animate their placement, so an event
arriving from a sync nudges its neighbours instead of teleporting them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 19:04:08 +02:00
e691336b5e style(calendar): make the page transition a proper shared-axis (#38, #53)
Paging between months, weeks and days looked rough, and the transition was the
reason: a full-width slide with no fade at all. AnimatedContent stacks the two
pages and both were fully opaque, so what you saw was one grid racing across
another and clipping at the viewport edge — the movement was carrying the entire
transition, over the longest distance available.

It is now M3's shared-axis X. The offset only hints the direction — a fifth of
the width — while a cross-fade does the swapping, position springs and opacity
eases, and the size transform no longer clips. All three calendar views share
this transition, so all three settle.

Also lifts the month view's swipe threshold from 6dp to the 24dp the week and
day views already used. Six is inside the distance a tap wanders, so brushing
the grid turned the page — which reads as the animation firing at random rather
than as an over-eager gesture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:57:45 +02:00
4cee160c12 fix(month): steady the split style's paging (#53)
Two things made a swipe between months feel rough, both from the grid and the
pane disagreeing about what was happening.

The grid sized itself to its month, 4 to 6 rows, so every swipe shunted the pane
up or down by a row while the grid content swapped underneath with no transition
at all. It now always reserves six rows, padding a short month with blank ones —
which both holds the pane still and makes the paged style's directional slide
safe to use here, since there is no longer a height change to animate under it.

The pane also flashed "Nothing scheduled" mid-page. Paging moves the selection
to the new month before that month's events have arrived, and the pane was
handed `instancesByDay[selected].orEmpty()` — so a day that simply wasn't loaded
yet was indistinguishable from a day with nothing on it, and got reported as
free. It now takes a nullable list and shows skeleton rows for the gap, saying
"still looking" rather than making a claim about the day.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:52:32 +02:00
a7ae8ff912 style(month): let the outline alone mark the split style's selected day (#53)
The selected cell also swapped its fill to primaryContainer, which lightened the
whole cell — a second state stacked on the day rather than a mark on it, and one
that fought today's circle when the two landed together. The outline and the day
number carry it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:46:18 +02:00
d23a1d5b6d style(month): drop the split style's divider (#53)
The rule between the compact grid and the day pane earns its place in the
Continuous header, where it closes a section off; here it only draws a line
between two halves the layout already separates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:45:32 +02:00
b417900ddd perf(month): bucket events by day before laying out the scrolling grids (#38)
Opening Continuous stayed slow, and the slowness tracked the number of enabled
calendars rather than the window size — which pointed past the provider query to
the layout behind it.

layoutCalendarWeek opens by filtering the instances it is handed down to the
ones touching its seven days. For a single month's grid that is nothing; for the
scrolling styles it was a scan of every instance in an eleven-month window,
repeated for each of a hundred-odd rows, with a time-zone conversion per check.
The work grew with events × rows, so switching every calendar on multiplied it.

- DayIndex buckets the window's events by the dates they cover, once, and each
  row takes the handful on its own seven days. Membership is decided by
  coversDay itself rather than re-derived from the timestamps — the all-day and
  timed cases have enough edge cases between them (UTC anchoring, exclusive
  ends, zero-length events at midnight) that a second implementation would
  drift. DayIndexTest pins row-for-row equality with the old full scan, in a
  zone east of UTC.
- Only the style on screen is laid out. Adding Dense had quietly doubled the
  work, since both layouts were built from every load and one was always thrown
  away.
- The paged flow no longer queries under a scrolling style, mirroring the gate
  the scrolling flow already had.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:41:03 +02:00
95f4d28bbb perf(month): open the scrolling styles on a small window and grow it (#38)
Opening Continuous made the first frame wait for nine months of recurrence
expansion when only one was about to be looked at — a provider Instances query
expands across its whole range, so the window's generosity was paid for up
front, every time.

The window now starts at one month either side of the visible one — about what
the paged style costs — and each completed load reaches two months further in
both directions until it spans eleven. The months a scroll can reach arrive
while the first one is already on screen, and because a widening is triggered by
the previous load landing rather than by a timer, the ladder can never outrun
the provider.

- The paged and split styles no longer run this query at all. The screen
  collects the flow whatever the style is set to, so until now every Month view
  opening paid for a window it would never draw.
- The reload trigger is derived from the current pad instead of being fixed, and
  is held strictly inside it: a trigger at or beyond the pad would re-fire the
  moment its own reload landed.
- The scrolling styles get their own skeleton — the layout they are about to
  become, at the same measurements, so arriving months replace it in place. It
  and the per-month placeholders now breathe, so a slow load reads as work
  rather than as an empty grid. Held still under reduced motion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:23:06 +02:00
24b005126e feat(month): add the Dense style, rule under the Continuous header (#38)
Continuous's month blocks make the boundaries legible, but the seams are exactly
what someone who wants a dense calendar doesn't want. Both are now offered:

- **Dense** is the old flowing layout, kept rather than replaced — one
  uninterrupted stream of weeks with months running into each other, the 1st
  naming its month, and the top bar keeping the full month title (there is no
  sticky header to defer to).
- Continuous gains a **rule under its month label**, closing the header off
  against the grid. On trial: it comes out again if it doesn't earn its place.

One provider query serves both. `ContinuousMonthUiState.Success` now carries the
same loaded window laid out twice — `monthsByIndex` clipped into blocks,
`weeksByIndex` left whole — rather than standing up a second flow and querying
the same range again. Both styles report *months* as they scroll, so the window
hysteresis is shared; `weekWindowFor` maps a month window onto the Dense rows it
covers, widened a week each side so boundary rows don't flicker.

The list state is keyed on style *and* week start: Dense indexes by week, which
the week-start preference moves, while Continuous indexes by month, which it
doesn't — carrying an offset between the two would land somewhere arbitrary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:10:54 +02:00
ee9d65068d fix(month): don't drag the continuous window back to 1900 (#38)
The continuous grid failed outright with "Could not read the calendar" the
moment it opened.

Before the first layout pass LazyListState reports no visible items, and the
screen defaulted that to item 0 — claiming January 1900 was on screen. The load
window widened around it, its pad ran past the epoch to index -4, and
yearMonthForIndex indexed Month.entries out of bounds; the flow's catch turned
the crash into the generic provider failure. Week indexing never showed this:
index 0 was a real week, so a bogus report only cost one wasted query.

- The visible-range report is skipped entirely while the list has no items,
  rather than standing in a default for them.
- clampMonthWindow holds any window inside 1900–2100, so a pad at either end
  can't produce an index with no month behind it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 17:59:53 +02:00
6e3d10858f refactor(month): make the continuous style a stack of month blocks (#38)
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>
2026-07-20 17:54:26 +02:00
94a82c2f6c refactor(settings): make the month style picker a live preview (#38, #53)
The first version of this picker invented its own visual language — free-
standing bordered cards with a hand-rolled check — which matched nothing else
in the app. It is now the family's standard shape: FullScreenPicker with
PickerDescription and connected GroupedRows, tonal highlight and the shared
SelectedCheck.

Above the rows sits a live, scaled-down Month view that changes as you pick a
style. It renders the *real* grid composables rather than a drawing of them, so
the preview cannot drift from what it depicts: MonthGrid, ContinuousMonthGrid,
SplitMonthGrid and SplitDayPane are now internal rather than private, and the
sample month runs through the same layoutMonthWeeks/layoutCalendarWeek the live
views use. The month, today's position, week start, colour softening and clock
format are all real; only the events are stand-ins, since a settings screen has
no business querying the provider for a thumbnail.

Selecting applies immediately and leaves the picker open — closing on tap would
hide the very thing the screen is for. Back exits, as in the App name picker.

Scaling note worth keeping: Modifier.requiredSize looks like the way to force a
full-viewport measurement, but it *centres* content that overflows the incoming
constraints, which left only the grid's bottom-right corner inside the clip. A
layout modifier that measures at Constraints.fixed and reports the scaled size
has no overflow to align and no dependence on parent alignment.

PickerDescription is internal now rather than duplicated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 17:34:16 +02:00
becb9a6710 feat(month): split style with a day pane (#53)
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>
2026-07-20 17:00:22 +02:00
542744e342 feat(month): continuous vertical scrolling (#38)
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>
2026-07-20 16:54:41 +02:00
7510e1f9af feat(settings): add the month view style picker (#38, #53)
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>
2026-07-20 16:45:42 +02:00
498250650c refactor(month): extract per-week layout and share the agenda's rows
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>
2026-07-20 16:36:57 +02:00
cd6ae31d0f Merge remote-tracking branch 'origin/release/v2.16.0' into feat/recurrence-exdate
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 11m20s
# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
2026-07-20 15:24:05 +02:00
463811056d refactor(ui): one selection check and one motion spec across the pickers
All checks were successful
Translations / check (pull_request) Successful in 6s
CI / ci (pull_request) Successful in 5m57s
Three inconsistencies the recurrence-picker review surfaced, all of the
same kind: the redesigned picker had quietly diverged from the family.

SelectedCheck existed as five copies app-side plus a private one in the
kit, and they had drifted — the kit drew Icons.Rounded.Check, every app
copy drew Icons.Default.Check. Delete all five against the kit's now
public primitive (floret-kit!2), which settles the glyph on Rounded.

The recurrence picker was the only full-screen picker whose selected row
carried no check, relying on the tonal highlight alone. Add it to all six
rows, matching OptionPicker, reminder, agenda range, timezone, calendar
and Settings.

Its two new AnimatedVisibility blocks were the only ones in the app using
Compose's bare defaults rather than expandEnter()/collapseExit(). Those
helpers honour rememberReduceMotion(), so the weekday and count cards
were ignoring the system "remove animations" setting — an accessibility
regression, not just a style drift.

Bumps the floret-kit pointer; needs floret-kit!2 merged first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 15:20:37 +02:00
98d76deee5 fix(edit): correct the recurrence picker's read-out and end handling
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>
2026-07-20 15:20:15 +02:00
20e18768eb Merge pull request 'fix(widget): scale the agenda widget with its size (#51)' (!88) from fix/agenda-widget-size-scaling into release/v2.16.0
Reviewed-on: #88
2026-07-20 13:11:17 +00:00
a34f29bcf8 fix(widget): address review of the agenda size scaling (#51)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m52s
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>
2026-07-20 14:47:37 +02:00
94355bf340 fix: address code-review findings on the 2.16.0 branch
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m43s
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>
2026-07-20 13:10:39 +02:00
722fc87259 fix(edit): honour week-start pref in recurrence picker + smooth weekday collapse
- The weekday toggles now order by the app's "Week starts on" setting
  (via WeekStartPref.resolveFirstDay), matching the month/week/agenda
  views, instead of always following the device locale. Threaded through
  a new EventEditViewModel.firstDayOfWeek flow.
- Carry the 16dp section gaps on each block rather than a parent
  spacedBy, so the weekday card's gap collapses together with the card
  under AnimatedVisibility — removing the end-of-animation jump in the
  ends section when switching away from Weekly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 13:04:57 +02:00
30fcbfa59f fix(widget): scale the agenda widget with its size (#51)
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>
2026-07-20 12:49:19 +02:00
7d357bad87 feat(settings): App name picker shows both names as preview cards
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 6m10s
Replace the single current-state hero + option rows with two selectable
launcher-mark cards (Calendula / Calendar), so the user sees what each choice
would look like, not just the current name. Tapping a card applies immediately
and highlights it (primary border + tinted container + check); the picker stays
open so the change is visible, and back exits — which also fixes the earlier
"hero doesn't update until reopened" gap.

Built on FullScreenPicker directly (the component OptionPicker wraps) to render
the custom card row; material-3 tokens throughout (surfaceContainerHigh /
primary / primaryContainer / outlineVariant, 24dp corners, 8dp-grid spacing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:09:10 +02:00
3ffe76412e feat(settings): launcher-mark hero on the App name picker
Add a preview hero to the App name picker header: the app's launcher mark over
the current name on a tonal surface card, the explanatory hint below it, then
generous space before the choices. Gives the picker the visual "display" the
inline hint lacked and separates the text from the options.

Follows the material-3 guidance — tonal surfaceContainerHigh card (no shadow),
28dp corner, 8dp-grid spacing, onSurface/onSurfaceVariant role pairing — and
reuses the onboarding BrandHero's squircle reconstruction of the adaptive icon
so it renders identically everywhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:05:14 +02:00
91c4a84818 refactor(settings): app-name as a full-screen chooser, not a switch
Replace the inline App name switch with a GroupedRow that opens a full-screen
OptionPicker (Calendula / Calendar), matching the app's other "choose one"
settings and the ReFra pattern the user preferred. The row shows the current
name; the picker's header carries the explanatory + icon-may-move hint. Leaves
room for more launcher names later without redesigning the row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 16:57:28 +02:00
ccd433fee1 refactor(edit): redesign the custom recurrence picker (#42)
Rebuild the custom step of the recurrence picker so complex-but-supported
rules (e.g. "every 2 weeks on Mon+Tue") are discoverable and legible,
rather than buried in a ragged stack of mismatched controls.

- Add a live, human-readable summary of the rule as it is built, via the
  existing recurrenceText humanizer.
- Group interval + frequency into one tonal card; the frequency is now a
  SingleChoiceSegmentedButtonRow (all four units visible) instead of a
  dropdown.
- Reveal the weekday picks with AnimatedVisibility and house them, the
  "every" controls, and the ends group in consistent 16dp-inset grouped
  cards; the count field folds into the bottom of the ends run.

No behaviour, domain, or RRULE changes: parse/render logic and the set of
expressible rules are unchanged. The EXDATE half of #42 is deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 16:56:55 +02:00
03f6b7c8c1 feat(settings): optional "Calendar" launcher name (#44)
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>
2026-07-19 16:39:08 +02:00
4148196a36 fix(agenda): stop yesterday's all-day event surfacing under today (#65)
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 5m53s
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>
2026-07-19 16:05:36 +02:00
9a1d753abc Merge pull request 'feat(views): optional jump-to-today button in the toolbar (#60)' (!84) from feat/today-button-in-toolbar into release/v2.16.0
Reviewed-on: #84
2026-07-19 10:26:09 +00:00
85d72ad051 refactor(views): extract shared TodayAction top-bar button
Collapse the today icon-button block that was copy-pasted into all four
calendar top bars (Week/Month/Day/Agenda) into one TodayAction composable in
ui/common. No behaviour change.

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