From fef2608a486406d72fd3464301ae96164f2ac510 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Fri, 21 Aug 2026 18:05:15 +1200 Subject: [PATCH 1/2] Give the three hero columns one width, and the label back the space it lost (#1502) Two reports, one cause: "REST" ellipsised to "R..." under the Today hero, and the three rings did not look the same size. Charge and Effort sized their columns to their own label rows. Rest alone sat in a Box pinned to the ring diameter, because that box anchors the card's source badge - "the badge may grow leftward, but its trailing edge always matches the Rest vessel". So the three columns came out different widths, which reads as rings of different sizes even though one `ring` value feeds all three vessels, and Rest's label had the LEAST room of the three despite REST being the shortest of the three words. That is the tell: if the labels were simply too long, CHARGE and EFFORT would break first. All three columns now take one shared `col`, derived from the same maxWidth the ring is. The badge keeps the vessel-aligned trailing edge it exists for by insetting exactly the slack the wider column introduces. The label was also paying for a counterweight. #937 wanted the WORD centred on the ring's axis rather than the word-plus-chevron block, and balanced the row with an invisible LEADING twin of the chevron - correct, but it spent a second 14.dp slot plus its gap on nothing. Inside Rest's ring-width box that left the text barely 50.dp on a compact screen, which is where "REST" ran out of room at a larger font scale. Centring in a Box gets the same axis for free: the column is symmetric, so a centred word sits on the ring's centre by construction and the chevron can float at the trailing edge instead of being counterweighted. The word reserves 16.dp each side so it can never run under the chevron at any label length. Net on a 360.dp screen the label goes from 50.8.dp to 68.0.dp, +34%; on a 432.dp screen from 66.0 to 92.0. REST at a large font scale fits with room to spare. The tap target grows to the full column and stays one target; RTL still mirrors (Box alignments and the AutoMirrored icon both flip); TalkBack loses nothing - the invisible twin was deliberately description-less, so nothing was announced. Android only; iOS lays the hero out separately and UI parity is feature-level. Full suite 4173 tests 0 failures; doc lint and i18n --ci clean. NOT visually verified. I cannot run the app, the arithmetic above is the whole argument, and the truncation is font-scale dependent - the reporter's device settings would confirm it. Worth a look on a device before trusting it. Reported by @mailingjash. --- .../src/main/java/com/noop/ui/TodayScreen.kt | 60 ++++++++++++------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index 653cbefa59..537d8585aa 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -2477,6 +2477,12 @@ private fun ScoreHeroRow( // enlarged centre, filling the width as one balanced row. Ring stroke 0.10 (WHOOP weight). val ringGap = 14.dp val ring = ((maxWidth - ringGap * 2) / 3.1f).coerceIn(90.dp, 112.dp) + // #1502: ONE width for all three columns. Charge and Effort used to size to their own label + // rows while Rest alone was pinned to `ring` (its box anchors the source badge), so the three + // columns came out different widths — read as "the rings aren't the same size" — and Rest's + // label had the least room of the three despite REST being the shortest word, which is why it + // was the one ellipsising to "R…". + val col = (maxWidth - ringGap * 2) / 3 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(ringGap, Alignment.CenterHorizontally), @@ -2485,6 +2491,7 @@ private fun ScoreHeroRow( // CHARGE, recovery 0–100, as a liquid VESSEL with the value counting up over it. Honest // empty / calibrating overlay; badges its recovery winner. HeroRingColumn( + modifier = Modifier.width(col), domain = DomainTheme.Charge, onInfo = { onScoreInfo(ScoreSection.CHARGE) }, onRingTap = onChargeTap, @@ -2523,7 +2530,11 @@ private fun ScoreHeroRow( } } // EFFORT, strain on the gauge, on the user's selected scale, as a liquid vessel. - HeroRingColumn(domain = DomainTheme.Effort, onInfo = { onScoreInfo(ScoreSection.EFFORT) }) { + HeroRingColumn( + modifier = Modifier.width(col), + domain = DomainTheme.Effort, + onInfo = { onScoreInfo(ScoreSection.EFFORT) }, + ) { Box(contentAlignment = Alignment.Center) { HeroScoreVessel( fraction = if (effortOutOf > 0) effortVal / effortOutOf else 0.0, @@ -2539,8 +2550,9 @@ private fun ScoreHeroRow( } // REST, sleep composite 0–100. Its fixed-width box also anchors the card-level source badge: // the badge may grow leftward, but its trailing edge always matches the Rest vessel. - Box(modifier = Modifier.width(ring)) { + Box(modifier = Modifier.width(col)) { HeroRingColumn( + modifier = Modifier.width(col), domain = DomainTheme.Rest, onInfo = { onScoreInfo(ScoreSection.REST) }, ) { @@ -2574,6 +2586,10 @@ private fun ScoreHeroRow( // Measure the full label even when it is wider than the Rest vessel, then // let it overflow left while preserving the vessel-aligned trailing edge. .wrapContentWidth(unbounded = true, align = Alignment.End) + // #1502: the box is now the shared column width rather than the vessel's, + // so inset by the slack to keep the badge's trailing edge on the VESSEL — + // the alignment this anchor exists for. + .padding(end = (col - ring) / 2) // Match iOS: centre the pill on the card border, aligned with the Rest vessel. .offset(y = -(Metrics.space16 + Metrics.sourceBadgeHeight / 2)) .semantics { contentDescription = uiString(R.string.l10n_today_screen_source_herosourcelabel_d3363687, heroSourceLabel) }, @@ -2592,6 +2608,7 @@ private fun ScoreHeroRow( */ @Composable private fun HeroRingColumn( + modifier: Modifier = Modifier, domain: DomainTheme, onInfo: () -> Unit, // A1: when non-null (Charge), the ring is tappable and opens the breakdown sheet. The chevron cue is @@ -2600,6 +2617,7 @@ private fun HeroRingColumn( ring: @Composable () -> Unit, ) { Column( + modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -2621,38 +2639,34 @@ private fun HeroRingColumn( } else { ring() } - Row( + // #937 wanted the WORD centred on the ring's axis, not the word-plus-chevron block, and got there + // by balancing the row with an invisible LEADING twin of the chevron. That worked, but it spent a + // second 14.dp slot plus its gap on nothing — and inside Rest's ring-width box that left the label + // barely 50.dp on a compact screen, so "REST" ellipsised to "R…" at a larger font scale (#1502). + // + // Centring in a Box gets the same axis for free: the column is symmetric, so a centred word sits on + // the ring's centre by construction, and the chevron floats at the trailing edge instead of paying + // for a counterweight. The word reserves 16.dp each side so it can never run under the chevron + // (which needs 14.dp), keeping the layout honest at any label length, and the whole box is the tap + // target. RTL still mirrors: Box alignments and the AutoMirrored icon both flip. + Box( modifier = Modifier + .fillMaxWidth() .clip(RoundedCornerShape(50)) .clickable { onInfo() } - .padding(horizontal = 6.dp, vertical = 2.dp), - horizontalArrangement = Arrangement.spacedBy(3.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(vertical = 2.dp), + contentAlignment = Alignment.Center, ) { - // #937 parity: an invisible LEADING twin of the trailing chevron. The word + chevron used to - // centre as ONE block, which sat the word visibly off the ring's axis (worst on short labels - // like REST). Balancing the row with a same-sized alpha-0 chevron re-centres the WORD itself - // under the ring while the real chevron stays on the trailing side. alpha(0f) keeps its layout - // slot, the clickable Row (the tap target) only ever grows, and the Row stays plain - // start-to-end content, no offset maths, so RTL mirrors identically (the icon is AutoMirrored - // anyway). Null description keeps it out of TalkBack: it is a spacer, not content. - Icon( - Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = Palette.textSecondary.copy(alpha = 0.6f), - modifier = Modifier - .size(14.dp) - .alpha(0f), - ) // #74: never wrap the hero label onto a second line — at a larger font/screen-zoom (Samsung // One UI defaults) "REST" could wrap, growing the whole hero card. One line, ellipsis if forced. Text(domain.label.uppercase(), style = NoopType.overline, color = Palette.textSecondary, - maxLines = 1, overflow = TextOverflow.Ellipsis) + maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp)) Icon( Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = uiString(R.string.l10n_today_screen_how_domain_label_is_calculated_8897768c, domain.label), tint = Palette.textSecondary.copy(alpha = 0.6f), - modifier = Modifier.size(14.dp), + modifier = Modifier.align(Alignment.CenterEnd).size(14.dp), ) } } From 02292a553f02260ee7071f25078ba5498dc164cd Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Fri, 21 Aug 2026 18:11:20 +1200 Subject: [PATCH 2/2] Don't let the ring outgrow its column on a narrow hero (#1502) The equal-column change had a crash in it. `col` is (maxWidth - 2*gap)/3 but `ring` carries a 90.dp floor, so once the hero is under about 298.dp wide the floor exceeds the column. The badge inset is (col - ring)/2, which then goes negative, and Modifier.padding throws on a negative value - taking out the Today screen rather than merely looking wrong. Reachable on a small phone, a split-screen pane, or a foldable's cover display: at 288.dp of hero width the inset is -1.7.dp. Ring is now capped at the column width, so a narrow hero shrinks its vessels with their columns instead of spilling out of them, and the inset is clamped at zero as a second line. Checked across 240-432.dp: the inset never goes negative, the ring never exceeds its column, and three columns plus two gaps still come to exactly maxWidth. Also moved `modifier` after the required parameters to match the rest of the file (TodayCardDismissButton and its neighbours); every call site names its arguments, so the order is free to fix. Full suite 4173 tests 0 failures; doc lint and i18n --ci clean. --- android/app/src/main/java/com/noop/ui/TodayScreen.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index 537d8585aa..620c6f305f 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -2476,13 +2476,16 @@ private fun ScoreHeroRow( // iOS parity (TodayView.scoreHeroRow): three EQUAL rings in CHARGE · EFFORT · REST order, no // enlarged centre, filling the width as one balanced row. Ring stroke 0.10 (WHOOP weight). val ringGap = 14.dp - val ring = ((maxWidth - ringGap * 2) / 3.1f).coerceIn(90.dp, 112.dp) // #1502: ONE width for all three columns. Charge and Effort used to size to their own label // rows while Rest alone was pinned to `ring` (its box anchors the source badge), so the three // columns came out different widths — read as "the rings aren't the same size" — and Rest's // label had the least room of the three despite REST being the shortest word, which is why it - // was the one ellipsising to "R…". + // was the one ellipsising to "R…". Three of these plus two gaps is exactly maxWidth. val col = (maxWidth - ringGap * 2) / 3 + // The 90.dp floor can exceed `col` once the hero is under ~298.dp wide — a small phone, a + // split-screen pane, a foldable's cover display. Unbounded, that overflowed the column; bounded, + // the vessel simply shrinks with its column instead of spilling out of it. + val ring = ((maxWidth - ringGap * 2) / 3.1f).coerceIn(90.dp, 112.dp).coerceAtMost(col) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(ringGap, Alignment.CenterHorizontally), @@ -2589,7 +2592,7 @@ private fun ScoreHeroRow( // #1502: the box is now the shared column width rather than the vessel's, // so inset by the slack to keep the badge's trailing edge on the VESSEL — // the alignment this anchor exists for. - .padding(end = (col - ring) / 2) + .padding(end = ((col - ring) / 2).coerceAtLeast(0.dp)) // Match iOS: centre the pill on the card border, aligned with the Rest vessel. .offset(y = -(Metrics.space16 + Metrics.sourceBadgeHeight / 2)) .semantics { contentDescription = uiString(R.string.l10n_today_screen_source_herosourcelabel_d3363687, heroSourceLabel) }, @@ -2608,9 +2611,9 @@ private fun ScoreHeroRow( */ @Composable private fun HeroRingColumn( - modifier: Modifier = Modifier, domain: DomainTheme, onInfo: () -> Unit, + modifier: Modifier = Modifier, // A1: when non-null (Charge), the ring is tappable and opens the breakdown sheet. The chevron cue is // overlaid by the caller INSIDE the ring box so it adds no stacked height (#762 self-sizing parity). onRingTap: (() -> Unit)? = null,