diff --git a/lib/ui2/activity/zones.dart b/lib/ui2/activity/zones.dart index b268061f4..ff4725bc1 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,26 @@ 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( + // 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), + ), + ]), + 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/cycle_screen.dart b/lib/ui2/screens/cycle_screen.dart index f9f18cdfe..0a985b75c 100644 --- a/lib/ui2/screens/cycle_screen.dart +++ b/lib/ui2/screens/cycle_screen.dart @@ -479,14 +479,24 @@ 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), + 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), @@ -496,13 +506,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/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/cycle_day_row_overflow_test.dart b/test/cycle_day_row_overflow_test.dart new file mode 100644 index 000000000..df014dac2 --- /dev/null +++ b/test/cycle_day_row_overflow_test.dart @@ -0,0 +1,73 @@ +// 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); + // The phase name wraps at large text instead of ending in an ellipsis. + expect(t.widget(find.byType(Pill).first).wrap, scale > 1.3); + }); + } +} diff --git a/test/ui2_screen_sweep_test.dart b/test/ui2_screen_sweep_test.dart new file mode 100644 index 000000000..ee07db544 --- /dev/null +++ b/test/ui2_screen_sweep_test.dart @@ -0,0 +1,339 @@ +// 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. Fourteen screens are swept. +// +// 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. + +import 'dart:io'; + +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/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'; +import 'package:openstrap_edge/ui2/activity/day_strain.dart'; +import 'package:openstrap_edge/ui2/activity/zones.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; + + +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( + 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', + ), + ), + '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, + 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', '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); + 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')); + }); + } + } +}