From e82c8bb7b4ebe5dd2d23c33085e0c53997b71b45 Mon Sep 17 00:00:00 2001 From: uvtqt Date: Fri, 13 Nov 2026 19:13:58 +0100 Subject: [PATCH 1/7] Cycle: let the day-in-cycle card wrap instead of overflowing The card's headline row put the cycle day and "counted from your last logged start" side by side with nothing allowed to wrap, and its header put the label before a Spacer and the phase pill. At a 390 pt phone width the headline overflowed at default size in the test font, and both rows overflowed at large system text (iOS reaches 3.1x). The caption is Flexible now, and the header is a Wrap: label and pill side by side when they fit, the pill on its own line when not. Co-Authored-By: Claude Opus 5.5 --- lib/ui2/screens/cycle_screen.dart | 30 +++++++---- test/cycle_day_row_overflow_test.dart | 71 +++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 test/cycle_day_row_overflow_test.dart diff --git a/lib/ui2/screens/cycle_screen.dart b/lib/ui2/screens/cycle_screen.dart index f9f18cdfe..cf29376f1 100644 --- a/lib/ui2/screens/cycle_screen.dart +++ b/lib/ui2/screens/cycle_screen.dart @@ -479,13 +479,20 @@ class _CycleTabState extends State with RevisionReload { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + // A Wrap, not a Row with a Spacer: at large text sizes or in a + // longer language the label and the phase pill do not fit one + // line, and the row overflowed. Side by side when they fit, the + // pill on its own line when they don't. + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: S.x2, + runSpacing: S.x2, children: [ Text( l?.cycleDayInThisCycle ?? 'DAY IN THIS CYCLE', style: F.over.copyWith(color: p.ink3), ), - const Spacer(), if (d.phase != 'unknown') Pill(_phaseLabel(l, d.phase), C.pink), ], ), @@ -496,13 +503,18 @@ class _CycleTabState extends State with RevisionReload { children: [ Text('${d.cycleDay}', style: F.n34.copyWith(color: p.ink)), const SizedBox(width: S.x2), - Text( - d.medianLength == null - ? (l?.cycleCountedFromLastStart ?? - 'counted from your last logged start') - : (l?.cycleOfAboutDays(d.medianLength!.round()) ?? - 'of about ${d.medianLength!.round()}'), - style: F.cap.copyWith(color: p.ink3), + // Wraps under itself rather than running off the card: + // "counted from your last logged start" is most of a line at + // the default size already. + Flexible( + child: Text( + d.medianLength == null + ? (l?.cycleCountedFromLastStart ?? + 'counted from your last logged start') + : (l?.cycleOfAboutDays(d.medianLength!.round()) ?? + 'of about ${d.medianLength!.round()}'), + style: F.cap.copyWith(color: p.ink3), + ), ), ], ), diff --git a/test/cycle_day_row_overflow_test.dart b/test/cycle_day_row_overflow_test.dart new file mode 100644 index 000000000..bcaf2ea35 --- /dev/null +++ b/test/cycle_day_row_overflow_test.dart @@ -0,0 +1,71 @@ +// The cycle screen's "day in this cycle" card must fit a phone at any text +// size. +// +// The headline row put the day number and "counted from your last logged +// start" side by side with nothing allowed to wrap, and the header row put +// its label before a Spacer and the phase pill. At a 390 pt width that +// overflowed by 147 pt in the test font, and by less, but still, with larger +// system text (iOS goes to 3.1x) or a longer language. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:openstrap_edge/data/local_repository.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/screens/cycle_screen.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +class _Repo extends LocalRepository { + _Repo({this.median}); + final num? median; + + @override + Future> getCycle() async => { + 'enabled': true, + 'phase': 'follicular', + 'cycle_day': 5, + 'median_length': ?median, + 'logs': [ + {'date': '2026-11-09', 'kind': 'start'}, + ], + }; + + @override + Future>> getCycleSymptoms() async => const {}; +} + +Future _pump(WidgetTester t, {num? median, double scale = 1}) async { + t.view.physicalSize = const Size(390 * 3, 4000 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final app = AppState.forTesting()..repo = _Repo(median: median); + addTearDown(app.dispose); + await t.pumpWidget(ChangeNotifierProvider.value( + value: app, + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: MaterialApp( + theme: buildTheme(Brightness.light), + home: const Scaffold(body: CycleTab()), + ), + ), + )); + await t.pumpAndSettle(); +} + +void main() { + for (final scale in const [1.0, 3.1]) { + testWidgets('no median yet, ${scale}x text', (t) async { + // The pump is the assertion: a RenderFlex overflow fails it. + await _pump(t, scale: scale); + expect(find.textContaining('counted from your last logged start'), + findsOneWidget); + }); + + testWidgets('with a median, ${scale}x text', (t) async { + await _pump(t, median: 29, scale: scale); + expect(find.textContaining('of about 29'), findsOneWidget); + }); + } +} From 0dab1013c671ad05b366fc3a847cc5570a4dd720 Mon Sep 17 00:00:00 2001 From: uvtqt Date: Fri, 13 Nov 2026 19:29:37 +0100 Subject: [PATCH 2/7] Sweep whole screens at large text, and fix the three that overflowed The component sweep covers the gallery, and no screen is in it. A new sweep renders seven screens from preloaded data at 390 pt with the bundled font, in English and German, at 1x, 2x and 3.1x text. On top of the cycle-day card (previous commit) it found: - Readiness: the score and band label inside the fixed 150 pt ring were 260 pt taller than the ring at 3.1x. They now scale down to fit it. - Heart-rate zones: the bpm range overflowed its row at 3.1x. At large text it may now wrap; at 1x the row is unchanged. - MetricRow: at large text the value gets its own line but still could not wrap, so a value that is a phrase (the cycle "What applies to you" row, in German) overflowed. It wraps there now; the one-line layout is unchanged. Co-Authored-By: Claude Opus 5.5 --- lib/ui2/activity/zones.dart | 35 +++- lib/ui2/grammar.dart | 29 ++-- lib/ui2/screens/readiness_detail.dart | 23 ++- test/ui2_screen_sweep_test.dart | 224 ++++++++++++++++++++++++++ 4 files changed, 286 insertions(+), 25 deletions(-) create mode 100644 test/ui2_screen_sweep_test.dart diff --git a/lib/ui2/activity/zones.dart b/lib/ui2/activity/zones.dart index b268061f4..d1df43ac8 100644 --- a/lib/ui2/activity/zones.dart +++ b/lib/ui2/activity/zones.dart @@ -470,15 +470,15 @@ class _ZonesDetailState extends State { ], ), ), - Text( - // One beat below the next edge, as [ZoneRows.range] prints - // it, so no bpm sits in two zones. - z.zone == 5 ? '${z.lo}+' : '${z.lo}–${z.hi > z.lo ? z.hi - 1 : z.hi}', - style: F.n17.copyWith(color: p.ink2), - ), const SizedBox(width: S.x2), - Text(l?.activityZonesBpmUnit ?? 'bpm', - style: F.cap.copyWith(color: p.ink3)), + // At large text the range may wrap (after its dash, or + // before the unit) instead of overflowing the row. Only + // there: flexed at 1x it would take half the row from the + // zone name. One Text.rich so the unit stays with its number. + if (bigText(context)) + Flexible(child: _range(z, p, l)) + else + _range(z, p, l), ], ), ], @@ -492,6 +492,25 @@ class _ZonesDetailState extends State { ); } + /// One zone's bpm range with its unit, as one run of text. + Widget _range(ZoneRow z, P p, AppLocalizations? l) => Text.rich( + TextSpan(children: [ + TextSpan( + // One beat below the next edge, as [ZoneRows.range] prints it, so + // no bpm sits in two zones. + text: z.zone == 5 + ? '${z.lo}+' + : '${z.lo}–${z.hi > z.lo ? z.hi - 1 : z.hi}', + style: F.n17.copyWith(color: p.ink2), + ), + TextSpan( + text: ' ${l?.activityZonesBpmUnit ?? 'bpm'}', + style: F.cap.copyWith(color: p.ink3), + ), + ]), + textAlign: TextAlign.end, + ); + /// WHERE THESE EDGES CAME FROM — the whole point of the screen. Three /// different claims, three different sentences, never a shared hedge. String _anchorCopy(AppLocalizations? l, ZonesData d) { diff --git a/lib/ui2/grammar.dart b/lib/ui2/grammar.dart index f4f96f4ca..e134e7249 100644 --- a/lib/ui2/grammar.dart +++ b/lib/ui2/grammar.dart @@ -1808,21 +1808,28 @@ class MetricRow extends StatelessWidget { if (sub.isNotEmpty) Text(sub, style: F.over.copyWith(color: p.ink3)), ], ); + final big = bigText(c); + final valueText = Text( + value, + style: F.body.copyWith(color: p.ink, fontWeight: FontWeight.w600), + // At large text the value has a line of its own (see below), and a + // value that is a WORD ('What applies to you' → a phrase) can still be + // wider than that line. It wraps there rather than overflowing the row. + maxLines: big ? null : 1, + overflow: big ? null : TextOverflow.ellipsis, + ); final amount = Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ - // NOT Flexible. Sharing the row's flex with the name gave the value - // a quarter of the width and no more, so 'Respiratory rate' shipped - // reading `1… br/min` — a truncated measurement, which this row is - // supposed to never do. It is the name that gives way now. - Text( - value, - style: F.body.copyWith(color: p.ink, fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + // NOT Flexible on the one-line row. Sharing the row's flex with the + // name gave the value a quarter of the width and no more, so + // 'Respiratory rate' shipped reading `1… br/min` — a truncated + // measurement, which this row is supposed to never do. It is the name + // that gives way there. On the large-text layout the value is alone + // on its line, so flexing there only lets a too-long one wrap. + if (big) Flexible(child: valueText) else valueText, if (unit.isNotEmpty) ...[ const SizedBox(width: 2), Text(displayUnit, style: F.over.copyWith(color: p.ink3)), @@ -1871,7 +1878,7 @@ class MetricRow extends StatelessWidget { .trim(), child: Padding( padding: const EdgeInsets.symmetric(vertical: S.x2), - child: bigText(c) + child: big // A dense row cannot stay one line at accessibility sizes without // truncating the measurement, and a truncated measurement is worse // than a taller row. diff --git a/lib/ui2/screens/readiness_detail.dart b/lib/ui2/screens/readiness_detail.dart index 2c1140962..46cece992 100644 --- a/lib/ui2/screens/readiness_detail.dart +++ b/lib/ui2/screens/readiness_detail.dart @@ -297,12 +297,23 @@ class _ReadinessDetailState extends State { p.track, stroke: 14, t: animate(c, 1)), ), - Column(mainAxisSize: MainAxisSize.min, children: [ - Text('${v.round()}', - style: F.n48.copyWith( - color: d.provisional ? p.ink3 : p.ink)), - Text(band.label, style: F.cap.copyWith(color: p.ink3)), - ]), + // The ring is a fixed 150 pt; at large text the score and + // its band label were 260 pt taller than its inside and ran + // out of it. They scale down to the ring's inner width + // instead (stroke 14 each side, plus breathing room). + Padding( + padding: const EdgeInsets.all(22), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Text('${v.round()}', + style: F.n48.copyWith( + color: d.provisional ? p.ink3 : p.ink)), + Text(band.label, + style: F.cap.copyWith(color: p.ink3)), + ]), + ), + ), ]), ), if (recoveryStateLine(d.head, l) case final note?) ...[ diff --git a/test/ui2_screen_sweep_test.dart b/test/ui2_screen_sweep_test.dart new file mode 100644 index 000000000..869c7d0d7 --- /dev/null +++ b/test/ui2_screen_sweep_test.dart @@ -0,0 +1,224 @@ +// Whole screens at phone width, at every text size a phone reaches. +// +// `ui2_component_sweep_test.dart` sweeps the gallery's COMPONENTS. No screen is +// in the gallery, and the cards a screen builds for itself were never swept: +// this sweep found four that overflowed at large text on a 390 pt phone +// (readiness ring, zone rows, the cycle-day card, a MetricRow holding a +// word), one of them in German already at 2x. +// +// The bundled type is loaded so text measures as on a phone, and German runs +// beside English because its words are the long ones. A screen added here is +// swept from then on; each takes its own preloaded data, so nothing touches +// the database. + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/models/metric.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/ui2/activity/day_strain.dart'; +import 'package:openstrap_edge/ui2/activity/zones.dart'; +import 'package:openstrap_edge/ui2/screens/cycle_screen.dart'; +import 'package:openstrap_edge/ui2/screens/day_steps.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +Future _loadType() async { + final files = Directory('assets/fonts/Manrope') + .listSync() + .whereType() + .where((f) => f.path.endsWith('.ttf')); + for (final family in const ['Manrope', '.SF Pro Text']) { + final loader = FontLoader(family); + for (final f in files) { + loader.addFont(f + .readAsBytes() + .then((b) => ByteData.sublistView(Uint8List.fromList(b)))); + } + await loader.load(); + } +} + +final _onset = DateTime(2026, 5, 19, 23).millisecondsSinceEpoch ~/ 1000; +final _wake = DateTime(2026, 5, 20, 7).millisecondsSinceEpoch ~/ 1000; +final _nine = DateTime(2026, 8, 17, 9).millisecondsSinceEpoch ~/ 1000; + +/// One fixture per screen, longest realistic values where there is a choice. +Map _screens() => { + 'sleep': () => SleepDetail( + data: SleepData( + day: '2026-05-20', + night: { + 'duration_min': 360, + 'in_bed_min': 480, + 'efficiency': .75, + 'onset_ts': _onset, + 'wake_ts': _wake, + 'hypnogram': [ + {'t': _onset, 'stage': 'light'}, + {'t': _onset + 3600, 'stage': 'deep'}, + {'t': _onset + 7200, 'stage': 'awake'}, + {'t': _onset + 7800, 'stage': 'rem'}, + {'t': _wake, 'stage': 'deep'}, + ], + }, + timeline: { + 'hr': [ + {'t': _onset, 'v': 50}, + {'t': _onset + 14400, 'v': 58}, + {'t': _wake, 'v': 66}, + ], + 'resp': [ + {'t': _onset + 14400, 'v': 17.5}, + ], + }, + ), + ), + 'readiness': () => ReadinessDetail( + data: ReadinessData( + readiness: const Metric( + value: 62, + unit: '', + confidence: .8, + tier: MetricTier.estimate), + series: [for (var i = 0; i < 28; i++) 40.0 + i], + anchor: DateTime(2026, 5, 20), + ), + ), + 'zones': () => ZonesDetail( + data: ZonesData(zones: [ + for (var i = 0; i < 5; i++) + ( + zone: i + 1, + name: 'Zone ${i + 1}', + lo: 100 + i * 18, + hi: 117 + i * 18, + ), + ], age: 34), + ), + 'circadian': () => CircadianDetail( + data: CircadianData(actogram: [ + for (var i = 0; i < 7; i++) List.filled(24, .5), + ]), + ), + 'day_strain': () => DayStrainDetail( + data: DayStrainData( + day: DateTime(2026, 5, 20), + curve: [ + for (var m = 0; m < 1440; m++) + m > 420 && m < 1140 ? (m - 420) / 60 : null, + ], + strain: 16.8, + zoneMin: const [212, 96, 41, 18, 4], + maxHrUsed: 190, + peakHr: 171, + wornMin: 743, + coveragePct: 51, + ), + ), + 'day_steps': () => DayStepsDetail( + data: DayStepsData( + spans: [ + DayStepSpan( + startTs: _nine, + endTs: _nine + 3600, + steps: 1200, + fromBand: false), + DayStepSpan( + startTs: _nine + 14400, + endTs: _nine + 18000, + steps: 3240, + fromBand: true, + activity: 'walking'), + ], + total: 4440, + strap: 3240, + phone: 1200, + bandLabel: 'WHOOP 4', + ), + ), + 'cycle': () => const CycleTab( + data: CycleData( + enabled: true, + phase: 'luteal', + cycleDay: 23, + medianLength: 29, + predictedNext: '2026-12-01', + predictedFrom: '2026-11-28', + predictedTo: '2026-12-03', + gapN: 4, + logs: [ + {'date': '2026-11-09', 'kind': 'start'}, + {'date': '2026-10-11', 'kind': 'start'}, + ], + ), + ), + }; + +void main() { + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + await _loadType(); + }); + + for (final locale in const ['en', 'de']) { + for (final scale in const [1.0, 2.0, 3.1]) { + testWidgets('no screen overflows: $locale, ${scale}x text', (t) async { + t.view.physicalSize = const Size(390 * 3, 9000 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final app = AppState.forTesting(); + addTearDown(app.dispose); + + final failures = []; + for (final e in _screens().entries) { + final errors = []; + final previous = FlutterError.onError; + FlutterError.onError = errors.add; + try { + await t.pumpWidget(ChangeNotifierProvider.value( + value: app, + child: MaterialApp( + locale: Locale(locale), + localizationsDelegates: + AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildTheme(Brightness.light), + builder: (c, child) => MediaQuery( + data: MediaQuery.of(c) + .copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: e.value(), + ), + )); + await t.pumpAndSettle(); + } finally { + FlutterError.onError = previous; + } + for (final err in errors) { + final s = err.toString(); + final what = RegExp(r'A RenderFlex overflowed by [^\n]+') + .firstMatch(s) + ?.group(0) ?? + s.split('\n').first; + final where = RegExp(r'lib/ui2/[^\s:]+:\d+') + .allMatches(s) + .map((m) => m.group(0)) + .toSet() + .join(', '); + failures.add('${e.key}: $what ($where)'); + } + await t.pumpWidget(const SizedBox()); + } + expect(failures, isEmpty, reason: failures.join('\n')); + }); + } + } +} From d816de6d7020bed1f63ef62f10a4d061bdeb288a Mon Sep 17 00:00:00 2001 From: uvtqt Date: Fri, 13 Nov 2026 19:30:00 +0100 Subject: [PATCH 3/7] Sweep test: drop three unneeded imports Co-Authored-By: Claude Opus 5.5 --- test/ui2_screen_sweep_test.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/ui2_screen_sweep_test.dart b/test/ui2_screen_sweep_test.dart index 869c7d0d7..89a829209 100644 --- a/test/ui2_screen_sweep_test.dart +++ b/test/ui2_screen_sweep_test.dart @@ -12,7 +12,6 @@ // the database. import 'dart:io'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -24,8 +23,6 @@ import 'package:openstrap_edge/models/metric.dart'; import 'package:openstrap_edge/state/app_state.dart'; import 'package:openstrap_edge/ui2/activity/day_strain.dart'; import 'package:openstrap_edge/ui2/activity/zones.dart'; -import 'package:openstrap_edge/ui2/screens/cycle_screen.dart'; -import 'package:openstrap_edge/ui2/screens/day_steps.dart'; import 'package:openstrap_edge/ui2/screens/screens.dart'; import 'package:openstrap_edge/ui2/ui2.dart'; From 40ee26cc22cf355eed445ac37af08ac503f883aa Mon Sep 17 00:00:00 2001 From: uvtqt Date: Fri, 13 Nov 2026 19:45:30 +0100 Subject: [PATCH 4/7] Screen sweep: add Home, Health, Investigate, Metric detail, Timeline, Beats, What changed Every screen that takes preloaded data is now swept, 14 in all. The seven added here are clean in English and German at 1x, 2x and 3.1x; each was checked to render its content (no spinner, 8 to 55 text widgets), not an empty shell. Co-Authored-By: Claude Opus 5.5 --- test/ui2_screen_sweep_test.dart | 120 +++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/test/ui2_screen_sweep_test.dart b/test/ui2_screen_sweep_test.dart index 89a829209..1a5f74f0a 100644 --- a/test/ui2_screen_sweep_test.dart +++ b/test/ui2_screen_sweep_test.dart @@ -4,7 +4,7 @@ // in the gallery, and the cards a screen builds for itself were never swept: // this sweep found four that overflowed at large text on a 390 pt phone // (readiness ring, zone rows, the cycle-day card, a MetricRow holding a -// word), one of them in German already at 2x. +// word), one of them in German already at 2x. Fourteen screens are swept. // // The bundled type is loaded so text measures as on a phone, and German runs // beside English because its words are the long ones. A screen added here is @@ -18,6 +18,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; +import 'package:openstrap_edge/ai/nightly_sweep.dart' show SweepFinding; import 'package:openstrap_edge/l10n/app_localizations.dart'; import 'package:openstrap_edge/models/metric.dart'; import 'package:openstrap_edge/state/app_state.dart'; @@ -46,6 +47,30 @@ final _onset = DateTime(2026, 5, 19, 23).millisecondsSinceEpoch ~/ 1000; final _wake = DateTime(2026, 5, 20, 7).millisecondsSinceEpoch ~/ 1000; final _nine = DateTime(2026, 8, 17, 9).millisecondsSinceEpoch ~/ 1000; + +Metric _m(num v) => Metric(value: v, confidence: .8, tier: MetricTier.high); + +/// Newest-last daily points at local noon, ending today. +List _daily(List v) { + final now = DateTime.now(); + return [ + for (var i = 0; i < v.length; i++) + if (v[i] != null) + ( + t: DateTime(now.year, now.month, now.day - (v.length - 1 - i), 12) + .millisecondsSinceEpoch ~/ + 1000, + v: v[i]!, + ), + ]; +} + +String _todayLabel() { + final n = DateTime.now(); + String two(int x) => x.toString().padLeft(2, '0'); + return '${n.year}-${two(n.month)}-${two(n.day)}'; +} + /// One fixture per screen, longest realistic values where there is a choice. Map _screens() => { 'sleep': () => SleepDetail( @@ -140,6 +165,99 @@ Map _screens() => { bandLabel: 'WHOOP 4', ), ), + 'home': () => HomeScreen( + data: HomeData( + dayId: '2026-10-04', + readiness: _m(62), + sleepMin: _m(412), + sleepNeedMin: _m(486), + strain: _m(14.6), + strainTarget: const {'value': 12, 'low': 10, 'high': 14}, + ), + ), + 'health': () => HealthScreen( + data: HealthData(charts: { + 'hrv': _daily([for (var i = 0; i < 30; i++) 60.0 + i % 7]), + 'rhr': _daily([for (var i = 0; i < 30; i++) 52.0 + i % 4]), + }), + ), + 'investigate': () => Investigate( + 'rhr', + data: InvestigateData( + day: _todayLabel(), + series: [for (var i = 0; i < 30; i++) 52.0 + i % 4], + stress: const {'si': 112.5, 'level': 'moderate', 'score': 54.2}, + ), + ), + 'metric_detail': () => MetricDetail( + 'rhr', + data: MetricData( + daysAvailable: 30, + series: _daily([for (var i = 0; i < 30; i++) 50.0 + i % 6]), + ), + ), + 'timeline': () => DayTimelineScreen( + data: TimelineData( + day: '2026-08-14', + graph: DayGraph( + dayStart: DateTime(2026, 8, 14).millisecondsSinceEpoch ~/ 1000, + hr: [for (var i = 0; i < 1440; i++) 60 + (i % 20).toDouble()], + movement: List.filled(1440, null), + ), + ), + ), + 'beats': () => Beats( + data: BeatsData( + day: '2026-08-15', + nn: [ + for (var i = 0; i < 2400; i++) + 880 + (i % 37) * 3.0 - (i % 11) * 4.0, + ], + rawBeats: 2685, + cleanFraction: .983, + poincare: const {'sd1': 29.9, 'sd2': 150.7, 'flag': false}, + bins: [ + for (var i = 0; i < 17; i++) + ( + startSec: i * 1800, + nBeats: 900, + v: 30 + i * 1.5, + lo: 26 + i * 1.5, + hi: 35 + i * 1.5, + ), + ], + firstThirdMs: 34.1, + lastThirdMs: 51.2, + shape: const Metric(value: 1, confidence: .9), + dcPoints: _daily([for (var i = 0; i < 30; i++) 7.5 + (i % 5) * .4]), + dcAnchors: 10435, + dc: const Metric(value: 8.27, confidence: .95), + rhythmPoints: + _daily([for (var i = 0; i < 30; i++) i == 17 ? 1 : 0]), + deviceFamily: 'gen4', + ), + ), + 'what_changed': () => WhatChangedScreen( + data: WhatChangedData( + day: _todayLabel(), + days: [_todayLabel()], + findings: const [ + SweepFinding( + key: 'rhr', + text: 'Resting heart rate was higher than your usual ' + 'for the third night running', + z: 2.4, + high: true), + SweepFinding( + key: 'rmssd', + text: 'Heart rate variability was well below your usual', + z: -2.1, + high: false), + ], + longestHistory: 30, + hadToday: true, + ), + ), 'cycle': () => const CycleTab( data: CycleData( enabled: true, From 637bd536f98761b41100d1f4e9330afa3ed18b07 Mon Sep 17 00:00:00 2001 From: uvtqt Date: Fri, 13 Nov 2026 19:49:36 +0100 Subject: [PATCH 5/7] Cycle: let the phase pill wrap at large text Review on #713: at large text a long phase name ended in an ellipsis. The pill wraps there now and stays one line at default sizes. Co-Authored-By: Claude Opus 5.5 --- lib/ui2/screens/cycle_screen.dart | 5 ++++- test/cycle_day_row_overflow_test.dart | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/ui2/screens/cycle_screen.dart b/lib/ui2/screens/cycle_screen.dart index cf29376f1..0a985b75c 100644 --- a/lib/ui2/screens/cycle_screen.dart +++ b/lib/ui2/screens/cycle_screen.dart @@ -493,7 +493,10 @@ class _CycleTabState extends State with RevisionReload { l?.cycleDayInThisCycle ?? 'DAY IN THIS CYCLE', style: F.over.copyWith(color: p.ink3), ), - if (d.phase != 'unknown') Pill(_phaseLabel(l, d.phase), C.pink), + if (d.phase != 'unknown') + // Wraps at large text rather than cutting a long phase name + // short with an ellipsis; one line at default sizes. + Pill(_phaseLabel(l, d.phase), C.pink, wrap: bigText(c)), ], ), const SizedBox(height: S.x2), diff --git a/test/cycle_day_row_overflow_test.dart b/test/cycle_day_row_overflow_test.dart index bcaf2ea35..df014dac2 100644 --- a/test/cycle_day_row_overflow_test.dart +++ b/test/cycle_day_row_overflow_test.dart @@ -66,6 +66,8 @@ void main() { testWidgets('with a median, ${scale}x text', (t) async { await _pump(t, median: 29, scale: scale); expect(find.textContaining('of about 29'), findsOneWidget); + // The phase name wraps at large text instead of ending in an ellipsis. + expect(t.widget(find.byType(Pill).first).wrap, scale > 1.3); }); } } From fc44401df69224cd536e409d3192882bd9dc13e7 Mon Sep 17 00:00:00 2001 From: uvtqt Date: Fri, 13 Nov 2026 19:50:04 +0100 Subject: [PATCH 6/7] Zones: keep "bpm" on the same line as its range Review on #714: the ordinary space before the unit was a break point, so at large text "bpm" could wrap onto a line of its own. A no-break space keeps it with the number. Co-Authored-By: Claude Opus 5.5 --- lib/ui2/activity/zones.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/ui2/activity/zones.dart b/lib/ui2/activity/zones.dart index d1df43ac8..ff4725bc1 100644 --- a/lib/ui2/activity/zones.dart +++ b/lib/ui2/activity/zones.dart @@ -504,7 +504,8 @@ class _ZonesDetailState extends State { style: F.n17.copyWith(color: p.ink2), ), TextSpan( - text: ' ${l?.activityZonesBpmUnit ?? 'bpm'}', + // A no-break space, so the unit never lands on a line of its own. + text: ' ${l?.activityZonesBpmUnit ?? 'bpm'}', style: F.cap.copyWith(color: p.ink3), ), ]), From 705d66bf3b9110ecc456e0271c88dd5264f19056 Mon Sep 17 00:00:00 2001 From: uvtqt Date: Fri, 13 Nov 2026 19:55:12 +0100 Subject: [PATCH 7/7] Screen sweep: run every shipped language, not only en and de All eight are clean; a long translation added later now fails here. Co-Authored-By: Claude Opus 5.5 --- test/ui2_screen_sweep_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/ui2_screen_sweep_test.dart b/test/ui2_screen_sweep_test.dart index 1a5f74f0a..ee07db544 100644 --- a/test/ui2_screen_sweep_test.dart +++ b/test/ui2_screen_sweep_test.dart @@ -6,8 +6,8 @@ // (readiness ring, zone rows, the cycle-day card, a MetricRow holding a // word), one of them in German already at 2x. Fourteen screens are swept. // -// The bundled type is loaded so text measures as on a phone, and German runs -// beside English because its words are the long ones. A screen added here is +// The bundled type is loaded so text measures as on a phone, and every shipped +// language runs, because the long words are not in English. A screen added here is // swept from then on; each takes its own preloaded data, so nothing touches // the database. @@ -282,7 +282,7 @@ void main() { await _loadType(); }); - for (final locale in const ['en', 'de']) { + for (final locale in const ['en', 'de', 'es', 'fr', 'hi', 'ru', 'th', 'zh']) { for (final scale in const [1.0, 2.0, 3.1]) { testWidgets('no screen overflows: $locale, ${scale}x text', (t) async { t.view.physicalSize = const Size(390 * 3, 9000 * 3);