screen-locker/stronglift_replacement/workout_app/lib/models/progression.dart
Krzysztof kuhy Rudnicki 38b56e1e2b workout_app: progression modes, inline warmup, injury pause, no app bar
- Per exercise: weight / reps / double progression (n=12, m=6), one pure
  rule shared by Finish and every preview; streaks and next step shown on
  the tile. Warmup is the first circle of the set row and can be turned off.
- Pause an exercise for n days (default 14): shown as paused, recorded as
  FAILED every workout so it steps down; skipped by the notification.
- App bar removed: Reset/Finish live in the rest strip, no rest labels.
- Schema v5 (progression_mode, reps_high, reps_low, has_warmup, paused_until),
  synced through the Firebase progression record; NULL warmup = plan default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeVXsWTuwjmZK5uvbw6vqF
2026-09-27 17:36:17 +02:00

142 lines
5.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// The progression rule: what a success, a failure, or a long break does to
/// an exercise's target, for each of the three progression modes.
///
/// Pure on purpose. `applyProgression` (what finishing a workout does) and
/// the workout/history previews ("→ 25 kg × 6") both call these functions,
/// so a preview can never promise something the finish does not deliver.
library;
import 'dart:developer';
import 'package:workout_app/models/exercise.dart';
/// Default rep ceiling `n` for [ProgressionMode.doubleProgression].
const int kDefaultRepsHigh = 12;
/// Default restart reps `m` for [ProgressionMode.doubleProgression].
const int kDefaultRepsLow = 6;
/// How an exercise advances once its success streak hits the threshold.
enum ProgressionMode {
/// +2.5 kg per step; +1 rep instead once the weight cap is reached.
weight('weight'),
/// +1 rep per step, weight never changes.
reps('reps'),
/// +1 rep per step until `n` reps, then +2.5 kg and back to `m` reps.
doubleProgression('double');
const ProgressionMode(this.storageKey);
/// Value stored in SQLite and Firebase. Never rename: synced data holds it.
final String storageKey;
/// Parses [raw]; null means "not set yet" and is silently [weight] (every
/// row written before modes existed). Anything else unknown is a bug in
/// some writer, so it is reported before falling back.
static ProgressionMode parse(Object? raw) {
if (raw == null) return weight;
for (final m in values) {
if (m.storageKey == raw) return m;
}
log(
'ProgressionMode: unknown mode "$raw" — treating it as weight '
'progression. Some writer stored a value this build does not know.',
level: 900,
);
return weight;
}
}
/// A working target: the weight and reps of every working set.
class ProgressionTarget {
/// Creates a [ProgressionTarget].
const ProgressionTarget(this.weight, this.reps);
/// Working weight in kg.
final double weight;
/// Target reps per set.
final int reps;
@override
String toString() => 'ProgressionTarget($weight kg × $reps)';
}
/// The inputs the rule needs; implemented by `ExerciseState`.
abstract interface class ProgressionInputs {
/// Current working weight in kg.
double get weight;
/// Current target reps.
int get reps;
/// Weight cap (the heaviest dumbbell available).
double get maxWeight;
/// The active progression mode.
ProgressionMode get mode;
/// Double progression: reps at which the weight goes up (`n`).
int get repsHigh;
/// Double progression: reps the heavier weight starts at (`m`).
int get repsLow;
}
/// The target after a success streak reaches its threshold.
ProgressionTarget targetAfterSuccess(ProgressionInputs s) {
final atCap = s.weight >= s.maxWeight;
final heavier = (s.weight + kWeightIncrement).clamp(0.0, s.maxWeight);
switch (s.mode) {
case ProgressionMode.weight:
return atCap
? ProgressionTarget(s.weight, s.reps + 1)
: ProgressionTarget(heavier, s.reps);
case ProgressionMode.reps:
return ProgressionTarget(s.weight, s.reps + 1);
case ProgressionMode.doubleProgression:
// `>=`, not `==`: an exercise switched into this mode above `n` reps
// (Situp sits at 30) must still promote, not climb forever.
if (s.reps < s.repsHigh || atCap) {
return ProgressionTarget(s.weight, s.reps + 1);
}
return ProgressionTarget(heavier, s.repsLow);
}
}
/// The target after a fail streak reaches its threshold, or after a break
/// of more than a week (one regression step in every mode).
ProgressionTarget targetAfterFailure(ProgressionInputs s) {
final lighter = (s.weight - kWeightIncrement).clamp(0.0, s.maxWeight);
switch (s.mode) {
case ProgressionMode.weight:
return ProgressionTarget(lighter, s.reps);
case ProgressionMode.reps:
return ProgressionTarget(s.weight, s.reps - 1 < 1 ? 1 : s.reps - 1);
case ProgressionMode.doubleProgression:
if (s.reps > s.repsLow) return ProgressionTarget(s.weight, s.reps - 1);
// At `m` the step down is the exact inverse of a step up: lighter and
// back to `n`. With no weight left to drop that would only make the
// exercise harder, so it stays put.
if (s.weight <= 0) return ProgressionTarget(s.weight, s.reps);
return ProgressionTarget(lighter, s.repsHigh);
}
}
/// Short label for moving from [from] to [to]: `25 kg`, `13 reps`,
/// `25 kg × 6`, or `same` when the rule has nowhere left to go.
String describeTargetChange(ProgressionInputs from, ProgressionTarget to) {
final weightChanged = to.weight != from.weight;
final repsChanged = to.reps != from.reps;
if (weightChanged && repsChanged) return '${_kg(to.weight)} kg × ${to.reps}';
if (weightChanged) return '${_kg(to.weight)} kg';
if (repsChanged) return '${to.reps} reps';
return 'same';
}
/// `25` for 25.0, `27.5` for 27.5 — no trailing `.0` noise in tight rows.
String _kg(double w) =>
w == w.roundToDouble() ? w.toInt().toString() : w.toString();