Make settings screen text-free

Headings are glyphs, helper paragraphs are gone, statuses are a
tick/warning glyph with the sentence spoken, the exercise summary is
glyphs + numbers, PC pairing a link glyph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-11 01:28:08 +01:00
parent 5fa160f114
commit b584c0441b
15 changed files with 271 additions and 244 deletions

View File

@ -54,7 +54,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _soundEnabled = true;
// Set after a Sync settings visit restores progression; the banner stays.
String? _progressionStatus;
/// Exercises the last sync-settings visit restored; null until one did.
int? _restoredCount;
@override
void initState() {
@ -126,10 +127,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
ProgressionSyncService().pullProgression)();
if (!mounted) return;
if (restored.changed) {
setState(
() => _progressionStatus =
'Restored ${restored.count} exercise(s) from Firebase.',
);
setState(() => _restoredCount = restored.count);
await _load();
}
}
@ -225,7 +223,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_SoundSection(enabled: _soundEnabled, onChanged: _setSound),
const SizedBox(height: 20),
_SyncSection(
progressionStatus: _progressionStatus,
restoredCount: _restoredCount,
onOpenSyncSettings: () => unawaited(_openSyncSettings()),
onOpenGitHubMirror: () => unawaited(_openGitHubMirror()),
),

View File

@ -25,20 +25,11 @@ class _ExercisesSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SectionHeader('PER EXERCISE'),
const SizedBox(height: 4),
Text(
'Progression, warmup, rest and injury pause. '
'Same settings as the workout tile.',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
// textgate: the helper sentence under the heading is gone.
const _SectionHeader(Icons.tune, 'PER EXERCISE'),
const SizedBox(height: 12),
...orderedNames.map((name) {
final s = states[name];
@ -57,19 +48,38 @@ class _ExerciseSettingsRow extends StatelessWidget {
final ExerciseState state;
final VoidCallback onTap;
String get _summary {
// textgate: was "kg · ↑3 ↓2 · rest 3:00 / 5:00 · warmup 1:00" (or
// "no warmup"). Now the mode glyph (or rep range), the thresholds, a
// timer glyph before the rests, and a sun glyph before the warmup rest
// (absent when there is no warmup).
Widget _summary(Color color) {
final s = state;
final mode = switch (s.mode) {
ProgressionMode.weight => 'kg',
ProgressionMode.reps => 'reps',
ProgressionMode.doubleProgression => '${s.repsLow}→${s.repsHigh} reps',
final style = TextStyle(color: color, fontSize: AppTextSize.caption);
final modeIcon = switch (s.mode) {
ProgressionMode.weight => Icons.fitness_center,
ProgressionMode.reps => Icons.repeat,
ProgressionMode.doubleProgression => null,
};
final warmup = s.hasWarmup
? 'warmup ${formatRest(s.restWarmupSecs)}'
: 'no warmup';
return '$mode · ↑${s.successThreshold} ↓${s.failThreshold} · '
'rest ${formatRest(s.restSuccessSecs)} / '
'${formatRest(s.restFailSecs)} · $warmup';
return Row(
key: const Key('exercise_settings_summary'),
children: [
if (modeIcon == null)
Text('${s.repsLow}→${s.repsHigh}', style: style)
else
Icon(modeIcon, size: 14, color: color),
Text(' ↑${s.successThreshold} ↓${s.failThreshold} ', style: style),
Icon(Icons.timer, size: 14, color: color),
Text(
' ${formatRest(s.restSuccessSecs)} / ${formatRest(s.restFailSecs)}',
style: style,
),
if (s.hasWarmup) ...[
const SizedBox(width: 8),
Icon(Icons.wb_sunny, size: 14, color: color),
Text(' ${formatRest(s.restWarmupSecs)}', style: style),
],
],
);
}
@override
@ -108,21 +118,26 @@ class _ExerciseSettingsRow extends StatelessWidget {
),
),
const SizedBox(height: 4),
Text(
_summary,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
_summary(colorScheme.onSurfaceVariant),
if (until != null) ...[
const SizedBox(height: 4),
Text(
'Paused — back on ${formatShortDate(until)}',
style: TextStyle(
color: colorScheme.error,
fontSize: AppTextSize.caption,
),
// textgate: was "Paused — back on 11 Oct".
Row(
children: [
Icon(
Icons.pause_circle_outline,
size: 14,
color: colorScheme.error,
),
const SizedBox(width: 4),
Text(
formatShortDate(until),
style: TextStyle(
color: colorScheme.error,
fontSize: AppTextSize.caption,
),
),
],
),
],
],

View File

@ -26,20 +26,11 @@ class _WeightsSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SectionHeader('WEIGHTS'),
const SizedBox(height: 4),
Text(
'Override current working weight. '
'Resets streak counters. Rounded to 2.5 kg.',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
// textgate: the helper sentence under the heading is gone.
const _SectionHeader(Icons.fitness_center, 'WEIGHTS'),
const SizedBox(height: 12),
...orderedNames.map((name) {
final w = weights[name];
@ -74,21 +65,11 @@ class _RepsSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SectionHeader('TARGET REPS'),
const SizedBox(height: 4),
Text(
'Override target reps per set. Resets streak counters. '
'Progression only ever raises reps, so this is the only way '
'to lower one.',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
// textgate: the helper sentence under the heading is gone.
const _SectionHeader(Icons.repeat, 'TARGET REPS'),
const SizedBox(height: 12),
...orderedNames.map((name) {
final r = reps[name];

View File

@ -20,7 +20,7 @@ class _PcLinkSection extends StatefulWidget {
class _PcLinkSectionState extends State<_PcLinkSection> {
final TextEditingController _host = TextEditingController();
bool? _paired;
String? _status;
({bool ok, String spoken})? _status;
@override
void initState() {
@ -44,32 +44,39 @@ class _PcLinkSectionState extends State<_PcLinkSection> {
Future<void> _save() async {
final host = _host.text.trim();
if (!isValidPcHost(host)) {
setState(() => _status = 'Not an IPv4 address or hostname: "$host".');
setState(
() => _status = (
ok: false,
spoken: 'Not an IPv4 address or hostname: "$host".',
),
);
return;
}
await PcPairing.saveHost(host);
SandboxLog.event('pc host saved', {'host': host});
setState(() => _status = 'Workouts are now sent to $host:$kPcPokePort.');
setState(
() => _status = (
ok: true,
spoken: 'Workouts are now sent to $host:$kPcPokePort.',
),
);
}
static String _pairedSemanticsLabel(bool? paired) => switch (paired) {
null => 'PC paired: checking…',
true => 'PC paired ✓',
false => 'PC paired ✗ — run scripts/pair_phone.sh on the PC',
};
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final paired = _paired;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SectionHeader('PC LINK'),
const SizedBox(height: AppSpacing.xs),
Text(
'A finished workout is sent straight to the PC on the home Wi-Fi, '
'so the credit lands in a second. Sync still delivers it otherwise.',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
// textgate: the explanation sentence under the heading is gone.
const _SectionHeader(Icons.computer, 'PC LINK'),
const SizedBox(height: AppSpacing.sm),
Row(
children: [
@ -96,27 +103,27 @@ class _PcLinkSectionState extends State<_PcLinkSection> {
],
),
const SizedBox(height: AppSpacing.sm),
Text(
switch (paired) {
null => 'PC paired: checking…',
true => 'PC paired ✓',
false => 'PC paired ✗ — run scripts/pair_phone.sh on the PC',
},
key: const Key('pc-paired'),
style: TextStyle(
// textgate: was "PC paired: checking…" / "PC paired ✓" / "PC
// paired ✗ — run scripts/pair_phone.sh on the PC". Now a link glyph
// (hourglass while checking, broken link when unpaired) in the
// success / warning colour.
Align(
alignment: Alignment.centerLeft,
child: Icon(
switch (paired) {
null => Icons.hourglass_empty,
true => Icons.link,
false => Icons.link_off,
},
key: const Key('pc-paired'),
size: 20,
color: paired ?? false ? status.success : status.warning,
fontSize: AppTextSize.caption,
semanticLabel: _pairedSemanticsLabel(paired),
),
),
if (_status != null) ...[
if (_status case final s?) ...[
const SizedBox(height: AppSpacing.xs),
Text(
_status!,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
_StatusGlyph(ok: s.ok, spoken: s.spoken),
],
],
);

View File

@ -4,19 +4,23 @@
// See history_screen_charts.dart for the full reasoning.
part of 'settings_screen.dart';
/// A section heading as a glyph (textgate: was an all-caps word; the word
/// survives as [spoken], the glyph's semantic label).
class _SectionHeader extends StatelessWidget {
const _SectionHeader(this.text);
const _SectionHeader(this.icon, this.spoken);
final String text;
final IconData icon;
final String spoken;
@override
Widget build(BuildContext context) {
return Text(
text,
style: TextStyle(
return Align(
alignment: Alignment.centerLeft,
child: Icon(
icon,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
letterSpacing: 1.4,
semanticLabel: spoken,
),
);
}
@ -56,7 +60,8 @@ class _RepsRow extends StatelessWidget {
SizedBox(
width: 72,
child: Text(
'$reps reps',
// textgate: was "12 reps"; the section's repeat glyph says it.
'×$reps',
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface,
@ -104,9 +109,8 @@ class _WeightRow extends StatelessWidget {
),
_StepperButton(
icon: Icons.remove,
onTap: () => onChanged(
(weight - kWeightIncrement).clamp(0.0, 999.0),
),
onTap: () =>
onChanged((weight - kWeightIncrement).clamp(0.0, 999.0)),
),
// Fixed-width container supports up to "999.9kg" (7 chars).
SizedBox(
@ -155,3 +159,27 @@ class _StepperButton extends StatelessWidget {
);
}
}
/// An action's outcome as a glyph: a tick (success colour) or a warning
/// sign (warning colour). textgate: replaces a painted status sentence;
/// the sentence survives as [spoken], the glyph's semantic label.
class _StatusGlyph extends StatelessWidget {
const _StatusGlyph({required this.ok, required this.spoken, super.key});
final bool ok;
final String spoken;
@override
Widget build(BuildContext context) {
final status = Theme.of(context).extension<AppStatusColors>()!;
return Align(
alignment: Alignment.centerLeft,
child: Icon(
ok ? Icons.check_circle : Icons.error_outline,
size: 18,
color: ok ? status.success : status.warning,
semanticLabel: spoken,
),
);
}
}

View File

@ -18,7 +18,7 @@ class _SandboxSectionState extends State<_SandboxSection> {
late final TextEditingController _rest = TextEditingController(
text: '${Sandbox.restSecs}',
);
String? _status;
({bool ok, String spoken})? _status;
@override
void dispose() {
@ -26,9 +26,9 @@ class _SandboxSectionState extends State<_SandboxSection> {
super.dispose();
}
void _report(String status) {
void _report(String status, {bool ok = true}) {
SandboxLog.event('sandbox settings', {'status': status});
setState(() => _status = status);
setState(() => _status = (ok: ok, spoken: status));
}
Future<void> _wipe() async {
@ -53,7 +53,10 @@ class _SandboxSectionState extends State<_SandboxSection> {
Future<void> _saveRest() async {
final secs = int.tryParse(_rest.text.trim());
if (secs == null || secs < 1) {
_report('Rest length must be a whole number of seconds, 1 or more.');
_report(
'Rest length must be a whole number of seconds, 1 or more.',
ok: false,
);
return;
}
await StorageService.instance.setSandboxRestSecs(secs);
@ -73,17 +76,8 @@ class _SandboxSectionState extends State<_SandboxSection> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SectionHeader('SANDBOX'),
const SizedBox(height: 4),
Text(
'This install is the sandbox: its own data, no sync (only the '
'signed sandbox poke to the PC LINK address, which the PC never '
'credits), no LAN server. Nothing here can reach the daily build.',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
// textgate: the explanation paragraph under the heading is gone.
const _SectionHeader(Icons.science, 'SANDBOX'),
const SizedBox(height: 12),
Wrap(
spacing: 8,
@ -151,14 +145,12 @@ class _SandboxSectionState extends State<_SandboxSection> {
),
],
),
if (_status != null) ...[
if (_status case final s?) ...[
const SizedBox(height: 8),
Text(
_status!,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
_StatusGlyph(
key: const Key('sandbox-status'),
ok: s.ok,
spoken: s.spoken,
),
],
],

View File

@ -13,13 +13,13 @@ part of 'settings_screen.dart';
/// The SYNC section: progression status line plus the two sync routes.
class _SyncSection extends StatelessWidget {
const _SyncSection({
required this.progressionStatus,
required this.restoredCount,
required this.onOpenSyncSettings,
required this.onOpenGitHubMirror,
});
/// Last progression-sync outcome, or null when there is nothing to report.
final String? progressionStatus;
/// Exercises the last progression pull restored, or null when none did.
final int? restoredCount;
/// Opens the Firebase sync settings screen.
final VoidCallback onOpenSyncSettings;
@ -33,15 +33,29 @@ class _SyncSection extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SectionHeader('SYNC'),
const _SectionHeader(Icons.sync, 'SYNC'),
const SizedBox(height: 4),
if (progressionStatus != null) ...[
Text(
progressionStatus!,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
// textgate: was "Restored N exercise(s) from Firebase."; now a
// download glyph and the count, in the success colour.
if (restoredCount != null) ...[
Row(
key: const Key('settings_restored'),
children: [
Icon(
Icons.cloud_download,
size: 18,
color: Theme.of(context).extension<AppStatusColors>()!.success,
semanticLabel: 'Restored from Firebase',
),
const SizedBox(width: 6),
Text(
'$restoredCount',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
],
),
const SizedBox(height: 8),
],
@ -50,14 +64,12 @@ class _SyncSection extends StatelessWidget {
color: colorScheme.surfaceContainerHigh,
child: Column(
children: [
// textgate: was "Sync settings" / "Firebase sync".
ListTile(
title: Text(
'Sync settings',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Firebase sync',
style: TextStyle(color: colorScheme.onSurfaceVariant),
leading: Icon(
Icons.cloud,
color: colorScheme.onSurface,
semanticLabel: 'Sync settings',
),
trailing: Icon(
Icons.chevron_right,
@ -69,14 +81,14 @@ class _SyncSection extends StatelessWidget {
height: 1,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.2),
),
// textgate: was "Advanced sync (GitHub)" / "Cutover mirror —
// not recommended": a code glyph, muted as the not-recommended
// route.
ListTile(
title: Text(
'Advanced sync (GitHub)',
style: TextStyle(color: colorScheme.onSurface),
),
subtitle: Text(
'Cutover mirror — not recommended',
style: TextStyle(color: colorScheme.onSurfaceVariant),
leading: Icon(
Icons.code,
color: colorScheme.onSurfaceVariant,
semanticLabel: 'Advanced sync (GitHub)',
),
trailing: Icon(
Icons.chevron_right,
@ -107,26 +119,11 @@ class _OfflineBackupSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SectionHeader('OFFLINE BACKUP'),
const SizedBox(height: 4),
Text(
storageGranted
? 'Granted. Progression is also written to '
'$kBackupPath, so it survives a reinstall even '
'with no network.'
: 'Optional. Progression is restored from Firebase on '
'a fresh install, so this is a second, offline '
'copy — not a requirement. Granting it also keeps '
'a readable snapshot at $kBackupPath.',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
// textgate: the explanation paragraph under the heading is gone.
const _SectionHeader(Icons.sd_storage, 'OFFLINE BACKUP'),
const SizedBox(height: 12),
if (storageGranted)
// Was "Storage permission granted": storage glyph + green tick.
@ -188,12 +185,10 @@ class _SettingsAppBar extends StatelessWidget implements PreferredSizeWidget {
iconTheme: IconThemeData(color: colorScheme.onSurface),
actions: [
const RunnerUpImportBadge(),
TextButton(
IconButton(
onPressed: loading ? null : onReset,
child: Text(
'Reset defaults',
style: TextStyle(color: colorScheme.error),
),
color: colorScheme.error,
icon: const Icon(Icons.restart_alt, semanticLabel: 'Reset defaults'),
),
],
);

View File

@ -15,6 +15,28 @@ import '../icon_finders.dart';
const _situp = 'Situp exercise settings';
/// A summary row read back as words: each glyph as the setting it stands
/// for, each number as painted. textgate: the row paints no words itself.
List<String> _summaries(WidgetTester tester) {
final glyphs = {
Icons.fitness_center: 'kg',
Icons.repeat: 'reps',
Icons.timer: 'rest',
Icons.wb_sunny: 'warmup',
};
return [
for (final row
in find.byKey(const Key('exercise_settings_summary')).evaluate())
[
for (final w in (row.widget as Row).children)
if (w is Icon)
glyphs[w.icon] ?? '?'
else if (w is Text)
w.data!.trim(),
].where((p) => p.isNotEmpty).join(' '),
];
}
void main() {
late int pushes;
late ProgressionSyncResult pushResult;
@ -80,10 +102,7 @@ void main() {
testWidgets('each row summarises its exercise', (tester) async {
await pumpSettings(tester);
// Situp is the one plan exercise without a warmup.
expect(
find.text('kg · ↑3 ↓2 · rest 3:00 / 5:00 · no warmup'),
findsOneWidget,
);
expect(_summaries(tester), contains('kg ↑3 ↓2 rest 3:00 / 5:00'));
});
testWidgets('a double-progression row names its rep range', (tester) async {
@ -98,7 +117,10 @@ void main() {
);
});
await pumpSettings(tester);
expect(find.textContaining('25→40 reps · ↑3 ↓2'), findsOneWidget);
expect(
_summaries(tester).where((r) => r.startsWith('25→40 ↑3 ↓2')),
hasLength(1),
);
});
testWidgets('edits persist, show on the row, and push once on close', (
@ -122,8 +144,8 @@ void main() {
expect(s.restSuccessSecs, 195);
expect(s.mode, ProgressionMode.reps);
expect(s.pausedUntil, isNotNull);
expect(find.textContaining('reps · ↑3 ↓2 · rest 3:15 / 5:00'), findsOne);
expect(find.textContaining('Paused — back on'), findsOneWidget);
expect(_summaries(tester), contains('reps ↑3 ↓2 rest 3:15 / 5:00'));
expect(find.byIcon(Icons.pause_circle_outline), findsOneWidget);
expect(find.byType(SnackBar), findsNothing);
});

View File

@ -10,6 +10,7 @@ import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import '../fake_secure_storage.dart';
import '../icon_finders.dart';
void main() {
setUpAll(() {
@ -62,14 +63,11 @@ void main() {
}
String pairedLine(WidgetTester tester) =>
tester.widget<Text>(find.byKey(const Key('pc-paired'))).data!;
tester.widget<Icon>(find.byKey(const Key('pc-paired'))).semanticLabel!;
testWidgets('a paired phone shows the host and a check', (tester) async {
await pump(
tester,
PcPairingState(host: '192.168.1.43', keyHex: 'c3' * 32),
);
expect(find.text('PC LINK'), findsOneWidget);
await pump(tester, PcPairingState(host: '192.168.1.43', keyHex: 'c3' * 32));
expect(iconLabeled('PC LINK'), findsOneWidget);
expect(pairedLine(tester), 'PC paired ✓');
final field = tester.widget<TextField>(find.byKey(const Key('pc-host')));
expect(field.controller!.text, '192.168.1.43');
@ -84,7 +82,7 @@ void main() {
await pump(tester, const PcPairingState(host: '10.0.0.2'));
await save(tester, 'not a host');
expect(
find.text('Not an IPv4 address or hostname: "not a host".'),
iconLabeled('Not an IPv4 address or hostname: "not a host".'),
findsOneWidget,
);
final saved = await tester.runAsync(StorageService.instance.getPcHost);
@ -98,7 +96,7 @@ void main() {
await pump(tester, const PcPairingState(host: '10.0.0.2'));
await save(tester, ' 192.168.1.77 ');
expect(
find.text('Workouts are now sent to 192.168.1.77:$kPcPokePort.'),
iconLabeled('Workouts are now sent to 192.168.1.77:$kPcPokePort.'),
findsOneWidget,
);
final saved = await tester.runAsync(StorageService.instance.getPcHost);

View File

@ -12,6 +12,7 @@ import 'package:workout_app/ui/theme.dart';
import '../fake_pc_pairing.dart';
import '../fake_secure_storage.dart';
import '../icon_finders.dart';
void main() {
setUpAll(() {
@ -86,17 +87,17 @@ void main() {
(tester) async {
await _pump(tester, _wrap());
await tester.scrollUntilVisible(
find.text('Sync settings'),
iconLabeled('Sync settings'),
500,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(find.text('Sync settings'));
await tester.tap(iconLabeled('Sync settings'));
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.textContaining('Restored'), findsNothing);
expect(iconLabeled('Restored from Firebase'), findsNothing);
},
);
@ -104,14 +105,12 @@ void main() {
tester,
) async {
await _pump(tester, _wrap());
await tester.scrollUntilVisible(find.text('TARGET REPS'), 200);
await tester.scrollUntilVisible(iconLabeled('TARGET REPS'), 200);
await tester.pumpAndSettle();
// Situp defaults to 30 reps and is the only exercise at that value.
final minus = find.descendant(
of: find
.ancestor(of: find.text('30 reps'), matching: find.byType(Row))
.first,
of: find.ancestor(of: find.text('×30'), matching: find.byType(Row)).first,
matching: find.byIcon(Icons.remove),
);
// Same 600ms debounce as weights: run on the real loop so the timer fires.

View File

@ -54,7 +54,7 @@ void main() {
// The list, explicitly: the section's own TextField is a Scrollable too,
// and scrolling that one never brings the header into view.
await tester.scrollUntilVisible(
find.text('SANDBOX'),
iconLabeled('SANDBOX'),
300,
scrollable: find.byType(Scrollable).first,
);
@ -79,7 +79,7 @@ void main() {
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pump();
expect(find.text('SANDBOX'), findsNothing);
expect(iconLabeled('SANDBOX'), findsNothing);
});
testWidgets('wipe resets the database and the rest override', (tester) async {
@ -92,7 +92,7 @@ void main() {
Sandbox.restSecs = 30;
await pump(tester);
await tapReal(tester, iconLabeled('Wipe sandbox data'));
expect(find.textContaining('Wiped —'), findsOneWidget);
expect(iconLabelContaining('Wiped —'), findsOneWidget);
expect(Sandbox.restSecs, Sandbox.defaultRestSecs);
final active = await tester.runAsync(
StorageService.instance.loadActiveSession,
@ -103,7 +103,7 @@ void main() {
testWidgets('mark today not done reports what it removed', (tester) async {
await pump(tester);
await tapReal(tester, iconLabeled('Mark today not done'));
expect(find.textContaining('Nothing logged today'), findsOneWidget);
expect(iconLabelContaining('Nothing logged today'), findsOneWidget);
final today = DateTime.now().toIso8601String().substring(0, 10);
await tester.runAsync(
@ -116,7 +116,7 @@ void main() {
),
);
await tapReal(tester, iconLabeled('Mark today not done'));
expect(find.textContaining('Removed 1 workout'), findsOneWidget);
expect(iconLabelContaining('Removed 1 workout'), findsOneWidget);
});
testWidgets('rest length: rejects junk, persists a whole number', (
@ -131,16 +131,16 @@ void main() {
);
await tester.enterText(field, 'abc');
await tapReal(tester, save);
expect(find.textContaining('whole number of seconds'), findsOneWidget);
expect(iconLabelContaining('whole number of seconds'), findsOneWidget);
expect(Sandbox.restSecs, Sandbox.defaultRestSecs);
await tester.enterText(field, '0');
await tapReal(tester, save);
expect(find.textContaining('whole number of seconds'), findsOneWidget);
expect(iconLabelContaining('whole number of seconds'), findsOneWidget);
await tester.enterText(field, '45');
await tapReal(tester, save);
expect(find.text('Every rest now lasts 45 s.'), findsOneWidget);
expect(iconLabeled('Every rest now lasts 45 s.'), findsOneWidget);
expect(Sandbox.restSecs, 45);
final saved = await tester.runAsync(
() => StorageService.instance.getSandboxRestSecs(5),
@ -157,7 +157,7 @@ void main() {
matching: find.text('10×'),
);
await tapReal(tester, tenX);
expect(find.text('GPS runs replay at 10×.'), findsOneWidget);
expect(iconLabeled('GPS runs replay at 10×.'), findsOneWidget);
expect(Sandbox.runReplayFactor, 10);
final saved = await tester.runAsync(
() => StorageService.instance.getSandboxReplayFactor(60),

View File

@ -84,7 +84,7 @@ void main() {
testWidgets('Reset dialog shows on tap and cancels', (tester) async {
await _pump(tester, _wrap());
await tester.tap(find.text('Reset defaults'));
await tester.tap(iconLabeled('Reset defaults'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(iconLabeled('Reset to defaults?'), findsOneWidget);
@ -101,7 +101,7 @@ void main() {
);
await _pump(tester, _wrap());
await tester.tap(find.text('Reset defaults'));
await tester.tap(iconLabeled('Reset defaults'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(iconLabeled('Reset'));
@ -135,11 +135,6 @@ void main() {
500,
scrollable: find.byType(Scrollable).first,
);
// Framed as optional: Firebase restores progression without it.
expect(
find.textContaining('Optional.', skipOffstage: false),
findsOneWidget,
);
// The scroll step can leave the row just below the fold.
await tester.ensureVisible(iconLabeled('Grant storage permission'));
@ -164,9 +159,5 @@ void main() {
expect(iconLabeled('Storage permission granted'), findsOneWidget);
expect(iconLabeled('Grant storage permission'), findsNothing);
expect(
find.textContaining('Granted.', skipOffstage: false),
findsOneWidget,
);
});
}

View File

@ -89,33 +89,31 @@ void main() {
testWidgets('shows WEIGHTS, TARGET REPS and PER EXERCISE', (tester) async {
await _pump(tester, _wrap());
expect(find.text('WEIGHTS'), findsOneWidget);
expect(iconLabeled('WEIGHTS'), findsOneWidget);
// TARGET REPS and the per-exercise list below it are off-screen at the test
// viewport height, so scroll them into view rather than asserting on
// whatever happens to be built.
await tester.scrollUntilVisible(find.text('TARGET REPS'), 200);
expect(find.text('TARGET REPS'), findsOneWidget);
await tester.scrollUntilVisible(find.text('PER EXERCISE'), 200);
expect(find.text('PER EXERCISE'), findsOneWidget);
await tester.scrollUntilVisible(iconLabeled('TARGET REPS'), 200);
expect(iconLabeled('TARGET REPS'), findsOneWidget);
await tester.scrollUntilVisible(iconLabeled('PER EXERCISE'), 200);
expect(iconLabeled('PER EXERCISE'), findsOneWidget);
});
testWidgets('increment reps button increases the target reps', (
tester,
) async {
await _pump(tester, _wrap());
await tester.scrollUntilVisible(find.text('TARGET REPS'), 200);
await tester.scrollUntilVisible(iconLabeled('TARGET REPS'), 200);
await tester.pumpAndSettle();
// Situp defaults to 30 reps and is the only exercise at that value.
expect(find.text('30 reps'), findsOneWidget);
expect(find.text('×30'), findsOneWidget);
final plus = find.descendant(
of: find
.ancestor(of: find.text('30 reps'), matching: find.byType(Row))
.first,
of: find.ancestor(of: find.text('×30'), matching: find.byType(Row)).first,
matching: find.byIcon(Icons.add),
);
await tester.tap(plus);
await tester.pumpAndSettle();
expect(find.text('31 reps'), findsOneWidget);
expect(find.text('×31'), findsOneWidget);
});
testWidgets('shows all exercise names from both workout plans', (
@ -129,7 +127,7 @@ void main() {
testWidgets('Reset defaults button is present', (tester) async {
await _pump(tester, _wrap());
expect(find.text('Reset defaults'), findsOneWidget);
expect(iconLabeled('Reset defaults'), findsOneWidget);
});
testWidgets('increment weight button increases weight', (tester) async {

View File

@ -87,22 +87,22 @@ void main() {
) async {
await _pump(tester, _wrap());
await tester.scrollUntilVisible(
find.text('Sync settings'),
iconLabeled('Sync settings'),
500,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('Sync settings'), findsOneWidget);
expect(find.text('Advanced sync (GitHub)'), findsOneWidget);
expect(iconLabeled('Sync settings'), findsOneWidget);
expect(iconLabeled('Advanced sync (GitHub)'), findsOneWidget);
});
testWidgets('opening Sync settings pushes the shared screen', (tester) async {
await _pump(tester, _wrap());
await tester.scrollUntilVisible(
find.text('Sync settings'),
iconLabeled('Sync settings'),
500,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(find.text('Sync settings'));
await tester.tap(iconLabeled('Sync settings'));
await tester.pumpAndSettle();
// The shared package's own screen -- its AppBar title, not this app's.
@ -115,13 +115,13 @@ void main() {
) async {
await _pump(tester, _wrap());
await tester.scrollUntilVisible(
find.text('Advanced sync (GitHub)'),
iconLabeled('Advanced sync (GitHub)'),
500,
scrollable: find.byType(Scrollable).first,
);
await tester.ensureVisible(find.text('Advanced sync (GitHub)'));
await tester.ensureVisible(iconLabeled('Advanced sync (GitHub)'));
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced sync (GitHub)'));
await tester.tap(iconLabeled('Advanced sync (GitHub)'));
await tester.pumpAndSettle();
expect(iconLabeled('Connect GitHub'), findsOneWidget);
@ -151,21 +151,19 @@ void main() {
),
);
await tester.scrollUntilVisible(
find.text('Sync settings'),
iconLabeled('Sync settings'),
500,
scrollable: find.byType(Scrollable).first,
);
await tester.tap(find.text('Sync settings'));
await tester.tap(iconLabeled('Sync settings'));
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
expect(pulled, isTrue);
expect(
find.text('Restored 7 exercise(s) from Firebase.'),
findsOneWidget,
);
expect(iconLabeled('Restored from Firebase'), findsOneWidget);
expect(find.text('7'), findsOneWidget);
},
);
}

View File

@ -44,7 +44,12 @@ void main() {
});
await tester.pump();
final buttons = find.byType(DataTransferButtons);
await tester.scrollUntilVisible(buttons, 300);
await tester.scrollUntilVisible(
buttons,
300,
// The PC address field is a Scrollable too, now on screen.
scrollable: find.byType(Scrollable).first,
);
expect(find.bySemanticsLabel('Export data'), findsOneWidget);
expect(find.bySemanticsLabel('Import data'), findsOneWidget);