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 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-11 01:27:59 +01:00
parent b24718710e
commit 1f8f602564
7 changed files with 166 additions and 51 deletions

View File

@ -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),
],
],
),
);
},
);

View File

@ -28,12 +28,17 @@ class WorkoutSummaryDialog extends StatelessWidget {
/// null where none was sent.
final Future<PokeResult>? 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 <path>" / "Sync failed: <error>".
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',
),
),
],

View File

@ -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')),

View File

@ -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,
);
});
}

View File

@ -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);

View File

@ -21,14 +21,24 @@ void main() {
await tester.pump();
}
Color? colorOf(WidgetTester tester) =>
tester.widget<Text>(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<Icon>(
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<PokeResult>();
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);
});
}

View File

@ -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.
});