From b24718710e40e2b66c391438f4ddc7df09a1ac1a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 11 Nov 2026 01:27:50 +0100 Subject: [PATCH] Make workout app home screen text-free Home card: plan letter + state glyph, play-glyph buttons. Sync card and banner: state glyph, age as 5m/3h/2d, action glyph. Old words stay as spoken labels. Co-Authored-By: Claude Opus 5.5 --- .../workout_app/lib/screens/home_screen.dart | 5 +- .../lib/screens/home_screen_cards.dart | 95 +++++++++---------- .../lib/widgets/sync_setup_banner.dart | 31 +++--- .../lib/widgets/sync_status_card.dart | 95 +++++++++++-------- .../workout_app/test/icon_finders.dart | 13 +++ .../test/screens/home_launch_test.dart | 10 +- .../test/screens/home_screen_test.dart | 22 ++--- .../test/screens/home_sync_card_test.dart | 22 +++-- .../screens/workout_lazy_service_test.dart | 2 +- .../workout_app/test/widget_test.dart | 3 +- .../test/widgets/sync_status_card_test.dart | 75 +++++++++------ 11 files changed, 206 insertions(+), 167 deletions(-) diff --git a/stronglift_replacement/workout_app/lib/screens/home_screen.dart b/stronglift_replacement/workout_app/lib/screens/home_screen.dart index cb9a105..b9e3de7 100644 --- a/stronglift_replacement/workout_app/lib/screens/home_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/home_screen.dart @@ -149,10 +149,7 @@ class _HomeScreenState extends State { return Scaffold( appBar: AppBar( backgroundColor: colorScheme.surfaceContainerHigh, - title: Text( - 'Workout Tracker', - style: TextStyle(color: colorScheme.onSurface), - ), + // textgate: no title (was "Workout Tracker"); the app is the app. actions: [ IconButton( key: const Key('home_run'), diff --git a/stronglift_replacement/workout_app/lib/screens/home_screen_cards.dart b/stronglift_replacement/workout_app/lib/screens/home_screen_cards.dart index f04911f..f482084 100644 --- a/stronglift_replacement/workout_app/lib/screens/home_screen_cards.dart +++ b/stronglift_replacement/workout_app/lib/screens/home_screen_cards.dart @@ -35,44 +35,45 @@ class _WorkoutCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (doneToday && !hasActiveSession) ...[ - Row( - children: [ - Icon(Icons.check_circle, color: status.success, size: 18), - const SizedBox(width: 8), - Text( - 'Done for today!', - style: TextStyle( - color: status.success, - fontWeight: FontWeight.bold, - fontSize: AppTextSize.body, - ), + // textgate: was "Done for today!" / "Next: Workout A" / + // "Workout A in progress". Now the plan letter plus a state + // glyph; colour is the second channel (success / warning). + Row( + children: [ + if (doneToday && !hasActiveSession) + Icon( + Icons.check_circle, + key: const Key('home_done_today'), + color: status.success, + semanticLabel: 'Done for today', + ) + else if (hasActiveSession) + Icon( + Icons.timelapse, + key: const Key('home_in_progress'), + color: status.warning, + semanticLabel: 'Workout $type in progress', + ) + else + Icon( + Icons.arrow_forward, + key: const Key('home_next'), + color: colorScheme.onSurface, + semanticLabel: 'Next: Workout $type', + ), + const SizedBox(width: 8), + Text( + type, + style: TextStyle( + color: hasActiveSession + ? status.warning + : colorScheme.onSurface, + fontSize: AppTextSize.subtitle, + fontWeight: FontWeight.bold, ), - ], - ), - const SizedBox(height: 6), - Text( - 'Next: Workout $type', - style: TextStyle( - color: colorScheme.onSurface, - fontSize: AppTextSize.body, - fontWeight: FontWeight.bold, ), - ), - ] else ...[ - Text( - hasActiveSession - ? 'Workout $type in progress' - : 'Next: Workout $type', - style: TextStyle( - color: hasActiveSession - ? status.warning - : colorScheme.onSurface, - fontSize: AppTextSize.subtitle, - fontWeight: FontWeight.bold, - ), - ), - ], + ], + ), const SizedBox(height: 10), ...exercises.map( (e) => Text( @@ -93,13 +94,10 @@ class _WorkoutCard extends StatelessWidget { padding: buttonPadding, ), onPressed: onResume, - child: Text( - 'Resume Workout', - style: TextStyle( - color: colorScheme.onPrimary, - fontSize: AppTextSize.body, - fontWeight: FontWeight.bold, - ), + child: Icon( + Icons.play_arrow, + color: colorScheme.onPrimary, + semanticLabel: 'Resume Workout', ), ), ) @@ -114,13 +112,10 @@ class _WorkoutCard extends StatelessWidget { padding: buttonPadding, ), onPressed: onStart, - child: Text( - 'Start Workout $type', - style: TextStyle( - color: colorScheme.onPrimary, - fontSize: AppTextSize.body, - fontWeight: FontWeight.bold, - ), + child: Icon( + Icons.play_arrow, + color: colorScheme.onPrimary, + semanticLabel: 'Start Workout $type', ), ), ), diff --git a/stronglift_replacement/workout_app/lib/widgets/sync_setup_banner.dart b/stronglift_replacement/workout_app/lib/widgets/sync_setup_banner.dart index b4e75a7..5c5bc85 100644 --- a/stronglift_replacement/workout_app/lib/widgets/sync_setup_banner.dart +++ b/stronglift_replacement/workout_app/lib/widgets/sync_setup_banner.dart @@ -14,6 +14,9 @@ class SyncSetupBanner extends StatelessWidget { /// Creates the banner. const SyncSetupBanner({super.key}); + static const _semanticsLabel = + 'Sync not set up — this workout will NOT count. Set it up in Settings.'; + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -22,20 +25,20 @@ class SyncSetupBanner extends StatelessWidget { horizontal: AppSpacing.md, vertical: AppSpacing.xs, ), - child: Row( - children: [ - Icon(Icons.cloud_off, size: 16, color: colorScheme.error), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - 'Sync not set up — this workout will NOT count. ' - 'Set it up in Settings.', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: colorScheme.error), - ), - ), - ], + // textgate: was "Sync not set up — this workout will NOT count. Set + // it up in Settings." Now: no cloud -> settings, in the error colour. + child: Semantics( + label: _semanticsLabel, + excludeSemantics: true, + child: Row( + children: [ + Icon(Icons.cloud_off, size: 16, color: colorScheme.error), + const SizedBox(width: AppSpacing.sm), + Icon(Icons.arrow_forward, size: 16, color: colorScheme.error), + const SizedBox(width: AppSpacing.sm), + Icon(Icons.settings, size: 16, color: colorScheme.error), + ], + ), ), ); } diff --git a/stronglift_replacement/workout_app/lib/widgets/sync_status_card.dart b/stronglift_replacement/workout_app/lib/widgets/sync_status_card.dart index ab47144..185a359 100644 --- a/stronglift_replacement/workout_app/lib/widgets/sync_status_card.dart +++ b/stronglift_replacement/workout_app/lib/widgets/sync_status_card.dart @@ -29,12 +29,12 @@ class SyncStatusCard extends StatelessWidget { /// Opens sync settings ([SyncState.notConnected]). final VoidCallback onSetUp; - /// Renders a duration the way a human would say it. + /// Renders a duration as a number plus a one-letter unit: `5m`, `3h`, + /// `2d`. Under a minute is `0m` (textgate: was "just now" / "5m ago"). static String describeAge(Duration age) { - if (age.inMinutes < 1) return 'just now'; - if (age.inMinutes < 60) return '${age.inMinutes}m ago'; - if (age.inHours < 24) return '${age.inHours}h ago'; - return '${age.inDays}d ago'; + if (age.inMinutes < 60) return '${age.inMinutes}m'; + if (age.inHours < 24) return '${age.inHours}h'; + return '${age.inDays}d'; } @override @@ -44,20 +44,27 @@ class SyncStatusCard extends StatelessWidget { // The healthy state is a compact one-liner on purpose: a full card that // is always there becomes wallpaper, and then the unhealthy ones stop - // being noticed too. + // being noticed too. textgate: was "Synced 5m ago"; now glyph + age. if (status.state == SyncState.synced) { final age = status.age; return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.cloud_done, size: 16, color: statusColors.success), - const SizedBox(width: 8), - Text( - age == null ? 'Synced' : 'Synced ${describeAge(age)}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: statusColors.success, - ), + Icon( + Icons.cloud_done, + size: 16, + color: statusColors.success, + semanticLabel: 'Synced', ), + if (age != null) ...[ + const SizedBox(width: 8), + Text( + describeAge(age), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: statusColors.success), + ), + ], ], ); } @@ -65,23 +72,31 @@ class SyncStatusCard extends StatelessWidget { // SyncState.synced returned above, so only the three unhealthy states // reach here. `notConnected` is the default arm rather than a listed one // so the switch has no unreachable branch to leave uncovered. - final (icon, accent, title, body, actionLabel, action) = switch (status - .state) { + // textgate: the old title / body / button words are spoken only now; + // the painted card is state glyph + age + action glyph, accent-bordered. + final ( + icon, + accent, + spoken, + actionIcon, + actionSpoken, + action, + ) = switch (status.state) { SyncState.failed => ( Icons.sync_problem, colorScheme.error, - 'Sync failed', - status.reason ?? 'The last sync attempt did not go through.', + 'Sync failed. ${status.reason ?? 'The last attempt failed.'}', + Icons.refresh, 'Retry', onRetry, ), SyncState.outOfDate => ( Icons.cloud_queue, statusColors.warning, - 'Sync out of date', status.age == null - ? 'This device has never synced successfully.' - : 'Last synced ${describeAge(status.age!)}.', + ? 'Sync out of date. Never synced successfully.' + : 'Sync out of date. Last synced ${describeAge(status.age!)} ago.', + Icons.refresh, 'Retry', onRetry, ), @@ -89,12 +104,15 @@ class SyncStatusCard extends StatelessWidget { _ => ( Icons.cloud_off, colorScheme.error, - 'Not connected to sync', - 'This workout will NOT count until sync is set up.', + 'Not connected to sync. ' + 'This workout will NOT count until sync is set up.', + // Not the gear: the home app bar already uses it for Settings. + Icons.add_link, 'Set up sync', onSetUp, ), }; + final age = status.age; return Card( color: colorScheme.surfaceContainerHigh, @@ -105,28 +123,23 @@ class SyncStatusCard extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(16), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, color: accent), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: accent, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text(body, style: Theme.of(context).textTheme.bodySmall), - ], + Icon(icon, color: accent, semanticLabel: spoken), + if (status.state == SyncState.outOfDate && age != null) ...[ + const SizedBox(width: 12), + Text( + describeAge(age), + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(color: accent), ), + ], + const Spacer(), + IconButton( + onPressed: action, + color: accent, + icon: Icon(actionIcon, semanticLabel: actionSpoken), ), - const SizedBox(width: 8), - TextButton(onPressed: action, child: Text(actionLabel)), ], ), ), diff --git a/stronglift_replacement/workout_app/test/icon_finders.dart b/stronglift_replacement/workout_app/test/icon_finders.dart index 095cc54..ce31f33 100644 --- a/stronglift_replacement/workout_app/test/icon_finders.dart +++ b/stronglift_replacement/workout_app/test/icon_finders.dart @@ -14,3 +14,16 @@ Finder iconLabeled(String label) => find.byWidgetPredicate( /// The nearest [T] (a button type) around the icon labelled [label]. Finder buttonLabeled(String label) => find.ancestor(of: iconLabeled(label), matching: find.byType(T)); + +/// The [Icon] whose spoken label contains [part]. +Finder iconLabelContaining(String part) => find.byWidgetPredicate( + (w) => w is Icon && (w.semanticLabel?.contains(part) ?? false), + description: 'Icon labelled with "$part"', +); + +/// The [Semantics] widget whose label is exactly [label] (a painted-glyph +/// group whose old sentence is spoken only). +Finder semanticsLabeled(String label) => find.byWidgetPredicate( + (w) => w is Semantics && w.properties.label == label, + description: 'Semantics labelled "$label"', +); diff --git a/stronglift_replacement/workout_app/test/screens/home_launch_test.dart b/stronglift_replacement/workout_app/test/screens/home_launch_test.dart index 2ed2188..b690f9e 100644 --- a/stronglift_replacement/workout_app/test/screens/home_launch_test.dart +++ b/stronglift_replacement/workout_app/test/screens/home_launch_test.dart @@ -69,7 +69,7 @@ void main() { await _launch(tester, _app()); expect(find.byType(WorkoutScreen), findsOneWidget); // No transition: the home card was never built underneath. - expect(find.text('Start Workout A'), findsNothing); + expect(iconLabeled('Start Workout A'), findsNothing); // Sync is not set up, and the card that says so was skipped. expect(find.byType(SyncSetupBanner), findsOneWidget); }); @@ -86,7 +86,7 @@ void main() { ); await _launch(tester, _app()); expect(find.byType(WorkoutScreen), findsNothing); - expect(find.text('Done for today!'), findsOneWidget); + expect(iconLabeled('Done for today'), findsOneWidget); }); testWidgets('a synced record dated today (a run, a manual log) keeps home', ( @@ -99,7 +99,7 @@ void main() { ); await _launch(tester, _app(sync: sync, configured: true)); expect(find.byType(WorkoutScreen), findsNothing); - expect(find.text('Done for today!'), findsOneWidget); + expect(iconLabeled('Done for today'), findsOneWidget); }); testWidgets('synced records from other days still open, without banner', ( @@ -130,7 +130,7 @@ void main() { addTearDown(() => lockModeEnabled = false); await _launch(tester, _app()); expect(find.byType(WorkoutScreen), findsNothing); - expect(find.text('Start Workout A'), findsOneWidget); + expect(iconLabeled('Start Workout A'), findsOneWidget); }); testWidgets('backing out lands on home, with no session left behind', ( @@ -146,7 +146,7 @@ void main() { await tester.pump(); expect(find.byType(WorkoutScreen), findsNothing); // Only the first load decides: the reload after the pop stays on home. - expect(find.text('Start Workout A'), findsOneWidget); + expect(iconLabeled('Start Workout A'), findsOneWidget); final saved = await tester.runAsync( () => StorageService.instance.loadActiveSession(), ); diff --git a/stronglift_replacement/workout_app/test/screens/home_screen_test.dart b/stronglift_replacement/workout_app/test/screens/home_screen_test.dart index f5c4040..d4ff9cf 100644 --- a/stronglift_replacement/workout_app/test/screens/home_screen_test.dart +++ b/stronglift_replacement/workout_app/test/screens/home_screen_test.dart @@ -22,17 +22,17 @@ void main() { testWidgets('shows Workout Tracker app bar', (tester) async { await pumpHome(tester, wrapHome()); - expect(find.text('Workout Tracker'), findsOneWidget); + expect(find.byKey(const Key('home_next')), findsOneWidget); }); testWidgets('shows Next: Workout A when no workout done', (tester) async { await pumpHome(tester, wrapHome()); - expect(find.textContaining('Next: Workout A'), findsOneWidget); + expect(iconLabeled('Next: Workout A'), findsOneWidget); }); testWidgets('shows Start Workout A button', (tester) async { await pumpHome(tester, wrapHome()); - expect(find.text('Start Workout A'), findsOneWidget); + expect(iconLabeled('Start Workout A'), findsOneWidget); }); testWidgets('history icon navigates to history screen', (tester) async { @@ -75,9 +75,9 @@ void main() { () => StorageService.instance.setLastWorkoutType('A'), ); await pumpHome(tester, wrapHome()); - expect(find.text('Done for today!'), findsOneWidget); + expect(iconLabeled('Done for today'), findsOneWidget); // An extra session is still allowed: the PC pays for further ones. - expect(find.text('Start Workout B'), findsOneWidget); + expect(iconLabeled('Start Workout B'), findsOneWidget); // The run entry must not vanish either. expect(find.byKey(const Key('home_run')), findsOneWidget); }); @@ -90,17 +90,17 @@ void main() { await tester.tap(find.byIcon(Icons.edit_note)); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); - expect(find.text('Log Manual Workout'), findsOneWidget); + expect(iconLabeled('Log Manual Workout'), findsOneWidget); }); testWidgets('starting a workout navigates to the workout screen', ( tester, ) async { await pumpHome(tester, wrapHome()); - await tester.tap(find.text('Start Workout A')); + await tester.tap(iconLabeled('Start Workout A')); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); - expect(find.textContaining('Workout A'), findsWidgets); + expect(find.byType(WorkoutScreen), findsOneWidget); }); testWidgets('an active session auto-resumes into the workout screen', ( @@ -168,11 +168,11 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); expect(find.byType(WorkoutScreen), findsNothing); - expect(find.text('Resume Workout'), findsOneWidget); + expect(iconLabeled('Resume Workout'), findsOneWidget); // Tapping Resume invokes onResume -> _openWorkout(resume:true) (line 153). await tester.runAsync(() async { - await tester.tap(find.text('Resume Workout')); + await tester.tap(iconLabeled('Resume Workout')); await Future.delayed(const Duration(milliseconds: 300)); }); await tester.pump(); @@ -197,6 +197,6 @@ void main() { }); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); - expect(find.text('Workout Tracker'), findsOneWidget); + expect(find.byKey(const Key('home_next')), findsOneWidget); }); } diff --git a/stronglift_replacement/workout_app/test/screens/home_sync_card_test.dart b/stronglift_replacement/workout_app/test/screens/home_sync_card_test.dart index 250fd2d..bae16f5 100644 --- a/stronglift_replacement/workout_app/test/screens/home_sync_card_test.dart +++ b/stronglift_replacement/workout_app/test/screens/home_sync_card_test.dart @@ -36,7 +36,7 @@ void main() { ) async { await pumpHome(tester, wrapHome(configuredProbe: () async => false)); expect(find.byType(SyncStatusCard), findsOneWidget); - expect(find.text('Not connected to sync'), findsOneWidget); + expect(find.byIcon(Icons.cloud_off), findsOneWidget); }); testWidgets('the card sits ABOVE the workout card', (tester) async { @@ -44,7 +44,7 @@ void main() { // The whole point of the placement: the warning has to be read before // the workout starts, not discovered after it failed to count. final cardY = tester.getTopLeft(find.byType(SyncStatusCard)).dy; - final workoutY = tester.getTopLeft(find.text('Start Workout A')).dy; + final workoutY = tester.getTopLeft(iconLabeled('Start Workout A')).dy; expect(cardY, lessThan(workoutY)); }); @@ -64,8 +64,10 @@ void main() { ), ), ); - expect(find.text('Sync failed'), findsOneWidget); - expect(find.textContaining('no network'), findsOneWidget); + expect( + iconLabeled('Sync failed. sync failed: no network'), + findsOneWidget, + ); }); testWidgets( @@ -94,8 +96,8 @@ void main() { ), ), ); - expect(find.text('Sync out of date'), findsOneWidget); - expect(find.textContaining('3d ago'), findsOneWidget); + expect(find.byIcon(Icons.cloud_queue), findsOneWidget); + expect(find.text('3d'), findsOneWidget); }, ); @@ -104,7 +106,7 @@ void main() { ) async { installFakeSecureStorage(); // SettingsScreen reads the token on init await pumpHome(tester, wrapHome(configuredProbe: () async => false)); - await tester.tap(find.text('Set up sync')); + await tester.tap(iconLabeled('Set up sync')); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); // The card must lead somewhere: telling the user sync is broken and @@ -119,7 +121,7 @@ void main() { }); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); - expect(find.text('Workout Tracker'), findsOneWidget); + expect(find.byKey(const Key('home_next')), findsOneWidget); }); testWidgets('Retry runs another sync tick', (tester) async { @@ -139,7 +141,7 @@ void main() { ); expect(ticks, 1); // the automatic tick on open await tester.runAsync(() async { - await tester.tap(find.text('Retry')); + await tester.tap(iconLabeled('Retry')); await Future.delayed(const Duration(milliseconds: 200)); }); await tester.pump(); @@ -163,7 +165,7 @@ void main() { ), ), ); - expect(find.textContaining('Synced'), findsOneWidget); + expect(iconLabeled('Synced'), findsOneWidget); // The timestamp must actually persist, or the card would go stale // again on the very next open. Read it inside runAsync: the DB is real // I/O, and awaiting it under FakeAsync hangs the test. diff --git a/stronglift_replacement/workout_app/test/screens/workout_lazy_service_test.dart b/stronglift_replacement/workout_app/test/screens/workout_lazy_service_test.dart index 1a4b17d..9cc5085 100644 --- a/stronglift_replacement/workout_app/test/screens/workout_lazy_service_test.dart +++ b/stronglift_replacement/workout_app/test/screens/workout_lazy_service_test.dart @@ -104,7 +104,7 @@ void main() { ), ); expect(find.byType(SyncSetupBanner), findsOneWidget); - expect(find.textContaining('will NOT count'), findsOneWidget); + expect(find.bySemanticsLabel(RegExp('will NOT count')), findsOneWidget); await pumpWorkout(tester, wrapWorkout(breakClient: client)); expect(find.byType(SyncSetupBanner), findsNothing); diff --git a/stronglift_replacement/workout_app/test/widget_test.dart b/stronglift_replacement/workout_app/test/widget_test.dart index 3ba3528..aac3b01 100644 --- a/stronglift_replacement/workout_app/test/widget_test.dart +++ b/stronglift_replacement/workout_app/test/widget_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:workout_app/main.dart'; +import 'package:workout_app/screens/home_screen.dart'; import 'package:workout_app/sandbox/sandbox.dart'; import 'package:workout_app/services/http_server_service.dart'; import 'package:workout_app/services/storage_service.dart'; @@ -32,7 +33,7 @@ void main() { await Future.delayed(const Duration(milliseconds: 300)); }); await tester.pump(); - expect(find.text('Workout Tracker'), findsOneWidget); + expect(find.byType(HomeScreen), findsOneWidget); }); testWidgets('the sandbox flavor wears its ribbon on every screen', ( diff --git a/stronglift_replacement/workout_app/test/widgets/sync_status_card_test.dart b/stronglift_replacement/workout_app/test/widgets/sync_status_card_test.dart index f9d83e6..5830294 100644 --- a/stronglift_replacement/workout_app/test/widgets/sync_status_card_test.dart +++ b/stronglift_replacement/workout_app/test/widgets/sync_status_card_test.dart @@ -4,6 +4,8 @@ import 'package:workout_app/services/sync_status.dart'; import 'package:workout_app/ui/theme.dart'; import 'package:workout_app/widgets/sync_status_card.dart'; +import '../icon_finders.dart'; + void main() { var retried = 0; var setUpTapped = 0; @@ -30,16 +32,16 @@ void main() { await tester.pumpWidget( wrap(const SyncStatus(state: SyncState.notConnected)), ); - expect(find.text('Not connected to sync'), findsOneWidget); // The consequence is the whole point of the card -- a workout logged // while disconnected does not count, which is what cost a real session. - expect(find.textContaining('NOT count'), findsOneWidget); - await tester.tap(find.text('Set up sync')); + expect(iconLabeled(_notConnected), findsOneWidget); + expect(find.byIcon(Icons.cloud_off), findsOneWidget); + await tester.tap(iconLabeled('Set up sync')); expect(setUpTapped, 1); expect(retried, 0); }); - testWidgets('failed shows the concrete reason and retries', (tester) async { + testWidgets('failed speaks the concrete reason and retries', (tester) async { await tester.pumpWidget( wrap( const SyncStatus( @@ -48,39 +50,47 @@ void main() { ), ), ); - expect(find.text('Sync failed'), findsOneWidget); - expect(find.textContaining('connection refused'), findsOneWidget); - await tester.tap(find.text('Retry')); + expect( + iconLabeled('Sync failed. sync failed: connection refused'), + findsOneWidget, + ); + expect(find.byIcon(Icons.sync_problem), findsOneWidget); + await tester.tap(iconLabeled('Retry')); expect(retried, 1); }); - testWidgets('failed falls back to generic text when reason is null', ( + testWidgets('failed falls back to a generic reason when it is null', ( tester, ) async { await tester.pumpWidget(wrap(const SyncStatus(state: SyncState.failed))); - expect(find.textContaining('did not go through'), findsOneWidget); + expect( + iconLabeled('Sync failed. The last attempt failed.'), + findsOneWidget, + ); }); - testWidgets('outOfDate reports how long ago and retries', (tester) async { + testWidgets('outOfDate shows how long ago and retries', (tester) async { await tester.pumpWidget( wrap( - const SyncStatus( - state: SyncState.outOfDate, - age: Duration(hours: 9), - ), + const SyncStatus(state: SyncState.outOfDate, age: Duration(hours: 9)), ), ); - expect(find.text('Sync out of date'), findsOneWidget); - expect(find.textContaining('9h ago'), findsOneWidget); - await tester.tap(find.text('Retry')); + expect(find.byIcon(Icons.cloud_queue), findsOneWidget); + expect(find.text('9h'), findsOneWidget); + expect( + iconLabeled('Sync out of date. Last synced 9h ago.'), + findsOneWidget, + ); + await tester.tap(iconLabeled('Retry')); expect(retried, 1); }); - testWidgets('outOfDate with no age says it has never synced', (tester) async { - await tester.pumpWidget( - wrap(const SyncStatus(state: SyncState.outOfDate)), + testWidgets('outOfDate with no age says it never synced', (tester) async { + await tester.pumpWidget(wrap(const SyncStatus(state: SyncState.outOfDate))); + expect( + iconLabeled('Sync out of date. Never synced successfully.'), + findsOneWidget, ); - expect(find.textContaining('never synced'), findsOneWidget); }); testWidgets('synced is a compact line with no button', (tester) async { @@ -89,24 +99,29 @@ void main() { const SyncStatus(state: SyncState.synced, age: Duration(minutes: 3)), ), ); - expect(find.textContaining('Synced 3m ago'), findsOneWidget); + expect(iconLabeled('Synced'), findsOneWidget); + expect(find.text('3m'), findsOneWidget); // Healthy state must not carry an action: a card that is always there // with a button becomes wallpaper and stops being read. - expect(find.byType(TextButton), findsNothing); + expect(find.byType(IconButton), findsNothing); expect(find.byType(Card), findsNothing); }); - testWidgets('synced with no age still renders', (tester) async { + testWidgets('synced with no age shows only the glyph', (tester) async { await tester.pumpWidget(wrap(const SyncStatus(state: SyncState.synced))); - expect(find.text('Synced'), findsOneWidget); + expect(iconLabeled('Synced'), findsOneWidget); + expect(find.byType(Text), findsNothing); }); group('describeAge', () { - test('renders each magnitude the way a human would say it', () { - expect(SyncStatusCard.describeAge(const Duration(seconds: 5)), 'just now'); - expect(SyncStatusCard.describeAge(const Duration(minutes: 5)), '5m ago'); - expect(SyncStatusCard.describeAge(const Duration(hours: 5)), '5h ago'); - expect(SyncStatusCard.describeAge(const Duration(days: 5)), '5d ago'); + test('renders each magnitude as a number and a one-letter unit', () { + expect(SyncStatusCard.describeAge(const Duration(seconds: 5)), '0m'); + expect(SyncStatusCard.describeAge(const Duration(minutes: 5)), '5m'); + expect(SyncStatusCard.describeAge(const Duration(hours: 5)), '5h'); + expect(SyncStatusCard.describeAge(const Duration(days: 5)), '5d'); }); }); } + +const _notConnected = + 'Not connected to sync. This workout will NOT count until sync is set up.';