workout_app: progression modes, inline warmup, injury pause, no app bar

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeVXsWTuwjmZK5uvbw6vqF
This commit is contained in:
Krzysztof Rudnicki 2026-09-27 17:36:17 +02:00
parent 97f4e49a5f
commit 38b56e1e2b
39 changed files with 2517 additions and 728 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<HistoryScreen> {
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<HistoryScreen> {
List<Widget> _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<HistoryScreen> {
],
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),

View File

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

View File

@ -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<WorkoutScreen> {
late List<List<int>> _doneReps;
late List<bool> _warmupTapped;
late DateTime _startTime;
late Timer _elapsedTimer;
Duration _elapsed = Duration.zero;
Map<String, ExerciseState> _exerciseStates = {};
@ -112,9 +109,6 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
} 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<WorkoutScreen> {
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<WorkoutScreen> {
// ── 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<WorkoutScreen> {
/// called from an extension, so the rest is in [_persistFinishedWorkout].
Future<void> _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<WorkoutScreen> {
// 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)),
),
),
),
);

View File

@ -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<AppStatusColors>()!;
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,
),
),
),
],
);
}
}

View File

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

View File

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

View File

@ -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<void>(
context: context,
barrierDismissible: false,
builder: (_) => WorkoutSummaryDialog(
session: session,
syncResult: syncResult,
),
builder: (_) =>
WorkoutSummaryDialog(session: session, syncResult: syncResult),
),
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String, dynamic> 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<String, dynamic>,
);
final record = Record.fromJson(jsonDecode(text) as Map<String, dynamic>);
final field = record.fields[_payloadField];
if (field == null) return null;
final payload = field.$1;

View File

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

View File

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

View File

@ -6,7 +6,6 @@ part of 'storage_service.dart';
/// Schema creation, migration, and seeding.
extension StorageServiceSchema on StorageService {
Future<void> _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<void> _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 ───────────────────────────────────────────────────────────────
}

View File

@ -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<void> applyProgression({
required Map<String, bool> succeededExercises,
required DateTime lastWorkoutDate,
@ -20,66 +24,47 @@ extension StorageServiceSessions on StorageService {
final state = await getExerciseState(entry.key);
if (state == null) continue;
final Map<String, Object> 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<int> restoreSyncedSessions(
List<Map<String, dynamic>> payloads,
) async {
Future<int> restoreSyncedSessions(List<Map<String, dynamic>> payloads) async {
if (payloads.isEmpty) return 0;
final existing = await _db.query('workout_history', columns: ['json']);
final seen = <String>{

View File

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

View File

@ -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<void> showExerciseSettingsSheet(
BuildContext context, {
required ExerciseState state,
required ValueChanged<ExerciseState> onChanged,
}) => showModalBottomSheet<void>(
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<ExerciseState> onChanged;
@override
State<ExerciseSettingsSheet> createState() => _ExerciseSettingsSheetState();
}
class _ExerciseSettingsSheetState extends State<ExerciseSettingsSheet> {
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<ProgressionMode>(
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)),
),
],
),
),
);
}
}

View File

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

View File

@ -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<bool> 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<ExerciseState> 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<AppStatusColors>()!;
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<void> showExerciseSettings(BuildContext context) =>
showExerciseSettingsSheet(
context,
state: state,
onChanged: onSettingsChanged,
);
}

View File

@ -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<int> onSuccessChanged;
final ValueChanged<int> 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<AppStatusColors>()!;
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<int> 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<AppStatusColors>()!;
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<AppStatusColors>()!;
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),
),
),
],
),
);
}
}

View File

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

View File

@ -112,3 +112,9 @@ Future<void> longPressReal(WidgetTester tester, Finder f) async {
/// no longer says anything; `active` is the discriminator.
bool restRunning(WidgetTester tester) =>
tester.widget<BreakBanner>(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<BreakBanner>(find.byType(BreakBanner)).breakRemaining;

View File

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

View File

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

View File

@ -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<RepCircle>(find.byType(RepCircle).at(i));
if (!circle.tapped) await tapReal(tester, find.byType(RepCircle).at(i));
}
expect(
tester.widgetList<RepCircle>(find.byType(RepCircle)).every(
(c) => c.tapped,
),
isTrue,
);
expect(tester.widget<BreakBanner>(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);
});
}

View File

@ -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<void> _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<void> 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<BreakBanner>(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<void>.delayed(const Duration(milliseconds: 200));
await tester.pump();
await tester.tap(find.widgetWithText(TextButton, 'Finish').last);
await Future<void>.delayed(const Duration(milliseconds: 800));
await tester.pump();
});
await tester.pump();
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<RepCircle>(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<void>.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);
});
}

View File

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

View File

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

View File

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

View File

@ -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<String, dynamic> _payload(FakeStore store, String name) =>
Record.fromJson(
jsonDecode(store.files[ProgressionSyncService.pathForExercise(name)]!)
as Map<String, dynamic>,
).fields['payload']!.$1
as Map<String, dynamic>;
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));
});
}

View File

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

View File

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

View File

@ -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<List<ExerciseState>> _pump(
WidgetTester tester, [
ExerciseState? state,
]) async {
final edits = <ExerciseState>[];
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: ExerciseSettingsSheet(state: state ?? _base, onChanged: edits.add),
),
),
);
return edits;
}
Future<void> _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);
});
}

View File

@ -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<bool>? tapped,
List<int>? 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<ExerciseState>? 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<Card>(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 = <ExerciseState>[];
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));
});
});
}