From 7e1b4e865e24d97779c3d295d9c3bf8922fa821c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 9 Oct 2026 14:55:10 +0200 Subject: [PATCH] workout_app: manual deload on demand (workout tile + Settings) "Deload now" in the exercise settings sheet runs exactly the step a fail streak would take (targetAfterFailure), resets both streaks, and records a progression_events row (schema v7, backed up and restored with the rest). No manual step up: the target still only climbs through finished workouts. Mid-workout it re-targets the exercise for the current session: untapped sets move to the new reps, tapped sets keep theirs, and the whole exercise is recorded at the new weight at Finish. From Settings it cancels pending weight/reps debounce writes so they cannot undo the deload. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KejiqD7y4NXRiPBKQeWHSy --- .../workout_app/lib/models/manual_deload.dart | 80 ++++++ .../lib/screens/settings_screen.dart | 16 ++ .../lib/screens/settings_screen_deload.dart | 38 +++ .../lib/screens/workout_screen.dart | 20 +- .../lib/screens/workout_screen_body.dart | 6 + .../lib/screens/workout_screen_breaks.dart | 80 +++++- .../lib/screens/workout_screen_finish.dart | 8 +- .../lib/screens/workout_screen_intents.dart | 12 +- .../lib/screens/workout_screen_session.dart | 6 +- .../lib/screens/workout_screen_taps.dart | 2 +- .../lib/services/storage_service.dart | 6 +- .../lib/services/storage_service_backup.dart | 12 + .../lib/services/storage_service_deload.dart | 145 ++++++++++ .../lib/services/storage_service_sandbox.dart | 1 + .../lib/services/storage_service_schema.dart | 24 ++ .../lib/widgets/exercise_settings_sheet.dart | 33 ++- .../exercise_settings_sheet_deload.dart | 90 +++++++ .../lib/widgets/exercise_tile.dart | 6 + .../screens/settings_screen_deload_test.dart | 165 ++++++++++++ .../screens/workout_screen_deload_test.dart | 250 ++++++++++++++++++ .../test/services/_deload_test_fixtures.dart | 32 +++ ...orage_service_deload_persistence_test.dart | 199 ++++++++++++++ .../services/storage_service_deload_test.dart | 195 ++++++++++++++ .../exercise_settings_sheet_deload_test.dart | 102 +++++++ .../widgets/exercise_settings_sheet_test.dart | 2 + .../test/widgets/exercise_tile_test.dart | 1 + 26 files changed, 1498 insertions(+), 33 deletions(-) create mode 100644 stronglift_replacement/workout_app/lib/models/manual_deload.dart create mode 100644 stronglift_replacement/workout_app/lib/screens/settings_screen_deload.dart create mode 100644 stronglift_replacement/workout_app/lib/services/storage_service_deload.dart create mode 100644 stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_deload.dart create mode 100644 stronglift_replacement/workout_app/test/screens/settings_screen_deload_test.dart create mode 100644 stronglift_replacement/workout_app/test/screens/workout_screen_deload_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/_deload_test_fixtures.dart create mode 100644 stronglift_replacement/workout_app/test/services/storage_service_deload_persistence_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/storage_service_deload_test.dart create mode 100644 stronglift_replacement/workout_app/test/widgets/exercise_settings_sheet_deload_test.dart diff --git a/stronglift_replacement/workout_app/lib/models/manual_deload.dart b/stronglift_replacement/workout_app/lib/models/manual_deload.dart new file mode 100644 index 0000000..5876c40 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/models/manual_deload.dart @@ -0,0 +1,80 @@ +/// A deload the user asked for, as opposed to one a fail streak earned. +/// +/// Only ever a step DOWN: there is deliberately no manual step up, so the +/// target can only climb through finished workouts. The step is +/// `targetAfterFailure`, the same rule a fail streak runs, so a manual deload +/// lands exactly where the automatic one would have. +library; + +import 'package:workout_app/models/exercise_state.dart'; + +/// Where a manual deload was started from; stored in `progression_events`. +enum DeloadSource { + /// The settings sheet opened from an exercise tile mid-workout. + workout('workout'), + + /// The settings sheet opened from the Settings screen. + settings('settings'); + + const DeloadSource(this.storageKey); + + /// Value stored in SQLite. Never rename: stored rows hold it. + final String storageKey; +} + +/// `progression_events.kind` of a manual deload. +const String kManualDeloadKind = 'manual_deload'; + +/// The outcome of `StorageService.manualDeload`. +class ManualDeloadResult { + /// Creates a result; [state] is null when nothing was changed. + const ManualDeloadResult({required this.state, required this.reason}); + + /// The exercise's state after the deload, or null when it did not apply. + final ExerciseState? state; + + /// What happened, as a sentence a human can act on. + final String reason; + + /// Whether the target actually went down. + bool get applied => state != null; +} + +/// One row of `progression_events`: a target change and what caused it. +class ProgressionEvent { + /// Creates a [ProgressionEvent]. + const ProgressionEvent({ + required this.at, + required this.exercise, + required this.kind, + required this.source, + required this.fromWeight, + required this.fromReps, + required this.toWeight, + required this.toReps, + }); + + /// When it happened (local time). + final DateTime at; + + /// Exercise name. + final String exercise; + + /// What kind of change; [kManualDeloadKind] is the only one so far. + final String kind; + + /// [DeloadSource.storageKey] of where it was started. + final String source; + + /// Weight before, in kg. + final double fromWeight; + + /// Reps before. + final int fromReps; + + /// Weight after, in kg. + final double toWeight; + + /// Reps after. + final int toReps; +} diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart index f65584f..fa7e076 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart @@ -26,6 +26,7 @@ import 'package:workout_app/ui/theme.dart'; import 'package:workout_app/widgets/exercise_settings_sheet.dart'; part 'settings_screen_actions.dart'; +part 'settings_screen_deload.dart'; part 'settings_screen_exercise_list.dart'; part 'settings_screen_exercise_sections.dart'; part 'settings_screen_rows.dart'; @@ -165,6 +166,11 @@ class _SettingsScreenState extends State { setState(() => _states[updated.name] = updated); unawaited(StorageService.instance.setExerciseSettings(updated)); }, + onDeload: () async { + final deloaded = await _deload(state.name); + if (deloaded != null) edited = true; + return deloaded; + }, ); if (!edited) return; final pushed = @@ -178,6 +184,16 @@ class _SettingsScreenState extends State { ); } + /// Shows a deloaded target everywhere this screen holds a copy of it. + /// + /// Here rather than in `settings_screen_deload.dart` because `setState` is + /// `@protected` and unreachable from an extension. + void _showDeloaded(ExerciseState next) => setState(() { + _states[next.name] = next; + _weights[next.name] = next.weight; + _reps[next.name] = next.reps; + }); + @override Widget build(BuildContext context) { return Scaffold( diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_deload.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_deload.dart new file mode 100644 index 0000000..d4cb7f3 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_deload.dart @@ -0,0 +1,38 @@ +// The Settings screen's side of a manual deload. +// +// A `part` so the extension keeps reaching the debounce timers and `context`; +// the one `setState` it needs goes through `_showDeloaded`. +part of 'settings_screen.dart'; + +/// Manual deload from the exercise settings sheet opened on this screen. +extension _SettingsScreenDeload on _SettingsScreenState { + /// Runs a manual deload of [name] and shows the new target. + /// + /// Cancels that exercise's pending weight/reps debounce first: a stepper + /// write queued 600 ms earlier would otherwise land after the deload and + /// quietly undo it. + Future _deload(String name) async { + _weightTimers.remove(name)?.cancel(); + _repsTimers.remove(name)?.cancel(); + final result = await StorageService.instance.manualDeload( + name, + source: DeloadSource.settings, + ); + if (!mounted) return null; + final next = result.state; + if (next == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(result.reason))); + return null; + } + SandboxLog.event('manual deload', { + 'exercise': name, + 'source': DeloadSource.settings.storageKey, + 'weight': next.weight, + 'reps': next.reps, + }); + _showDeloaded(next); + return next; + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart index 4efac65..881da89 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart @@ -79,6 +79,8 @@ class _WorkoutScreenState extends State { late List _warmupTapped; late DateTime _startTime; + /// This session's targets: a copy, so a mid-workout deload can re-target. + late final List _exercises = List.of(widget.exercises); Map _exerciseStates = {}; // Break state. The deadline is the source of truth: `_breakClock` is what @@ -136,14 +138,14 @@ class _WorkoutScreenState extends State { void _initFresh() { _startTime = DateTime.now(); _tapped = List.generate( - widget.exercises.length, - (i) => List.filled(widget.exercises[i].sets, false), + _exercises.length, + (i) => List.filled(_exercises[i].sets, false), ); _doneReps = List.generate( - widget.exercises.length, - (i) => List.filled(widget.exercises[i].sets, widget.exercises[i].reps), + _exercises.length, + (i) => List.filled(_exercises[i].sets, _exercises[i].reps), ); - _warmupTapped = List.filled(widget.exercises.length, false); + _warmupTapped = List.filled(_exercises.length, false); } Future _loadExerciseStates() async { @@ -184,9 +186,8 @@ class _WorkoutScreenState extends State { /// Runs [fn] inside `setState` on behalf of this library's extensions. /// /// `setState` is `@protected`, so an extension cannot call it directly. This - /// shim is the one seam through which `workout_screen_breaks.dart` and - /// `workout_screen_taps.dart` mutate state; keeping it named makes those - /// writes greppable from here. + /// shim is the one seam through which the breaks, taps, intents and deload + /// code mutate state; keeping it named makes those writes greppable here. void _applyBreakState(VoidCallback fn) => setState(fn); // ── Finish / Reset ───────────────────────────────────────────────────────── @@ -222,7 +223,7 @@ class _WorkoutScreenState extends State { child: Scaffold( body: SafeArea( child: _WorkoutBody( - exercises: widget.exercises, + exercises: _exercises, exerciseStates: _exerciseStates, tapped: _tapped, doneReps: _doneReps, @@ -239,6 +240,7 @@ class _WorkoutScreenState extends State { onLongPressCircle: _resetCircle, onTapWarmup: _tapWarmup, onSettingsChanged: (s) => unawaited(_onSettingsChanged(s)), + onDeload: _deloadExercise, ), ), ), diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_body.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_body.dart index 7064798..d63c94c 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_body.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_body.dart @@ -32,6 +32,7 @@ class _WorkoutBody extends StatelessWidget { required this.onLongPressCircle, required this.onTapWarmup, required this.onSettingsChanged, + required this.onDeload, }); /// The exercises in this session, in display order. @@ -86,6 +87,10 @@ class _WorkoutBody extends StatelessWidget { /// Invoked with the edited state after every settings-sheet change. final ValueChanged onSettingsChanged; + /// Invoked with the exercise index on a confirmed "Deload now"; resolves to + /// the exercise's new state, or null when nothing changed. + final Future Function(int exIdx) onDeload; + @override Widget build(BuildContext context) { return Column( @@ -139,6 +144,7 @@ class _WorkoutBody extends StatelessWidget { onLongPressCircle: (s) => onLongPressCircle(i, s), onTapWarmup: () => onTapWarmup(i), onSettingsChanged: onSettingsChanged, + onDeload: () => onDeload(i), ); } } diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_breaks.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_breaks.dart index 03e8b47..6b9562e 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_breaks.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_breaks.dart @@ -1,4 +1,5 @@ -// Rest-period timers, per-exercise state lookups and settings edits. +// Rest-period timers, per-exercise state lookups, settings edits and +// mid-workout deloads. // // See workout_screen_session.dart for why this is a `part`. `setState` is // `@protected` and unreachable from an extension, so these mutate through the @@ -23,15 +24,13 @@ extension _WorkoutScreenBreaks on _WorkoutScreenState { /// Whether exercise [exIdx] is on an injury pause right now. bool _isPaused(int exIdx) => - _exerciseStates[widget.exercises[exIdx].name]?.isPausedAt( - DateTime.now(), - ) ?? + _exerciseStates[_exercises[exIdx].name]?.isPausedAt(DateTime.now()) ?? false; /// Exercise [exIdx]'s stored state, or its defaults while the states are /// still loading: that is where its rest lengths live. ExerciseState _stateOf(int exIdx) { - final ex = widget.exercises[exIdx]; + final ex = _exercises[exIdx]; return _exerciseStates[ex.name] ?? ExerciseState.initial(ex); } @@ -127,7 +126,7 @@ extension _WorkoutScreenBreaks on _WorkoutScreenState { await StorageService.instance.setExerciseSettings(updated); if (!mounted) return; _applyBreakState(() => _exerciseStates[updated.name] = updated); - final exIdx = widget.exercises.indexWhere((e) => e.name == updated.name); + final exIdx = _exercises.indexWhere((e) => e.name == updated.name); final paused = updated.isPausedAt(DateTime.now()); final warmupRestGone = !updated.hasWarmup && _breakForSetIdx == -1; if (_inBreak && _breakForExIdx == exIdx && (paused || warmupRestGone)) { @@ -136,4 +135,73 @@ extension _WorkoutScreenBreaks on _WorkoutScreenState { // Always: a pause changes the notification's "sets left" and next set. unawaited(_saveActiveSession()); } + + /// Runs a confirmed "Deload now" for exercise [exIdx] and re-targets it in + /// this session; resolves to the new state, or null when nothing changed. + /// + /// Sets not yet tapped move to the new target reps; tapped sets keep what + /// was recorded, and count as a success if that meets the new target. The + /// whole exercise is then recorded at the new weight at Finish, where + /// `applyProgression` applies this session on top of the deloaded row. + Future _deloadExercise(int exIdx) async { + final from = _exercises[exIdx]; + // After Finish the session is cleared; saving it here would resurrect it. + if (_finished) { + return _deloadRefused( + 'The workout is already finished, so nothing was deloaded.', + ); + } + final ManualDeloadResult result; + try { + result = await StorageService.instance.manualDeload( + from.name, + source: DeloadSource.workout, + ); + } on Object catch (e, st) { + log( + 'WorkoutScreen: deload of ${from.name} failed: $e', + level: 1000, + stackTrace: st, + ); + return _deloadRefused('The deload could not be saved: $e'); + } + final next = result.state; + if (next == null) return _deloadRefused(result.reason); + SandboxLog.event('manual deload', { + 'exercise': from.name, + 'fromWeight': from.weight, + 'fromReps': from.reps, + 'toWeight': next.weight, + 'toReps': next.reps, + }); + // The stored target moved whether or not this screen is still up, so the + // remote copy is stale either way. + unawaited( + ProgressionSyncService().pushProgression().then((r) { + if (!r.changed) log('Progression not synced: ${r.reason}', level: 1000); + }), + ); + if (!mounted) return next; + _applyBreakState(() { + _exercises[exIdx] = from.copyWith(weight: next.weight, reps: next.reps); + _exerciseStates[from.name] = next; + for (var s = 0; s < _tapped[exIdx].length; s++) { + if (!_tapped[exIdx][s]) _doneReps[exIdx][s] = next.reps; + } + // A lower target can turn the set this rest belongs to into a success. + _recomputeBreakIfNeeded(exIdx, _breakForSetIdx); + }); + // Also pushes the notification snapshot, which reads `_exercises`. + unawaited(_saveActiveSession(toFirebase: true)); + return next; + } + + /// Shows and logs [why] a deload did not happen; returns null. + ExerciseState? _deloadRefused(String why) { + log('WorkoutScreen: deload not applied — $why', level: 900); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(why))); + } + return null; + } } diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_finish.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_finish.dart index 3d90b72..5ef8754 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_finish.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_finish.dart @@ -13,8 +13,8 @@ extension _WorkoutScreenFinish on _WorkoutScreenState { final endTime = DateTime.now(); final results = []; - for (var i = 0; i < widget.exercises.length; i++) { - final ex = widget.exercises[i]; + for (var i = 0; i < _exercises.length; i++) { + final ex = _exercises[i]; // A paused exercise is recorded as FAILED every time, whatever was // tapped before the pause: the user asked for it to step down while // they are injured, not to come back at the load it left with. @@ -54,8 +54,8 @@ extension _WorkoutScreenFinish on _WorkoutScreenState { final lastDate = await storage.getLastWorkoutDate() ?? _startTime; await storage.applyProgression( succeededExercises: { - for (int i = 0; i < widget.exercises.length; i++) - widget.exercises[i].name: results[i].succeeded, + for (int i = 0; i < _exercises.length; i++) + _exercises[i].name: results[i].succeeded, }, lastWorkoutDate: lastDate, ); diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart index f2686c8..1a81f38 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart @@ -12,13 +12,13 @@ extension _WorkoutScreenIntents on _WorkoutScreenState { BreakSnapshot _buildSnapshot() { final next = _nextTarget(); final last = _lastRecorded(); - final nextEx = next == null ? null : widget.exercises[next.$1]; + final nextEx = next == null ? null : _exercises[next.$1]; return BreakSnapshot( workoutType: widget.workoutType, breakEndMs: _breakClock?.endTime.millisecondsSinceEpoch ?? 0, breakDurationSecs: _breakDurationSecs, breakFailSecs: Sandbox.rest( - _breakForExIdx >= 0 && _breakForExIdx < widget.exercises.length + _breakForExIdx >= 0 && _breakForExIdx < _exercises.length ? _stateOf(_breakForExIdx).restFailSecs : kDefaultRestFailSecs, ), @@ -45,11 +45,11 @@ extension _WorkoutScreenIntents on _WorkoutScreenState { /// set of the lift the user is standing at rather than jumping ahead. (int, int)? _nextTarget() { final preferred = _breakForExIdx; - if (preferred >= 0 && preferred < widget.exercises.length) { + if (preferred >= 0 && preferred < _exercises.length) { final inExercise = _firstUntappedIn(preferred); if (inExercise != null) return (preferred, inExercise); } - for (var ex = 0; ex < widget.exercises.length; ex++) { + for (var ex = 0; ex < _exercises.length; ex++) { final setIdx = _firstUntappedIn(ex); if (setIdx != null) return (ex, setIdx); } @@ -72,7 +72,7 @@ extension _WorkoutScreenIntents on _WorkoutScreenState { if (_breakForExIdx >= 0 && _breakForSetIdx >= 0) { return (_breakForExIdx, _breakForSetIdx); } - for (var ex = widget.exercises.length - 1; ex >= 0; ex--) { + for (var ex = _exercises.length - 1; ex >= 0; ex--) { for (var s = _tapped[ex].length - 1; s >= 0; s--) { if (_tapped[ex][s]) return (ex, s); } @@ -181,7 +181,7 @@ extension _WorkoutScreenIntents on _WorkoutScreenState { } bool _isValidSet(BreakIntent intent) { - final exOk = intent.exIdx >= 0 && intent.exIdx < widget.exercises.length; + final exOk = intent.exIdx >= 0 && intent.exIdx < _exercises.length; if (!exOk || intent.setIdx < 0 || intent.setIdx >= _tapped[intent.exIdx].length) { diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart index f1ab5b8..59b3d5b 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart @@ -134,7 +134,7 @@ extension _WorkoutScreenSession on _WorkoutScreenState { if (_breakForExIdx != exIdx || _breakForSetIdx != setIdx) return; if (_breakForSetIdx == -1) return; // warmup break, never recompute - final succeeded = _doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps; + final succeeded = _doneReps[exIdx][setIdx] >= _exercises[exIdx].reps; final state = _stateOf(exIdx); final newDuration = Sandbox.rest( succeeded ? state.restSuccessSecs : state.restFailSecs, @@ -147,7 +147,7 @@ extension _WorkoutScreenSession on _WorkoutScreenState { /// True when [setIdx] is the last untapped set of exercise [exIdx]. bool _isLastSetOfExercise(int exIdx, int setIdx) { - final sets = widget.exercises[exIdx].sets; + final sets = _exercises[exIdx].sets; for (var s = 0; s < sets; s++) { if (s != setIdx && !_tapped[exIdx][s]) return false; } @@ -160,7 +160,7 @@ extension _WorkoutScreenSession on _WorkoutScreenState { /// resting inside an exercise they have finished. _Rest? _restAfterSet(int exIdx, int setIdx) { if (_isLastSetOfExercise(exIdx, setIdx)) return null; - final succeeded = _doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps; + final succeeded = _doneReps[exIdx][setIdx] >= _exercises[exIdx].reps; final state = _stateOf(exIdx); return _Rest( succeeded ? state.restSuccessSecs : state.restFailSecs, diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_taps.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_taps.dart index 38f79f6..ee0db50 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_taps.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_taps.dart @@ -63,7 +63,7 @@ extension _WorkoutScreenTaps on _WorkoutScreenState { if (_finished) return; _applyBreakState(() { _tapped[exIdx][setIdx] = false; - _doneReps[exIdx][setIdx] = widget.exercises[exIdx].reps; + _doneReps[exIdx][setIdx] = _exercises[exIdx].reps; }); if (_breakForExIdx == exIdx && _breakForSetIdx == setIdx) { _cancelBreak(); diff --git a/stronglift_replacement/workout_app/lib/services/storage_service.dart b/stronglift_replacement/workout_app/lib/services/storage_service.dart index 7ad566d..a7a5a17 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service.dart @@ -3,6 +3,7 @@ library; import 'dart:async'; import 'dart:convert'; +import 'dart:developer' show log; import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; @@ -10,6 +11,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/exercise_state.dart'; +import 'package:workout_app/models/manual_deload.dart'; import 'package:workout_app/models/progression.dart'; import 'package:workout_app/models/workout_plan.dart'; import 'package:workout_app/services/backup_service.dart'; @@ -18,9 +20,11 @@ import 'package:workout_app/services/progression_sync_service.dart'; // Re-exported: ExerciseState was defined here until it moved to models/, and // every caller still reaches it through this import. export 'package:workout_app/models/exercise_state.dart'; +export 'package:workout_app/models/manual_deload.dart'; export 'package:workout_app/models/progression.dart'; part 'storage_service_backup.dart'; +part 'storage_service_deload.dart'; part 'storage_service_exercises.dart'; part 'storage_service_sandbox.dart'; part 'storage_service_schema.dart'; @@ -78,7 +82,7 @@ class StorageService { // coverage:ignore-end _db = await openDatabase( dbPath, - version: 6, + version: 7, onCreate: _createSchema, onUpgrade: _migrateSchema, // In tests resetForTesting() reopens a ':memory:' DB per test; sqflite's diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart b/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart index 4c6c44c..8ad8055 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_backup.dart @@ -21,6 +21,7 @@ extension StorageServiceBackup on StorageService { final exerciseRows = await _db.query('exercise_state'); final historyRows = await _db.query('workout_history'); final settingsRows = await _db.query('settings'); + final eventRows = await _db.query('progression_events'); if (historyRows.isEmpty) { final existing = await BackupService.instance.readBackup(); @@ -41,6 +42,7 @@ extension StorageServiceBackup on StorageService { 'exercise_state': exerciseRows, 'workout_history': historyRows, 'settings': settingsRows, + 'progression_events': eventRows, }); } @@ -161,6 +163,16 @@ extension StorageServiceBackup on StorageService { conflictAlgorithm: ConflictAlgorithm.replace, ); } + // Absent from backups written before schema v7; `?? []` keeps those + // restorable, they just bring no deload log with them. + for (final row in (backup['progression_events'] as List? ?? []) + .cast>()) { + await txn.insert( + 'progression_events', + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } }); } } diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_deload.dart b/stronglift_replacement/workout_app/lib/services/storage_service_deload.dart new file mode 100644 index 0000000..4dc1d95 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/storage_service_deload.dart @@ -0,0 +1,145 @@ +// Manual deload and the progression_events audit trail it writes. +// +// Its own part because it owns its own table: exercises.dart edits a row in +// place, this one changes the target AND records why, atomically. See +// storage_service_backup.dart for why these are `part` extensions. +part of 'storage_service.dart'; + +/// Manual deload and the `progression_events` log. +extension StorageServiceDeload on StorageService { + /// Steps [name]'s target down once, exactly as a fail streak would. + /// + /// The step is [targetAfterFailure], so a manual deload lands where the + /// automatic one would have, in whatever mode the exercise is in. Streaks + /// are cleared like every other hand edit: the old ones counted workouts at + /// a target that no longer exists. + /// + /// The target change and its `progression_events` row are written in one + /// transaction, so the log can never claim a deload that did not happen or + /// miss one that did. Returns a null [ManualDeloadResult.state] -- with the + /// reason -- when nothing changed. + Future manualDeload( + String name, { + required DeloadSource source, + }) async { + final state = await getExerciseState(name); + if (state == null) { + return _deloadRefused( + 'No saved state for "$name", so there is nothing to deload. ' + 'Reopen the app to re-seed the exercise defaults.', + ); + } + + final t = targetAfterFailure(state); + final from = '${_deloadKg(state.weight)} kg × ${state.reps}'; + if (t.weight == state.weight && t.reps == state.reps) { + // weight mode at 0 kg, reps mode at 1 rep, double progression at the + // low rep count with no weight left: the rule has nowhere lower to go. + return _deloadRefused( + '$name is already at $from, the lowest target its ' + '${state.mode.storageKey} progression can step down to. ' + 'Nothing was changed.', + ); + } + + final changed = await _db.transaction((txn) async { + final rows = await txn.update( + 'exercise_state', + { + 'weight': t.weight, + 'reps': t.reps, + 'success_streak': 0, + 'fail_streak': 0, + }, + where: 'name = ?', + whereArgs: [name], + ); + // Only log what actually landed: a row deleted since the read above + // must not leave an event describing a deload nobody received. + if (rows != 1) return false; + await txn.insert('progression_events', { + 'at': DateTime.now().toIso8601String(), + 'exercise': name, + 'kind': kManualDeloadKind, + 'source': source.storageKey, + 'from_weight': state.weight, + 'from_reps': state.reps, + 'to_weight': t.weight, + 'to_reps': t.reps, + }); + return true; + }); + if (!changed) { + return _deloadRefused( + '$name disappeared from exercise_state while deloading; ' + 'nothing was changed or logged.', + ); + } + + unawaited(_backupNow()); + final reason = '$name: $from → ${_deloadKg(t.weight)} kg × ${t.reps}'; + log('StorageService: manual deload (${source.storageKey}) $reason'); + return ManualDeloadResult( + state: state.copyWith( + weight: t.weight, + reps: t.reps, + successStreak: 0, + failStreak: 0, + ), + reason: reason, + ); + } + + /// Every logged target change, newest first; only [exercise]'s if given. + /// + /// A row whose `at` does not parse is skipped with a warning rather than + /// failing the whole list: one corrupt row should not hide the others. + Future> getProgressionEvents({ + String? exercise, + }) async { + final rows = await _db.query( + 'progression_events', + where: exercise == null ? null : 'exercise = ?', + whereArgs: exercise == null ? null : [exercise], + // `id` breaks ties between events logged within the same microsecond. + orderBy: 'at DESC, id DESC', + ); + final events = []; + for (final r in rows) { + final at = DateTime.tryParse(r['at']! as String); + if (at == null) { + log( + 'StorageService: progression_events row ${r['id']} has an ' + 'unparseable timestamp "${r['at']}" — leaving it out of the list. ' + 'Some writer stored a non-ISO-8601 value.', + level: 900, + ); + continue; + } + events.add( + ProgressionEvent( + at: at, + exercise: r['exercise']! as String, + kind: r['kind']! as String, + source: r['source']! as String, + fromWeight: (r['from_weight']! as num).toDouble(), + fromReps: r['from_reps']! as int, + toWeight: (r['to_weight']! as num).toDouble(), + toReps: r['to_reps']! as int, + ), + ); + } + return events; + } + + /// Logs [reason] as a warning and wraps it in a not-applied result. + ManualDeloadResult _deloadRefused(String reason) { + log('StorageService: manual deload refused — $reason', level: 900); + return ManualDeloadResult(state: null, reason: reason); + } +} + +/// `100` for 100.0, `97.5` for 97.5 -- the same rendering as the progression +/// previews (`describeTargetChange`), whose helper is private to its library. +String _deloadKg(double w) => + w == w.roundToDouble() ? w.toInt().toString() : w.toString(); diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_sandbox.dart b/stronglift_replacement/workout_app/lib/services/storage_service_sandbox.dart index 25ca2ad..2c50d36 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_sandbox.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_sandbox.dart @@ -19,6 +19,7 @@ extension StorageServiceSandbox on StorageService { Future wipeAll() async { for (final table in const [ 'workout_history', + 'progression_events', 'active_session', 'settings', 'exercise_state', diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart b/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart index 49a2317..502a584 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart @@ -10,6 +10,25 @@ const _restColumns = 'rest_fail_secs INTEGER NOT NULL DEFAULT $kDefaultRestFailSecs, ' 'rest_warmup_secs INTEGER NOT NULL DEFAULT $kDefaultRestWarmupSecs'; +/// The v7 audit trail of target changes, shared by create and migrate. +/// +/// Only manual deloads land here so far (see `manual_deload.dart`); finished +/// workouts still move targets silently. `at` is local ISO-8601, the same +/// format `workout_history.date` uses. +const _progressionEventsTable = ''' + CREATE TABLE IF NOT EXISTS progression_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + exercise TEXT NOT NULL, + kind TEXT NOT NULL, + source TEXT NOT NULL, + from_weight REAL NOT NULL, + from_reps INTEGER NOT NULL, + to_weight REAL NOT NULL, + to_reps INTEGER NOT NULL + ) +'''; + /// Schema creation, migration, and seeding. extension StorageServiceSchema on StorageService { Future _createSchema(Database db, int version) async { @@ -53,6 +72,7 @@ extension StorageServiceSchema on StorageService { json TEXT NOT NULL ) '''); + await db.execute(_progressionEventsTable); } Future _migrateSchema( @@ -105,6 +125,10 @@ extension StorageServiceSchema on StorageService { await db.execute('ALTER TABLE exercise_state ADD COLUMN $column'); } } + if (oldVersion < 7) { + // A new table, nothing to backfill: deloads before v7 left no trace. + await db.execute(_progressionEventsTable); + } } Future _seedDefaultsIfNeeded() async { diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet.dart index b061d7a..5155edf 100644 --- a/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet.dart +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet.dart @@ -1,19 +1,27 @@ /// Bottom sheet for one exercise's progression mode, rep range, warmup, -/// streak thresholds, rest lengths and injury pause — opened from the mode -/// chip on its workout tile and from the Settings screen. +/// streak thresholds, rest lengths, injury pause and manual deload — opened +/// from the mode chip on its workout tile and from the Settings screen. library; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/exercise_state.dart'; import 'package:workout_app/models/progression.dart'; import 'package:workout_app/ui/theme.dart'; +part 'exercise_settings_sheet_deload.dart'; part 'exercise_settings_sheet_rest.dart'; part 'exercise_settings_sheet_rows.dart'; /// Opens the settings sheet for [state]; [onChanged] runs on every edit. /// +/// [onDeload] runs after the user confirms "Deload now". It is separate +/// from [onChanged] because it moves weight and reps, which the settings +/// write path deliberately never touches; the host persists it and returns +/// the new state, or null when nothing changed. +/// /// Edits apply immediately rather than on a Save button: the sheet is a /// quick mid-workout adjustment, and a dismissed sheet losing its changes /// would be the surprising outcome. @@ -21,12 +29,17 @@ Future showExerciseSettingsSheet( BuildContext context, { required ExerciseState state, required ValueChanged onChanged, + required Future Function() onDeload, }) => showModalBottomSheet( context: context, // Taller than the default 9/16-of-the-screen cap once the rest rows are // in; the sheet sizes to its content and scrolls on a short screen. isScrollControlled: true, - builder: (_) => ExerciseSettingsSheet(state: state, onChanged: onChanged), + builder: (_) => ExerciseSettingsSheet( + state: state, + onChanged: onChanged, + onDeload: onDeload, + ), ); /// Upper bound for the double-progression rep ceiling `n`. @@ -41,6 +54,7 @@ class ExerciseSettingsSheet extends StatefulWidget { const ExerciseSettingsSheet({ required this.state, required this.onChanged, + required this.onDeload, super.key, }); @@ -50,6 +64,9 @@ class ExerciseSettingsSheet extends StatefulWidget { /// Called with the updated state after every edit. final ValueChanged onChanged; + /// Persists a manual deload; returns the new state, or null if none. + final Future Function() onDeload; + @override State createState() => _ExerciseSettingsSheetState(); } @@ -63,6 +80,15 @@ class _ExerciseSettingsSheetState extends State { widget.onChanged(next); } + /// Confirms, then deloads. The returned state replaces [_s] so a later + /// edit in this same sheet does not hand the host a stale weight. + Future _deload() async { + if (!await _confirmDeload(context, _s)) return; + final next = await widget.onDeload(); + if (next == null || !mounted) return; + setState(() => _s = next); + } + String get _modeHint => switch (_s.mode) { ProgressionMode.weight => '+$kWeightIncrement kg per step; +1 rep once at the ' @@ -187,6 +213,7 @@ class _ExerciseSettingsSheetState extends State { ), onResume: () => _update(_s.copyWith(resume: true)), ), + _DeloadRow(state: _s, onDeload: () => unawaited(_deload())), ], ), ), diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_deload.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_deload.dart new file mode 100644 index 0000000..7e74df4 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_deload.dart @@ -0,0 +1,90 @@ +// The manual "Deload now" row of the exercise settings sheet. +// +// `part` file: split out for the 250-line cap; these widgets stay private. +part of 'exercise_settings_sheet.dart'; + +/// `Deload 100 kg × 5 → 97.5 kg [Deload now]` on one fixed-height line. +/// +/// Only ever a step down, and only the step a fail streak would take +/// ([targetAfterFailure]): the target goes up through finished workouts +/// alone. Disabled when the rule has nowhere lower to go. +class _DeloadRow extends StatelessWidget { + const _DeloadRow({required this.state, required this.onDeload}); + + final ExerciseState state; + final VoidCallback onDeload; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final change = describeTargetChange(state, targetAfterFailure(state)); + final canDeload = change != 'same'; + return SizedBox( + height: 40, + child: Row( + children: [ + Expanded( + child: Text( + canDeload + ? 'Deload ${_target(state)} → $change' + : 'Deload nothing lower to go to', + style: TextStyle( + color: colorScheme.onSurface, + fontSize: AppTextSize.label, + ), + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + onPressed: canDeload ? onDeload : null, + child: const Text('Deload now'), + ), + ], + ), + ); + } +} + +/// `100 kg × 5`: the current target, as the confirm dialog and row show it. +String _target(ExerciseState s) { + final kg = s.weight == s.weight.roundToDouble() + ? s.weight.toInt().toString() + : s.weight.toString(); + return '$kg kg × ${s.reps}'; +} + +/// Asks before deloading [s]: the step cannot be undone by hand, only +/// earned back by finished workouts. +Future _confirmDeload(BuildContext context, ExerciseState s) async { + final colorScheme = Theme.of(context).colorScheme; + final change = describeTargetChange(s, targetAfterFailure(s)); + final ok = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + backgroundColor: colorScheme.surfaceContainerHigh, + title: Text( + 'Deload ${s.name}?', + style: TextStyle(color: colorScheme.onSurface), + ), + content: Text( + '${_target(s)} → $change\n\n' + 'Streaks reset. Only finished workouts raise it again.', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text( + 'Cancel', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text('Deload', style: TextStyle(color: colorScheme.error)), + ), + ], + ), + ); + return ok ?? false; +} diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart index 7e2719b..eaf1799 100644 --- a/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart @@ -29,6 +29,7 @@ class ExerciseTile extends StatelessWidget { required this.onLongPressCircle, required this.onTapWarmup, required this.onSettingsChanged, + required this.onDeload, super.key, }); @@ -59,6 +60,10 @@ class ExerciseTile extends StatelessWidget { /// Called with the edited state after every change in the settings sheet. final ValueChanged onSettingsChanged; + /// Called when the user confirms "Deload now" in the settings sheet; + /// returns the deloaded state, or null when nothing changed. + final Future Function() onDeload; + /// Gap between circles in the set row. static const double circleGap = 8; @@ -179,5 +184,6 @@ class ExerciseTile extends StatelessWidget { context, state: state, onChanged: onSettingsChanged, + onDeload: onDeload, ); } diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_deload_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_deload_test.dart new file mode 100644 index 0000000..27186f5 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_deload_test.dart @@ -0,0 +1,165 @@ +// Manual deload from the Settings screen's exercise sheet: the stored and +// shown weights move, a refusal is spoken, a pending stepper write cannot +// undo it, and the progression push fires when the sheet closes. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/screens/settings_screen.dart'; +import 'package:workout_app/services/progression_sync_service.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/exercise_settings_sheet.dart'; + +import '../fake_secure_storage.dart'; + +const _situp = 'Situp exercise settings'; +const _lunge = 'Dumbbell Lunge exercise settings'; + +void main() { + late int pushes; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeSecureStorage(); + pushes = 0; + }); + + Future pumpSettings(WidgetTester tester) async { + // Tall enough that no scrolling (which advances the fake clock) is needed + // between a stepper tap and the deload: the debounce is 600 ms. + tester.view + ..physicalSize = const Size(800, 3000) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: SettingsScreen( + firebaseFactory: () async => null, + accountLoader: () async => null, + sessionProbe: () async => false, + storageChecker: () async => false, + progressionPuller: () async => + const ProgressionSyncResult(changed: false, reason: 'stub'), + progressionPusher: () async { + pushes++; + return const ProgressionSyncResult( + changed: true, + reason: 'pushed', + ); + }, + ), + ), + ); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + } + + Future openExercise(WidgetTester tester, String label) async { + await tester.tap(find.bySemanticsLabel(label)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(ExerciseSettingsSheet), findsOneWidget); + } + + /// Taps "Deload now" and confirms; the storage write is a real future. + Future deloadNow(WidgetTester tester) async { + await tester.tap(find.text('Deload now')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tap(find.widgetWithText(TextButton, 'Deload')); + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 300)), + ); + await tester.pumpAndSettle(); + } + + Future closeSheet(WidgetTester tester) async { + Navigator.of(tester.element(find.byType(ExerciseSettingsSheet))).pop(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 300)), + ); + await tester.pumpAndSettle(); + } + + /// The weight shown on the WEIGHTS row of the first lunge. + Future lungeWeight(WidgetTester tester) async { + final row = find + .ancestor( + of: find.text('Dumbbell Lunge').first, + matching: find.byType(Row), + ) + .first; + return tester + .widget( + find.descendant(of: row, matching: find.textContaining('kg')), + ) + .data!; + } + + Future stored(WidgetTester tester, String name) async => + (await tester.runAsync( + () => StorageService.instance.getExerciseState(name), + ))!; + + testWidgets('deloading updates the list, the store and pushes on close', ( + tester, + ) async { + await pumpSettings(tester); + expect(await lungeWeight(tester), '7.5kg'); + await openExercise(tester, _lunge); + await deloadNow(tester); + + expect(find.text('Deload 5 kg × 12 → 2.5 kg'), findsOneWidget); + expect((await stored(tester, 'Dumbbell Lunge')).weight, 5); + expect(pushes, 0); + + await closeSheet(tester); + expect(pushes, 1); + expect(await lungeWeight(tester), '5.0kg'); + }); + + testWidgets('a refused deload shows why and pushes nothing', (tester) async { + await pumpSettings(tester); + await openExercise(tester, _situp); + // The sheet still offers a step down, but the store is already at 0 kg. + await tester.runAsync( + () => StorageService.instance.setExerciseWeight('Situp', 0), + ); + await deloadNow(tester); + + expect(find.textContaining('Situp is already at 0 kg × 30'), findsOne); + expect(find.text('Deload 10 kg × 30 → 7.5 kg'), findsOneWidget); + await closeSheet(tester); + expect(pushes, 0); + }); + + testWidgets('a pending stepper write cannot undo the deload', (tester) async { + await pumpSettings(tester); + // First weight row is the lunge; this queues a 600 ms debounced write + // of 10 kg. + await tester.tap(find.byIcon(Icons.add).first); + await tester.pump(); + expect(await lungeWeight(tester), '10.0kg'); + await openExercise(tester, _lunge); + await deloadNow(tester); + + await tester.pump(const Duration(milliseconds: 700)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 300)), + ); + await tester.pump(); + expect((await stored(tester, 'Dumbbell Lunge')).weight, 5); + await closeSheet(tester); + expect(await lungeWeight(tester), '5.0kg'); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/workout_screen_deload_test.dart b/stronglift_replacement/workout_app/test/screens/workout_screen_deload_test.dart new file mode 100644 index 0000000..5803ab0 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/workout_screen_deload_test.dart @@ -0,0 +1,250 @@ +// "Deload now" from an exercise tile mid-workout: the tile re-targets, tapped +// sets keep what was recorded, Finish records the new weight, and every +// refusal (at the floor, storage failing, already finished) says so. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/models/exercise.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/widgets/rep_circle.dart'; +import 'package:workout_app/widgets/workout_summary_dialog.dart'; + +import '../fake_audio_platform.dart'; +import '../fake_break_service.dart'; +import '../fake_secure_storage.dart'; +import '_workout_screen_test_fixtures.dart'; + +// Real plan names, seeded as Lunge 7.5 kg x 12 and Row 22.5 kg x 6. +const _lunge = Exercise(name: 'Dumbbell Lunge', sets: 3, reps: 12, weight: 7.5); +const _row = Exercise(name: 'Dumbbell Row', sets: 3, reps: 6, weight: 22.5); +const List _both = [_lunge, _row]; + +void main() { + late FakeForegroundBreakClient client; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeSecureStorage(); + installFakeAudioPlatform(); + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + client = FakeForegroundBreakClient(); + }); + + /// The settings sheet is taller than the default 800x600 test surface. + void tallScreen(WidgetTester tester) { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + } + + Future pumpBoth(WidgetTester tester) { + tallScreen(tester); + return pumpWorkout( + tester, + wrapWorkout( + exercises: _both, + savedState: blankSaved(_both), + breakClient: client, + ), + ); + } + + /// Opens Lunge's settings sheet and taps "Deload now". + Future openDeload(WidgetTester tester) async { + await tapReal( + tester, + find.bySemanticsLabel('Dumbbell Lunge progression settings'), + ); + await tester.pump(const Duration(seconds: 1)); // sheet slide-in + await tapReal(tester, find.text('Deload now')); + } + + /// Deloads Lunge through the sheet, confirming or cancelling the dialog. + Future deloadLunge(WidgetTester tester, {bool confirm = true}) async { + await openDeload(tester); + expect(find.text('Deload Dumbbell Lunge?'), findsOneWidget); + await tapReal(tester, find.text(confirm ? 'Deload' : 'Cancel')); + await tester.pump(const Duration(milliseconds: 300)); + } + + /// Finish -> confirm -> summary dialog up, with the writes done. + Future finish(WidgetTester tester) async { + await tester.runAsync(() async { + await tester.tap(find.widgetWithText(TextButton, 'Finish')); + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pump(); + await tester.tap(find.widgetWithText(TextButton, 'Finish').last); + await Future.delayed(const Duration(milliseconds: 800)); + await tester.pump(); + }); + await tester.pump(const Duration(milliseconds: 300)); + } + + Future> lastSavedWorkout(WidgetTester tester) async { + final rows = await tester.runAsync( + () => StorageService.instance.getWorkoutHistory(), + ); + return jsonDecode(rows!.single['json']! as String) as Map; + } + + testWidgets('re-targets the tile and untapped sets, keeps tapped ones', ( + tester, + ) async { + await pumpBoth(tester); + expect(find.text('3×12×7.5kg'), findsOneWidget); + final lunge = find.byType(RepCircle).at(0); + await tapReal(tester, lunge); // set 1: 12 reps, a success -> rest starts + await tapReal(tester, lunge); // set 1 again: one rep short, 11 + expect(restRunning(tester), isTrue); + + await deloadLunge(tester); + + expect(find.text('3×12×5.0kg'), findsOneWidget); + expect(find.text('3×12×7.5kg'), findsNothing); + expect(find.text('3×6×22.5kg'), findsOneWidget); + final circles = tester.widgetList(find.byType(RepCircle)); + expect(circles.first.tapped, isTrue); + expect(circles.first.doneReps, 11); + expect(circles.elementAt(1).tapped, isFalse); + expect(circles.elementAt(1).doneReps, 12); + expect(restRunning(tester), isTrue); + + final stored = await tester.runAsync( + () => StorageService.instance.getExerciseState(_lunge.name), + ); + expect(stored!.weight, 5.0); + final events = await tester.runAsync( + () => StorageService.instance.getProgressionEvents(exercise: _lunge.name), + ); + expect(events!.single.source, DeloadSource.workout.storageKey); + expect(events.single.toWeight, 5.0); + }); + + testWidgets('cancelling the dialog changes nothing', (tester) async { + await pumpBoth(tester); + await deloadLunge(tester, confirm: false); + expect(find.text('3×12×7.5kg'), findsOneWidget); + final events = await tester.runAsync( + StorageService.instance.getProgressionEvents, + ); + expect(events, isEmpty); + }); + + testWidgets('Finish records the deloaded exercise at the new weight', ( + tester, + ) async { + await pumpBoth(tester); + await deloadLunge(tester); + await tester.tapAt(const Offset(5, 5)); // scrim: close the sheet + await tester.pump(const Duration(seconds: 1)); + for (var s = 0; s < _both.length * 3; s++) { + if (restRunning(tester)) await tapReal(tester, find.text('Skip')); + await tapReal(tester, find.byType(RepCircle).at(s)); + } + + await finish(tester); + expect(find.byType(WorkoutSummaryDialog), findsOneWidget); + + final saved = await lastSavedWorkout(tester); + final encoded = jsonEncode((saved['exercises'] as List).first); + expect(encoded, contains('5.0')); + expect(encoded, isNot(contains('7.5'))); + }); + + testWidgets('refuses at the floor and says so', (tester) async { + await pumpBoth(tester); + // Walk the stored target to 0 kg behind the screen's back, so the sheet + // still offers a deload that storage can no longer give. + await tester.runAsync(() async { + ManualDeloadResult r; + do { + r = await StorageService.instance.manualDeload( + _lunge.name, + source: DeloadSource.settings, + ); + } while (r.applied); + }); + + await deloadLunge(tester); + + expect(find.textContaining('already at 0 kg'), findsOneWidget); + expect(find.text('3×12×7.5kg'), findsOneWidget); + }); + + testWidgets('refuses and shows the error when storage throws', ( + tester, + ) async { + final dir = Directory.systemTemp.createTempSync('deload_test_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final path = '${dir.path}/workout.db'; + StorageService.resetForTesting(dbPath: path); + await tester.runAsync(StorageService.init); + await pumpBoth(tester); + await tester.runAsync(() async { + final other = await databaseFactoryFfi.openDatabase(path); + await other.execute('DROP TABLE exercise_state'); + await other.close(); + }); + + await deloadLunge(tester); + + expect( + find.textContaining('The deload could not be saved'), + findsOneWidget, + ); + expect(find.text('3×12×7.5kg'), findsOneWidget); + }); + + testWidgets('refuses once the workout is finished', (tester) async { + tallScreen(tester); + await pumpWorkout( + tester, + wrapWorkout( + exercises: _both, + savedState: completeSaved(), + breakClient: client, + ), + ); + await finish(tester); + await tester.tap(find.text('Back to Home')); + await tester.pump(const Duration(milliseconds: 300)); + + await deloadLunge(tester); + + expect(find.textContaining('already finished'), findsOneWidget); + final stored = await tester.runAsync( + () => StorageService.instance.getExerciseState(_lunge.name), + ); + expect(stored!.weight, 7.5); + }); + + testWidgets('a screen closed mid-deload still stores the deload', ( + tester, + ) async { + await pumpBoth(tester); + await openDeload(tester); + await tester.runAsync(() async { + await tester.tap(find.text('Deload')); + await Future.delayed(Duration.zero); + await tester.pumpWidget(const SizedBox()); + await Future.delayed(const Duration(milliseconds: 500)); + }); + + final stored = await tester.runAsync( + () => StorageService.instance.getExerciseState(_lunge.name), + ); + expect(stored!.weight, 5.0); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/_deload_test_fixtures.dart b/stronglift_replacement/workout_app/test/services/_deload_test_fixtures.dart new file mode 100644 index 0000000..c0774ee --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/_deload_test_fixtures.dart @@ -0,0 +1,32 @@ +import 'package:workout_app/models/exercise_state.dart'; +import 'package:workout_app/models/progression.dart'; + +/// A synthetic exercise state for deload tests; [maxWeight] is high enough +/// that `targetAfterFailure`'s clamp never interferes. +ExerciseState deloadState({ + String name = 'Deload Lift', + double weight = 100, + int reps = 5, + ProgressionMode mode = ProgressionMode.weight, + int repsHigh = 12, + int repsLow = 6, + int successStreak = 2, + int failStreak = 1, +}) => ExerciseState( + name: name, + weight: weight, + reps: reps, + successStreak: successStreak, + failStreak: failStreak, + maxWeight: 200, + successThreshold: 3, + failThreshold: 2, + mode: mode, + repsHigh: repsHigh, + repsLow: repsLow, + hasWarmup: true, + pausedUntil: null, + restSuccessSecs: kDefaultRestSuccessSecs, + restFailSecs: kDefaultRestFailSecs, + restWarmupSecs: kDefaultRestWarmupSecs, +); diff --git a/stronglift_replacement/workout_app/test/services/storage_service_deload_persistence_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_deload_persistence_test.dart new file mode 100644 index 0000000..461ca29 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/storage_service_deload_persistence_test.dart @@ -0,0 +1,199 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '_deload_test_fixtures.dart'; + +StorageService get _svc => StorageService.instance; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + late Directory dir; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('deload_persist'); + BackupService.baseDir = p.join(dir.path, 'backup'); + }); + + tearDown(() async { + BackupService.baseDir = kBackupDir; + StorageService.resetForTesting(); + await dir.delete(recursive: true); + }); + + /// Opens the DB at [path] through StorageService, then a second raw handle. + Future openBoth(String path) async { + StorageService.resetForTesting(dbPath: path); + await StorageService.init(); + final raw = await databaseFactory.openDatabase(path); + return raw; + } + + Map eventRow({String at = '2026-10-01T08:00:00.000'}) => { + 'at': at, + 'exercise': 'Deload Lift', + 'kind': kManualDeloadKind, + 'source': 'settings', + 'from_weight': 100.0, + 'from_reps': 5, + 'to_weight': 97.5, + 'to_reps': 5, + }; + + test('v6 database migrates to v7 with an empty progression_events', () async { + final path = p.join(dir.path, 'v6.db'); + final old = await databaseFactory.openDatabase( + path, + options: OpenDatabaseOptions( + version: 6, + onCreate: (db, _) async { + await db.execute( + 'CREATE TABLE exercise_state (name TEXT PRIMARY KEY, ' + 'weight REAL NOT NULL, reps INTEGER NOT NULL, ' + 'success_streak INTEGER NOT NULL DEFAULT 0, ' + 'fail_streak INTEGER NOT NULL DEFAULT 0, ' + 'max_weight REAL NOT NULL, ' + 'success_threshold INTEGER NOT NULL DEFAULT 3, ' + 'fail_threshold INTEGER NOT NULL DEFAULT 2, ' + "progression_mode TEXT NOT NULL DEFAULT 'weight', " + 'reps_high INTEGER NOT NULL DEFAULT 12, ' + 'reps_low INTEGER NOT NULL DEFAULT 6, has_warmup INTEGER, ' + 'paused_until TEXT, ' + 'rest_success_secs INTEGER NOT NULL DEFAULT 180, ' + 'rest_fail_secs INTEGER NOT NULL DEFAULT 300, ' + 'rest_warmup_secs INTEGER NOT NULL DEFAULT 180)', + ); + await db.execute( + 'CREATE TABLE workout_history (id INTEGER PRIMARY KEY ' + 'AUTOINCREMENT, date TEXT NOT NULL, workout_type TEXT NOT NULL, ' + 'duration_seconds INTEGER NOT NULL, succeeded INTEGER NOT NULL, ' + 'json TEXT NOT NULL)', + ); + await db.execute( + 'CREATE TABLE settings (key TEXT PRIMARY KEY, ' + 'value TEXT NOT NULL)', + ); + await db.execute( + 'CREATE TABLE active_session (id INTEGER PRIMARY KEY ' + 'CHECK (id = 1), json TEXT NOT NULL)', + ); + await db.insert('exercise_state', { + 'name': 'Deload Lift', + 'weight': 42.5, + 'reps': 7, + 'max_weight': 100.0, + }); + }, + ), + ); + await old.close(); + + StorageService.resetForTesting(dbPath: path); + await StorageService.init(); + + expect(await _svc.getProgressionEvents(), isEmpty); + // The pre-existing row survived and the new table is writable by deload. + final kept = (await _svc.getExerciseState('Deload Lift'))!; + expect((kept.weight, kept.reps), (42.5, 7)); + final r = await _svc.manualDeload( + 'Deload Lift', + source: DeloadSource.settings, + ); + expect(r.applied, isTrue); + expect(await _svc.getProgressionEvents(), hasLength(1)); + }); + + test('a row with an unparsable timestamp is skipped, not fatal', () async { + final raw = await openBoth(p.join(dir.path, 'corrupt.db')); + await raw.insert('progression_events', eventRow(at: 'last tuesday')); + await raw.insert('progression_events', eventRow()); + await raw.close(); + + final events = await _svc.getProgressionEvents(); + expect(events, hasLength(1)); + expect(events.single.at, DateTime(2026, 10, 1, 8)); + }); + + test('refuses and logs nothing if the row vanishes mid-deload', () async { + final raw = await openBoth(p.join(dir.path, 'vanish.db')); + await _svc.replaceExerciseState(deloadState()); + // RAISE(IGNORE) makes the UPDATE touch 0 rows, as a concurrent delete + // between the read and the transaction would. + await raw.execute( + 'CREATE TRIGGER skip_update BEFORE UPDATE ON exercise_state ' + 'BEGIN SELECT RAISE(IGNORE); END', + ); + + final r = await _svc.manualDeload( + 'Deload Lift', + source: DeloadSource.workout, + ); + expect(r.applied, isFalse); + expect(r.reason, contains('disappeared from exercise_state')); + expect(await raw.query('progression_events'), isEmpty); + await raw.close(); + }); + + group('backup', () { + Future> backupWithEvents(int count) async { + // _backupNow is fire-and-forget; poll until it has landed. + for (var i = 0; i < 100; i++) { + final b = await BackupService.instance.readBackup(); + final events = (b?['progression_events'] as List?) ?? const []; + if (events.length == count) return b!; + await Future.delayed(const Duration(milliseconds: 20)); + } + fail('backup never reached $count progression_events'); + } + + test('events round-trip through backup and restore', () async { + StorageService.resetForTesting(); + await StorageService.init(); + await _svc.replaceExerciseState(deloadState()); + await _svc.manualDeload('Deload Lift', source: DeloadSource.settings); + final original = (await _svc.getProgressionEvents()).single; + await backupWithEvents(1); + + // A reinstall: brand-new empty DB, then restore from the backup file. + StorageService.resetForTesting(); + await StorageService.init(); + expect(await _svc.getProgressionEvents(), isEmpty); + await _svc.restoreFromBackupIfNeeded(); + + final restored = (await _svc.getProgressionEvents()).single; + expect(restored.at, original.at); + expect( + (restored.exercise, restored.kind, restored.source), + ('Deload Lift', 'manual_deload', 'settings'), + ); + expect((restored.fromWeight, restored.toWeight), (100.0, 97.5)); + expect((restored.fromReps, restored.toReps), (5, 5)); + final st = (await _svc.getExerciseState('Deload Lift'))!; + expect(st.weight, 97.5); + }); + + test('a pre-v7 backup without the key still restores', () async { + Directory(BackupService.baseDir).createSync(recursive: true); + File(p.join(BackupService.baseDir, 'backup.json')).writeAsStringSync( + jsonEncode({ + 'exercise_state': [], + 'workout_history': [], + 'settings': [], + }), + ); + StorageService.resetForTesting(); + await StorageService.init(); + await _svc.restoreFromBackupIfNeeded(); + expect(await _svc.getProgressionEvents(), isEmpty); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/storage_service_deload_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_deload_test.dart new file mode 100644 index 0000000..428a6c2 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/storage_service_deload_test.dart @@ -0,0 +1,195 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/services/backup_service.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '_deload_test_fixtures.dart'; + +StorageService get _svc => StorageService.instance; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + late Directory backupDir; + + setUp(() async { + // Deloads trigger a fire-and-forget backup: keep it off /sdcard. + backupDir = await Directory.systemTemp.createTemp('deload_backup'); + BackupService.baseDir = backupDir.path; + StorageService.resetForTesting(); + await StorageService.init(); + }); + + tearDown(() async { + BackupService.baseDir = kBackupDir; + await backupDir.delete(recursive: true); + }); + + Future seed(String name, {required double weight}) => + _svc.replaceExerciseState(deloadState(name: name, weight: weight)); + + group('manualDeload steps down exactly like a fail streak', () { + test('weight mode: 100 kg becomes 97.5 kg, reps unchanged', () async { + await _svc.replaceExerciseState(deloadState()); + final r = await _svc.manualDeload( + 'Deload Lift', + source: DeloadSource.settings, + ); + expect(r.applied, isTrue); + expect((r.state!.weight, r.state!.reps), (97.5, 5)); + expect(r.reason, 'Deload Lift: 100 kg × 5 → 97.5 kg × 5'); + final stored = (await _svc.getExerciseState('Deload Lift'))!; + expect((stored.weight, stored.reps), (97.5, 5)); + }); + + test('reps mode: one rep fewer, weight untouched', () async { + await _svc.replaceExerciseState( + deloadState(mode: ProgressionMode.reps, reps: 20), + ); + final r = await _svc.manualDeload( + 'Deload Lift', + source: DeloadSource.workout, + ); + expect((r.state!.weight, r.state!.reps), (100.0, 19)); + expect((await _svc.getExerciseState('Deload Lift'))!.reps, 19); + }); + + test('double progression above repsLow drops one rep', () async { + await _svc.replaceExerciseState( + deloadState(mode: ProgressionMode.doubleProgression, reps: 9), + ); + final r = await _svc.manualDeload( + 'Deload Lift', + source: DeloadSource.workout, + ); + expect((r.state!.weight, r.state!.reps), (100.0, 8)); + }); + + test( + 'double progression at repsLow goes lighter and to repsHigh', + () async { + await _svc.replaceExerciseState( + deloadState(mode: ProgressionMode.doubleProgression, reps: 6), + ); + final r = await _svc.manualDeload( + 'Deload Lift', + source: DeloadSource.workout, + ); + expect((r.state!.weight, r.state!.reps), (97.5, 12)); + final stored = (await _svc.getExerciseState('Deload Lift'))!; + expect((stored.weight, stored.reps), (97.5, 12)); + }, + ); + + test('clears both streaks, in the result and in the database', () async { + await _svc.replaceExerciseState(deloadState()); + final r = await _svc.manualDeload( + 'Deload Lift', + source: DeloadSource.settings, + ); + expect((r.state!.successStreak, r.state!.failStreak), (0, 0)); + final stored = (await _svc.getExerciseState('Deload Lift'))!; + expect((stored.successStreak, stored.failStreak), (0, 0)); + }); + + test('renders non-integer weights without a trailing .0', () async { + await _svc.replaceExerciseState(deloadState(weight: 22.5)); + final r = await _svc.manualDeload( + 'Deload Lift', + source: DeloadSource.settings, + ); + expect(r.reason, 'Deload Lift: 22.5 kg × 5 → 20 kg × 5'); + }); + }); + + group('manualDeload refusals change nothing', () { + test('unknown exercise: null state, reason, no event', () async { + final r = await _svc.manualDeload( + 'Nonexistent', + source: DeloadSource.settings, + ); + expect(r.applied, isFalse); + expect(r.state, isNull); + expect(r.reason, contains('No saved state for "Nonexistent"')); + expect(await _svc.getProgressionEvents(), isEmpty); + }); + + test('weight mode at 0 kg has nowhere lower to go', () async { + await _svc.replaceExerciseState(deloadState(weight: 0)); + final r = await _svc.manualDeload( + 'Deload Lift', + source: DeloadSource.workout, + ); + expect(r.applied, isFalse); + expect(r.reason, contains('already at 0 kg × 5')); + expect(r.reason, contains('weight progression')); + final stored = (await _svc.getExerciseState('Deload Lift'))!; + expect((stored.weight, stored.reps), (0.0, 5)); + // Refusal must not wipe the streaks either. + expect((stored.successStreak, stored.failStreak), (2, 1)); + expect(await _svc.getProgressionEvents(), isEmpty); + }); + + test('reps mode at 1 rep is the floor', () async { + await _svc.replaceExerciseState( + deloadState(mode: ProgressionMode.reps, reps: 1), + ); + final r = await _svc.manualDeload( + 'Deload Lift', + source: DeloadSource.workout, + ); + expect(r.applied, isFalse); + expect(r.reason, contains('reps progression')); + expect(await _svc.getProgressionEvents(), isEmpty); + }); + }); + + group('progression_events', () { + test('a deload writes one fully populated row', () async { + await _svc.replaceExerciseState(deloadState()); + final before = DateTime.now(); + await _svc.manualDeload('Deload Lift', source: DeloadSource.workout); + final after = DateTime.now(); + + final events = await _svc.getProgressionEvents(); + expect(events, hasLength(1)); + final e = events.single; + expect(e.exercise, 'Deload Lift'); + expect(e.kind, kManualDeloadKind); + expect(e.kind, 'manual_deload'); + expect(e.source, 'workout'); + expect((e.fromWeight, e.fromReps), (100.0, 5)); + expect((e.toWeight, e.toReps), (97.5, 5)); + expect(e.at.isBefore(before), isFalse); + expect(e.at.isAfter(after), isFalse); + }); + + test('source is stored per call', () async { + await seed('Deload Lift', weight: 100); + await _svc.manualDeload('Deload Lift', source: DeloadSource.settings); + expect((await _svc.getProgressionEvents()).single.source, 'settings'); + }); + + test('newest first, filterable by exercise', () async { + await seed('Lift One', weight: 100); + await seed('Lift Two', weight: 50); + await _svc.manualDeload('Lift One', source: DeloadSource.settings); + await _svc.manualDeload('Lift Two', source: DeloadSource.settings); + await _svc.manualDeload('Lift One', source: DeloadSource.workout); + + final all = await _svc.getProgressionEvents(); + expect( + [for (final e in all) (e.exercise, e.toWeight)], + [('Lift One', 95.0), ('Lift Two', 47.5), ('Lift One', 97.5)], + ); + final one = await _svc.getProgressionEvents(exercise: 'Lift One'); + expect([for (final e in one) e.toWeight], [95.0, 97.5]); + expect(await _svc.getProgressionEvents(exercise: 'Nobody'), isEmpty); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/exercise_settings_sheet_deload_test.dart b/stronglift_replacement/workout_app/test/widgets/exercise_settings_sheet_deload_test.dart new file mode 100644 index 0000000..cfc0500 --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/exercise_settings_sheet_deload_test.dart @@ -0,0 +1,102 @@ +// The "Deload now" row: confirm dialog, host hand-off, re-render. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/exercise_state.dart'; +import 'package:workout_app/models/workout_plan.dart'; +import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/exercise_settings_sheet.dart'; + +final _bench = ExerciseState.initial(workoutA[1]); // 22.5 kg x 12 + +Future _pump( + WidgetTester tester, + ExerciseState state, + Future Function() onDeload, +) async { + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: ExerciseSettingsSheet( + state: state, + onChanged: (_) {}, + onDeload: onDeload, + ), + ), + ), + ); + await tester.ensureVisible(find.text('Deload now')); + await tester.pump(); +} + +TextButton _button(WidgetTester tester) => + tester.widget(find.widgetWithText(TextButton, 'Deload now')); + +void main() { + testWidgets('the row names the current and the next target', (tester) async { + await _pump(tester, _bench, () async => null); + expect(find.text('Deload 22.5 kg × 12 → 20 kg'), findsOneWidget); + expect(_button(tester).onPressed, isNotNull); + }); + + testWidgets('a whole-kilo weight prints without a decimal', (tester) async { + await _pump(tester, _bench.copyWith(weight: 20), () async => null); + expect(find.text('Deload 20 kg × 12 → 17.5 kg'), findsOneWidget); + }); + + testWidgets('confirming deloads and the row shows the next step down', ( + tester, + ) async { + var calls = 0; + await _pump(tester, _bench, () async { + calls++; + return _bench.copyWith(weight: 20); + }); + await tester.tap(find.text('Deload now')); + await tester.pumpAndSettle(); + expect(find.text('Deload Dumbbell Bench Press?'), findsOneWidget); + expect(find.textContaining('22.5 kg × 12 → 20 kg'), findsWidgets); + expect(calls, 0); + + await tester.tap(find.widgetWithText(TextButton, 'Deload')); + await tester.pumpAndSettle(); + expect(calls, 1); + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('Deload 20 kg × 12 → 17.5 kg'), findsOneWidget); + }); + + testWidgets('cancelling leaves the host and the row alone', (tester) async { + var calls = 0; + await _pump(tester, _bench, () async { + calls++; + return null; + }); + await tester.tap(find.text('Deload now')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(calls, 0); + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('Deload 22.5 kg × 12 → 20 kg'), findsOneWidget); + }); + + testWidgets('a refused deload (null) keeps the shown state', (tester) async { + var calls = 0; + await _pump(tester, _bench, () async { + calls++; + return null; + }); + await tester.tap(find.text('Deload now')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, 'Deload')); + await tester.pumpAndSettle(); + expect(calls, 1); + expect(find.text('Deload 22.5 kg × 12 → 20 kg'), findsOneWidget); + }); + + testWidgets('at the floor the row is disabled and says why', (tester) async { + await _pump(tester, _bench.copyWith(weight: 0), () async => null); + expect(find.text('Deload nothing lower to go to'), findsOneWidget); + expect(_button(tester).onPressed, isNull); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/exercise_settings_sheet_test.dart b/stronglift_replacement/workout_app/test/widgets/exercise_settings_sheet_test.dart index 98badb0..2c18a5b 100644 --- a/stronglift_replacement/workout_app/test/widgets/exercise_settings_sheet_test.dart +++ b/stronglift_replacement/workout_app/test/widgets/exercise_settings_sheet_test.dart @@ -20,6 +20,7 @@ Future> _pump( body: ExerciseSettingsSheet( state: state ?? _base, onChanged: edits.add, + onDeload: () async => null, ), ), ), @@ -172,6 +173,7 @@ void main() { context, state: _base, onChanged: (_) {}, + onDeload: () async => null, ), child: const Text('open'), ), diff --git a/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart b/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart index d30cf95..6d6aec5 100644 --- a/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart +++ b/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart @@ -36,6 +36,7 @@ ExerciseTile _tile({ onLongPressCircle: onLongPressCircle ?? (_) {}, onTapWarmup: onTapWarmup ?? () {}, onSettingsChanged: onSettingsChanged ?? (_) {}, + onDeload: () async => null, ); Color _cardColor(WidgetTester tester) =>