mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-14 18:10:56 +01:00
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:
parent
97f4e49a5f
commit
38b56e1e2b
@ -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.
|
||||
|
||||
@ -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]}';
|
||||
141
stronglift_replacement/workout_app/lib/models/progression.dart
Normal file
141
stronglift_replacement/workout_app/lib/models/progression.dart
Normal 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();
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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: '↓',
|
||||
),
|
||||
],
|
||||
|
||||
@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
}
|
||||
|
||||
@ -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>{
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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));
|
||||
});
|
||||
}
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user