diff --git a/stronglift_replacement/workout_app/lib/models/break_snapshot.dart b/stronglift_replacement/workout_app/lib/models/break_snapshot.dart index 404e3b8..90fbd79 100644 --- a/stronglift_replacement/workout_app/lib/models/break_snapshot.dart +++ b/stronglift_replacement/workout_app/lib/models/break_snapshot.dart @@ -93,7 +93,7 @@ class BreakSnapshot { /// Full length of the running rest, for the countdown's denominator. final int breakDurationSecs; - /// Human label of the running rest, e.g. 'Rest (3 min — well done!)'. + /// Human label of the running rest, e.g. 'Rest' or 'Warmup rest'. final String breakLabel; /// Exercise the running rest belongs to, or -1. diff --git a/stronglift_replacement/workout_app/lib/models/exercise_state.dart b/stronglift_replacement/workout_app/lib/models/exercise_state.dart new file mode 100644 index 0000000..fd6058e --- /dev/null +++ b/stronglift_replacement/workout_app/lib/models/exercise_state.dart @@ -0,0 +1,180 @@ +/// Per-exercise progression state, as stored in SQLite and synced to +/// Firebase. +library; + +import 'dart:developer'; + +import 'package:workout_app/models/exercise.dart'; +import 'package:workout_app/models/progression.dart'; + +/// Per-exercise progression state stored in SQLite. +/// +/// Every field is `required` deliberately: a state rebuilt field by field +/// (a threshold edit, a restore) that forgot one would silently reset it, +/// and a required parameter makes the compiler find every such site. +class ExerciseState implements ProgressionInputs { + /// Creates an [ExerciseState] with all progression fields. + ExerciseState({ + required this.name, + required this.weight, + required this.reps, + required this.successStreak, + required this.failStreak, + required this.maxWeight, + required this.successThreshold, + required this.failThreshold, + required this.mode, + required this.repsHigh, + required this.repsLow, + required this.hasWarmup, + required this.pausedUntil, + }); + + /// The state a never-trained [ex] starts from. + factory ExerciseState.initial(Exercise ex) => ExerciseState( + name: ex.name, + weight: ex.weight, + reps: ex.reps, + successStreak: 0, + failStreak: 0, + maxWeight: ex.maxWeight, + successThreshold: 3, + failThreshold: 2, + mode: ProgressionMode.weight, + repsHigh: kDefaultRepsHigh, + repsLow: kDefaultRepsLow, + hasWarmup: ex.hasWarmup, + pausedUntil: null, + ); + + /// Exercise name (matches [Exercise.name], used as primary key). + final String name; + + /// Current working weight in kg. + @override + double weight; + + /// Current target reps per set. + @override + int reps; + + /// Consecutive successful workouts since last progression. + int successStreak; + + /// Consecutive failed workouts since last regression. + int failStreak; + + /// Weight cap; reps increase instead of weight when this is reached. + @override + final double maxWeight; + + /// Successes needed in a row before the target goes up. + int successThreshold; + + /// Failures needed in a row before the target goes down. + int failThreshold; + + /// How the target goes up and down. + @override + final ProgressionMode mode; + + /// Double progression: reps at which the weight goes up (`n`). + @override + final int repsHigh; + + /// Double progression: reps the heavier weight starts at (`m`). + @override + final int repsLow; + + /// Whether this exercise gets a warmup set (and its rest). + final bool hasWarmup; + + /// The first day the exercise is back after an injury pause (a local + /// midnight), or null when it is not paused. While paused it is recorded + /// as FAILED in every workout, so progression steps down instead of the + /// exercise coming back at the load it left with. + final DateTime? pausedUntil; + + /// Whether the exercise is paused at [now]. + bool isPausedAt(DateTime now) { + final until = pausedUntil; + return until != null && now.isBefore(until); + } + + /// Returns a copy with the given fields replaced. + ExerciseState copyWith({ + double? weight, + int? reps, + int? successStreak, + int? failStreak, + int? successThreshold, + int? failThreshold, + ProgressionMode? mode, + int? repsHigh, + int? repsLow, + bool? hasWarmup, + DateTime? pausedUntil, + bool resume = false, + }) => ExerciseState( + name: name, + weight: weight ?? this.weight, + reps: reps ?? this.reps, + successStreak: successStreak ?? this.successStreak, + failStreak: failStreak ?? this.failStreak, + maxWeight: maxWeight, + successThreshold: successThreshold ?? this.successThreshold, + failThreshold: failThreshold ?? this.failThreshold, + mode: mode ?? this.mode, + repsHigh: repsHigh ?? this.repsHigh, + repsLow: repsLow ?? this.repsLow, + hasWarmup: hasWarmup ?? this.hasWarmup, + // `resume` exists because a null argument cannot mean "clear it". + pausedUntil: resume ? null : (pausedUntil ?? this.pausedUntil), + ); +} + +/// First local midnight [days] days after [now]: the day a pause started at +/// [now] ends. +DateTime pauseEnd(DateTime now, int days) => + DateTime(now.year, now.month, now.day + days); + +/// `2026-10-11` for storage; the inverse of [parsePauseDate]. +String formatPauseDate(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; + +/// Reads a stored pause date; null means not paused. +/// +/// Anything unreadable is reported and treated as "not paused": the +/// exercise then shows up in the workout, which is the visible failure. +DateTime? parsePauseDate(Object? raw) { + if (raw == null) return null; + final parsed = raw is String ? DateTime.tryParse(raw) : null; + if (parsed == null) { + log( + 'ExerciseState: unreadable paused_until "$raw" — treating the ' + 'exercise as not paused.', + level: 900, + ); + } + return parsed; +} + +const _months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +/// `11 Oct`: how a pause's end date is shown. +String formatShortDate(DateTime d) => '${d.day} ${_months[d.month - 1]}'; diff --git a/stronglift_replacement/workout_app/lib/models/progression.dart b/stronglift_replacement/workout_app/lib/models/progression.dart new file mode 100644 index 0000000..1ab56cd --- /dev/null +++ b/stronglift_replacement/workout_app/lib/models/progression.dart @@ -0,0 +1,141 @@ +/// 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(); diff --git a/stronglift_replacement/workout_app/lib/models/workout_plan.dart b/stronglift_replacement/workout_app/lib/models/workout_plan.dart index 3adc20f..4d7235f 100644 --- a/stronglift_replacement/workout_app/lib/models/workout_plan.dart +++ b/stronglift_replacement/workout_app/lib/models/workout_plan.dart @@ -9,12 +9,7 @@ const double kSitupMaxWeight = 10; /// Plan A: lower-body and push/pull focus. final workoutA = [ const Exercise(name: 'Dumbbell Lunge', sets: 5, reps: 12, weight: 7.5), - const Exercise( - name: 'Dumbbell Bench Press', - sets: 5, - reps: 12, - weight: 22.5, - ), + const Exercise(name: 'Dumbbell Bench Press', sets: 5, reps: 12, weight: 22.5), const Exercise(name: 'Dumbbell Row', sets: 4, reps: 6, weight: 22.5), const Exercise(name: 'Dumbbell Curl', sets: 3, reps: 12, weight: 12.5), ]; @@ -33,12 +28,7 @@ final workoutB = [ reps: 12, weight: 7.5, ), - const Exercise( - name: 'Dumbbell Bench Press', - sets: 5, - reps: 12, - weight: 22.5, - ), + const Exercise(name: 'Dumbbell Bench Press', sets: 5, reps: 12, weight: 22.5), const Exercise( name: 'Situp', sets: 3, @@ -48,3 +38,16 @@ final workoutB = [ hasWarmup: false, ), ]; + +/// The plan's own warmup default for [name]; true for an unknown name. +/// +/// What a NULL `has_warmup` means. The column is nullable on purpose: rows +/// from before the toggle existed, old backups and old Firebase records all +/// lack it, and a blanket default of "on" would quietly give Situp its +/// warmup back on every restore. +bool planHasWarmup(String name) { + for (final ex in [...workoutA, ...workoutB]) { + if (ex.name == name) return ex.hasWarmup; + } + return true; +} diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen.dart b/stronglift_replacement/workout_app/lib/screens/history_screen.dart index 2e82e39..6d769a3 100644 --- a/stronglift_replacement/workout_app/lib/screens/history_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/history_screen.dart @@ -11,7 +11,6 @@ import 'dart:math'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; -import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/workout_sync_service.dart'; import 'package:workout_app/ui/theme.dart'; @@ -143,10 +142,7 @@ class _HistoryScreenState extends State { return Scaffold( appBar: AppBar( backgroundColor: colorScheme.surfaceContainerHigh, - title: Text( - 'Progress', - style: TextStyle(color: colorScheme.onSurface), - ), + title: Text('Progress', style: TextStyle(color: colorScheme.onSurface)), iconTheme: IconThemeData(color: colorScheme.onSurface), ), body: _loading @@ -179,9 +175,7 @@ class _HistoryScreenState extends State { List _buildTotalView() => [ const _SectionLabel('TOTAL VOLUME (2-session rolling avg, kg)'), const SizedBox(height: 6), - _WeightChart( - points: _rollingAvg2(_totalVolumePoints(_rows)), - ), + _WeightChart(points: _rollingAvg2(_totalVolumePoints(_rows))), const SizedBox(height: 16), WorkoutCalendar( workoutDates: _allWorkoutDates(_rows, _syncedRows), @@ -218,9 +212,7 @@ class _HistoryScreenState extends State { ], const _SectionLabel('WEIGHT OVER TIME'), const SizedBox(height: 6), - _WeightChart( - points: _exerciseWeightPoints(_rows, name), - ), + _WeightChart(points: _exerciseWeightPoints(_rows, name)), const SizedBox(height: 16), WorkoutCalendar( workoutDates: _exerciseDates(_rows, name), diff --git a/stronglift_replacement/workout_app/lib/screens/history_screen_charts.dart b/stronglift_replacement/workout_app/lib/screens/history_screen_charts.dart index dca23a0..0056a4e 100644 --- a/stronglift_replacement/workout_app/lib/screens/history_screen_charts.dart +++ b/stronglift_replacement/workout_app/lib/screens/history_screen_charts.dart @@ -73,15 +73,6 @@ class _ProgressStatsCard extends StatelessWidget { final ExerciseState state; - String _nextWeightLabel(double current, double max, double inc) { - if (current >= max) return '+1 rep'; - return '+${inc}kg (${(current + inc).clamp(0.0, max)}kg)'; - } - - String _prevWeightLabel(double current, double inc) { - return '-${inc}kg (${(current - inc).clamp(0.0, double.infinity)}kg)'; - } - @override Widget build(BuildContext context) { final successLeft = state.successThreshold - state.successStreak; @@ -99,7 +90,7 @@ class _ProgressStatsCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${state.name} — ${state.weight}kg', + '${state.name} — ${state.weight}kg × ${state.reps}', style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.bold, @@ -113,11 +104,8 @@ class _ProgressStatsCard extends StatelessWidget { current: state.successStreak, threshold: state.successThreshold, leftLabel: '$successLeft more', - actionLabel: _nextWeightLabel( - state.weight, - state.maxWeight, - kWeightIncrement, - ), + // The same rule applyProgression runs, in every mode. + actionLabel: describeTargetChange(state, targetAfterSuccess(state)), direction: '↑', ), const SizedBox(height: 6), @@ -127,7 +115,7 @@ class _ProgressStatsCard extends StatelessWidget { current: state.failStreak, threshold: state.failThreshold, leftLabel: '$failLeft more', - actionLabel: _prevWeightLabel(state.weight, kWeightIncrement), + actionLabel: describeTargetChange(state, targetAfterFailure(state)), direction: '↓', ), ], diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart index 648ce46..27651b7 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart @@ -31,7 +31,6 @@ import 'package:workout_app/widgets/break_banner.dart'; import 'package:workout_app/widgets/exercise_tile.dart'; import 'package:workout_app/widgets/workout_summary_dialog.dart'; -part 'workout_screen_appbar.dart'; part 'workout_screen_body.dart'; part 'workout_screen_breaks.dart'; part 'workout_screen_dialogs.dart'; @@ -74,8 +73,6 @@ class _WorkoutScreenState extends State { late List> _doneReps; late List _warmupTapped; late DateTime _startTime; - late Timer _elapsedTimer; - Duration _elapsed = Duration.zero; Map _exerciseStates = {}; @@ -112,9 +109,6 @@ class _WorkoutScreenState extends State { } else { _initFresh(); } - _elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) { - setState(() => _elapsed = DateTime.now().difference(_startTime)); - }); // Coming back to the foreground is the one moment the countdown is // guaranteed to be stale: ticks stop while the app is away, and the clock // has to be re-read before the user sees a frame. @@ -149,12 +143,15 @@ class _WorkoutScreenState extends State { setState(() { _exerciseStates = {for (final s in states) s.name: s}; }); + // The first snapshot can go out before this load lands, and it would + // count a paused exercise's sets until the next tap. A no-op if the + // service has not started yet: start() builds its own, current one. + unawaited(_breaks.push(_buildSnapshot())); } } @override void dispose() { - _elapsedTimer.cancel(); _breakTimer?.cancel(); _lifecycle.dispose(); // Deliberately NOT stopping the break service here. The back button pops @@ -175,7 +172,21 @@ class _WorkoutScreenState extends State { // ── Helpers ──────────────────────────────────────────────────────────────── - bool get _allSetsCompleted => _tapped.every((row) => row.every((t) => t)); + /// Every set recorded, not counting paused exercises: those have no sets + /// to tap and are recorded as failed at Finish. + bool get _allSetsCompleted { + for (var i = 0; i < _tapped.length; i++) { + if (!_isPaused(i) && !_tapped[i].every((t) => t)) return false; + } + return true; + } + + /// Whether exercise [exIdx] is on an injury pause right now. + bool _isPaused(int exIdx) => + _exerciseStates[widget.exercises[exIdx].name]?.isPausedAt( + DateTime.now(), + ) ?? + false; /// Runs [fn] inside `setState` on behalf of this library's extensions. /// @@ -193,7 +204,6 @@ class _WorkoutScreenState extends State { /// called from an extension, so the rest is in [_persistFinishedWorkout]. Future _finishWorkout() async { SandboxLog.event('workout finish', {'type': widget.workoutType}); - _elapsedTimer.cancel(); _breakTimer?.cancel(); // Before the state flips: the service must not outlive the workout, and // with stopWithTask="false" nothing else will ever stop it. @@ -212,30 +222,30 @@ class _WorkoutScreenState extends State { // finish, so the route is pinned until the workout is finished or reset. // Outside lock mode the back button behaves exactly as it always has. canPop: !lockModeEnabled, + // No app bar (2026-09-27): the workout type and clock were not worth + // their height, and Reset/Finish now live in the rest strip. SafeArea + // keeps that strip out from under the status bar the app bar used to + // pad away. child: Scaffold( - appBar: _WorkoutAppBar( - title: - 'Workout ${widget.workoutType} · ${_formatDuration(_elapsed)}', - finished: _finished, - allSetsCompleted: _allSetsCompleted, - onReset: () => unawaited(_confirmReset()), - onFinish: () => unawaited(_confirmFinish()), - ), - body: _WorkoutBody( - exercises: widget.exercises, - exerciseStates: _exerciseStates, - tapped: _tapped, - doneReps: _doneReps, - warmupTapped: _warmupTapped, - inBreak: _inBreak, - breakRemaining: _breakRemaining, - breakLabel: _breakLabel, - onSkipBreak: _skipBreak, - onTapCircle: _tapCircle, - onLongPressCircle: _resetCircle, - onTapWarmup: _tapWarmup, - onThresholdChanged: (name, success, fail) => - unawaited(_onThresholdChanged(name, success, fail)), + body: SafeArea( + child: _WorkoutBody( + exercises: widget.exercises, + exerciseStates: _exerciseStates, + tapped: _tapped, + doneReps: _doneReps, + warmupTapped: _warmupTapped, + inBreak: _inBreak, + breakRemaining: _breakRemaining, + finished: _finished, + allSetsCompleted: _allSetsCompleted, + onSkipBreak: _skipBreak, + onReset: () => unawaited(_confirmReset()), + onFinish: () => unawaited(_confirmFinish()), + onTapCircle: _tapCircle, + onLongPressCircle: _resetCircle, + onTapWarmup: _tapWarmup, + onSettingsChanged: (s) => unawaited(_onSettingsChanged(s)), + ), ), ), ); diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_appbar.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_appbar.dart deleted file mode 100644 index bba7ed4..0000000 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_appbar.dart +++ /dev/null @@ -1,68 +0,0 @@ -// The active-workout app bar: elapsed clock plus Reset / Finish actions. -// -// See workout_screen_session.dart for why this is a `part`. -part of 'workout_screen.dart'; - -/// App bar for [WorkoutScreen], showing the running clock and the two -/// destructive actions. -/// -/// A widget rather than a method so the state class stays under the file-length -/// cap; it reads only what it is given and never calls `setState` itself. -class _WorkoutAppBar extends StatelessWidget implements PreferredSizeWidget { - const _WorkoutAppBar({ - required this.title, - required this.finished, - required this.allSetsCompleted, - required this.onReset, - required this.onFinish, - }); - - /// Full app-bar title, already including the formatted elapsed time. - final String title; - - /// Whether the workout is over — hides both actions once true. - final bool finished; - - /// Whether every set is tapped; Finish stays disabled until it is. - final bool allSetsCompleted; - - /// Invoked when the user taps Reset. - final VoidCallback onReset; - - /// Invoked when the user taps Finish. - final VoidCallback onFinish; - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final status = Theme.of(context).extension()!; - return AppBar( - automaticallyImplyLeading: false, - backgroundColor: colorScheme.surfaceContainerHigh, - title: Text(title, style: TextStyle(color: colorScheme.onSurface)), - actions: [ - if (!finished) - TextButton( - onPressed: onReset, - child: Text('Reset', style: TextStyle(color: colorScheme.error)), - ), - if (!finished) - TextButton( - onPressed: allSetsCompleted ? onFinish : null, - child: Text( - 'Finish', - style: TextStyle( - color: allSetsCompleted - ? status.success - : colorScheme.onSurfaceVariant, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ); - } -} diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_body.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_body.dart index 4ce9817..b7acaf9 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_body.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_body.dart @@ -22,19 +22,22 @@ class _WorkoutBody extends StatelessWidget { required this.warmupTapped, required this.inBreak, required this.breakRemaining, - required this.breakLabel, + required this.finished, + required this.allSetsCompleted, required this.onSkipBreak, + required this.onReset, + required this.onFinish, required this.onTapCircle, required this.onLongPressCircle, required this.onTapWarmup, - required this.onThresholdChanged, + required this.onSettingsChanged, }); /// The exercises in this session, in display order. final List exercises; - /// Progression state per exercise name; a missing entry falls back to - /// the tile's default thresholds. + /// Progression state per exercise name; a missing entry (not loaded yet) + /// falls back to [ExerciseState.initial] for this session's targets. final Map exerciseStates; /// Per-exercise, per-set completion flags. @@ -52,12 +55,21 @@ class _WorkoutBody extends StatelessWidget { /// Seconds left in the current rest period. final int breakRemaining; - /// Human-readable label for the current rest period. - final String breakLabel; + /// Whether the workout is over; hides Reset and Finish. + final bool finished; + + /// Whether every set is recorded; enables Finish. + final bool allSetsCompleted; /// Invoked when the user skips the rest period. final VoidCallback onSkipBreak; + /// Invoked when the user taps Reset. + final VoidCallback onReset; + + /// Invoked when the user taps Finish. + final VoidCallback onFinish; + /// Invoked with (exerciseIndex, setIndex) when a set circle is tapped. final void Function(int exIdx, int setIdx) onTapCircle; @@ -67,8 +79,8 @@ class _WorkoutBody extends StatelessWidget { /// Invoked with the exercise index when its warmup is tapped. final void Function(int exIdx) onTapWarmup; - /// Invoked with (exerciseName, successThreshold, failThreshold) on edit. - final void Function(String name, int success, int fail) onThresholdChanged; + /// Invoked with the edited state after every settings-sheet change. + final ValueChanged onSettingsChanged; @override Widget build(BuildContext context) { @@ -77,8 +89,11 @@ class _WorkoutBody extends StatelessWidget { BreakBanner( active: inBreak, breakRemaining: breakRemaining, - breakLabel: inBreak ? breakLabel : 'Rest', onSkip: onSkipBreak, + finished: finished, + canFinish: allSetsCompleted, + onReset: onReset, + onFinish: onFinish, ), Expanded( child: LayoutBuilder( @@ -108,20 +123,17 @@ class _WorkoutBody extends StatelessWidget { } Widget _tile(int i) { - final exName = exercises[i].name; - final state = exerciseStates[exName]; + final ex = exercises[i]; return ExerciseTile( - exercise: exercises[i], + exercise: ex, + state: exerciseStates[ex.name] ?? ExerciseState.initial(ex), tapped: tapped[i], doneReps: doneReps[i], warmupTapped: warmupTapped[i], - successThreshold: state?.successThreshold ?? 3, - failThreshold: state?.failThreshold ?? 2, onTapCircle: (s) => onTapCircle(i, s), onLongPressCircle: (s) => onLongPressCircle(i, s), onTapWarmup: () => onTapWarmup(i), - onThresholdChanged: (success, fail) => - onThresholdChanged(exName, success, fail), + onSettingsChanged: onSettingsChanged, ); } } diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_breaks.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_breaks.dart index 8a465b3..ead378f 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_breaks.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_breaks.dart @@ -1,4 +1,4 @@ -// Rest-period timers and per-exercise threshold edits. +// Rest-period timers and per-exercise settings edits. // // See workout_screen_session.dart for why this is a `part`. `setState` is // `@protected` and unreachable from an extension, so these mutate through the @@ -86,16 +86,30 @@ extension _WorkoutScreenBreaks on _WorkoutScreenState { unawaited(_saveActiveSession()); } - Future _onThresholdChanged(String name, int success, int fail) async { - await StorageService.instance.setExerciseThresholds( - name, - successThreshold: success, - failThreshold: fail, - ); - if (mounted) { - _applyBreakState(() { - _writeThresholds(name, success, fail); - }); + /// Persists an edit from an exercise's settings sheet and shows it. + /// + /// Turning a warmup off while its own rest is running ends that rest: the + /// user just said the warmup is not part of this exercise any more. + /// Pausing the exercise ends any rest of it, for the same reason. + Future _onSettingsChanged(ExerciseState updated) async { + SandboxLog.event('exercise settings', { + 'exercise': updated.name, + 'mode': updated.mode.storageKey, + 'repsHigh': updated.repsHigh, + 'repsLow': updated.repsLow, + 'warmup': updated.hasWarmup, + 'pausedUntil': updated.pausedUntil?.toIso8601String(), + }); + await StorageService.instance.setExerciseSettings(updated); + if (!mounted) return; + _applyBreakState(() => _exerciseStates[updated.name] = updated); + final exIdx = widget.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)) { + _cancelBreak(); } + // Always: a pause changes the notification's "sets left" and next set. + unawaited(_saveActiveSession()); } } diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_finish.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_finish.dart index 7068dca..3d90b72 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_finish.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_finish.dart @@ -5,7 +5,6 @@ part of 'workout_screen.dart'; /// The write-everything step that runs once a workout is marked finished. extension _WorkoutScreenFinish on _WorkoutScreenState { - /// Writes the finished session, applies progression, and shows the summary. /// /// Called by [_WorkoutScreenState._finishWorkout], which owns the `setState` @@ -16,15 +15,19 @@ extension _WorkoutScreenFinish on _WorkoutScreenState { for (var i = 0; i < widget.exercises.length; i++) { final ex = widget.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. + final paused = _isPaused(i); results.add( ExerciseResult( exercise: ex, - warmupDone: _warmupTapped[i], + warmupDone: !paused && _warmupTapped[i], sets: List.generate( ex.sets, (s) => SetResult( targetReps: ex.reps, - doneReps: _tapped[i][s] ? _doneReps[i][s] : 0, + doneReps: !paused && _tapped[i][s] ? _doneReps[i][s] : 0, weight: ex.weight, ), ), @@ -100,10 +103,8 @@ extension _WorkoutScreenFinish on _WorkoutScreenState { showDialog( context: context, barrierDismissible: false, - builder: (_) => WorkoutSummaryDialog( - session: session, - syncResult: syncResult, - ), + builder: (_) => + WorkoutSummaryDialog(session: session, syncResult: syncResult), ), ); } diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart index 1e4b8d3..d4640d7 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart @@ -52,6 +52,9 @@ extension _WorkoutScreenIntents on _WorkoutScreenState { } int? _firstUntappedIn(int exIdx) { + // A paused exercise has nothing left to do, so the notification never + // offers it as the next set. + if (_isPaused(exIdx)) return null; for (var s = 0; s < _tapped[exIdx].length; s++) { if (!_tapped[exIdx][s]) return s; } @@ -74,8 +77,9 @@ extension _WorkoutScreenIntents on _WorkoutScreenState { int _setsRemaining() { var count = 0; - for (final row in _tapped) { - for (final done in row) { + for (var ex = 0; ex < _tapped.length; ex++) { + if (_isPaused(ex)) continue; + for (final done in _tapped[ex]) { if (!done) count++; } } @@ -131,6 +135,10 @@ extension _WorkoutScreenIntents on _WorkoutScreenState { _skipBreak(); case BreakIntentKind.done: if (!_isValidSet(intent)) return; + if (_isPaused(intent.exIdx)) { + _dropIntent(intent, 'that exercise is paused'); + return; + } if (_tapped[intent.exIdx][intent.setIdx]) { _dropIntent(intent, 'that set was already recorded'); return; @@ -152,7 +160,8 @@ extension _WorkoutScreenIntents on _WorkoutScreenState { bool _isValidSet(BreakIntent intent) { final exOk = intent.exIdx >= 0 && intent.exIdx < widget.exercises.length; - if (!exOk || intent.setIdx < 0 || + if (!exOk || + intent.setIdx < 0 || intent.setIdx >= _tapped[intent.exIdx].length) { _dropIntent(intent, 'it points at a set this workout does not have'); return false; diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart index 47ff888..f62a461 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart @@ -140,9 +140,7 @@ extension _WorkoutScreenSession on _WorkoutScreenState { if (newDuration == clock.durationSecs) return; _breakClock = clock.withDuration(newDuration); - _breakLabel = Sandbox.restLabel( - succeeded ? 'Rest (3 min — well done!)' : 'Rest (5 min — keep going!)', - ); + _breakLabel = Sandbox.restLabel('Rest'); } /// True when [setIdx] is the last untapped set of exercise [exIdx]. @@ -162,8 +160,8 @@ extension _WorkoutScreenSession on _WorkoutScreenState { if (_isLastSetOfExercise(exIdx, setIdx)) return null; final succeeded = _doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps; return succeeded - ? const _Rest(_successBreakSecs, 'Rest (3 min — well done!)') - : const _Rest(_failBreakSecs, 'Rest (5 min — keep going!)'); + ? const _Rest(_successBreakSecs, 'Rest') + : const _Rest(_failBreakSecs, 'Rest'); } /// Plays the sound and haptic that tell the user the rest period is over. @@ -185,26 +183,6 @@ extension _WorkoutScreenSession on _WorkoutScreenState { } } - /// Rewrites [name]'s thresholds in `_exerciseStates`, in place. - /// - /// A no-op when the screen holds no state for [name], so editing an unloaded - /// exercise cannot insert a half-built entry. - void _writeThresholds(String name, int success, int fail) { - final s = _exerciseStates[name]; - if (s != null) { - _exerciseStates[name] = ExerciseState( - name: s.name, - weight: s.weight, - reps: s.reps, - successStreak: s.successStreak, - failStreak: s.failStreak, - maxWeight: s.maxWeight, - successThreshold: success, - failThreshold: fail, - ); - } - } - /// Ends the break early at the user's request and re-persists the session. void _skipBreak() { SandboxLog.event('break skip'); @@ -213,13 +191,6 @@ extension _WorkoutScreenSession on _WorkoutScreenState { } } -/// Formats [d] as `h:mm:ss`, dropping the hours part under an hour. -String _formatDuration(Duration d) { - final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); - final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '${d.inHours > 0 ? '${d.inHours}:' : ''}$m:$s'; -} - /// A rest period's duration and the label shown on the break banner. class _Rest { const _Rest(this.seconds, this.label); @@ -227,6 +198,6 @@ class _Rest { /// How long the rest lasts. final int seconds; - /// Banner text explaining why this rest length was chosen. + /// Notification text for this rest. final String label; } diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_taps.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_taps.dart index 663a5d2..e80cbd1 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_taps.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_taps.dart @@ -52,7 +52,7 @@ extension _WorkoutScreenTaps on _WorkoutScreenState { if (_finished || _warmupTapped[exIdx]) return; _applyBreakState(() => _warmupTapped[exIdx] = true); if (!_inBreak) { - _startBreak(_warmupBreakSecs, 'Warmup rest (3 min)', exIdx, -1); + _startBreak(_warmupBreakSecs, 'Warmup rest', exIdx, -1); } unawaited(_saveActiveSession(toFirebase: true)); } diff --git a/stronglift_replacement/workout_app/lib/services/break_task_handler.dart b/stronglift_replacement/workout_app/lib/services/break_task_handler.dart index 7316dc1..aa16c6e 100644 --- a/stronglift_replacement/workout_app/lib/services/break_task_handler.dart +++ b/stronglift_replacement/workout_app/lib/services/break_task_handler.dart @@ -175,11 +175,7 @@ class BreakTaskHandler { /// The UI overwrites this with the truth as soon as it drains the queue — /// but it may be dead, and a button that visibly does nothing reads as /// broken. Only the fields the press certainly changes are moved. - BreakSnapshot _predict( - BreakIntentKind kind, - BreakSnapshot s, - DateTime now, - ) { + BreakSnapshot _predict(BreakIntentKind kind, BreakSnapshot s, DateTime now) { switch (kind) { case BreakIntentKind.skipBreak: return s.copyWith(breakEndMs: 0); @@ -200,7 +196,7 @@ class BreakTaskHandler { return s.copyWith( breakEndMs: clock.endTime.millisecondsSinceEpoch, breakDurationSecs: failSecs, - breakLabel: 'Rest (5 min — keep going!)', + breakLabel: 'Rest', ); } } diff --git a/stronglift_replacement/workout_app/lib/services/progression_sync_service.dart b/stronglift_replacement/workout_app/lib/services/progression_sync_service.dart index d12bb1f..1a3a3e4 100644 --- a/stronglift_replacement/workout_app/lib/services/progression_sync_service.dart +++ b/stronglift_replacement/workout_app/lib/services/progression_sync_service.dart @@ -100,8 +100,18 @@ class ProgressionSyncService { 'max_weight': s.maxWeight, 'success_threshold': s.successThreshold, 'fail_threshold': s.failThreshold, + 'progression_mode': s.mode.storageKey, + 'reps_high': s.repsHigh, + 'reps_low': s.repsLow, + 'has_warmup': s.hasWarmup, + 'paused_until': switch (s.pausedUntil) { + null => null, + final d => formatPauseDate(d), + }, }; + // The last five fields are lenient: records pushed before they existed + // must still restore, with the same meaning a NULL column has locally. static ExerciseState _stateFromJson(Map j) => ExerciseState( name: j['name']! as String, weight: (j['weight']! as num).toDouble(), @@ -111,6 +121,11 @@ class ProgressionSyncService { maxWeight: (j['max_weight']! as num).toDouble(), successThreshold: (j['success_threshold'] as num?)?.toInt() ?? 3, failThreshold: (j['fail_threshold'] as num?)?.toInt() ?? 2, + mode: ProgressionMode.parse(j['progression_mode']), + repsHigh: (j['reps_high'] as num?)?.toInt() ?? kDefaultRepsHigh, + repsLow: (j['reps_low'] as num?)?.toInt() ?? kDefaultRepsLow, + hasWarmup: j['has_warmup'] as bool? ?? planHasWarmup(j['name']! as String), + pausedUntil: parsePauseDate(j['paused_until']), ); /// The remote path holding [name]'s progression record. @@ -149,9 +164,7 @@ class ProgressionSyncService { final text = await client.getFileText(path); if (text == null) return null; try { - final record = Record.fromJson( - jsonDecode(text) as Map, - ); + final record = Record.fromJson(jsonDecode(text) as Map); final field = record.fields[_payloadField]; if (field == null) return null; final payload = field.$1; diff --git a/stronglift_replacement/workout_app/lib/services/storage_service.dart b/stronglift_replacement/workout_app/lib/services/storage_service.dart index 274832d..e8b946b 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service.dart @@ -9,55 +9,23 @@ import 'package:path/path.dart' as p; 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/progression.dart'; import 'package:workout_app/models/workout_plan.dart'; import 'package:workout_app/services/backup_service.dart'; 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/progression.dart'; + part 'storage_service_backup.dart'; part 'storage_service_exercises.dart'; part 'storage_service_sandbox.dart'; part 'storage_service_schema.dart'; part 'storage_service_sessions.dart'; -/// Per-exercise progression state stored in SQLite. -class ExerciseState { - /// Creates an [ExerciseState] with all required progression fields. - ExerciseState({ - required this.name, - required this.weight, - required this.reps, - required this.successStreak, - required this.failStreak, - required this.maxWeight, - required this.successThreshold, - required this.failThreshold, - }); - - /// Exercise name (matches [Exercise.name], used as primary key). - final String name; - - /// Current working weight in kg. - double weight; - - /// Current target reps per set. - int reps; - - /// Consecutive successful workouts since last progression. - int successStreak; - - /// Consecutive failed workouts since last regression. - int failStreak; - - /// Weight cap; reps increase instead of weight when this is reached. - final double maxWeight; - - /// Successes needed in a row before weight/reps increase. - int successThreshold; - - /// Failures needed in a row before weight decreases. - int failThreshold; -} - /// Singleton SQLite service for workout data persistence. class StorageService { StorageService._(); @@ -110,7 +78,7 @@ class StorageService { // coverage:ignore-end _db = await openDatabase( dbPath, - version: 3, + version: 5, onCreate: _createSchema, onUpgrade: _migrateSchema, // In tests resetForTesting() reopens a ':memory:' DB per test; sqflite's diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart b/stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart index bbe0009..ea534d7 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart @@ -17,8 +17,9 @@ extension StorageServiceExercises on StorageService { ); if (rows.isEmpty) return null; final r = rows.first; + final warmup = r['has_warmup'] as int?; return ExerciseState( - name: r['name']! as String, + name: name, weight: r['weight']! as double, reps: r['reps']! as int, successStreak: r['success_streak']! as int, @@ -26,6 +27,11 @@ extension StorageServiceExercises on StorageService { maxWeight: r['max_weight']! as double, successThreshold: r['success_threshold'] as int? ?? 3, failThreshold: r['fail_threshold'] as int? ?? 2, + mode: ProgressionMode.parse(r['progression_mode']), + repsHigh: r['reps_high'] as int? ?? kDefaultRepsHigh, + repsLow: r['reps_low'] as int? ?? kDefaultRepsLow, + hasWarmup: warmup == null ? planHasWarmup(name) : warmup != 0, + pausedUntil: parsePauseDate(r['paused_until']), ); } @@ -48,15 +54,41 @@ extension StorageServiceExercises on StorageService { }) async { await _db.update( 'exercise_state', - { - 'success_threshold': successThreshold, - 'fail_threshold': failThreshold, - }, + {'success_threshold': successThreshold, 'fail_threshold': failThreshold}, where: 'name = ?', whereArgs: [name], ); } + /// Updates how [state]'s exercise progresses and whether it warms up. + /// + /// Streaks are kept: they count workouts, not a mode, and switching mode + /// mid-streak should not cost the user the sessions already banked. The + /// current weight and reps are untouched too — the new mode takes effect + /// at the next finished workout. + Future setExerciseSettings(ExerciseState state) async { + await _db.update( + 'exercise_state', + { + 'success_threshold': state.successThreshold, + 'fail_threshold': state.failThreshold, + 'progression_mode': state.mode.storageKey, + 'reps_high': state.repsHigh, + 'reps_low': state.repsLow, + 'has_warmup': state.hasWarmup ? 1 : 0, + 'paused_until': _pauseColumn(state), + }, + where: 'name = ?', + whereArgs: [state.name], + ); + unawaited(_backupNow()); + } + + String? _pauseColumn(ExerciseState s) { + final until = s.pausedUntil; + return until == null ? null : formatPauseDate(until); + } + /// Sets the target reps for [name], resetting streaks. /// /// Progression can only ever raise reps (by one, and only once an exercise @@ -94,6 +126,11 @@ extension StorageServiceExercises on StorageService { 'max_weight': state.maxWeight, 'success_threshold': state.successThreshold, 'fail_threshold': state.failThreshold, + 'progression_mode': state.mode.storageKey, + 'reps_high': state.repsHigh, + 'reps_low': state.repsLow, + 'has_warmup': state.hasWarmup ? 1 : 0, + 'paused_until': _pauseColumn(state), }, conflictAlgorithm: ConflictAlgorithm.replace); } @@ -121,7 +158,13 @@ extension StorageServiceExercises on StorageService { result.add(ex); // coverage:ignore-end } else { - result.add(ex.copyWith(weight: state.weight, reps: state.reps)); + result.add( + ex.copyWith( + weight: state.weight, + reps: state.reps, + hasWarmup: state.hasWarmup, + ), + ); } } return result; diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart b/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart index fa54e6f..2b1f1dc 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart @@ -6,7 +6,6 @@ part of 'storage_service.dart'; /// Schema creation, migration, and seeding. extension StorageServiceSchema on StorageService { - Future _createSchema(Database db, int version) async { await db.execute(''' CREATE TABLE exercise_state ( @@ -17,7 +16,12 @@ extension StorageServiceSchema on StorageService { 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 + fail_threshold INTEGER NOT NULL DEFAULT 2, + progression_mode TEXT NOT NULL DEFAULT 'weight', + reps_high INTEGER NOT NULL DEFAULT $kDefaultRepsHigh, + reps_low INTEGER NOT NULL DEFAULT $kDefaultRepsLow, + has_warmup INTEGER, + paused_until TEXT ) '''); await db.execute(''' @@ -69,6 +73,24 @@ extension StorageServiceSchema on StorageService { '(id INTEGER PRIMARY KEY CHECK (id = 1), json TEXT NOT NULL)', ); } + if (oldVersion < 4) { + // has_warmup is left NULL: that reads as the plan's default (see + // planHasWarmup), so existing rows keep exactly the warmups they had. + for (final column in [ + "progression_mode TEXT NOT NULL DEFAULT 'weight'", + 'reps_high INTEGER NOT NULL DEFAULT $kDefaultRepsHigh', + 'reps_low INTEGER NOT NULL DEFAULT $kDefaultRepsLow', + 'has_warmup INTEGER', + ]) { + await db.execute('ALTER TABLE exercise_state ADD COLUMN $column'); + } + } + if (oldVersion < 5) { + // NULL = not paused. + await db.execute( + 'ALTER TABLE exercise_state ADD COLUMN paused_until TEXT', + ); + } } Future _seedDefaultsIfNeeded() async { @@ -88,6 +110,10 @@ extension StorageServiceSchema on StorageService { 'max_weight': ex.maxWeight, 'success_threshold': 3, 'fail_threshold': 2, + 'progression_mode': ProgressionMode.weight.storageKey, + 'reps_high': kDefaultRepsHigh, + 'reps_low': kDefaultRepsLow, + 'has_warmup': ex.hasWarmup ? 1 : 0, }); } } @@ -107,5 +133,4 @@ extension StorageServiceSchema on StorageService { // coverage:ignore-end // ── Settings ─────────────────────────────────────────────────────────────── - } diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_sessions.dart b/stronglift_replacement/workout_app/lib/services/storage_service_sessions.dart index eaae978..0b8cca9 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_sessions.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_sessions.dart @@ -9,6 +9,10 @@ part of 'storage_service.dart'; /// Progression application and session recording. extension StorageServiceSessions on StorageService { /// Applies progressive overload or regression based on [succeededExercises]. + /// + /// The step itself comes from [targetAfterSuccess] / [targetAfterFailure], + /// the same functions the workout screen previews with, so what the tile + /// promises is what finishing does. Future applyProgression({ required Map succeededExercises, required DateTime lastWorkoutDate, @@ -20,66 +24,47 @@ extension StorageServiceSessions on StorageService { final state = await getExerciseState(entry.key); if (state == null) continue; + final Map update; if (hadBreak) { - final newWeight = (state.weight - kWeightIncrement).clamp( - 0.0, - state.maxWeight, - ); - await _db.update( - 'exercise_state', - {'weight': newWeight, 'success_streak': 0, 'fail_streak': 0}, - where: 'name = ?', - whereArgs: [entry.key], - ); - continue; - } - - if (entry.value) { + // A week off is one regression step, in whatever mode is active. + final t = targetAfterFailure(state); + update = { + 'weight': t.weight, + 'reps': t.reps, + 'success_streak': 0, + 'fail_streak': 0, + }; + } else if (entry.value) { final newStreak = state.successStreak + 1; - final shouldProgress = newStreak >= state.successThreshold; - var newWeight = state.weight; - var newReps = state.reps; - - if (shouldProgress) { - if (state.weight >= state.maxWeight) { - newReps = state.reps + 1; - } else { - newWeight = (state.weight + kWeightIncrement).clamp( - 0.0, - state.maxWeight, - ); - } - } - - await _db.update( - 'exercise_state', - { - 'weight': newWeight, - 'reps': newReps, - 'success_streak': shouldProgress ? 0 : newStreak, - 'fail_streak': 0, - }, - where: 'name = ?', - whereArgs: [entry.key], - ); + final step = newStreak >= state.successThreshold; + final t = step + ? targetAfterSuccess(state) + : ProgressionTarget(state.weight, state.reps); + update = { + 'weight': t.weight, + 'reps': t.reps, + 'success_streak': step ? 0 : newStreak, + 'fail_streak': 0, + }; } else { final newStreak = state.failStreak + 1; - final shouldRegress = newStreak >= state.failThreshold; - final newWeight = shouldRegress - ? (state.weight - kWeightIncrement).clamp(0.0, state.maxWeight) - : state.weight; - - await _db.update( - 'exercise_state', - { - 'weight': newWeight, - 'fail_streak': shouldRegress ? 0 : newStreak, - 'success_streak': 0, - }, - where: 'name = ?', - whereArgs: [entry.key], - ); + final step = newStreak >= state.failThreshold; + final t = step + ? targetAfterFailure(state) + : ProgressionTarget(state.weight, state.reps); + update = { + 'weight': t.weight, + 'reps': t.reps, + 'fail_streak': step ? 0 : newStreak, + 'success_streak': 0, + }; } + await _db.update( + 'exercise_state', + update, + where: 'name = ?', + whereArgs: [entry.key], + ); } } @@ -124,9 +109,7 @@ extension StorageServiceSessions on StorageService { /// engine acted on. /// /// Returns the number of sessions restored. - Future restoreSyncedSessions( - List> payloads, - ) async { + Future restoreSyncedSessions(List> payloads) async { if (payloads.isEmpty) return 0; final existing = await _db.query('workout_history', columns: ['json']); final seen = { diff --git a/stronglift_replacement/workout_app/lib/widgets/break_banner.dart b/stronglift_replacement/workout_app/lib/widgets/break_banner.dart index a47ec08..477d035 100644 --- a/stronglift_replacement/workout_app/lib/widgets/break_banner.dart +++ b/stronglift_replacement/workout_app/lib/widgets/break_banner.dart @@ -4,45 +4,72 @@ library; import 'package:flutter/material.dart'; import 'package:workout_app/ui/theme.dart'; -/// Fixed-height strip showing a rest countdown and a skip button. +/// Fixed-height strip: the rest countdown, Skip, and the workout's Reset and +/// Finish actions. /// /// The strip is always laid out, rest or no rest: an appearing banner used /// to steal its height from the exercise list and shift every tile down /// (2026-09-20). Between rests it renders the same shape muted, so starting -/// a rest changes colours and the number, never a position. +/// a rest changes colours and the number, never a position. The same holds +/// for the buttons: a hidden one keeps its space. +/// +/// There is no label. The colour of the number already says whether a rest +/// is running, and "Rest (5 min — keep going!)" only cost height (2026-09-27). class BreakBanner extends StatelessWidget { /// Creates a [BreakBanner]. const BreakBanner({ required this.active, required this.breakRemaining, - required this.breakLabel, required this.onSkip, + required this.finished, + required this.canFinish, + required this.onReset, + required this.onFinish, super.key, }); /// Total strip height; a constant so the layout below never depends on /// the text metrics of what the strip happens to show. - static const double height = 64; + static const double height = 56; - /// Whether a rest is running. When false the strip is muted and Skip is - /// hidden (its space is kept). + /// Whether a rest is running. When false the countdown is muted and Skip + /// is hidden (its space is kept). final bool active; /// Seconds remaining in the current break. final int breakRemaining; - /// Display label for the break (e.g. 'Rest' or 'Warmup rest'). - final String breakLabel; - /// Called when the user taps the Skip button. final VoidCallback onSkip; + /// Whether the workout is over; hides Reset and Finish (space kept). + final bool finished; + + /// Whether every set is recorded; Finish is disabled until it is. + final bool canFinish; + + /// Called when the user taps Reset. + final VoidCallback onReset; + + /// Called when the user taps Finish. + final VoidCallback onFinish; + String _fmt(int secs) { final m = (secs ~/ 60).toString().padLeft(2, '0'); final s = (secs % 60).toString().padLeft(2, '0'); return '$m:$s'; } + /// [child] when [visible], otherwise the same box left empty. + static Widget _slot({required bool visible, required Widget child}) => + Visibility( + visible: visible, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: child, + ); + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -55,44 +82,47 @@ class BreakBanner extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - breakLabel, - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, - ), - ), - Text( - _fmt(breakRemaining), - style: TextStyle( - // Warning (caution/pending) while the countdown runs; - // muted between rests so the idle strip recedes. - color: active - ? status.warning - : colorScheme.onSurfaceVariant, - fontSize: AppTextSize.title, - fontWeight: FontWeight.bold, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), - ], + Text( + _fmt(breakRemaining), + style: TextStyle( + // Warning (caution/pending) while the countdown runs; muted + // between rests so the idle strip recedes. + color: active ? status.warning : colorScheme.onSurfaceVariant, + fontSize: AppTextSize.title, + fontWeight: FontWeight.bold, + fontFeatures: const [FontFeature.tabularFigures()], ), ), - Visibility( + const Spacer(), + _slot( visible: active, - maintainSize: true, - maintainAnimation: true, - maintainState: true, child: TextButton( onPressed: onSkip, child: Text('Skip', style: TextStyle(color: colorScheme.primary)), ), ), + _slot( + visible: !finished, + child: TextButton( + onPressed: onReset, + child: Text('Reset', style: TextStyle(color: colorScheme.error)), + ), + ), + _slot( + visible: !finished, + child: TextButton( + onPressed: canFinish ? onFinish : null, + child: Text( + 'Finish', + style: TextStyle( + color: canFinish + ? status.success + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + ), + ), ], ), ); diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet.dart new file mode 100644 index 0000000..9d35357 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet.dart @@ -0,0 +1,188 @@ +/// Bottom sheet for one exercise's progression mode, rep range, warmup and +/// streak thresholds — opened from the mode chip on its workout tile. +library; + +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_rows.dart'; + +/// Opens the settings sheet for [state]; [onChanged] runs on every edit. +/// +/// 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. +Future showExerciseSettingsSheet( + BuildContext context, { + required ExerciseState state, + required ValueChanged onChanged, +}) => showModalBottomSheet( + context: context, + builder: (_) => ExerciseSettingsSheet(state: state, onChanged: onChanged), +); + +/// Upper bound for the double-progression rep ceiling `n`. +const int _maxRepsHigh = 50; + +/// Default injury-pause length. +const int kDefaultPauseDays = 14; + +/// The sheet's contents. Public so tests can pump it without a route. +class ExerciseSettingsSheet extends StatefulWidget { + /// Creates an [ExerciseSettingsSheet]. + const ExerciseSettingsSheet({ + required this.state, + required this.onChanged, + super.key, + }); + + /// The state being edited, as it was when the sheet opened. + final ExerciseState state; + + /// Called with the updated state after every edit. + final ValueChanged onChanged; + + @override + State createState() => _ExerciseSettingsSheetState(); +} + +class _ExerciseSettingsSheetState extends State { + late ExerciseState _s = widget.state; + int _pauseDays = kDefaultPauseDays; + + void _update(ExerciseState next) { + setState(() => _s = next); + widget.onChanged(next); + } + + String get _modeHint => switch (_s.mode) { + ProgressionMode.weight => + '+$kWeightIncrement kg per step; +1 rep once at the ' + '${_s.maxWeight} kg cap', + ProgressionMode.reps => '+1 rep per step; the weight never changes', + ProgressionMode.doubleProgression => + '+1 rep per step up to ${_s.repsHigh}, then +$kWeightIncrement kg ' + 'and back to ${_s.repsLow}', + }; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final caption = TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ); + final isDouble = _s.mode == ProgressionMode.doubleProgression; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _s.name, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.bold, + fontSize: AppTextSize.body, + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment( + value: ProgressionMode.weight, + label: Text('Weight'), + ), + ButtonSegment( + value: ProgressionMode.reps, + label: Text('Reps'), + ), + ButtonSegment( + value: ProgressionMode.doubleProgression, + label: Text('Reps→kg'), + ), + ], + selected: {_s.mode}, + onSelectionChanged: (m) => _update(_s.copyWith(mode: m.first)), + ), + ), + const SizedBox(height: 8), + // Fixed two-line box: the hint's length differs per mode, and a + // sheet that changes height under the finger is the jump the + // user asked never to see. + SizedBox( + height: 34, + child: Text(_modeHint, style: caption, maxLines: 2), + ), + // Kept laid out in every mode for the same reason. + Visibility( + visible: isDouble, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: _SettingRow( + label: '+kg at', + name: 'Top reps', + value: _s.repsHigh, + min: _s.repsLow + 1, + max: _maxRepsHigh, + suffix: 'reps, restart at', + trailing: _Stepper( + name: 'Restart reps', + value: _s.repsLow, + min: 1, + max: _s.repsHigh - 1, + onChanged: (v) => _update(_s.copyWith(repsLow: v)), + ), + onChanged: (v) => _update(_s.copyWith(repsHigh: v)), + ), + ), + const SizedBox(height: 4), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Warmup set'), + value: _s.hasWarmup, + onChanged: (v) => _update(_s.copyWith(hasWarmup: v)), + ), + _SettingRow( + label: '↑ after', + name: 'Wins needed', + value: _s.successThreshold, + min: 1, + max: 5, + suffix: 'wins, ↓ after', + trailing: _Stepper( + name: 'Fails allowed', + value: _s.failThreshold, + min: 1, + max: 5, + onChanged: (v) => _update(_s.copyWith(failThreshold: v)), + ), + onChanged: (v) => _update(_s.copyWith(successThreshold: v)), + ), + const SizedBox(height: 8), + _PauseRow( + pausedUntil: _s.isPausedAt(DateTime.now()) + ? _s.pausedUntil + : null, + days: _pauseDays, + onDaysChanged: (v) => setState(() => _pauseDays = v), + onPause: () => _update( + _s.copyWith(pausedUntil: pauseEnd(DateTime.now(), _pauseDays)), + ), + onResume: () => _update(_s.copyWith(resume: true)), + ), + ], + ), + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_rows.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_rows.dart new file mode 100644 index 0000000..fca6fb9 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_rows.dart @@ -0,0 +1,198 @@ +// Rows and steppers for the exercise settings sheet. +// +// `part` file: split out for the 250-line cap; these widgets stay private. +part of 'exercise_settings_sheet.dart'; + +/// `label [stepper] suffix [trailing]` on one line. +class _SettingRow extends StatelessWidget { + const _SettingRow({ + required this.label, + required this.name, + required this.value, + required this.min, + required this.max, + required this.suffix, + required this.trailing, + required this.onChanged, + }); + + final String label; + + /// Semantic name of the leading stepper. + final String name; + final int value; + final int min; + final int max; + final String suffix; + final Widget trailing; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final style = TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: AppTextSize.label, + ); + return Row( + children: [ + Text(label, style: style), + const SizedBox(width: 8), + _Stepper( + name: name, + value: value, + min: min, + max: max, + onChanged: onChanged, + ), + const SizedBox(width: 8), + Text(suffix, style: style), + const SizedBox(width: 8), + trailing, + ], + ); + } +} + +/// `[-] value [+]`, with the buttons disabled at the bounds. +class _Stepper extends StatelessWidget { + const _Stepper({ + required this.name, + required this.value, + required this.min, + required this.max, + required this.onChanged, + }); + + /// What is being stepped; names the buttons for screen readers and + /// `android_ui`, which otherwise see two identical unlabelled icons. + final String name; + final int value; + final int min; + final int max; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _btn( + context, + '$name down', + Icons.remove, + value > min ? value - 1 : null, + ), + SizedBox( + width: 28, + child: Text( + '$value', + textAlign: TextAlign.center, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: AppTextSize.label, + fontWeight: FontWeight.bold, + ), + ), + ), + _btn(context, '$name up', Icons.add, value < max ? value + 1 : null), + ], + ); + } + + Widget _btn(BuildContext context, String label, IconData icon, int? next) { + final colorScheme = Theme.of(context).colorScheme; + return Semantics( + container: true, + button: true, + label: label, + excludeSemantics: true, + child: InkWell( + onTap: next == null ? null : () => onChanged(next), + borderRadius: BorderRadius.circular(AppRadius.sm), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + alignment: Alignment.center, + child: Icon( + icon, + size: 16, + color: next != null + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } +} + +/// Pause controls, one fixed-height line in both states: +/// `Pause for [14] days [Pause]`, or `Paused — back on 11 Oct [Resume]`. +class _PauseRow extends StatelessWidget { + const _PauseRow({ + required this.pausedUntil, + required this.days, + required this.onDaysChanged, + required this.onPause, + required this.onResume, + }); + + /// End of the running pause, or null when not paused. + final DateTime? pausedUntil; + final int days; + final ValueChanged onDaysChanged; + final VoidCallback onPause; + final VoidCallback onResume; + + static const int _maxDays = 60; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final style = TextStyle( + color: colorScheme.onSurface, + fontSize: AppTextSize.label, + ); + final until = pausedUntil; + return SizedBox( + height: 40, + child: Row( + children: until == null + ? [ + Text('Pause for', style: style), + const SizedBox(width: 8), + _Stepper( + name: 'Pause days', + value: days, + min: 1, + max: _maxDays, + onChanged: onDaysChanged, + ), + const SizedBox(width: 8), + Text('days', style: style), + const Spacer(), + TextButton(onPressed: onPause, child: const Text('Pause')), + ] + : [ + Icon( + Icons.pause_circle_outline, + size: 18, + color: colorScheme.error, + ), + const SizedBox(width: 8), + Text( + 'Paused — back on ${formatShortDate(until)}', + style: style, + ), + const Spacer(), + TextButton(onPressed: onResume, child: const Text('Resume')), + ], + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart index 913cc50..7e2719b 100644 --- a/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_tile.dart @@ -1,33 +1,43 @@ -/// Card widget for a single exercise showing warmup and main-set rep circles. +/// Card widget for a single exercise: header with the progression-mode chip, +/// one row of warmup + set circles, and the progress line. library; 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'; +import 'package:workout_app/widgets/exercise_settings_sheet.dart'; import 'package:workout_app/widgets/rep_circle.dart'; part 'exercise_tile_rows.dart'; /// Card widget displaying warmup and working-set rep circles for one exercise. +/// +/// Its height is the same in every state — warmup on or off, any mode, any +/// streak, done or not — so the workout column never rescales mid-workout. +/// A disabled warmup leaves its slot empty rather than closing it up. class ExerciseTile extends StatelessWidget { /// Creates an [ExerciseTile]. const ExerciseTile({ required this.exercise, + required this.state, required this.tapped, required this.doneReps, required this.warmupTapped, - required this.successThreshold, - required this.failThreshold, required this.onTapCircle, required this.onLongPressCircle, required this.onTapWarmup, - required this.onThresholdChanged, + required this.onSettingsChanged, super.key, }); - /// The exercise definition to display. + /// The exercise definition to display (this session's targets). final Exercise exercise; + /// Progression state: mode, streaks, thresholds, warmup toggle. + final ExerciseState state; + /// Per-set tap state; true when a set circle has been tapped. final List tapped; @@ -37,12 +47,6 @@ class ExerciseTile extends StatelessWidget { /// Whether the warmup circle has been tapped. final bool warmupTapped; - /// Success streak threshold shown in the inline settings row. - final int successThreshold; - - /// Fail streak threshold shown in the inline settings row. - final int failThreshold; - /// Called when a working-set circle is tapped. final void Function(int setIdx) onTapCircle; @@ -52,8 +56,14 @@ class ExerciseTile extends StatelessWidget { /// Called when the warmup circle is tapped. final VoidCallback onTapWarmup; - /// Called when the user changes thresholds inline (newSuccess, newFail). - final void Function(int success, int fail) onThresholdChanged; + /// Called with the edited state after every change in the settings sheet. + final ValueChanged onSettingsChanged; + + /// Gap between circles in the set row. + static const double circleGap = 8; + + /// Height of the set row; the paused placeholder takes exactly this. + static const double setRowHeight = 52; bool get _allCompleted => tapped.every((t) => t); @@ -64,18 +74,24 @@ class ExerciseTile extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final status = Theme.of(context).extension()!; + final paused = state.isPausedAt(DateTime.now()); var headerColor = colorScheme.surfaceContainerHigh; - if (_allCompleted) { + // A paused card never turns green/red: its taps no longer count. + if (_allCompleted && !paused) { headerColor = _allSucceeded ? status.success : colorScheme.error; } // A filled success/danger card needs on-fill text throughout (tokens.md: // one on-fill value for all four fills, never a per-fill judgment call). - final onHeader = _allCompleted - ? colorScheme.onPrimary - : colorScheme.onSurface; - final onHeaderMuted = _allCompleted + final filled = _allCompleted && !paused; + final onHeaderMuted = filled ? colorScheme.onPrimary : colorScheme.onSurfaceVariant; + // Paused: the whole header recedes to the muted ink. + final onHeader = filled + ? colorScheme.onPrimary + : paused + ? onHeaderMuted + : colorScheme.onSurface; return Card( color: headerColor, @@ -89,6 +105,8 @@ class ExerciseTile extends StatelessWidget { Expanded( child: Text( exercise.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: TextStyle( color: onHeader, fontWeight: FontWeight.bold, @@ -96,6 +114,13 @@ class ExerciseTile extends StatelessWidget { ), ), ), + const SizedBox(width: 6), + _ModeChip( + state: state, + color: onHeaderMuted, + onTap: () => showExerciseSettings(context), + ), + const SizedBox(width: 8), Text( '${exercise.sets}×${exercise.reps}×${exercise.weight}kg', style: TextStyle( @@ -106,40 +131,53 @@ class ExerciseTile extends StatelessWidget { ], ), const SizedBox(height: 8), - if (exercise.hasWarmup) ...[ - _WarmupRow( - warmupWeight: exercise.warmupWeight, - tapped: warmupTapped, - onTap: onTapWarmup, + // A Row, not a Wrap: a set row that spilled onto a second line + // would change the tile's height, and a Row overflowing fails + // loudly in tests instead. + if (paused) + _PausedRow(until: state.pausedUntil!, color: onHeaderMuted) + else + Row( + children: [ + Visibility( + visible: state.hasWarmup, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: _WarmupCircle( + semanticLabel: '${exercise.name} warmup', + warmupWeight: exercise.warmupWeight, + tapped: warmupTapped, + onTap: onTapWarmup, + ), + ), + for (var s = 0; s < exercise.sets; s++) ...[ + const SizedBox(width: circleGap), + RepCircle( + semanticLabel: '${exercise.name} set ${s + 1}', + targetReps: exercise.reps, + doneReps: doneReps[s], + tapped: tapped[s], + onTap: () => onTapCircle(s), + onLongPress: () => onLongPressCircle(s), + ), + ], + ], ), - const SizedBox(height: 10), - ], - Wrap( - spacing: 8, - runSpacing: 8, - children: List.generate( - exercise.sets, - (s) => RepCircle( - semanticLabel: '${exercise.name} set ${s + 1}', - targetReps: exercise.reps, - doneReps: doneReps[s], - tapped: tapped[s], - onTap: () => onTapCircle(s), - onLongPress: () => onLongPressCircle(s), - ), - ), - ), // Color inherited from the shared dividerTheme (line-dark). const Divider(height: 20), - _ThresholdRow( - successThreshold: successThreshold, - failThreshold: failThreshold, - onSuccessChanged: (v) => onThresholdChanged(v, failThreshold), - onFailChanged: (v) => onThresholdChanged(successThreshold, v), - ), + _ProgressRow(state: state, onFill: filled), ], ), ), ); } + + /// Opens the settings sheet for this exercise. + Future showExerciseSettings(BuildContext context) => + showExerciseSettingsSheet( + context, + state: state, + onChanged: onSettingsChanged, + ); } diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_tile_rows.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_tile_rows.dart index b7a72a3..dbb7b89 100644 --- a/stronglift_replacement/workout_app/lib/widgets/exercise_tile_rows.dart +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_tile_rows.dart @@ -1,129 +1,81 @@ -// Threshold row, mini stepper and warm-up row for ExerciseTile. +// Mode chip, inline warmup circle and progress line for ExerciseTile. // // `part` file: these widgets are library-private and stay that way. // See history_screen_charts.dart for the full reasoning. part of 'exercise_tile.dart'; -class _ThresholdRow extends StatelessWidget { - const _ThresholdRow({ - required this.successThreshold, - required this.failThreshold, - required this.onSuccessChanged, - required this.onFailChanged, +/// Tappable chip naming the progression mode; opens the settings sheet. +/// +/// Fixed width, so switching mode never moves the header's other text. +class _ModeChip extends StatelessWidget { + const _ModeChip({ + required this.state, + required this.color, + required this.onTap, }); - final int successThreshold; - final int failThreshold; - final ValueChanged onSuccessChanged; - final ValueChanged onFailChanged; + final ExerciseState state; + final Color color; + final VoidCallback onTap; + + static const double width = 64; + + String get _text => switch (state.mode) { + ProgressionMode.weight => 'kg', + ProgressionMode.reps => 'reps', + ProgressionMode.doubleProgression => '${state.repsLow}→${state.repsHigh}', + }; @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final status = Theme.of(context).extension()!; - final captionStyle = TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, - ); - return Row( - children: [ - Icon(Icons.trending_up, size: 13, color: status.success), - const SizedBox(width: 4), - Text('after', style: captionStyle), - const SizedBox(width: 6), - _MiniStepper( - value: successThreshold, - onChanged: onSuccessChanged, - ), - const SizedBox(width: 4), - Text('↑', style: captionStyle), - const Spacer(), - Icon(Icons.trending_down, size: 13, color: colorScheme.error), - const SizedBox(width: 4), - Text('after', style: captionStyle), - const SizedBox(width: 6), - _MiniStepper( - value: failThreshold, - onChanged: onFailChanged, - ), - const SizedBox(width: 4), - Text('↓', style: captionStyle), - ], - ); - } -} - -class _MiniStepper extends StatelessWidget { - const _MiniStepper({required this.value, required this.onChanged}); - - final int value; - final ValueChanged onChanged; - - static const _min = 1; - static const _max = 5; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - _btn( - context, - Icons.remove, - value > _min ? () => onChanged(value - 1) : null, - ), - SizedBox( - width: 22, - child: Text( - '$value', - textAlign: TextAlign.center, - style: TextStyle( - color: colorScheme.onSurface, - fontSize: AppTextSize.caption, - ), + return Semantics( + container: true, + button: true, + label: '${state.name} progression settings', + excludeSemantics: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.sm), + child: Container( + width: width, + height: 26, + decoration: BoxDecoration( + border: Border.all(color: color), + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.tune, size: 13, color: color), + const SizedBox(width: 3), + Flexible( + child: Text( + _text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: color, fontSize: AppTextSize.caption), + ), + ), + ], ), - ), - _btn( - context, - Icons.add, - value < _max ? () => onChanged(value + 1) : null, - ), - ], - ); - } - - Widget _btn(BuildContext context, IconData icon, VoidCallback? onTap) { - final colorScheme = Theme.of(context).colorScheme; - return GestureDetector( - onTap: onTap, - child: Container( - width: 22, - height: 22, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - alignment: Alignment.center, - child: Icon( - icon, - size: 12, - color: onTap != null - ? colorScheme.onSurface - : colorScheme.onSurfaceVariant, ), ), ); } } -class _WarmupRow extends StatelessWidget { - const _WarmupRow({ +/// The warmup as the first circle of the set row: outlined, the size of a +/// set circle, showing `W×5` over the warmup weight. +class _WarmupCircle extends StatelessWidget { + const _WarmupCircle({ + required this.semanticLabel, required this.warmupWeight, required this.tapped, required this.onTap, }); + final String semanticLabel; final double warmupWeight; final bool tapped; final VoidCallback onTap; @@ -132,51 +84,136 @@ class _WarmupRow extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final status = Theme.of(context).extension()!; - final mutedStyle = TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, - ); - return Row( - children: [ - Text('Warmup 1×5×', style: mutedStyle), - Text('${warmupWeight}kg', style: mutedStyle), - const SizedBox(width: 10), - GestureDetector( - onTap: tapped ? null : onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 36, - height: 36, - decoration: BoxDecoration( - shape: BoxShape.circle, - // Tapped = a completed milestone, same semantic as every other - // "done" indicator in this app (rep circles, calendar days). - color: tapped ? status.success : Colors.transparent, - border: Border.all( - color: tapped ? status.success : colorScheme.outline, - width: 2, - ), - ), - alignment: Alignment.center, - child: Icon( - tapped ? Icons.check : Icons.fitness_center, - // on-fill on the filled circle; muted on the empty outline one. - color: tapped - ? colorScheme.onPrimary - : colorScheme.onSurfaceVariant, - size: 16, + // Tapped = a completed milestone, same semantic as every other "done" + // indicator in this app. On-fill ink on the filled circle; muted on the + // outline-only one, which is what sets it apart from a working set. + final fg = tapped ? colorScheme.onPrimary : colorScheme.onSurfaceVariant; + final small = TextStyle(color: fg, fontSize: AppTextSize.caption); + return GestureDetector( + onTap: tapped ? null : onTap, + child: Semantics( + container: true, + button: true, + label: semanticLabel, + excludeSemantics: true, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 52, + height: 52, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: tapped ? status.success : Colors.transparent, + border: Border.all( + color: tapped ? status.success : colorScheme.outline, + width: 2, ), ), + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'W×5', + style: small.copyWith(fontWeight: FontWeight.bold, height: 1.1), + ), + Text('$warmupWeight', style: small.copyWith(height: 1.1)), + ], + ), ), - const SizedBox(width: 6), - Text( - tapped ? 'done' : 'optional', - style: TextStyle( - color: tapped ? status.success : colorScheme.onSurfaceVariant, - fontSize: AppTextSize.caption, + ), + ); + } +} + +/// `↑ 2/3 → 25 kg` … `↓ 0/2 → 20 kg`: both streaks against their +/// thresholds, and what the next step up or down will set. +/// +/// The targets come from the same rule `applyProgression` runs, so the +/// line cannot promise a step the finish would not take. +class _ProgressRow extends StatelessWidget { + const _ProgressRow({required this.state, required this.onFill}); + + final ExerciseState state; + + /// Whether the card is a filled success/danger colour. + final bool onFill; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final status = Theme.of(context).extension()!; + final textColor = onFill + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant; + final up = describeTargetChange(state, targetAfterSuccess(state)); + final down = describeTargetChange(state, targetAfterFailure(state)); + return Row( + children: [ + Expanded( + child: _half( + Icons.trending_up, + onFill ? textColor : status.success, + '${state.successStreak}/${state.successThreshold} → $up', + textColor, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _half( + Icons.trending_down, + onFill ? textColor : colorScheme.error, + '${state.failStreak}/${state.failThreshold} → $down', + textColor, + ), + ), + ], + ); + } + + Widget _half(IconData icon, Color iconColor, String text, Color color) { + return Row( + children: [ + Icon(icon, size: 14, color: iconColor), + const SizedBox(width: 4), + Expanded( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: color, fontSize: AppTextSize.caption), ), ), ], ); } } + +/// Stands in for the set row while the exercise is paused: same height, so +/// pausing moves nothing on the screen. +class _PausedRow extends StatelessWidget { + const _PausedRow({required this.until, required this.color}); + + final DateTime until; + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: ExerciseTile.setRowHeight, + child: Row( + children: [ + Icon(Icons.pause_circle_outline, size: 20, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Paused — back on ${formatShortDate(until)} · counts as failed', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: color, fontSize: AppTextSize.label), + ), + ), + ], + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/test/models/progression_test.dart b/stronglift_replacement/workout_app/test/models/progression_test.dart new file mode 100644 index 0000000..291c00f --- /dev/null +++ b/stronglift_replacement/workout_app/test/models/progression_test.dart @@ -0,0 +1,247 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/models/exercise.dart'; +import 'package:workout_app/models/exercise_state.dart'; +import 'package:workout_app/models/progression.dart'; + +ExerciseState _s({ + required ProgressionMode mode, + double weight = 20, + int reps = 8, + double maxWeight = 27.5, + int repsHigh = 12, + int repsLow = 6, +}) => ExerciseState( + name: 'X', + weight: weight, + reps: reps, + successStreak: 0, + failStreak: 0, + maxWeight: maxWeight, + successThreshold: 3, + failThreshold: 2, + mode: mode, + repsHigh: repsHigh, + repsLow: repsLow, + hasWarmup: true, + pausedUntil: null, +); + +void _expectTarget(ProgressionTarget t, double weight, int reps) { + expect((t.weight, t.reps), (weight, reps), reason: '$t'); +} + +void main() { + group('ProgressionMode.parse', () { + test('reads every storage key back to its mode', () { + for (final m in ProgressionMode.values) { + expect(ProgressionMode.parse(m.storageKey), m); + } + }); + + test('null (a row from before modes existed) is weight', () { + expect(ProgressionMode.parse(null), ProgressionMode.weight); + }); + + test('an unknown value falls back to weight', () { + expect(ProgressionMode.parse('sideways'), ProgressionMode.weight); + }); + }); + + group('weight mode (unchanged behaviour)', () { + test('success below the cap adds 2.5 kg', () { + _expectTarget(targetAfterSuccess(_s(mode: ProgressionMode.weight)), 22.5, 8); + }); + + test('success above the cap clamps to it', () { + final s = _s(mode: ProgressionMode.weight, weight: 26); + _expectTarget(targetAfterSuccess(s), 27.5, 8); + }); + + test('success at the cap adds a rep instead', () { + final s = _s(mode: ProgressionMode.weight, weight: 27.5); + _expectTarget(targetAfterSuccess(s), 27.5, 9); + }); + + test('failure drops 2.5 kg, never below zero', () { + _expectTarget(targetAfterFailure(_s(mode: ProgressionMode.weight)), 17.5, 8); + final light = _s(mode: ProgressionMode.weight, weight: 1); + _expectTarget(targetAfterFailure(light), 0, 8); + }); + }); + + group('reps mode', () { + test('success adds a rep and never touches the weight', () { + _expectTarget(targetAfterSuccess(_s(mode: ProgressionMode.reps)), 20, 9); + }); + + test('failure removes a rep, floored at 1', () { + _expectTarget(targetAfterFailure(_s(mode: ProgressionMode.reps)), 20, 7); + final one = _s(mode: ProgressionMode.reps, reps: 1); + _expectTarget(targetAfterFailure(one), 20, 1); + }); + }); + + group('double progression', () { + const mode = ProgressionMode.doubleProgression; + + test('below n: +1 rep', () { + _expectTarget(targetAfterSuccess(_s(mode: mode, reps: 11)), 20, 12); + }); + + test('at n: +2.5 kg and back to m', () { + _expectTarget(targetAfterSuccess(_s(mode: mode, reps: 12)), 22.5, 6); + }); + + test('above n (switched in at 30 reps) still promotes', () { + _expectTarget(targetAfterSuccess(_s(mode: mode, reps: 30)), 22.5, 6); + }); + + test('at n on the weight cap keeps adding reps', () { + final s = _s(mode: mode, reps: 12, weight: 27.5); + _expectTarget(targetAfterSuccess(s), 27.5, 13); + }); + + test('failure above m removes a rep', () { + _expectTarget(targetAfterFailure(_s(mode: mode, reps: 9)), 20, 8); + }); + + test('failure at m is the exact inverse of a step up', () { + final s = _s(mode: mode, reps: 6); + final down = targetAfterFailure(s); + _expectTarget(down, 17.5, 12); + final back = targetAfterSuccess( + s.copyWith(weight: down.weight, reps: down.reps), + ); + _expectTarget(back, 20, 6); + }); + + test('failure at m with no weight left stays put', () { + _expectTarget(targetAfterFailure(_s(mode: mode, reps: 6, weight: 0)), 0, 6); + }); + }); + + group('describeTargetChange', () { + final from = _s(mode: ProgressionMode.weight, weight: 20, reps: 8); + + test('weight and reps', () { + expect( + describeTargetChange(from, const ProgressionTarget(22.5, 6)), + '22.5 kg × 6', + ); + }); + + test('weight only drops a trailing .0', () { + expect(describeTargetChange(from, const ProgressionTarget(25, 8)), '25 kg'); + }); + + test('reps only', () { + expect(describeTargetChange(from, const ProgressionTarget(20, 9)), '9 reps'); + }); + + test('no change', () { + expect(describeTargetChange(from, const ProgressionTarget(20, 8)), 'same'); + }); + + test('toString names both fields', () { + expect(const ProgressionTarget(20, 8).toString(), contains('20')); + }); + }); + + group('ExerciseState', () { + test('initial copies the plan and the plan warmup', () { + const ex = Exercise( + name: 'Situp', + sets: 3, + reps: 30, + weight: 10, + maxWeight: 10, + hasWarmup: false, + ); + final s = ExerciseState.initial(ex); + expect(s.weight, 10); + expect(s.reps, 30); + expect(s.maxWeight, 10); + expect(s.hasWarmup, isFalse); + expect(s.mode, ProgressionMode.weight); + expect((s.repsHigh, s.repsLow), (kDefaultRepsHigh, kDefaultRepsLow)); + expect((s.successThreshold, s.failThreshold), (3, 2)); + }); + + test('copyWith replaces each field and keeps the rest', () { + final s = _s(mode: ProgressionMode.weight); + final c = s.copyWith( + weight: 1, + reps: 2, + successStreak: 3, + failStreak: 4, + successThreshold: 5, + failThreshold: 1, + mode: ProgressionMode.reps, + repsHigh: 20, + repsLow: 10, + hasWarmup: false, + ); + expect(c.name, 'X'); + expect(c.maxWeight, 27.5); + expect((c.weight, c.reps, c.successStreak, c.failStreak), (1, 2, 3, 4)); + expect((c.successThreshold, c.failThreshold), (5, 1)); + expect((c.mode, c.repsHigh, c.repsLow, c.hasWarmup), ( + ProgressionMode.reps, + 20, + 10, + false, + )); + final same = s.copyWith(); + expect((same.weight, same.reps, same.mode, same.hasWarmup), ( + 20, + 8, + ProgressionMode.weight, + true, + )); + }); + }); + + group('injury pause', () { + final now = DateTime(2026, 9, 27, 17, 30); + + test('pauseEnd is local midnight n days on', () { + expect(pauseEnd(now, 14), DateTime(2026, 10, 11)); + }); + + test('isPausedAt is true until that midnight, false from it', () { + final s = _s(mode: ProgressionMode.weight).copyWith( + pausedUntil: pauseEnd(now, 14), + ); + expect(s.isPausedAt(now), isTrue); + expect(s.isPausedAt(DateTime(2026, 10, 10, 23, 59)), isTrue); + expect(s.isPausedAt(DateTime(2026, 10, 11)), isFalse); + expect(_s(mode: ProgressionMode.weight).isPausedAt(now), isFalse); + }); + + test('copyWith keeps a pause, and resume clears it', () { + final paused = _s(mode: ProgressionMode.weight).copyWith( + pausedUntil: DateTime(2026, 10, 11), + ); + expect(paused.copyWith(reps: 3).pausedUntil, DateTime(2026, 10, 11)); + expect(paused.copyWith(resume: true).pausedUntil, isNull); + }); + + test('dates round-trip through storage format', () { + final d = DateTime(2026, 1, 5); + expect(formatPauseDate(d), '2026-01-05'); + expect(parsePauseDate(formatPauseDate(d)), d); + }); + + test('null, garbage and non-strings read as not paused', () { + expect(parsePauseDate(null), isNull); + expect(parsePauseDate('soon'), isNull); + expect(parsePauseDate(42), isNull); + }); + + test('formatShortDate', () { + expect(formatShortDate(DateTime(2026, 10, 11)), '11 Oct'); + expect(formatShortDate(DateTime(2026, 1, 1)), '1 Jan'); + expect(formatShortDate(DateTime(2026, 12, 31)), '31 Dec'); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/_workout_screen_test_fixtures.dart b/stronglift_replacement/workout_app/test/screens/_workout_screen_test_fixtures.dart index add9d73..dfe7411 100644 --- a/stronglift_replacement/workout_app/test/screens/_workout_screen_test_fixtures.dart +++ b/stronglift_replacement/workout_app/test/screens/_workout_screen_test_fixtures.dart @@ -112,3 +112,9 @@ Future longPressReal(WidgetTester tester, Finder f) async { /// no longer says anything; `active` is the discriminator. bool restRunning(WidgetTester tester) => tester.widget(find.byType(BreakBanner)).active; + +/// Seconds left on the rest strip. The strip carries no label any more, so +/// "which rest is this" is read from the length: a success or warmup rest +/// starts at 180, a failed set's at 300. +int restSecs(WidgetTester tester) => + tester.widget(find.byType(BreakBanner)).breakRemaining; diff --git a/stronglift_replacement/workout_app/test/screens/workout_breaks_restore_test.dart b/stronglift_replacement/workout_app/test/screens/workout_breaks_restore_test.dart index f42e1ba..e7910fa 100644 --- a/stronglift_replacement/workout_app/test/screens/workout_breaks_restore_test.dart +++ b/stronglift_replacement/workout_app/test/screens/workout_breaks_restore_test.dart @@ -77,7 +77,7 @@ void main() { await pumpWorkout(tester, wrapWorkout(savedState: savedWithBreak(90))); expect(restRunning(tester), isTrue); - expect(find.textContaining('well done'), findsOneWidget); + expect(restSecs(tester), inInclusiveRange(80, 90)); expect( audio.resumedSources.where((s) => s.contains('break_end')), isEmpty, diff --git a/stronglift_replacement/workout_app/test/screens/workout_breaks_test.dart b/stronglift_replacement/workout_app/test/screens/workout_breaks_test.dart index 9b1c79c..fb396cb 100644 --- a/stronglift_replacement/workout_app/test/screens/workout_breaks_test.dart +++ b/stronglift_replacement/workout_app/test/screens/workout_breaks_test.dart @@ -51,8 +51,8 @@ void main() { await pumpWorkout(tester, wrapWorkout()); await tapReal(tester, find.byType(RepCircle).first); expect(restRunning(tester), isTrue); - // Success break (5 done >= 5 target). - expect(find.textContaining('well done'), findsOneWidget); + // Success break (5 done >= 5 target): the 3-minute rest. + expect(restSecs(tester), inInclusiveRange(175, 180)); await tapReal(tester, find.text('Skip')); expect(restRunning(tester), isFalse); @@ -60,8 +60,9 @@ void main() { testWidgets('tapping warmup starts a warmup break', (tester) async { await pumpWorkout(tester, wrapWorkout()); - await tapReal(tester, find.byIcon(Icons.fitness_center).first); - expect(find.textContaining('Warmup rest'), findsOneWidget); + await tapReal(tester, find.bySemanticsLabel(RegExp(r' warmup$')).first); + expect(restRunning(tester), isTrue); + expect(restSecs(tester), inInclusiveRange(175, 180)); }); testWidgets('re-tapping a set decrements reps and recomputes the break', ( @@ -70,10 +71,10 @@ void main() { await pumpWorkout(tester, wrapWorkout()); final circle = find.byType(RepCircle).first; await tapReal(tester, circle); // done=5 -> success break - expect(find.textContaining('well done'), findsOneWidget); + expect(restSecs(tester), inInclusiveRange(175, 180)); await tapReal(tester, circle); // done 5 -> 4, below target -> fail break - expect(find.textContaining('keep going'), findsOneWidget); + expect(restSecs(tester), inInclusiveRange(295, 300)); }); testWidgets('long-pressing a set resets it and cancels its break', ( @@ -152,18 +153,20 @@ void main() { testWidgets('changing a threshold in the workout persists it', ( tester, ) async { - // A real exercise has a progression-state row, so _onThresholdChanged's - // state-update branch runs (and the write actually lands). + // A real exercise has a progression-state row, so the settings write + // actually lands. final name = workoutA.first.name; await pumpWorkout(tester, wrapWorkout(exercises: [workoutA.first])); - // The success stepper's "+" is the first Icons.add in the single tile. - await tapReal(tester, find.byIcon(Icons.add).first); + await tapReal(tester, find.bySemanticsLabel('$name progression settings')); + await tester.pumpAndSettle(); + await tapReal(tester, find.bySemanticsLabel('Wins needed up')); final state = await tester.runAsync( () => StorageService.instance.getExerciseState(name), ); expect(state!.successThreshold, 4); // default 3 -> +1 + expect(find.textContaining('0/4'), findsOneWidget); }); testWidgets('restores an in-progress break and finishes it', (tester) async { diff --git a/stronglift_replacement/workout_app/test/screens/workout_layout_test.dart b/stronglift_replacement/workout_app/test/screens/workout_layout_test.dart index 215a112..3777125 100644 --- a/stronglift_replacement/workout_app/test/screens/workout_layout_test.dart +++ b/stronglift_replacement/workout_app/test/screens/workout_layout_test.dart @@ -16,6 +16,7 @@ import 'package:workout_app/models/workout_plan.dart'; import 'package:workout_app/sandbox/sandbox.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/widgets/break_banner.dart'; +import 'package:workout_app/widgets/exercise_settings_sheet.dart'; import 'package:workout_app/widgets/exercise_tile.dart'; import 'package:workout_app/widgets/rep_circle.dart'; @@ -115,7 +116,8 @@ void main() { expect(find.byType(Scrollable), findsNothing); expect(tester.getSize(find.byType(BreakBanner)).height, BreakBanner.height); expect(restRunning(tester), isFalse); - expect(find.text('Rest'), findsOneWidget); + // No label on the strip any more: the countdown alone says it. + expect(find.textContaining('Rest'), findsNothing); expect(find.text('00:00'), findsOneWidget); await _shot(tester, 'idle'); @@ -128,6 +130,48 @@ void main() { await tapReal(tester, find.text('Skip')); expect(restRunning(tester), isFalse); expect(tileRects(), idle); + + // The settings sheet: opens over the column, fits without scrolling, + // and editing it moves nothing underneath. + await tapReal( + tester, + find.bySemanticsLabel('Dumbbell Romanian Deadlift progression settings'), + ); + await tester.pumpAndSettle(); + expect(find.byType(ExerciseSettingsSheet), findsOneWidget); + final sheetIdle = tester.getRect(find.byType(ExerciseSettingsSheet)); + await tapReal(tester, find.text('Reps→kg')); + expect(tester.getRect(find.byType(ExerciseSettingsSheet)), sheetIdle); + await tapReal(tester, find.text('Warmup set')); + expect(tester.getRect(find.byType(ExerciseSettingsSheet)), sheetIdle); + expect(find.byType(Scrollable), findsNothing); + await _shot(tester, 'sheet'); + Navigator.of(tester.element(find.byType(ExerciseSettingsSheet))).pop(); + await tester.pumpAndSettle(); + + expect(find.text('6→12'), findsOneWidget); + expect( + find.bySemanticsLabel('Dumbbell Romanian Deadlift warmup').hitTestable(), + findsNothing, + ); + expect(tileRects(), idle); + await _shot(tester, 'double_no_warmup'); + + // Every set recorded: filled cards, Finish enabled, same geometry. + for (var i = 0; i < find.byType(RepCircle).evaluate().length; i++) { + if (restRunning(tester)) await tapReal(tester, find.text('Skip')); + final circle = tester.widget(find.byType(RepCircle).at(i)); + if (!circle.tapped) await tapReal(tester, find.byType(RepCircle).at(i)); + } + expect( + tester.widgetList(find.byType(RepCircle)).every( + (c) => c.tapped, + ), + isTrue, + ); + expect(tester.widget(find.byType(BreakBanner)).canFinish, true); + expect(tileRects(), idle); + await _shot(tester, 'all_done'); }); testWidgets('landscape would need the scale floor; portrait is locked', ( @@ -162,7 +206,6 @@ void main() { }); await pumpWorkout(tester, wrapWorkout(type: 'B', exercises: workoutB)); await tapReal(tester, find.byType(RepCircle).first); - expect(find.text('Rest (7 s — sandbox)'), findsOneWidget); expect(find.text('00:07'), findsOneWidget); }); } diff --git a/stronglift_replacement/workout_app/test/screens/workout_pause_test.dart b/stronglift_replacement/workout_app/test/screens/workout_pause_test.dart new file mode 100644 index 0000000..8e4e4fe --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/workout_pause_test.dart @@ -0,0 +1,153 @@ +// An exercise on an injury pause: no sets to tap, Finish does not wait for +// it, the notification skips it, and it is recorded as FAILED. +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/break_intent.dart'; +import 'package:workout_app/services/break_intent_queue.dart'; +import 'package:workout_app/services/break_intent_store.dart'; +import 'package:workout_app/models/exercise.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/widgets/break_banner.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, so each has a progression-state row to pause. +const _lunge = Exercise(name: 'Dumbbell Lunge', sets: 2, reps: 5, weight: 10); +const _situp = Exercise(name: 'Situp', sets: 2, reps: 5, weight: 5); + +Future _pause(String name) async { + final s = (await StorageService.instance.getExerciseState(name))!; + await StorageService.instance.setExerciseSettings( + s.copyWith(pausedUntil: pauseEnd(DateTime.now(), 14), failThreshold: 1), + ); +} + +void main() { + late FakeForegroundBreakClient client; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeSecureStorage(); + installFakeAudioPlatform(); + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + client = FakeForegroundBreakClient(); + }); + + Future pumpPausedSitup(WidgetTester tester) async { + await tester.runAsync(() => _pause('Situp')); + await pumpWorkout( + tester, + wrapWorkout(exercises: const [_lunge, _situp], breakClient: client), + ); + } + + testWidgets('the paused exercise shows no sets and says so', (tester) async { + await pumpPausedSitup(tester); + expect(find.byType(RepCircle), findsNWidgets(_lunge.sets)); + expect(find.textContaining('Paused — back on'), findsOneWidget); + }); + + testWidgets('Finish unlocks without it, and it is recorded as failed', ( + tester, + ) async { + await pumpPausedSitup(tester); + for (var s = 0; s < _lunge.sets; s++) { + if (restRunning(tester)) await tapReal(tester, find.text('Skip')); + await tapReal(tester, find.byType(RepCircle).at(s)); + } + expect( + tester.widget(find.byType(BreakBanner)).canFinish, + isTrue, + ); + // The notification never offers the paused exercise's sets. + expect(client.pushed.last.setsRemaining, 0); + + await tester.runAsync(() async { + await tester.tap(find.widgetWithText(TextButton, 'Finish')); + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pump(); + await tester.tap(find.widgetWithText(TextButton, 'Finish').last); + await Future.delayed(const Duration(milliseconds: 800)); + await tester.pump(); + }); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(WorkoutSummaryDialog), findsOneWidget); + expect(find.text('Situp: ✗'), findsOneWidget); + + final situp = await tester.runAsync( + () => StorageService.instance.getExerciseState('Situp'), + ); + // failThreshold 1: the one "failure" already stepped the weight down. + expect(situp!.weight, 7.5); + }); + + testWidgets('pausing mid-rest ends that exercise\'s rest', (tester) async { + await pumpWorkout( + tester, + wrapWorkout(exercises: const [_lunge, _situp], breakClient: client), + ); + await tapReal(tester, find.byType(RepCircle).first); + expect(restRunning(tester), isTrue); + + await tapReal( + tester, + find.bySemanticsLabel('Dumbbell Lunge progression settings'), + ); + await tester.pumpAndSettle(); + await tapReal(tester, find.text('Pause')); + expect(restRunning(tester), isFalse); + expect(find.text('Resume'), findsOneWidget); + + // Resuming brings the sets back; the tap made before the pause stays. + await tapReal(tester, find.text('Resume')); + Navigator.of(tester.element(find.text('Pause'))).pop(); + await tester.pumpAndSettle(); + expect(find.byType(RepCircle), findsNWidgets(4)); + expect(tester.widget(find.byType(RepCircle).first).tapped, true); + }); + + testWidgets('a notification "done" on a paused exercise is dropped', ( + tester, + ) async { + await pumpPausedSitup(tester); + // Nothing inside the paused exercise is offered as next. + expect(client.pushed.last.nextExName, 'Dumbbell Lunge'); + expect(client.pushed.last.setsRemaining, _lunge.sets); + + // A stale press aimed at Situp's first set must not record it. + await tester.runAsync( + () => BreakIntentQueue(PrefsBreakIntentStore()).enqueue( + kind: BreakIntentKind.done, + exIdx: 1, + setIdx: 0, + now: DateTime.now(), + ), + ); + await tester.runAsync(() async { + client.onNudge!(); + await Future.delayed(const Duration(milliseconds: 400)); + }); + await tester.pump(); + final saved = await tester.runAsync( + () => StorageService.instance.loadActiveSession(), + ); + // Applying it would have tapped the set and saved the session. + expect(saved, isNull); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/workout_screen_intents_test.dart b/stronglift_replacement/workout_app/test/screens/workout_screen_intents_test.dart index 4d80d27..6a96e72 100644 --- a/stronglift_replacement/workout_app/test/screens/workout_screen_intents_test.dart +++ b/stronglift_replacement/workout_app/test/screens/workout_screen_intents_test.dart @@ -123,12 +123,13 @@ void main() { testWidgets('− 1 rep stretches the rest from 3 minutes to 5', (tester) async { await pump(tester, saved: savedWithBreak(170)); - expect(find.textContaining('well done'), findsOneWidget); + expect(restSecs(tester), lessThanOrEqualTo(170)); await seed([(BreakIntentKind.minusRep, 0, 0)]); await nudge(tester); - expect(find.textContaining('keep going'), findsOneWidget); + // Re-cut to 300 from the same start: 10 s in, so about 290 left. + expect(restSecs(tester), inInclusiveRange(280, 290)); expect(lastPushed().breakDurationSecs, 300); }); diff --git a/stronglift_replacement/workout_app/test/screens/workout_screen_test.dart b/stronglift_replacement/workout_app/test/screens/workout_screen_test.dart index fd9c4f8..62d0451 100644 --- a/stronglift_replacement/workout_app/test/screens/workout_screen_test.dart +++ b/stronglift_replacement/workout_app/test/screens/workout_screen_test.dart @@ -35,9 +35,16 @@ void main() { installFakeAudioPlatform(); }); - testWidgets('shows Workout A in app bar', (tester) async { + testWidgets('has no app bar: no workout type, no elapsed clock', ( + tester, + ) async { + // Dropped 2026-09-27 for room: Reset/Finish live in the rest strip. await pumpWorkout(tester, wrapWorkout()); - expect(find.textContaining('Workout A'), findsOneWidget); + expect(find.byType(AppBar), findsNothing); + expect(find.textContaining('Workout A'), findsNothing); + // The strip's idle countdown is the only 00:00 on screen. + expect(find.text('00:00'), findsOneWidget); + expect(find.byType(SafeArea), findsWidgets); }); testWidgets('shows exercise tiles for all exercises', (tester) async { @@ -128,16 +135,4 @@ void main() { await tester.tap(find.text('Cancel')); await tester.pump(); }); - - testWidgets('elapsed timer shows time in app bar', (tester) async { - await pumpWorkout(tester, wrapWorkout()); - // The idle rest strip shows 00:00 too; the app bar's clock carries the - // workout type in the same string. - expect(find.textContaining('Workout A · 00:00'), findsOneWidget); - }); - - testWidgets('B workout type shows in app bar', (tester) async { - await pumpWorkout(tester, wrapWorkout(type: 'B')); - expect(find.textContaining('Workout B'), findsOneWidget); - }); } diff --git a/stronglift_replacement/workout_app/test/services/break_task_handler_test.dart b/stronglift_replacement/workout_app/test/services/break_task_handler_test.dart index 72dfb93..5f1a75a 100644 --- a/stronglift_replacement/workout_app/test/services/break_task_handler_test.dart +++ b/stronglift_replacement/workout_app/test/services/break_task_handler_test.dart @@ -161,7 +161,8 @@ void main() { final s = handler.snapshot!; expect(s.breakDurationSecs, 300); - expect(s.breakLabel, contains('5 min')); + // No "5 min — keep going!" any more: the countdown says it. + expect(s.breakLabel, 'Rest'); // Re-cut from the original start: 60s in, so 240s left of 300. expect( DateTime.fromMillisecondsSinceEpoch(s.breakEndMs) diff --git a/stronglift_replacement/workout_app/test/services/progression_sync_fields_test.dart b/stronglift_replacement/workout_app/test/services/progression_sync_fields_test.dart new file mode 100644 index 0000000..3a7ce59 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/progression_sync_fields_test.dart @@ -0,0 +1,88 @@ +// The progression-mode, warmup and pause fields on a Firebase progression +// record: pushed, pulled back, and read leniently from records written +// before they existed. +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/services/progression_sync_service.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '_progression_test_fixtures.dart'; + +Map _payload(FakeStore store, String name) => + Record.fromJson( + jsonDecode(store.files[ProgressionSyncService.pathForExercise(name)]!) + as Map, + ).fields['payload']!.$1 + as Map; + +void main() { + late FakeStore store; + late ProgressionSyncService sync; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + store = FakeStore(); + sync = ProgressionSyncService(firebaseFactory: () async => store); + StorageService.resetForTesting(); + await StorageService.init(); + }); + + test('a record from before modes existed restores with the defaults', () async { + seedRemote(store, 'Situp', weight: 5, reps: 31, maxWeight: 10); + seedRemote(store, 'Dumbbell Row', weight: 27.5, reps: 6); + + await sync.pullProgression(); + + final situp = (await StorageService.instance.getExerciseState('Situp'))!; + expect(situp.mode, ProgressionMode.weight); + expect((situp.repsHigh, situp.repsLow), (12, 6)); + // Missing has_warmup = the plan's default, never a blanket "on". + expect(situp.hasWarmup, isFalse); + expect(situp.pausedUntil, isNull); + final row = (await StorageService.instance.getExerciseState( + 'Dumbbell Row', + ))!; + expect(row.hasWarmup, isTrue); + }); + + test('mode, rep range, warmup and pause survive a push and a pull', () async { + final storage = StorageService.instance; + final situp = (await storage.getExerciseState('Situp'))!; + await storage.setExerciseSettings( + situp.copyWith( + mode: ProgressionMode.doubleProgression, + repsHigh: 20, + repsLow: 10, + hasWarmup: true, + pausedUntil: DateTime(2026, 10, 11), + ), + ); + await markSynced(); + await sync.pushProgression(); + + final pushed = _payload(store, 'Situp'); + expect(pushed['progression_mode'], 'double'); + expect((pushed['reps_high'], pushed['reps_low']), (20, 10)); + expect(pushed['has_warmup'], isTrue); + expect(pushed['paused_until'], '2026-10-11'); + expect(_payload(store, 'Dumbbell Row')['paused_until'], isNull); + + // A reinstall: fresh database, same Firebase. + StorageService.resetForTesting(); + await StorageService.init(); + await sync.pullProgression(); + + final back = (await StorageService.instance.getExerciseState('Situp'))!; + expect(back.mode, ProgressionMode.doubleProgression); + expect((back.repsHigh, back.repsLow), (20, 10)); + expect(back.hasWarmup, isTrue); + expect(back.pausedUntil, DateTime(2026, 10, 11)); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/storage_service_modes_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_modes_test.dart new file mode 100644 index 0000000..c4f04f6 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/storage_service_modes_test.dart @@ -0,0 +1,289 @@ +// Progression modes, the per-exercise warmup toggle, and the v3 -> v4 +// schema upgrade that added both. +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/models/workout_plan.dart'; +import 'package:workout_app/services/storage_service.dart'; + +StorageService get _svc => StorageService.instance; + +final _yesterday = DateTime.now().subtract(const Duration(days: 1)); + +/// Builds a database exactly as a v3 install left it: the v3 schema, a +/// finished workout from today, and a workout in progress. +Future _writeV3Database(String path) async { + final db = await databaseFactory.openDatabase( + path, + options: OpenDatabaseOptions( + version: 3, + 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)', + ); + 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)', + ); + }, + ), + ); + for (final ex in [...workoutA, ...workoutB]) { + await db.insert('exercise_state', { + 'name': ex.name, + 'weight': 17.5, + 'reps': 9, + 'success_streak': 2, + 'fail_streak': 1, + 'max_weight': ex.maxWeight, + 'success_threshold': 5, + 'fail_threshold': 4, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await db.insert('workout_history', { + 'date': '2026-09-27', + 'workout_type': 'A', + 'duration_seconds': 4000, + 'succeeded': 1, + 'json': '{"workoutType":"A"}', + }); + await db.insert('settings', {'key': 'last_workout_type', 'value': 'A'}); + await db.insert('active_session', {'id': 1, 'json': jsonEncode(_session)}); + await db.close(); +} + +const _session = { + 'workoutType': 'B', + 'startTimeMs': 1790516739892, + 'tapped': [ + [true, true, true, true, true], + [true, true, true, true, true], + [true, true, true, true, false], + [false, false, false], + ], + 'doneReps': [ + [9, 9, 9, 9, 9], + [12, 12, 12, 12, 12], + [12, 12, 12, 12, 12], + [31, 31, 31], + ], + 'warmupTapped': [true, true, true, false], +}; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + }); + + group('v3 -> v4 upgrade', () { + late Directory dir; + setUp(() async { + dir = await Directory.systemTemp.createTemp('mw_v4'); + final file = p.join(dir.path, 'v3.db'); + await _writeV3Database(file); + StorageService.resetForTesting(dbPath: file); + await StorageService.init(); + }); + tearDown(() => dir.delete(recursive: true)); + + test('keeps a workout in progress byte for byte', () async { + expect(await _svc.loadActiveSession(), _session); + }); + + test("keeps today's finished workout and the history marker", () async { + expect(await _svc.getLastWorkoutDate(), DateTime(2026, 9, 27)); + expect(await _svc.getNextWorkoutType(), 'B'); + expect(await _svc.looksFreshlyInstalled(), isFalse); + }); + + test('keeps progression and gives every row weight mode', () async { + for (final ex in [...workoutA, ...workoutB]) { + final s = (await _svc.getExerciseState(ex.name))!; + expect((s.weight, s.reps, s.successStreak, s.failStreak), ( + 17.5, + 9, + 2, + 1, + )); + expect((s.successThreshold, s.failThreshold), (5, 4)); + expect(s.mode, ProgressionMode.weight); + expect((s.repsHigh, s.repsLow), (kDefaultRepsHigh, kDefaultRepsLow)); + // NULL column -> the plan's default: Situp stays warmup-free. + expect(s.hasWarmup, ex.hasWarmup, reason: ex.name); + } + }); + }); + + test('v4 -> v5 (the phone today) keeps settings and adds no pause', () async { + final dir = await Directory.systemTemp.createTemp('mw_v5'); + addTearDown(() => dir.delete(recursive: true)); + final file = p.join(dir.path, 'v4.db'); + await _writeV3Database(file); + // Upgrade to exactly v4 the way the v4 build did, and set a mode there. + final v4 = await databaseFactory.openDatabase( + file, + options: OpenDatabaseOptions( + version: 4, + onUpgrade: (db, _, _) async { + await db.execute( + "ALTER TABLE exercise_state ADD COLUMN progression_mode TEXT " + "NOT NULL DEFAULT 'weight'", + ); + await db.execute( + 'ALTER TABLE exercise_state ADD COLUMN reps_high INTEGER ' + 'NOT NULL DEFAULT 12', + ); + await db.execute( + 'ALTER TABLE exercise_state ADD COLUMN reps_low INTEGER ' + 'NOT NULL DEFAULT 6', + ); + await db.execute( + 'ALTER TABLE exercise_state ADD COLUMN has_warmup INTEGER', + ); + }, + ), + ); + await v4.update('exercise_state', { + 'progression_mode': 'double', + 'has_warmup': 0, + }, where: "name = 'Situp'"); + await v4.close(); + + StorageService.resetForTesting(dbPath: file); + await StorageService.init(); + final situp = (await _svc.getExerciseState('Situp'))!; + expect(situp.mode, ProgressionMode.doubleProgression); + expect(situp.hasWarmup, isFalse); + expect(situp.pausedUntil, isNull); + expect(await _svc.getLastWorkoutDate(), DateTime(2026, 9, 27)); + }); + + group('setExerciseSettings', () { + test('persists mode, rep range, warmup and thresholds', () async { + final name = workoutA.first.name; + final s = (await _svc.getExerciseState(name))!; + await _svc.setExerciseSettings( + s.copyWith( + mode: ProgressionMode.doubleProgression, + repsHigh: 15, + repsLow: 8, + hasWarmup: false, + successThreshold: 1, + failThreshold: 4, + ), + ); + final after = (await _svc.getExerciseState(name))!; + expect(after.mode, ProgressionMode.doubleProgression); + expect((after.repsHigh, after.repsLow), (15, 8)); + expect(after.hasWarmup, isFalse); + expect((after.successThreshold, after.failThreshold), (1, 4)); + // The target itself is untouched until the next finished workout. + expect((after.weight, after.reps), (s.weight, s.reps)); + }); + + test('persists a pause and its resume', () async { + final s = (await _svc.getExerciseState('Situp'))!; + await _svc.setExerciseSettings( + s.copyWith(pausedUntil: DateTime(2026, 10, 11)), + ); + expect( + (await _svc.getExerciseState('Situp'))!.pausedUntil, + DateTime(2026, 10, 11), + ); + await _svc.setExerciseSettings(s.copyWith(resume: true)); + expect((await _svc.getExerciseState('Situp'))!.pausedUntil, isNull); + }); + + test('reaches the workout through getCurrentExercises', () async { + final name = workoutA.first.name; + final s = (await _svc.getExerciseState(name))!; + await _svc.setExerciseSettings(s.copyWith(hasWarmup: false)); + final exercises = await _svc.getCurrentExercises('A'); + expect(exercises.first.hasWarmup, isFalse); + expect(exercises[1].hasWarmup, isTrue); + }); + }); + + group('applyProgression per mode', () { + Future run( + ProgressionMode mode, { + required bool success, + required int reps, + DateTime? last, + }) async { + final name = workoutA.first.name; + final s = (await _svc.getExerciseState(name))!; + await _svc.setExerciseSettings( + s.copyWith(mode: mode, successThreshold: 1, failThreshold: 1), + ); + await _svc.setExerciseReps(name, reps); + await _svc.setExerciseWeight(name, 10); + await _svc.applyProgression( + succeededExercises: {name: success}, + lastWorkoutDate: last ?? _yesterday, + ); + return (await _svc.getExerciseState(name))!; + } + + test('reps: success adds a rep', () async { + final s = await run(ProgressionMode.reps, success: true, reps: 10); + expect((s.weight, s.reps, s.successStreak), (10, 11, 0)); + }); + + test('reps: failure removes a rep', () async { + final s = await run(ProgressionMode.reps, success: false, reps: 10); + expect((s.weight, s.reps, s.failStreak), (10, 9, 0)); + }); + + test('double: success at n moves up in weight and back to m', () async { + final s = await run( + ProgressionMode.doubleProgression, + success: true, + reps: 12, + ); + expect((s.weight, s.reps), (12.5, 6)); + }); + + test('double: failure at m moves down in weight and up to n', () async { + final s = await run( + ProgressionMode.doubleProgression, + success: false, + reps: 6, + ); + expect((s.weight, s.reps), (7.5, 12)); + }); + + test('a week off is one regression step in the active mode', () async { + final s = await run( + ProgressionMode.reps, + success: true, + reps: 10, + last: DateTime.now().subtract(const Duration(days: 9)), + ); + expect((s.weight, s.reps, s.successStreak, s.failStreak), (10, 9, 0, 0)); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/break_banner_test.dart b/stronglift_replacement/workout_app/test/widgets/break_banner_test.dart index 2808542..79c5279 100644 --- a/stronglift_replacement/workout_app/test/widgets/break_banner_test.dart +++ b/stronglift_replacement/workout_app/test/widgets/break_banner_test.dart @@ -8,64 +8,51 @@ Widget _wrap(Widget child) => MaterialApp( home: Scaffold(body: child), ); +BreakBanner _banner({ + bool active = true, + int breakRemaining = 90, + VoidCallback? onSkip, + bool finished = false, + bool canFinish = false, + VoidCallback? onReset, + VoidCallback? onFinish, +}) => BreakBanner( + active: active, + breakRemaining: breakRemaining, + onSkip: onSkip ?? () {}, + finished: finished, + canFinish: canFinish, + onReset: onReset ?? () {}, + onFinish: onFinish ?? () {}, +); + +Visibility _slotOf(WidgetTester tester, String text) => + tester.widget( + find.ancestor(of: find.text(text), matching: find.byType(Visibility)), + ); + void main() { group('BreakBanner', () { - testWidgets('displays label and formatted time', (tester) async { - await tester.pumpWidget( - _wrap( - BreakBanner( - active: true, - breakRemaining: 90, - breakLabel: 'Rest', - onSkip: () {}, - ), - ), - ); - expect(find.text('Rest'), findsOneWidget); + testWidgets('shows only the formatted time — no label', (tester) async { + await tester.pumpWidget(_wrap(_banner())); expect(find.text('01:30'), findsOneWidget); + expect(find.textContaining('Rest'), findsNothing); }); testWidgets('formats time below one minute correctly', (tester) async { - await tester.pumpWidget( - _wrap( - BreakBanner( - active: true, - breakRemaining: 5, - breakLabel: 'Warmup rest', - onSkip: () {}, - ), - ), - ); + await tester.pumpWidget(_wrap(_banner(breakRemaining: 5))); expect(find.text('00:05'), findsOneWidget); }); testWidgets('skip button calls onSkip', (tester) async { var skipped = false; - await tester.pumpWidget( - _wrap( - BreakBanner( - active: true, - breakRemaining: 60, - breakLabel: 'Rest', - onSkip: () => skipped = true, - ), - ), - ); + await tester.pumpWidget(_wrap(_banner(onSkip: () => skipped = true))); await tester.tap(find.text('Skip')); expect(skipped, isTrue); }); testWidgets('keeps its height and hides Skip while idle', (tester) async { - await tester.pumpWidget( - _wrap( - BreakBanner( - active: false, - breakRemaining: 0, - breakLabel: 'Rest', - onSkip: () {}, - ), - ), - ); + await tester.pumpWidget(_wrap(_banner(active: false, breakRemaining: 0))); expect( tester.getSize(find.byType(BreakBanner)).height, BreakBanner.height, @@ -73,25 +60,43 @@ void main() { expect(find.text('00:00'), findsOneWidget); // Skip keeps its space (nothing may move when a rest starts) but is // neither visible nor tappable. - final skip = tester.widget( - find.ancestor(of: find.text('Skip'), matching: find.byType(Visibility)), - ); + final skip = _slotOf(tester, 'Skip'); expect(skip.visible, isFalse); expect(skip.maintainSize, isTrue); }); - testWidgets('zero seconds formats as 00:00', (tester) async { + testWidgets('Reset calls onReset', (tester) async { + var reset = false; + await tester.pumpWidget(_wrap(_banner(onReset: () => reset = true))); + await tester.tap(find.text('Reset')); + expect(reset, isTrue); + }); + + testWidgets('Finish is disabled until every set is done', (tester) async { + var finished = 0; + await tester.pumpWidget(_wrap(_banner(onFinish: () => finished++))); + await tester.tap(find.text('Finish')); + expect(finished, 0); + await tester.pumpWidget( - _wrap( - BreakBanner( - active: true, - breakRemaining: 0, - breakLabel: 'Rest', - onSkip: () {}, - ), - ), + _wrap(_banner(canFinish: true, onFinish: () => finished++)), ); - expect(find.text('00:00'), findsOneWidget); + await tester.tap(find.text('Finish')); + expect(finished, 1); + }); + + testWidgets('a finished workout hides Reset and Finish, space kept', ( + tester, + ) async { + await tester.pumpWidget(_wrap(_banner())); + final finishAt = tester.getRect(find.text('Finish')); + await tester.pumpWidget(_wrap(_banner(finished: true))); + for (final label in ['Reset', 'Finish']) { + final slot = _slotOf(tester, label); + expect(slot.visible, isFalse, reason: label); + expect(slot.maintainSize, isTrue, reason: label); + } + expect(tester.getRect(find.text('Finish')), finishAt); }); }); } diff --git a/stronglift_replacement/workout_app/test/widgets/exercise_settings_sheet_test.dart b/stronglift_replacement/workout_app/test/widgets/exercise_settings_sheet_test.dart new file mode 100644 index 0000000..6ef27ad --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/exercise_settings_sheet_test.dart @@ -0,0 +1,145 @@ +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/progression.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 _base = ExerciseState.initial(workoutA.first); + +Future> _pump( + WidgetTester tester, [ + ExerciseState? state, +]) async { + final edits = []; + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold( + body: ExerciseSettingsSheet(state: state ?? _base, onChanged: edits.add), + ), + ), + ); + return edits; +} + +Future _tapLabel(WidgetTester tester, String label) async { + await tester.tap(find.bySemanticsLabel(label)); + await tester.pump(); +} + +void main() { + testWidgets('switching mode reports it and keeps the sheet height', ( + tester, + ) async { + final edits = await _pump(tester); + final before = tester.getSize(find.byType(ExerciseSettingsSheet)); + expect(find.textContaining('+2.5 kg per step'), findsOneWidget); + + await tester.tap(find.text('Reps')); + await tester.pump(); + expect(edits.last.mode, ProgressionMode.reps); + expect(find.textContaining('the weight never changes'), findsOneWidget); + + await tester.tap(find.text('Reps→kg')); + await tester.pump(); + expect(edits.last.mode, ProgressionMode.doubleProgression); + expect(find.textContaining('up to 12, then'), findsOneWidget); + expect(tester.getSize(find.byType(ExerciseSettingsSheet)), before); + }); + + testWidgets('the rep range row only takes input in double mode', ( + tester, + ) async { + await _pump(tester); + expect(find.bySemanticsLabel('Top reps up'), findsNothing); + await tester.tap(find.text('Reps→kg')); + await tester.pump(); + expect(find.bySemanticsLabel('Top reps up'), findsOneWidget); + }); + + testWidgets('n and m step and never cross', (tester) async { + final edits = await _pump( + tester, + _base.copyWith( + mode: ProgressionMode.doubleProgression, + repsHigh: 7, + repsLow: 6, + ), + ); + // m cannot reach n, n cannot drop to m. + await _tapLabel(tester, 'Restart reps up'); + await _tapLabel(tester, 'Top reps down'); + expect(edits, isEmpty); + + await _tapLabel(tester, 'Top reps up'); + expect(edits.last.repsHigh, 8); + await _tapLabel(tester, 'Restart reps up'); + expect(edits.last.repsLow, 7); + await _tapLabel(tester, 'Restart reps down'); + expect(edits.last.repsLow, 6); + await _tapLabel(tester, 'Top reps down'); + expect(edits.last.repsHigh, 7); + }); + + testWidgets('warmup switch and thresholds are reported', (tester) async { + final edits = await _pump(tester); + await tester.tap(find.text('Warmup set')); + await tester.pump(); + expect(edits.last.hasWarmup, isFalse); + + await _tapLabel(tester, 'Wins needed down'); + expect(edits.last.successThreshold, 2); + await _tapLabel(tester, 'Fails allowed up'); + expect(edits.last.failThreshold, 3); + // Every edit carries the earlier ones, not just its own field. + expect(edits.last.hasWarmup, isFalse); + }); + + testWidgets('pause length steps from 14 and sets the end date', ( + tester, + ) async { + final edits = await _pump(tester); + expect(find.text('$kDefaultPauseDays'), findsOneWidget); + await _tapLabel(tester, 'Pause days up'); + await _tapLabel(tester, 'Pause days up'); + await _tapLabel(tester, 'Pause days down'); + await tester.tap(find.text('Pause')); + await tester.pump(); + expect( + edits.last.pausedUntil, + pauseEnd(DateTime.now(), kDefaultPauseDays + 1), + ); + expect(find.textContaining('Paused — back on'), findsOneWidget); + + await tester.tap(find.text('Resume')); + await tester.pump(); + expect(edits.last.pausedUntil, isNull); + expect(find.text('Pause'), findsOneWidget); + }); + + testWidgets('showExerciseSettingsSheet opens it as a bottom sheet', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Builder( + builder: (context) => TextButton( + onPressed: () => showExerciseSettingsSheet( + context, + state: _base, + onChanged: (_) {}, + ), + child: const Text('open'), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.text(_base.name), findsOneWidget); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart b/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart index d82a406..5dffdaf 100644 --- a/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart +++ b/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:workout_app/ui/theme.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'; +import 'package:workout_app/widgets/exercise_settings_sheet.dart'; import 'package:workout_app/widgets/exercise_tile.dart'; import 'package:workout_app/widgets/rep_circle.dart'; @@ -12,29 +15,32 @@ Widget _wrap(Widget child) => MaterialApp( const _exercise = Exercise(name: 'Squat', sets: 3, reps: 5, weight: 20.0); +final _state = ExerciseState.initial(_exercise); + ExerciseTile _tile({ + ExerciseState? state, List? tapped, List? doneReps, bool warmupTapped = false, - int successThreshold = 3, - int failThreshold = 2, void Function(int)? onTapCircle, void Function(int)? onLongPressCircle, VoidCallback? onTapWarmup, - void Function(int, int)? onThresholdChanged, + ValueChanged? onSettingsChanged, }) => ExerciseTile( exercise: _exercise, + state: state ?? _state, tapped: tapped ?? [false, false, false], doneReps: doneReps ?? [5, 5, 5], warmupTapped: warmupTapped, - successThreshold: successThreshold, - failThreshold: failThreshold, onTapCircle: onTapCircle ?? (_) {}, onLongPressCircle: onLongPressCircle ?? (_) {}, onTapWarmup: onTapWarmup ?? () {}, - onThresholdChanged: onThresholdChanged ?? (_, __) {}, + onSettingsChanged: onSettingsChanged ?? (_) {}, ); +Color _cardColor(WidgetTester tester) => + tester.widget(find.byType(Card)).color!; + void main() { group('ExerciseTile', () { testWidgets('shows exercise name and weight info', (tester) async { @@ -43,120 +49,155 @@ void main() { expect(find.textContaining('3×5×20.0kg'), findsOneWidget); }); - testWidgets('shows warmup weight', (tester) async { + testWidgets('warmup is the first circle, with its weight', (tester) async { await tester.pumpWidget(_wrap(_tile())); - // warmupWeight for 20kg squat = 10kg (50%) - expect( - find.textContaining('${_exercise.warmupWeight}kg'), - findsOneWidget, - ); + expect(find.text('W×5'), findsOneWidget); + expect(find.text('${_exercise.warmupWeight}'), findsOneWidget); + final warmup = tester.getRect(find.bySemanticsLabel('Squat warmup')); + final firstSet = tester.getRect(find.byType(RepCircle).first); + expect(warmup.left, lessThan(firstSet.left)); + expect(warmup.height, firstSet.height); }); testWidgets('calls onTapCircle when set circle tapped', (tester) async { var tappedIdx = -1; - await tester.pumpWidget( - _wrap(_tile(onTapCircle: (i) => tappedIdx = i)), - ); - // Tap the first RepCircle (index 0) + await tester.pumpWidget(_wrap(_tile(onTapCircle: (i) => tappedIdx = i))); await tester.tap(find.byType(RepCircle).first); expect(tappedIdx, 0); }); testWidgets('calls onLongPressCircle on long press', (tester) async { var idx = -1; - await tester.pumpWidget( - _wrap(_tile(onLongPressCircle: (i) => idx = i)), - ); + await tester.pumpWidget(_wrap(_tile(onLongPressCircle: (i) => idx = i))); await tester.longPress(find.byType(RepCircle).first); expect(idx, 0); }); - testWidgets('calls onTapWarmup when warmup circle tapped', (tester) async { - var called = false; - await tester.pumpWidget(_wrap(_tile(onTapWarmup: () => called = true))); - // The warmup circle is the GestureDetector wrapping the AnimatedContainer. - // Find the warmup area by finding the fitness_center icon. - await tester.tap(find.byIcon(Icons.fitness_center)); - expect(called, isTrue); - }); + testWidgets('calls onTapWarmup once, then shows it done', (tester) async { + var calls = 0; + await tester.pumpWidget(_wrap(_tile(onTapWarmup: () => calls++))); + await tester.tap(find.bySemanticsLabel('Squat warmup')); + expect(calls, 1); - testWidgets('header is green when all sets succeeded', (tester) async { await tester.pumpWidget( - _wrap( - _tile( - tapped: [true, true, true], - doneReps: [5, 5, 5], - ), - ), + _wrap(_tile(warmupTapped: true, onTapWarmup: () => calls++)), ); - // Just verify it renders without errors - expect(find.byType(ExerciseTile), findsOneWidget); + await tester.pumpAndSettle(); + await tester.tap(find.bySemanticsLabel('Squat warmup')); + expect(calls, 1, reason: 'a done warmup is not tappable again'); }); - testWidgets('header is red when all sets tapped but some failed', ( + testWidgets('warmup off keeps its slot: nothing moves', (tester) async { + await tester.pumpWidget(_wrap(_tile())); + final setsWith = tester.getRect(find.byType(RepCircle).first); + final tileWith = tester.getSize(find.byType(ExerciseTile)); + + await tester.pumpWidget( + _wrap(_tile(state: _state.copyWith(hasWarmup: false))), + ); + expect( + find.bySemanticsLabel('Squat warmup').hitTestable(), + findsNothing, + ); + expect(tester.getRect(find.byType(RepCircle).first), setsWith); + expect(tester.getSize(find.byType(ExerciseTile)), tileWith); + }); + + testWidgets('card fill: green on success, red on a failed set', ( + tester, + ) async { + await tester.pumpWidget(_wrap(_tile())); + final idle = _cardColor(tester); + + await tester.pumpWidget( + _wrap(_tile(tapped: [true, true, true], doneReps: [5, 5, 5])), + ); + final green = _cardColor(tester); + expect(green, isNot(idle)); + + await tester.pumpWidget( + _wrap(_tile(tapped: [true, true, true], doneReps: [5, 5, 3])), + ); + expect(_cardColor(tester), isNot(green)); + expect(_cardColor(tester), isNot(idle)); + }); + + testWidgets('progress line shows streaks and the next step each way', ( tester, ) async { + await tester.pumpWidget( + _wrap(_tile(state: _state.copyWith(successStreak: 2, failStreak: 1))), + ); + expect(find.text('2/3 → 22.5 kg'), findsOneWidget); + expect(find.text('1/2 → 17.5 kg'), findsOneWidget); + + await tester.pumpWidget( + _wrap(_tile(state: _state.copyWith(mode: ProgressionMode.reps))), + ); + expect(find.text('0/3 → 6 reps'), findsOneWidget); + expect(find.text('0/2 → 4 reps'), findsOneWidget); + }); + + testWidgets('mode chip names the mode', (tester) async { + await tester.pumpWidget(_wrap(_tile())); + expect(find.text('kg'), findsOneWidget); + await tester.pumpWidget( + _wrap(_tile(state: _state.copyWith(mode: ProgressionMode.reps))), + ); + expect(find.text('reps'), findsOneWidget); await tester.pumpWidget( _wrap( _tile( + state: _state.copyWith(mode: ProgressionMode.doubleProgression), + ), + ), + ); + expect(find.text('6→12'), findsOneWidget); + }); + + testWidgets('mode chip opens the sheet and reports edits', (tester) async { + final edits = []; + await tester.pumpWidget(_wrap(_tile(onSettingsChanged: edits.add))); + await tester.tap(find.bySemanticsLabel('Squat progression settings')); + await tester.pumpAndSettle(); + expect(find.byType(ExerciseSettingsSheet), findsOneWidget); + await tester.tap(find.text('Reps')); + await tester.pump(); + expect(edits.single.mode, ProgressionMode.reps); + }); + + testWidgets('paused: no circles, same height, never filled', ( + tester, + ) async { + await tester.pumpWidget(_wrap(_tile())); + final size = tester.getSize(find.byType(ExerciseTile)); + final idle = _cardColor(tester); + + final until = pauseEnd(DateTime.now(), 14); + await tester.pumpWidget( + _wrap( + _tile( + state: _state.copyWith(pausedUntil: until), tapped: [true, true, true], - doneReps: [5, 5, 3], ), ), ); - expect(find.byType(ExerciseTile), findsOneWidget); + expect(find.byType(RepCircle), findsNothing); + expect(find.bySemanticsLabel('Squat warmup'), findsNothing); + expect( + find.textContaining('back on ${formatShortDate(until)}'), + findsOneWidget, + ); + expect(tester.getSize(find.byType(ExerciseTile)), size); + expect(_cardColor(tester), idle); }); - testWidgets('success threshold stepper increments', (tester) async { - var newSuccess = 0; + testWidgets('an expired pause shows the sets again', (tester) async { + final past = DateTime.now().subtract(const Duration(days: 1)); await tester.pumpWidget( - _wrap( - _tile( - successThreshold: 2, - onThresholdChanged: (s, _) => newSuccess = s, - ), - ), + _wrap(_tile(state: _state.copyWith(pausedUntil: past))), ); - // The first add icon belongs to the success stepper - final addIcons = find.byIcon(Icons.add); - await tester.tap(addIcons.first); - expect(newSuccess, 3); - }); - - testWidgets('fail threshold stepper decrements', (tester) async { - var newFail = 0; - await tester.pumpWidget( - _wrap( - _tile( - failThreshold: 3, - onThresholdChanged: (_, f) => newFail = f, - ), - ), - ); - // The last remove icon belongs to the fail stepper - final removeIcons = find.byIcon(Icons.remove); - await tester.tap(removeIcons.last); - expect(newFail, 2); - }); - - testWidgets('stepper min/max clamps are respected', (tester) async { - var callCount = 0; - // successThreshold = 1 (at min), tapping minus should not fire - await tester.pumpWidget( - _wrap( - _tile( - successThreshold: 1, - failThreshold: 5, - onThresholdChanged: (_, __) => callCount++, - ), - ), - ); - // First minus (success, at min 1) → no callback - await tester.tap(find.byIcon(Icons.remove).first); - expect(callCount, 0); - // Last add (fail, at max 5) → no callback - await tester.tap(find.byIcon(Icons.add).last); - expect(callCount, 0); + expect(find.byType(RepCircle), findsNWidgets(3)); }); }); }