Skip to content

today: let the Recovery Vitals rows open their trends, like the cards do (Android + Apple) - #1902

Merged
ryanbr merged 3 commits into
mainfrom
fix/recovery-vitals-rows-tappable
Sep 5, 2026
Merged

ryanbr merged 3 commits into
mainfrom
fix/recovery-vitals-rows-tappable

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 5, 2026 •

Copy link
Copy Markdown
Owner

From a screenshot: HRV 31 ms, Resting HR 62 bpm and Breaths 13.3 rpm in the
RECOVERY VITALS card, none of them tappable, while every neighbouring card in the
same scroll - Session, Journal, Data sources - carries a chevron and opens
something.

What was actually wrong

The three vitals already have trends. The HRV / Resting HR / Respiratory
dashboard cards open vital_detail/hrv|rhr|resp, the route is registered in
AppRoot.kt, and VitalDetailScreen renders it. The RECOVERY VITALS summary
card shows the same three metrics and had no way through - the one place on Today
where a metric was a dead end.

Worth being precise, because the report described it as a regression: nothing
removed a handler.
These rows never had one. The card mirrors the iOS
recoveryVitalsSection, which does not have one either. What makes the
expectation reasonable anyway is that the same three metrics ARE tappable as
cards, so the app teaches you they open, then gives you a copy that doesn't.

The keys are sourced, not repeated

The rows take their keys from dashboardCardMetricKey via a small vitalRowKey
helper rather than writing "hrv"/"rhr"/"resp" again. A literal here could
send the row and the card to different trends for the same vital and nothing
would catch it - the same "one value, two places" shape behind today's read-cap
bug. Sourcing it makes drift impossible rather than merely tested; the tests pin
it regardless, including that the three cards always resolve so the
requireNotNull cannot fire in the field.

The chevron

Added only when a row goes somewhere. dashboardCardDestination already states
the rule for cards - every one resolves to a destination, so the chevron is
always honest (#706/#684) - and this was its inverse: three rows that could have
gone somewhere and did not say so.

Verification

  • Android 5316 unit tests, 0 failures; 3 new; doc-comment lint clean.
  • No new strings, so no i18n or release-lint surface.

Not verified on a device. The tap target, the chevron's placement in the row,
and the trend screens opening with real data are all things only a build shows.

Both platforms

Android and Apple, because both carried the identical dead end from the identical
defaultSelection. On Apple that meant BOTH live Today views - LiquidTodayView
and TodayView are chosen by liquidTodayEnabled and neither is legacy. They use
different row builders (vitalRow with spacing, metricRow with dividers), so
each got the change in its own idiom.

Each builder takes an OPTIONAL route and renders exactly what shipped when it is
nil, so a row that cannot navigate gains neither a link nor a chevron.
LiquidPressStyle is not decoration: a bare NavigationLink applies default link
chrome and would tint the whole row, which is why cardLink already carries it.

Correcting two things I said earlier in this PR.

I claimed the Apple view had "no metric-detail navigation in reach". Wrong -
cardLink is in the same file and is a plain NavigationLink(value:). I had
grepped for the wrong name and concluded the mechanism was missing rather than
differently spelled. The real obstacle was the two Today views, which is smaller
than what I reported.

I also justified the optional route by saying it left "the three other
metricRow callers untouched". There are none. metricRow has exactly three
call sites and all three are these vitals rows; my count had included the
definition line. The optional route still earns its place - it keeps a
non-navigating row honest and mirrors the Android twin, where the key genuinely
can be null - but not for the reason I gave.

Routes are each platform's own: hrv / rhr / resp_rate on Apple, hrv /
rhr / resp on Android. Respiratory differing is correct, not drift - these
are internal route keys, not a shared contract, and mirroring Android's would
push to a destination Apple does not have.

What is verified, and what is not

Android: 5316 tests, 0 failures, 3 new; doc-comment lint clean; no new strings.

Apple: nothing has been compiled or seen. Both files parse, and every name
relied on already resolves in the file using it - TabRoute.metric exists,
LiquidPressStyle is already used in TodayView, and
navigationDestination(for: TabRoute.self) is registered centrally with
TodayView already using NavigationLink(value:) elsewhere. That last check
mattered most: a link with no matching destination silently does nothing, which
would have reproduced the exact bug this fixes.

Still unseen on any device, on either platform: the tap targets, the chevrons'
placement, and whether the press style reads correctly on a row inside a frosted
card. The Android touch target is about 26dp against a 48dp guideline - see the
comment below.

From a screenshot: HRV 31 ms, Resting HR 62 bpm and Breaths 13.3 rpm sitting in
the RECOVERY VITALS card, none of them tappable, while every neighbouring card in
the same scroll - Session, Journal, Data sources - carries a chevron and opens
something.

The three vitals already HAVE trends. The HRV / Resting HR / Respiratory
dashboard cards open `vital_detail/hrv|rhr|resp`, the route is registered and
VitalDetailScreen renders it. The summary card shows the same three metrics and
simply had no way through, so it was the one place on Today where a metric was a
dead end. Nothing removed a handler - these rows never had one; the card was
built to mirror the iOS section, which does not have one either.

The rows take their keys from `dashboardCardMetricKey` rather than repeating the
strings. A literal here could send the row and the card to different trends for
the same vital with nothing to catch it, which is the same "one value, two
places" shape that caused today's read-cap bug; sourcing it makes drift
impossible rather than merely tested. The tests pin both halves anyway - that the
keys match the cards', and that the three cards always resolve, so the
requireNotNull cannot fire in the field.

The chevron appears only when a row goes somewhere. `dashboardCardDestination`
already states that rule for cards - every one resolves to a destination, so the
chevron is always honest - and this was its inverse: three rows that could have
gone somewhere and did not say so.

Android only. The iOS `recoveryVitalsSection` has the identical dead end, but
that view has no metric-detail navigation in reach, so wiring it is a different
change and not one I can verify.
The first draft resolved each row's key through a helper that asserted the key
was present. That turns a missing destination into a crash on tap - the worst
possible trade for a navigation convenience, and on the one surface a user
reaches for when a number looks wrong.

The row now takes a nullable key and loses the tap AND the chevron together when
it is absent, which is also the honest rendering: a row that cannot go anywhere
should not claim it can. The degradation is not hypothetical either - cards with
their own screen (Sleep, Stress, Coach) return null by design, and a future vital
row pointed at one would have hit the assert.

Drift is still impossible: the key is passed straight from
`dashboardCardMetricKey`, so the row and its dashboard-card twin cannot open
different trends for the same vital. The tests now pin the null path too, so the
degradation is covered rather than assumed.
@ryanbr

ryanbr commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Two things a second read turned up that I deliberately have NOT changed, because
both are visual and I cannot see this on a device. Recording them so they are
decisions rather than oversights.

1. The rows will ripple; almost nothing else on this screen does.

17 of the 23 clickable uses in TodayScreen.kt pass indication = null and
supply a liquidPress animation instead - the screen deliberately avoids the
Material ripple. Mine is a plain Modifier.clickable, so it gets the default.

I left it that way on purpose, and the reasoning cuts both ways. liquidPress is
built for whole cards, where pressing scales the card; applying it to one row
inside a frosted card is not obviously right and I cannot check how it looks.
There is also precedent for a plain clickable on this screen (the info
affordance). And the specific complaint that started this was "nothing happens" -
tap feedback has some value here beyond consistency.

But it IS the minority convention, and if the ripple looks wrong against the
frosted surface, swapping to the interactionSource + indication = null +
liquidPress pattern is a small change.

2. The touch target is about 26dp tall, against Android's 48dp guideline.

The row's height is driven by the 26dp liquid vessel; there is no explicit
minimum. Full width makes it easy to hit horizontally, and the 12dp gaps mean a
mis-tap usually lands on an adjacent vital rather than nothing - but it is under
guideline, and a small target is exactly what makes a fix still feel broken.

Raising it changes the card's height (three rows, so roughly 50dp taller unless
the inter-row spacing absorbs it), which is a design change I would rather have
looked at than assumed. Modifier.clickable{}.padding(vertical = …) expands the
hit area outward, and dropping spacedBy by the same amount would keep the card
the same height - but that is exactly the sort of trade worth seeing before
committing to.

