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:
Krzysztof Rudnicki 2026-10-09 14:55:10 +02:00
parent 0b809c4af1
commit 7e1b4e865e
26 changed files with 1498 additions and 33 deletions

View File

@ -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;
}

View File

@ -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(

View File

@ -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;
}
}

View File

@ -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,
),
),
),

View File

@ -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),
);
}
}

View File

@ -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;
}
}

View File

@ -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,
);

View File

@ -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) {

View File

@ -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,

View File

@ -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();

View File

@ -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

View File

@ -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,
);
}
});
}
}

View File

@ -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();

View File

@ -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',

View File

@ -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 {

View File

@ -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())),
],
),
),

View File

@ -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;
}

View File

@ -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,
);
}

View File

@ -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');
});
}

View File

@ -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);
});
}

View File

@ -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,
);

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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);
});
}

View File

@ -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'),
),

View File

@ -36,6 +36,7 @@ ExerciseTile _tile({
onLongPressCircle: onLongPressCircle ?? (_) {},
onTapWarmup: onTapWarmup ?? () {},
onSettingsChanged: onSettingsChanged ?? (_) {},
onDeload: () async => null,
);
Color _cardColor(WidgetTester tester) =>