From 1f8f602564d86b50cb2bd2247cfc677dca0f4c4c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 11 Nov 2026 01:27:59 +0100 Subject: [PATCH] Make workout summary and PC line text-free Summary title is a trophy/flag glyph, duration mm:ss, save state a glyph, home button a glyph. The PC line is PC glyph + outcome glyph + the PC's own numbers. Co-Authored-By: Claude Opus 5.5 --- .../workout_app/lib/widgets/pc_poke_line.dart | 60 ++++++++++++++---- .../lib/widgets/workout_summary_dialog.dart | 62 +++++++++++------- .../test/screens/workout_cues_test.dart | 3 +- .../workout_finish_stop_failure_test.dart | 6 +- .../screens/workout_screen_deload_test.dart | 3 +- .../test/widgets/pc_poke_line_test.dart | 63 +++++++++++++++++-- .../widgets/workout_summary_dialog_test.dart | 20 +++--- 7 files changed, 166 insertions(+), 51 deletions(-) diff --git a/stronglift_replacement/workout_app/lib/widgets/pc_poke_line.dart b/stronglift_replacement/workout_app/lib/widgets/pc_poke_line.dart index dd6e212..251ca5c 100644 --- a/stronglift_replacement/workout_app/lib/widgets/pc_poke_line.dart +++ b/stronglift_replacement/workout_app/lib/widgets/pc_poke_line.dart @@ -5,7 +5,8 @@ import 'package:flutter/material.dart'; import 'package:workout_app/services/pc_poke_result.dart'; import 'package:workout_app/ui/theme.dart'; -/// Shows "PC: applying…" until [poke] resolves, then its [PokeResult.label]. +/// A spinner until [poke] resolves, then a PC glyph, an outcome glyph and +/// the numbers the PC sent back. [PokeResult.label] is the spoken label. /// /// The service bounds [poke] to 2 s, so this never spins forever: an /// unreachable PC turns into the sync fallback line on its own. @@ -36,12 +37,12 @@ class PcPokeLine extends StatelessWidget { ), ), const SizedBox(width: AppSpacing.sm), - Text( - 'PC: applying…', - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, - ), + // textgate: was "PC: applying…". + Icon( + Icons.computer, + size: AppTextSize.body, + color: colorScheme.onSurfaceVariant, + semanticLabel: 'PC: applying…', ), ], ); @@ -52,10 +53,47 @@ class PcPokeLine extends StatelessWidget { PokeOutcome.alreadyPaidToday => colorScheme.onSurfaceVariant, PokeOutcome.notCounted || PokeOutcome.failed => status.warning, }; - return Text( - result.label, - key: const Key('pc-poke-result'), - style: TextStyle(color: color, fontSize: AppTextSize.caption), + // textgate: was the sentence in [PokeResult.label]; now PC glyph + + // outcome glyph + the PC's own numbers, outcome colour as channel 2. + final glyph = switch (result.outcome) { + PokeOutcome.credited || PokeOutcome.sandbox => Icons.check_circle, + PokeOutcome.duplicate || + PokeOutcome.alreadyPaidToday => Icons.done_all, + PokeOutcome.notCounted => Icons.block, + PokeOutcome.failed => Icons.schedule, + }; + final style = TextStyle(color: color, fontSize: AppTextSize.caption); + final minutes = result.gamingBudgetMinutes; + return Semantics( + label: result.label, + excludeSemantics: true, + child: Row( + key: const Key('pc-poke-result'), + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.computer, size: AppTextSize.body, color: color), + const SizedBox(width: AppSpacing.xs), + Icon(glyph, size: AppTextSize.body, color: color), + if (result.outcome == PokeOutcome.credited) ...[ + if (result.shutdown != null) ...[ + const SizedBox(width: AppSpacing.sm), + Icon(Icons.power_settings_new, size: 14, color: color), + Text(' ${result.shutdown}', style: style), + ], + if (minutes != null) ...[ + const SizedBox(width: AppSpacing.sm), + Icon(Icons.sports_esports, size: 14, color: color), + Text(' ${(minutes / 60).toStringAsFixed(1)}h', style: style), + ], + ], + // Sandbox: the round trip in milliseconds behind a timer glyph. + if (result.outcome == PokeOutcome.sandbox) ...[ + const SizedBox(width: AppSpacing.sm), + Icon(Icons.timer, size: 14, color: color), + Text(' ${result.roundTripMs ?? 0}', style: style), + ], + ], + ), ); }, ); diff --git a/stronglift_replacement/workout_app/lib/widgets/workout_summary_dialog.dart b/stronglift_replacement/workout_app/lib/widgets/workout_summary_dialog.dart index d19e97e..8f5b6ef 100644 --- a/stronglift_replacement/workout_app/lib/widgets/workout_summary_dialog.dart +++ b/stronglift_replacement/workout_app/lib/widgets/workout_summary_dialog.dart @@ -28,12 +28,17 @@ class WorkoutSummaryDialog extends StatelessWidget { /// null where none was sent. final Future? pcPoke; + /// `45:30` or `1:05:00` (textgate: was "Duration: 45m 30s"). String _fmt(Duration d) { final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '${d.inHours > 0 ? '${d.inHours}h ' : ''}${m}m ${s}s'; + return d.inHours > 0 ? '${d.inHours}:$m:$s' : '$m:$s'; } + String get _saveSemanticsLabel => syncResult.success + ? 'Saved to ${syncResult.path}' // semantics only + : 'Sync failed: ${syncResult.error}'; // semantics only + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -41,20 +46,31 @@ class WorkoutSummaryDialog extends StatelessWidget { final succeeded = session.fullySucceeded; return AlertDialog( backgroundColor: colorScheme.surfaceContainerHigh, - title: Text( - succeeded ? 'Workout Complete! 💪' : 'Workout Done', - style: TextStyle( - color: succeeded ? status.success : status.warning, - fontWeight: FontWeight.bold, - ), + // textgate: was "Workout Complete! 💪" / "Workout Done". + title: Icon( + succeeded ? Icons.emoji_events : Icons.flag, + size: 40, + color: succeeded ? status.success : status.warning, + semanticLabel: succeeded ? 'Workout Complete!' : 'Workout Done', ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Duration: ${_fmt(session.duration)}', - style: TextStyle(color: colorScheme.onSurfaceVariant), + Row( + children: [ + Icon( + Icons.timer, + size: 18, + color: colorScheme.onSurfaceVariant, + semanticLabel: 'Duration', + ), + const SizedBox(width: AppSpacing.sm), + Text( + _fmt(session.duration), + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ], ), const SizedBox(height: 8), ...session.exercises.map( @@ -66,16 +82,15 @@ class WorkoutSummaryDialog extends StatelessWidget { ), ), const SizedBox(height: 12), - Text( - syncResult.success - ? 'Saved to ${syncResult.path}' - : 'Sync failed: ${syncResult.error}', - style: TextStyle( - color: syncResult.success - ? colorScheme.onSurfaceVariant - : colorScheme.error, - fontSize: AppTextSize.caption, - ), + // textgate: was "Saved to " / "Sync failed: ". + Icon( + syncResult.success ? Icons.save : Icons.error_outline, + key: const Key('summary_save_state'), + size: 18, + color: syncResult.success + ? colorScheme.onSurfaceVariant + : colorScheme.error, + semanticLabel: _saveSemanticsLabel, ), if (pcPoke != null) ...[ const SizedBox(height: AppSpacing.sm), @@ -88,9 +103,10 @@ class WorkoutSummaryDialog extends StatelessWidget { onPressed: () { Navigator.of(context).popUntil((r) => r.isFirst); }, - child: Text( - 'Back to Home', - style: TextStyle(color: colorScheme.primary), + child: Icon( + Icons.home, + color: colorScheme.primary, + semanticLabel: 'Back to Home', ), ), ], diff --git a/stronglift_replacement/workout_app/test/screens/workout_cues_test.dart b/stronglift_replacement/workout_app/test/screens/workout_cues_test.dart index 287d99e..1aca7d4 100644 --- a/stronglift_replacement/workout_app/test/screens/workout_cues_test.dart +++ b/stronglift_replacement/workout_app/test/screens/workout_cues_test.dart @@ -17,6 +17,7 @@ import '../fake_audio_platform.dart'; import '../fake_break_service.dart'; import '../fake_secure_storage.dart'; import '_workout_screen_test_fixtures.dart'; +import '../icon_finders.dart'; const _select = 'HapticFeedbackType.selectionClick'; const _medium = 'HapticFeedbackType.mediumImpact'; @@ -155,7 +156,7 @@ void main() { testWidgets('a failed workout gets exactly the same cue', (tester) async { await pumpWorkout(tester, wrapWorkout(savedState: partialSaved())); await finish(tester); - expect(find.textContaining('Workout Done'), findsOneWidget); + expect(iconLabeled('Workout Done'), findsOneWidget); expect(haptics, contains(_medium)); expect( audio.resumedSources.where((s) => s.contains('workout_done')), diff --git a/stronglift_replacement/workout_app/test/screens/workout_finish_stop_failure_test.dart b/stronglift_replacement/workout_app/test/screens/workout_finish_stop_failure_test.dart index f3979aa..e354730 100644 --- a/stronglift_replacement/workout_app/test/screens/workout_finish_stop_failure_test.dart +++ b/stronglift_replacement/workout_app/test/screens/workout_finish_stop_failure_test.dart @@ -11,6 +11,7 @@ import '../fake_audio_platform.dart'; import '../fake_break_service.dart'; import '../fake_secure_storage.dart'; import '_workout_screen_test_fixtures.dart'; +import '../icon_finders.dart'; /// A client whose teardown fails once [failStop] is armed. class _FailingStopClient extends FakeForegroundBreakClient { @@ -63,6 +64,9 @@ void main() { expect(client.failStop, isFalse, reason: 'the failing stop really ran'); expect(tester.takeException(), isNull); // The summary's PC line: this test host is never paired. - expect(find.text('PC: will credit within 60 s (via sync)'), findsOneWidget); + expect( + semanticsLabeled('PC: will credit within 60 s (via sync)'), + findsOneWidget, + ); }); } diff --git a/stronglift_replacement/workout_app/test/screens/workout_screen_deload_test.dart b/stronglift_replacement/workout_app/test/screens/workout_screen_deload_test.dart index 3fb266c..1492a2a 100644 --- a/stronglift_replacement/workout_app/test/screens/workout_screen_deload_test.dart +++ b/stronglift_replacement/workout_app/test/screens/workout_screen_deload_test.dart @@ -18,6 +18,7 @@ import '../fake_audio_platform.dart'; import '../fake_break_service.dart'; import '../fake_secure_storage.dart'; import '_workout_screen_test_fixtures.dart'; +import '../icon_finders.dart'; // Real plan names, seeded as Lunge 7.5 kg x 12 and Row 22.5 kg x 6. const _lunge = Exercise(name: 'Dumbbell Lunge', sets: 3, reps: 12, weight: 7.5); @@ -215,7 +216,7 @@ void main() { ), ); await finish(tester); - await tester.tap(find.text('Back to Home')); + await tester.tap(iconLabeled('Back to Home')); await tester.pump(const Duration(milliseconds: 300)); await deloadLunge(tester); diff --git a/stronglift_replacement/workout_app/test/widgets/pc_poke_line_test.dart b/stronglift_replacement/workout_app/test/widgets/pc_poke_line_test.dart index 40e0be6..8cc95bc 100644 --- a/stronglift_replacement/workout_app/test/widgets/pc_poke_line_test.dart +++ b/stronglift_replacement/workout_app/test/widgets/pc_poke_line_test.dart @@ -21,14 +21,24 @@ void main() { await tester.pump(); } - Color? colorOf(WidgetTester tester) => - tester.widget(find.byKey(const Key('pc-poke-result'))).style!.color; + // The outcome colour lives on the glyphs (textgate: no sentence painted). + Color? colorOf(WidgetTester tester) => tester + .widget( + find + .descendant( + of: find.byKey(const Key('pc-poke-result')), + matching: find.byType(Icon), + ) + .first, + ) + .color; testWidgets('pending shows "applying" with a spinner', (tester) async { final never = Completer(); await show(tester, never.future); expect(find.byKey(const Key('pc-poke-pending')), findsOneWidget); - expect(find.text('PC: applying…'), findsOneWidget); + expect(find.bySemanticsLabel('PC: applying…'), findsOneWidget); + expect(find.byIcon(Icons.computer), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsOneWidget); }); @@ -45,7 +55,12 @@ void main() { ), ), ); - expect(find.text('PC ✓ (sandbox, not credited) · 77 ms'), findsOneWidget); + expect( + find.bySemanticsLabel('PC ✓ (sandbox, not credited) · 77 ms'), + findsOneWidget, + ); + expect(find.text(' 77'), findsOneWidget); + expect(find.byIcon(Icons.check_circle), findsOneWidget); expect(colorOf(tester), status.success); }); @@ -56,7 +71,8 @@ void main() { const PokeResult(outcome: PokeOutcome.duplicate, reason: 'dup'), ), ); - expect(find.text('PC: already credited'), findsOneWidget); + expect(find.bySemanticsLabel('PC: already credited'), findsOneWidget); + expect(find.byIcon(Icons.done_all), findsOneWidget); expect(colorOf(tester), theme.colorScheme.onSurfaceVariant); }); @@ -64,7 +80,42 @@ void main() { tester, ) async { await show(tester, Future.value(const PokeResult.failed('timed out'))); - expect(find.text('PC: will credit within 60 s (via sync)'), findsOneWidget); + expect( + find.bySemanticsLabel('PC: will credit within 60 s (via sync)'), + findsOneWidget, + ); + expect(find.byIcon(Icons.schedule), findsOneWidget); + expect(colorOf(tester), status.warning); + }); + + testWidgets('a credit shows the new shutdown and gaming hours', ( + tester, + ) async { + await show( + tester, + Future.value( + const PokeResult( + outcome: PokeOutcome.credited, + reason: 'credited', + shutdown: '22:00', + gamingBudgetMinutes: 90, + ), + ), + ); + expect(find.text(' 22:00'), findsOneWidget); + expect(find.text(' 1.5h'), findsOneWidget); + expect(find.byIcon(Icons.power_settings_new), findsOneWidget); + expect(colorOf(tester), status.success); + }); + + testWidgets('not counted is a warning block glyph', (tester) async { + await show( + tester, + Future.value( + const PokeResult(outcome: PokeOutcome.notCounted, reason: 'short'), + ), + ); + expect(find.byIcon(Icons.block), findsOneWidget); expect(colorOf(tester), status.warning); }); } diff --git a/stronglift_replacement/workout_app/test/widgets/workout_summary_dialog_test.dart b/stronglift_replacement/workout_app/test/widgets/workout_summary_dialog_test.dart index 111b6dd..b096011 100644 --- a/stronglift_replacement/workout_app/test/widgets/workout_summary_dialog_test.dart +++ b/stronglift_replacement/workout_app/test/widgets/workout_summary_dialog_test.dart @@ -8,6 +8,8 @@ import 'package:workout_app/services/sync_service.dart'; import 'package:workout_app/ui/theme.dart'; import 'package:workout_app/widgets/workout_summary_dialog.dart'; +import '../icon_finders.dart'; + WorkoutSession _session({ bool allSucceeded = true, Duration duration = const Duration(minutes: 45, seconds: 30), @@ -51,7 +53,8 @@ void main() { ), ), ); - expect(find.textContaining('Workout Complete'), findsOneWidget); + expect(iconLabeled('Workout Complete!'), findsOneWidget); + expect(find.byIcon(Icons.emoji_events), findsOneWidget); }); testWidgets('shows "Workout Done" when not fully succeeded', ( @@ -70,10 +73,11 @@ void main() { ), ), ); - expect(find.text('Workout Done'), findsOneWidget); + expect(iconLabeled('Workout Done'), findsOneWidget); + expect(find.byIcon(Icons.flag), findsOneWidget); }); - testWidgets('shows duration in mm m ss s format for sub-hour workout', ( + testWidgets('shows duration as mm:ss for a sub-hour workout', ( tester, ) async { await tester.pumpWidget( @@ -85,7 +89,7 @@ void main() { ), ), ); - expect(find.textContaining('45m 30s'), findsOneWidget); + expect(find.text('45:30'), findsOneWidget); }); testWidgets('shows hours in duration for long workouts', (tester) async { @@ -100,7 +104,7 @@ void main() { ), ), ); - expect(find.textContaining('1h'), findsOneWidget); + expect(find.text('1:05:03'), findsOneWidget); }); testWidgets('shows exercise name with check mark on success', ( @@ -149,7 +153,7 @@ void main() { ), ); expect( - find.textContaining('Saved to /sdcard/workout_result.json'), + iconLabeled('Saved to /sdcard/workout_result.json'), findsOneWidget, ); }); @@ -169,7 +173,7 @@ void main() { ), ); expect( - find.textContaining('Sync failed: No writable external path'), + iconLabeled('Sync failed: No writable external path'), findsOneWidget, ); }); @@ -186,7 +190,7 @@ void main() { ), ), ); - await tester.tap(find.text('Back to Home')); + await tester.tap(iconLabeled('Back to Home')); await tester.pumpAndSettle(); // No exception = navigator popped cleanly. });