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