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 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-11 01:27:50 +01:00
parent e5bfdd9a9a
commit b24718710e
11 changed files with 206 additions and 167 deletions

View File

@ -149,10 +149,7 @@ class _HomeScreenState extends State<HomeScreen> {
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'),

View File

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

View File

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

View File

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

View File

@ -14,3 +14,16 @@ Finder iconLabeled(String label) => find.byWidgetPredicate(
/// The nearest [T] (a button type) around the icon labelled [label].
Finder buttonLabeled<T extends Widget>(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"',
);

View File

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

View File

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

View File

@ -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<void>.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.

View File

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

View File

@ -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<void>.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', (

View File

@ -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.';