mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 16:14:01 +01:00
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:
parent
b24718710e
commit
1f8f602564
@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -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',
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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')),
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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.
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user