mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KejiqD7y4NXRiPBKQeWHSy
This commit is contained in:
parent
0b809c4af1
commit
7e1b4e865e
@ -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;
|
||||
}
|
||||
@ -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<SettingsScreen> {
|
||||
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<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
||||
@ -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<ExerciseState?> _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;
|
||||
}
|
||||
}
|
||||
@ -79,6 +79,8 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
||||
late List<bool> _warmupTapped;
|
||||
late DateTime _startTime;
|
||||
|
||||
/// This session's targets: a copy, so a mid-workout deload can re-target.
|
||||
late final List<Exercise> _exercises = List.of(widget.exercises);
|
||||
Map<String, ExerciseState> _exerciseStates = {};
|
||||
|
||||
// Break state. The deadline is the source of truth: `_breakClock` is what
|
||||
@ -136,14 +138,14 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
||||
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<void> _loadExerciseStates() async {
|
||||
@ -184,9 +186,8 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
|
||||
/// 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<WorkoutScreen> {
|
||||
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<WorkoutScreen> {
|
||||
onLongPressCircle: _resetCircle,
|
||||
onTapWarmup: _tapWarmup,
|
||||
onSettingsChanged: (s) => unawaited(_onSettingsChanged(s)),
|
||||
onDeload: _deloadExercise,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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<ExerciseState> 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<ExerciseState?> 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ExerciseState?> _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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,8 +13,8 @@ extension _WorkoutScreenFinish on _WorkoutScreenState {
|
||||
final endTime = DateTime.now();
|
||||
final results = <ExerciseResult>[];
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<Map<String, dynamic>>()) {
|
||||
await txn.insert(
|
||||
'progression_events',
|
||||
row,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ManualDeloadResult> 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<List<ProgressionEvent>> 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 = <ProgressionEvent>[];
|
||||
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();
|
||||
@ -19,6 +19,7 @@ extension StorageServiceSandbox on StorageService {
|
||||
Future<void> wipeAll() async {
|
||||
for (final table in const [
|
||||
'workout_history',
|
||||
'progression_events',
|
||||
'active_session',
|
||||
'settings',
|
||||
'exercise_state',
|
||||
|
||||
@ -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<void> _createSchema(Database db, int version) async {
|
||||
@ -53,6 +72,7 @@ extension StorageServiceSchema on StorageService {
|
||||
json TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
await db.execute(_progressionEventsTable);
|
||||
}
|
||||
|
||||
Future<void> _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<void> _seedDefaultsIfNeeded() async {
|
||||
|
||||
@ -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<void> showExerciseSettingsSheet(
|
||||
BuildContext context, {
|
||||
required ExerciseState state,
|
||||
required ValueChanged<ExerciseState> onChanged,
|
||||
required Future<ExerciseState?> Function() onDeload,
|
||||
}) => showModalBottomSheet<void>(
|
||||
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<ExerciseState> onChanged;
|
||||
|
||||
/// Persists a manual deload; returns the new state, or null if none.
|
||||
final Future<ExerciseState?> Function() onDeload;
|
||||
|
||||
@override
|
||||
State<ExerciseSettingsSheet> createState() => _ExerciseSettingsSheetState();
|
||||
}
|
||||
@ -63,6 +80,15 @@ class _ExerciseSettingsSheetState extends State<ExerciseSettingsSheet> {
|
||||
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<void> _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<ExerciseSettingsSheet> {
|
||||
),
|
||||
onResume: () => _update(_s.copyWith(resume: true)),
|
||||
),
|
||||
_DeloadRow(state: _s, onDeload: () => unawaited(_deload())),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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<bool> _confirmDeload(BuildContext context, ExerciseState s) async {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final change = describeTargetChange(s, targetAfterFailure(s));
|
||||
final ok = await showDialog<bool>(
|
||||
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;
|
||||
}
|
||||
@ -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<ExerciseState> onSettingsChanged;
|
||||
|
||||
/// Called when the user confirms "Deload now" in the settings sheet;
|
||||
/// returns the deloaded state, or null when nothing changed.
|
||||
final Future<ExerciseState?> 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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<void> 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<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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<void>.delayed(const Duration(milliseconds: 300)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> closeSheet(WidgetTester tester) async {
|
||||
Navigator.of(tester.element(find.byType(ExerciseSettingsSheet))).pop();
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 300)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// The weight shown on the WEIGHTS row of the first lunge.
|
||||
Future<String> lungeWeight(WidgetTester tester) async {
|
||||
final row = find
|
||||
.ancestor(
|
||||
of: find.text('Dumbbell Lunge').first,
|
||||
matching: find.byType(Row),
|
||||
)
|
||||
.first;
|
||||
return tester
|
||||
.widget<Text>(
|
||||
find.descendant(of: row, matching: find.textContaining('kg')),
|
||||
)
|
||||
.data!;
|
||||
}
|
||||
|
||||
Future<ExerciseState> 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<void>.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');
|
||||
});
|
||||
}
|
||||
@ -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<Exercise> _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<void> 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<void> 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<void> 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<void> finish(WidgetTester tester) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish'));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish').last);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
await tester.pump();
|
||||
});
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> lastSavedWorkout(WidgetTester tester) async {
|
||||
final rows = await tester.runAsync(
|
||||
() => StorageService.instance.getWorkoutHistory(),
|
||||
);
|
||||
return jsonDecode(rows!.single['json']! as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
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<RepCircle>(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<void>.delayed(Duration.zero);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
});
|
||||
|
||||
final stored = await tester.runAsync(
|
||||
() => StorageService.instance.getExerciseState(_lunge.name),
|
||||
);
|
||||
expect(stored!.weight, 5.0);
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
@ -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<Database> openBoth(String path) async {
|
||||
StorageService.resetForTesting(dbPath: path);
|
||||
await StorageService.init();
|
||||
final raw = await databaseFactory.openDatabase(path);
|
||||
return raw;
|
||||
}
|
||||
|
||||
Map<String, Object?> 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<Map<String, dynamic>> 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<void>.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': <Object>[],
|
||||
'workout_history': <Object>[],
|
||||
'settings': <Object>[],
|
||||
}),
|
||||
);
|
||||
StorageService.resetForTesting();
|
||||
await StorageService.init();
|
||||
await _svc.restoreFromBackupIfNeeded();
|
||||
expect(await _svc.getProgressionEvents(), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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<void> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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<void> _pump(
|
||||
WidgetTester tester,
|
||||
ExerciseState state,
|
||||
Future<ExerciseState?> 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<TextButton>(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);
|
||||
});
|
||||
}
|
||||
@ -20,6 +20,7 @@ Future<List<ExerciseState>> _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'),
|
||||
),
|
||||
|
||||
@ -36,6 +36,7 @@ ExerciseTile _tile({
|
||||
onLongPressCircle: onLongPressCircle ?? (_) {},
|
||||
onTapWarmup: onTapWarmup ?? () {},
|
||||
onSettingsChanged: onSettingsChanged ?? (_) {},
|
||||
onDeload: () async => null,
|
||||
);
|
||||
|
||||
Color _cardColor(WidgetTester tester) =>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user