Worth checking both on the next testing build.

The iOS card had the identical dead end: three vitals that are tappable as
dashboard cards and inert as rows, from the same defaultSelection Android ships.
Leaving it meant the same report arriving from an iPhone.

BOTH live Today views, because both carry it - LiquidTodayView and TodayView are
chosen by liquidTodayEnabled and neither is legacy. They use different row
builders (vitalRow with spacing, metricRow with dividers), so each gets the
change in its own idiom rather than one being bent to the other's shape.

Each row builder takes an OPTIONAL route and renders exactly what shipped before
when it is nil - the three other metricRow callers are untouched, and a row that
cannot navigate gains neither a link nor a chevron. LiquidPressStyle is not
decoration: a bare NavigationLink applies the default link chrome and would tint
the whole row, which is why cardLink already carries it.

Routes are the iOS ones, taken from liquidCard's own cases - hrv, rhr and
resp_rate. Respiratory is deliberately NOT Android's "resp": these are each
platform's own route keys, not a shared contract, and mirroring Android's would
push to a destination that does not exist here.

Correcting the record: the earlier claim that this view had no metric-detail
navigation in reach was wrong. cardLink sits in the same file and is a plain
NavigationLink; I had grepped for the wrong name and concluded the mechanism was
absent rather than differently spelled. What actually made it non-trivial was the
two views, which is a smaller obstacle than the one I reported.

Verified only as far as this machine allows: both files parse, doc-comment lint
and the i18n gate are clean, and every name relied on already resolves in the
files that now use it - TabRoute.metric, LiquidPressStyle, and a
navigationDestination(for: TabRoute.self) registered centrally, with TodayView
already using NavigationLink(value:) at another call site. Nothing here has been
compiled or seen.
@ryanbr ryanbr changed the title today: let the Recovery Vitals rows open their trends, like the cards do today: let the Recovery Vitals rows open their trends, like the cards do (Android + Apple) Sep 5, 2026
@ryanbr
ryanbr merged commit 2a06c24 into main Sep 5, 2026
6 checks passed
@ryanbr
ryanbr deleted the fix/recovery-vitals-rows-tappable branch September 5, 2026 23:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant