mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 19:53:55 +01:00
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:
parent
5fa160f114
commit
b584c0441b
@ -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()),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user