workout_app: warmup ×6, per-exercise rests, exercise sheet in Settings

- Warmup is always 6 reps (kWarmupReps): even, one constant.
- Per exercise: rest after success / fail / warmup (0:30-10:00, 15 s
  steps; defaults 3:00/5:00/3:00). Schema v6 NOT NULL DEFAULT columns,
  synced in the Firebase progression record (lenient for older records).
  The notification carries the fail rest so -1 rep re-cuts to it.
- Settings: PER EXERCISE list replaces PROGRESSION THRESHOLDS; each row
  opens the workout tile's sheet and pushes progression when it closes.

Claude-Session: https://claude.ai/code/session_01KeVXsWTuwjmZK5uvbw6vqF

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-09-27 18:55:56 +02:00
parent 38b56e1e2b
commit d35edfc10c
38 changed files with 1042 additions and 540 deletions

View File

@ -15,6 +15,7 @@ class BreakSnapshot {
required this.workoutType,
required this.breakEndMs,
required this.breakDurationSecs,
required this.breakFailSecs,
required this.breakLabel,
required this.breakForExIdx,
required this.breakForSetIdx,
@ -49,6 +50,7 @@ class BreakSnapshot {
final keys = [
'breakEndMs',
'breakDurationSecs',
'breakFailSecs',
'breakForExIdx',
'breakForSetIdx',
'nextExIdx',
@ -67,6 +69,7 @@ class BreakSnapshot {
workoutType: workoutType,
breakEndMs: i('breakEndMs')!,
breakDurationSecs: i('breakDurationSecs')!,
breakFailSecs: i('breakFailSecs')!,
breakLabel: breakLabel,
breakForExIdx: i('breakForExIdx')!,
breakForSetIdx: i('breakForSetIdx')!,
@ -93,6 +96,10 @@ class BreakSnapshot {
/// Full length of the running rest, for the countdown's denominator.
final int breakDurationSecs;
/// The running rest's exercise's fail rest, so a `−1 rep` press can re-cut
/// the rest while the UI is dead.
final int breakFailSecs;
/// Human label of the running rest, e.g. 'Rest' or 'Warmup rest'.
final String breakLabel;
@ -146,6 +153,7 @@ class BreakSnapshot {
'workoutType': workoutType,
'breakEndMs': breakEndMs,
'breakDurationSecs': breakDurationSecs,
'breakFailSecs': breakFailSecs,
'breakLabel': breakLabel,
'breakForExIdx': breakForExIdx,
'breakForSetIdx': breakForSetIdx,
@ -173,6 +181,7 @@ class BreakSnapshot {
workoutType: workoutType,
breakEndMs: breakEndMs ?? this.breakEndMs,
breakDurationSecs: breakDurationSecs ?? this.breakDurationSecs,
breakFailSecs: breakFailSecs,
breakLabel: breakLabel ?? this.breakLabel,
breakForExIdx: breakForExIdx,
breakForSetIdx: breakForSetIdx,

View File

@ -7,6 +7,9 @@ const double kDefaultMaxWeight = 27.5;
/// Weight increment used for progression steps (kg).
const double kWeightIncrement = 2.5;
/// Reps in every warmup set. Even on purpose: the user alternates sides.
const int kWarmupReps = 6;
/// Immutable definition of a single exercise and its current target state.
class Exercise {
/// Creates an exercise with the given parameters.

View File

@ -7,6 +7,24 @@ import 'dart:developer';
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/models/progression.dart';
/// Default rest after a set that hit its target reps.
const int kDefaultRestSuccessSecs = 180;
/// Default rest after a set that fell short: the longer one.
const int kDefaultRestFailSecs = 300;
/// Default rest after the warmup set.
const int kDefaultRestWarmupSecs = 180;
/// Shortest rest the settings sheet offers.
const int kMinRestSecs = 30;
/// Longest rest the settings sheet offers.
const int kMaxRestSecs = 600;
/// Step of the settings sheet's rest steppers.
const int kRestStepSecs = 15;
/// Per-exercise progression state stored in SQLite.
///
/// Every field is `required` deliberately: a state rebuilt field by field
@ -28,6 +46,9 @@ class ExerciseState implements ProgressionInputs {
required this.repsLow,
required this.hasWarmup,
required this.pausedUntil,
required this.restSuccessSecs,
required this.restFailSecs,
required this.restWarmupSecs,
});
/// The state a never-trained [ex] starts from.
@ -45,6 +66,9 @@ class ExerciseState implements ProgressionInputs {
repsLow: kDefaultRepsLow,
hasWarmup: ex.hasWarmup,
pausedUntil: null,
restSuccessSecs: kDefaultRestSuccessSecs,
restFailSecs: kDefaultRestFailSecs,
restWarmupSecs: kDefaultRestWarmupSecs,
);
/// Exercise name (matches [Exercise.name], used as primary key).
@ -95,6 +119,15 @@ class ExerciseState implements ProgressionInputs {
/// exercise coming back at the load it left with.
final DateTime? pausedUntil;
/// Rest after a set that hit its target reps, in seconds.
final int restSuccessSecs;
/// Rest after a set that fell short, in seconds.
final int restFailSecs;
/// Rest after the warmup set, in seconds.
final int restWarmupSecs;
/// Whether the exercise is paused at [now].
bool isPausedAt(DateTime now) {
final until = pausedUntil;
@ -114,6 +147,9 @@ class ExerciseState implements ProgressionInputs {
int? repsLow,
bool? hasWarmup,
DateTime? pausedUntil,
int? restSuccessSecs,
int? restFailSecs,
int? restWarmupSecs,
bool resume = false,
}) => ExerciseState(
name: name,
@ -130,6 +166,9 @@ class ExerciseState implements ProgressionInputs {
hasWarmup: hasWarmup ?? this.hasWarmup,
// `resume` exists because a null argument cannot mean "clear it".
pausedUntil: resume ? null : (pausedUntil ?? this.pausedUntil),
restSuccessSecs: restSuccessSecs ?? this.restSuccessSecs,
restFailSecs: restFailSecs ?? this.restFailSecs,
restWarmupSecs: restWarmupSecs ?? this.restWarmupSecs,
);
}
@ -178,3 +217,7 @@ const _months = [
/// `11 Oct`: how a pause's end date is shown.
String formatShortDate(DateTime d) => '${d.day} ${_months[d.month - 1]}';
/// `3:00`: how a rest length is shown.
String formatRest(int secs) =>
'${secs ~/ 60}:${(secs % 60).toString().padLeft(2, '0')}';

View File

@ -1,5 +1,5 @@
/// Settings screen: per-exercise streak thresholds and manual weight
/// overrides, plus links to the sync surfaces.
/// Settings screen: manual weight and rep overrides, each exercise's
/// settings sheet, plus links to the sync surfaces.
///
/// "Sync settings" is the shared `sync_settings_ui` package (Firebase sync;
/// no Backup section -- see the class doc on [SettingsScreen] for why).
@ -23,20 +23,20 @@ import 'package:workout_app/services/google_sign_in_backend.dart';
import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/exercise_settings_sheet.dart';
part 'settings_screen_actions.dart';
part 'settings_screen_exercise_list.dart';
part 'settings_screen_exercise_sections.dart';
part 'settings_screen_rows.dart';
part 'settings_screen_sandbox.dart';
part 'settings_screen_sections.dart';
part 'settings_screen_thresholds.dart';
part 'settings_screen_widget.dart';
class _SettingsScreenState extends State<SettingsScreen> {
bool _loading = true;
final Map<String, int> _successThresholds = {};
final Map<String, int> _failThresholds = {};
final Map<String, ExerciseState> _states = {};
final Map<String, double> _weights = {};
final Map<String, int> _reps = {};
@ -97,8 +97,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (!mounted) return;
setState(() {
for (final s in states) {
_successThresholds[s.name] = s.successThreshold;
_failThresholds[s.name] = s.failThreshold;
_states[s.name] = s;
_weights[s.name] = s.weight;
_reps[s.name] = s.reps;
}
@ -153,15 +152,29 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
}
Future<void> _onThresholdChanged(String name, int success, int fail) async {
setState(() {
_successThresholds[name] = success;
_failThresholds[name] = fail;
});
await StorageService.instance.setExerciseThresholds(
name,
successThreshold: success,
failThreshold: fail,
/// Opens [state]'s settings sheet. Every edit is saved as it happens, the
/// same as from the workout tile; once the sheet closes the result goes to
/// Firebase, so a reinstall before the next workout keeps it.
Future<void> _openExercise(ExerciseState state) async {
var edited = false;
await showExerciseSettingsSheet(
context,
state: state,
onChanged: (updated) {
edited = true;
setState(() => _states[updated.name] = updated);
unawaited(StorageService.instance.setExerciseSettings(updated));
},
);
if (!edited) return;
final pushed =
await (widget.progressionPusher ??
ProgressionSyncService().pushProgression)();
if (!mounted || pushed.changed) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Saved on this phone, not synced: ${pushed.reason}'),
),
);
}
@ -189,12 +202,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
onRepsChanged: _onRepsChanged,
),
const SizedBox(height: 20),
_ThresholdsSection(
_ExercisesSection(
orderedNames: _orderedNames,
successThresholds: _successThresholds,
failThresholds: _failThresholds,
onThresholdChanged: (name, success, fail) =>
unawaited(_onThresholdChanged(name, success, fail)),
states: _states,
onOpen: (s) => unawaited(_openExercise(s)),
),
const SizedBox(height: 20),
_SyncSection(

View File

@ -0,0 +1,136 @@
// The PER EXERCISE section: one row per exercise, opening the same settings
// sheet as the mode chip on its workout tile.
//
// See settings_screen_rows.dart for why this is a `part` file. Reusing the
// sheet rather than mirroring its controls here is the point: the two
// surfaces cannot drift apart when there is only one of them.
part of 'settings_screen.dart';
/// The PER EXERCISE section.
class _ExercisesSection extends StatelessWidget {
const _ExercisesSection({
required this.orderedNames,
required this.states,
required this.onOpen,
});
/// Exercise names in display order.
final List<String> orderedNames;
/// Stored state per exercise; a missing entry renders nothing.
final Map<String, ExerciseState> states;
/// Opens the settings sheet for the tapped exercise.
final ValueChanged<ExerciseState> onOpen;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SectionHeader('PER EXERCISE'),
const SizedBox(height: 4),
Text(
'Progression, warmup, rest and injury pause. '
'Same settings as the workout tile.',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
const SizedBox(height: 12),
...orderedNames.map((name) {
final s = states[name];
if (s == null) return const SizedBox.shrink();
return _ExerciseSettingsRow(state: s, onTap: () => onOpen(s));
}),
],
);
}
}
/// `Squat` over `kg · ↑3 ↓2 · rest 3:00 / 5:00 · warmup 3:00`.
class _ExerciseSettingsRow extends StatelessWidget {
const _ExerciseSettingsRow({required this.state, required this.onTap});
final ExerciseState state;
final VoidCallback onTap;
String get _summary {
final s = state;
final mode = switch (s.mode) {
ProgressionMode.weight => 'kg',
ProgressionMode.reps => 'reps',
ProgressionMode.doubleProgression => '${s.repsLow}→${s.repsHigh} reps',
};
final warmup = s.hasWarmup
? 'warmup ${formatRest(s.restWarmupSecs)}'
: 'no warmup';
return '$mode · ↑${s.successThreshold} ↓${s.failThreshold} · '
'rest ${formatRest(s.restSuccessSecs)} / '
'${formatRest(s.restFailSecs)} · $warmup';
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final until = state.isPausedAt(DateTime.now()) ? state.pausedUntil : null;
return Semantics(
container: true,
button: true,
label: '${state.name} exercise settings',
excludeSemantics: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.name,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: AppTextSize.label,
),
),
const SizedBox(height: 4),
Text(
_summary,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
if (until != null) ...[
const SizedBox(height: 4),
Text(
'Paused — back on ${formatShortDate(until)}',
style: TextStyle(
color: colorScheme.error,
fontSize: AppTextSize.caption,
),
),
],
],
),
),
Icon(Icons.tune, color: colorScheme.onSurfaceVariant),
],
),
),
),
);
}
}

View File

@ -1,4 +1,4 @@
// The WEIGHTS, TARGET REPS and PROGRESSION THRESHOLDS sections.
// The WEIGHTS and TARGET REPS sections.
//
// A `part` for the same reason as settings_screen_sections.dart: these stay
// library-private. Each takes the ordered names plus the one map it reads, so
@ -103,59 +103,3 @@ class _RepsSection extends StatelessWidget {
);
}
}
/// The PROGRESSION THRESHOLDS section: streak lengths before a weight change.
class _ThresholdsSection extends StatelessWidget {
const _ThresholdsSection({
required this.orderedNames,
required this.successThresholds,
required this.failThresholds,
required this.onThresholdChanged,
});
/// Exercise names in display order.
final List<String> orderedNames;
/// Successes before a weight increase, per exercise; defaults to 3.
final Map<String, int> successThresholds;
/// Failures before a weight decrease, per exercise; defaults to 2.
final Map<String, int> failThresholds;
/// Invoked with (name, successThreshold, failThreshold) on either change.
final void Function(String name, int success, int fail) onThresholdChanged;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SectionHeader('PROGRESSION THRESHOLDS'),
const SizedBox(height: 4),
Text(
'Consecutive successes (↑) or failures (↓) '
'before weight changes.',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
const SizedBox(height: 12),
...orderedNames.map((name) {
final sThresh = successThresholds[name] ?? 3;
final fThresh = failThresholds[name] ?? 2;
return _ExerciseThresholdCard(
name: name,
successThreshold: sThresh,
failThreshold: fThresh,
onSuccessChanged: (v) =>
onThresholdChanged(name, v, failThresholds[name]!),
onFailChanged: (v) =>
onThresholdChanged(name, successThresholds[name]!, v),
);
}),
],
);
}
}

View File

@ -1,123 +0,0 @@
// The stepper button and the per-exercise threshold card.
//
// See settings_screen_rows.dart for why these are `part` files.
part of 'settings_screen.dart';
class _ExerciseThresholdCard extends StatelessWidget {
const _ExerciseThresholdCard({
required this.name,
required this.successThreshold,
required this.failThreshold,
required this.onSuccessChanged,
required this.onFailChanged,
});
final String name;
final int successThreshold;
final int failThreshold;
final ValueChanged<int> onSuccessChanged;
final ValueChanged<int> onFailChanged;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: AppTextSize.label,
),
),
const SizedBox(height: 10),
_ThresholdRow(
label: '↑ Increase after N successes',
value: successThreshold,
color: status.success,
onChanged: onSuccessChanged,
),
const SizedBox(height: 6),
_ThresholdRow(
label: '↓ Decrease after N failures',
value: failThreshold,
color: colorScheme.error,
onChanged: onFailChanged,
),
],
),
);
}
}
class _ThresholdRow extends StatelessWidget {
const _ThresholdRow({
required this.label,
required this.value,
required this.color,
required this.onChanged,
});
final String label;
final int value;
final Color color;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: AppTextSize.caption,
),
),
),
const SizedBox(width: 8),
for (int i = 1; i <= 5; i++)
Padding(
padding: const EdgeInsets.only(left: 4),
child: GestureDetector(
onTap: () => onChanged(i),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == value
? color
: colorScheme.surfaceContainerHighest,
),
alignment: Alignment.center,
child: Text(
'$i',
style: TextStyle(
// on-fill on the selected (filled) circle.
color: i == value
? colorScheme.onPrimary
: colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: AppTextSize.label,
),
),
),
),
),
],
);
}
}

View File

@ -1,12 +1,12 @@
// The public SettingsScreen widget and its injectable seams.
//
// A `part` so the widget stays in the same library as its private state class
// while the constructor and its eleven test seams — each needing its own doc
// while the constructor and its twelve test seams — each needing its own doc
// comment under `public_member_api_docs` — live somewhere the state logic can
// be read without scrolling past them.
part of 'settings_screen.dart';
/// Screen for editing per-exercise thresholds and manual weight overrides.
/// Screen for per-exercise settings and manual weight/rep overrides.
///
/// No [BackupSlot] is wired into the shared Sync settings screen: unlike the
/// notes app and home_inventory, workout_app has no user-facing export/import
@ -33,6 +33,7 @@ class SettingsScreen extends StatefulWidget {
this.storageChecker,
this.storageRequester,
this.progressionPuller,
this.progressionPusher,
});
/// Injectable HTTP client, passed through to [GitHubMirrorScreen] so its
@ -84,6 +85,10 @@ class SettingsScreen extends StatefulWidget {
/// into the shared screen's connect flow.
final Future<ProgressionSyncResult> Function()? progressionPuller;
/// Pushes progression after an exercise's settings sheet closes with edits.
/// Injected so tests never reach Firebase.
final Future<ProgressionSyncResult> Function()? progressionPusher;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}

View File

@ -5,11 +5,6 @@
// state class's `_applyBreakState` shim instead.
part of 'workout_screen.dart';
// Rest lengths (the sandbox flavor overrides them all in _startBreak).
const _successBreakSecs = 180; // 3 min after successful set
const _failBreakSecs = 300; // 5 min after failed set
const _warmupBreakSecs = 180; // 3 min after warmup
// How late a restored break's end cue may still be played. Long enough to
// cover the app being killed and reopened mid-rest; short enough that
// resuming yesterday's session does not blast the sound across the gym.
@ -17,6 +12,13 @@ const _expiredBreakGraceSecs = 120;
/// Starting, ticking, cancelling and finishing the rest period between sets.
extension _WorkoutScreenBreaks on _WorkoutScreenState {
/// Exercise [exIdx]'s stored state, or its defaults while the states are
/// still loading: that is where its rest lengths live.
ExerciseState _stateOf(int exIdx) {
final ex = widget.exercises[exIdx];
return _exerciseStates[ex.name] ?? ExerciseState.initial(ex);
}
void _startBreak(int secs, String label, int exIdx, int setIdx) {
// The one place every rest is born, so the sandbox's short override
// applies to warmup, success and fail rests alike.
@ -24,6 +26,9 @@ extension _WorkoutScreenBreaks on _WorkoutScreenState {
final restLabel = Sandbox.restLabel(label);
SandboxLog.event('break start', {
'secs': restSecs,
// The exercise's own rest, before the sandbox override: the only way
// to see per-exercise rest lengths in the sandbox flavor.
'planned': secs,
'label': restLabel,
'exercise': exIdx,
'set': setIdx,
@ -99,6 +104,9 @@ extension _WorkoutScreenBreaks on _WorkoutScreenState {
'repsLow': updated.repsLow,
'warmup': updated.hasWarmup,
'pausedUntil': updated.pausedUntil?.toIso8601String(),
'restSuccess': updated.restSuccessSecs,
'restFail': updated.restFailSecs,
'restWarmup': updated.restWarmupSecs,
});
await StorageService.instance.setExerciseSettings(updated);
if (!mounted) return;

View File

@ -17,6 +17,11 @@ extension _WorkoutScreenIntents on _WorkoutScreenState {
workoutType: widget.workoutType,
breakEndMs: _breakClock?.endTime.millisecondsSinceEpoch ?? 0,
breakDurationSecs: _breakDurationSecs,
breakFailSecs: Sandbox.rest(
_breakForExIdx >= 0 && _breakForExIdx < widget.exercises.length
? _stateOf(_breakForExIdx).restFailSecs
: kDefaultRestFailSecs,
),
breakLabel: _breakLabel,
breakForExIdx: _breakForExIdx,
breakForSetIdx: _breakForSetIdx,

View File

@ -123,7 +123,7 @@ extension _WorkoutScreenSession on _WorkoutScreenState {
}
/// When the user decrements reps on the set that triggered the current break,
/// switch between 3-min (success) and 5-min (fail) durations.
/// switch between the exercise's success and fail rest lengths.
void _recomputeBreakIfNeeded(int exIdx, int setIdx) {
// Read the clock ONCE. `_inBreak` re-reads DateTime.now() every time it is
// touched, so guarding on it and then dereferencing `_breakClock!` is a
@ -134,8 +134,9 @@ extension _WorkoutScreenSession on _WorkoutScreenState {
if (_breakForSetIdx == -1) return; // warmup break, never recompute
final succeeded = _doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps;
final state = _stateOf(exIdx);
final newDuration = Sandbox.rest(
succeeded ? _successBreakSecs : _failBreakSecs,
succeeded ? state.restSuccessSecs : state.restFailSecs,
);
if (newDuration == clock.durationSecs) return;
@ -159,9 +160,11 @@ extension _WorkoutScreenSession on _WorkoutScreenState {
_Rest? _restAfterSet(int exIdx, int setIdx) {
if (_isLastSetOfExercise(exIdx, setIdx)) return null;
final succeeded = _doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps;
return succeeded
? const _Rest(_successBreakSecs, 'Rest')
: const _Rest(_failBreakSecs, 'Rest');
final state = _stateOf(exIdx);
return _Rest(
succeeded ? state.restSuccessSecs : state.restFailSecs,
'Rest',
);
}
/// Plays the sound and haptic that tell the user the rest period is over.

View File

@ -52,7 +52,7 @@ extension _WorkoutScreenTaps on _WorkoutScreenState {
if (_finished || _warmupTapped[exIdx]) return;
_applyBreakState(() => _warmupTapped[exIdx] = true);
if (!_inBreak) {
_startBreak(_warmupBreakSecs, 'Warmup rest', exIdx, -1);
_startBreak(_stateOf(exIdx).restWarmupSecs, 'Warmup rest', exIdx, -1);
}
unawaited(_saveActiveSession(toFirebase: true));
}

View File

@ -187,8 +187,9 @@ class BreakTaskHandler {
case BreakIntentKind.minusRep:
if (!s.hasBreak || s.breakForSetIdx < 0) return s;
// A decrement turns a success into a failure, which is the longer
// rest. Re-cut from the start, exactly as the screen does.
const failSecs = 300;
// rest. Re-cut from the start, exactly as the screen does, to the
// length the screen sent for this exercise.
final failSecs = s.breakFailSecs;
final clock = BreakClock(
endTime: DateTime.fromMillisecondsSinceEpoch(s.breakEndMs),
durationSecs: s.breakDurationSecs,

View File

@ -108,10 +108,13 @@ class ProgressionSyncService {
null => null,
final d => formatPauseDate(d),
},
'rest_success_secs': s.restSuccessSecs,
'rest_fail_secs': s.restFailSecs,
'rest_warmup_secs': s.restWarmupSecs,
};
// The last five fields are lenient: records pushed before they existed
// must still restore, with the same meaning a NULL column has locally.
// Every field after fail_threshold is lenient: records pushed before it
// existed must still restore, with the 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(),
@ -126,6 +129,12 @@ class ProgressionSyncService {
repsLow: (j['reps_low'] as num?)?.toInt() ?? kDefaultRepsLow,
hasWarmup: j['has_warmup'] as bool? ?? planHasWarmup(j['name']! as String),
pausedUntil: parsePauseDate(j['paused_until']),
restSuccessSecs:
(j['rest_success_secs'] as num?)?.toInt() ?? kDefaultRestSuccessSecs,
restFailSecs:
(j['rest_fail_secs'] as num?)?.toInt() ?? kDefaultRestFailSecs,
restWarmupSecs:
(j['rest_warmup_secs'] as num?)?.toInt() ?? kDefaultRestWarmupSecs,
);
/// The remote path holding [name]'s progression record.

View File

@ -78,7 +78,7 @@ class StorageService {
// coverage:ignore-end
_db = await openDatabase(
dbPath,
version: 5,
version: 6,
onCreate: _createSchema,
onUpgrade: _migrateSchema,
// In tests resetForTesting() reopens a ':memory:' DB per test; sqflite's

View File

@ -32,6 +32,9 @@ extension StorageServiceExercises on StorageService {
repsLow: r['reps_low'] as int? ?? kDefaultRepsLow,
hasWarmup: warmup == null ? planHasWarmup(name) : warmup != 0,
pausedUntil: parsePauseDate(r['paused_until']),
restSuccessSecs: r['rest_success_secs']! as int,
restFailSecs: r['rest_fail_secs']! as int,
restWarmupSecs: r['rest_warmup_secs']! as int,
);
}
@ -60,7 +63,8 @@ extension StorageServiceExercises on StorageService {
);
}
/// Updates how [state]'s exercise progresses and whether it warms up.
/// Updates how [state]'s exercise progresses, whether it warms up and how
/// long it rests.
///
/// Streaks are kept: they count workouts, not a mode, and switching mode
/// mid-streak should not cost the user the sessions already banked. The
@ -77,6 +81,7 @@ extension StorageServiceExercises on StorageService {
'reps_low': state.repsLow,
'has_warmup': state.hasWarmup ? 1 : 0,
'paused_until': _pauseColumn(state),
..._restColumnValues(state),
},
where: 'name = ?',
whereArgs: [state.name],
@ -84,6 +89,12 @@ extension StorageServiceExercises on StorageService {
unawaited(_backupNow());
}
Map<String, int> _restColumnValues(ExerciseState s) => {
'rest_success_secs': s.restSuccessSecs,
'rest_fail_secs': s.restFailSecs,
'rest_warmup_secs': s.restWarmupSecs,
};
String? _pauseColumn(ExerciseState s) {
final until = s.pausedUntil;
return until == null ? null : formatPauseDate(until);
@ -131,6 +142,7 @@ extension StorageServiceExercises on StorageService {
'reps_low': state.repsLow,
'has_warmup': state.hasWarmup ? 1 : 0,
'paused_until': _pauseColumn(state),
..._restColumnValues(state),
}, conflictAlgorithm: ConflictAlgorithm.replace);
}

View File

@ -4,6 +4,12 @@
// See storage_service_backup.dart for why these are `part` extensions.
part of 'storage_service.dart';
/// The v6 rest-length columns, shared by create and migrate.
const _restColumns =
'rest_success_secs INTEGER NOT NULL DEFAULT $kDefaultRestSuccessSecs, '
'rest_fail_secs INTEGER NOT NULL DEFAULT $kDefaultRestFailSecs, '
'rest_warmup_secs INTEGER NOT NULL DEFAULT $kDefaultRestWarmupSecs';
/// Schema creation, migration, and seeding.
extension StorageServiceSchema on StorageService {
Future<void> _createSchema(Database db, int version) async {
@ -21,7 +27,8 @@ extension StorageServiceSchema on StorageService {
reps_high INTEGER NOT NULL DEFAULT $kDefaultRepsHigh,
reps_low INTEGER NOT NULL DEFAULT $kDefaultRepsLow,
has_warmup INTEGER,
paused_until TEXT
paused_until TEXT,
$_restColumns
)
''');
await db.execute('''
@ -91,6 +98,13 @@ extension StorageServiceSchema on StorageService {
'ALTER TABLE exercise_state ADD COLUMN paused_until TEXT',
);
}
if (oldVersion < 6) {
// NOT NULL with a default, so every existing row -- and every v5 row a
// raw backup restore inserts later -- gets the old fixed rests.
for (final column in _restColumns.split(', ')) {
await db.execute('ALTER TABLE exercise_state ADD COLUMN $column');
}
}
}
Future<void> _seedDefaultsIfNeeded() async {
@ -114,6 +128,9 @@ extension StorageServiceSchema on StorageService {
'reps_high': kDefaultRepsHigh,
'reps_low': kDefaultRepsLow,
'has_warmup': ex.hasWarmup ? 1 : 0,
'rest_success_secs': kDefaultRestSuccessSecs,
'rest_fail_secs': kDefaultRestFailSecs,
'rest_warmup_secs': kDefaultRestWarmupSecs,
});
}
}

View File

@ -1,5 +1,6 @@
/// Bottom sheet for one exercise's progression mode, rep range, warmup and
/// streak thresholds — opened from the mode chip on its workout tile.
/// Bottom sheet for one exercise's progression mode, rep range, warmup,
/// streak thresholds, rest lengths and injury pause — opened from the mode
/// chip on its workout tile and from the Settings screen.
library;
import 'package:flutter/material.dart';
@ -8,6 +9,7 @@ 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_rest.dart';
part 'exercise_settings_sheet_rows.dart';
/// Opens the settings sheet for [state]; [onChanged] runs on every edit.
@ -21,6 +23,9 @@ Future<void> showExerciseSettingsSheet(
required ValueChanged<ExerciseState> onChanged,
}) => showModalBottomSheet<void>(
context: context,
// Taller than the default 9/16-of-the-screen cap once the rest rows are
// in; the sheet sizes to its content and scrolls on a short screen.
isScrollControlled: true,
builder: (_) => ExerciseSettingsSheet(state: state, onChanged: onChanged),
);
@ -77,7 +82,7 @@ class _ExerciseSettingsSheetState extends State<ExerciseSettingsSheet> {
);
final isDouble = _s.mode == ProgressionMode.doubleProgression;
return SafeArea(
child: Padding(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
@ -169,6 +174,8 @@ class _ExerciseSettingsSheetState extends State<ExerciseSettingsSheet> {
onChanged: (v) => _update(_s.copyWith(successThreshold: v)),
),
const SizedBox(height: 8),
_RestRows(state: _s, onChanged: _update),
const SizedBox(height: 8),
_PauseRow(
pausedUntil: _s.isPausedAt(DateTime.now())
? _s.pausedUntil

View File

@ -0,0 +1,76 @@
// Rest-length rows for the exercise settings sheet.
//
// `part` file: split out for the 250-line cap; these widgets stay private.
part of 'exercise_settings_sheet.dart';
/// `Rest ✓ [3:00] ✗ [5:00]`, then `Warmup rest [3:00]`.
///
/// The warmup line stays laid out when the warmup is off, so toggling it
/// never changes the sheet's height under the finger.
class _RestRows extends StatelessWidget {
const _RestRows({required this.state, required this.onChanged});
final ExerciseState state;
final ValueChanged<ExerciseState> onChanged;
_Stepper _stepper(String name, int value, ValueChanged<int> onChanged) =>
_Stepper(
name: name,
value: value,
min: kMinRestSecs,
max: kMaxRestSecs,
step: kRestStepSecs,
format: formatRest,
onChanged: onChanged,
);
@override
Widget build(BuildContext context) {
final style = TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: AppTextSize.label,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('Rest ✓', style: style),
const SizedBox(width: 8),
_stepper(
'Success rest',
state.restSuccessSecs,
(v) => onChanged(state.copyWith(restSuccessSecs: v)),
),
const SizedBox(width: 12),
Text('✗', style: style),
const SizedBox(width: 8),
_stepper(
'Fail rest',
state.restFailSecs,
(v) => onChanged(state.copyWith(restFailSecs: v)),
),
],
),
const SizedBox(height: 8),
Visibility(
visible: state.hasWarmup,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: Row(
children: [
Text('Warmup rest', style: style),
const SizedBox(width: 8),
_stepper(
'Warmup rest',
state.restWarmupSecs,
(v) => onChanged(state.copyWith(restWarmupSecs: v)),
),
],
),
),
],
);
}
}

View File

@ -61,6 +61,8 @@ class _Stepper extends StatelessWidget {
required this.min,
required this.max,
required this.onChanged,
this.step = 1,
this.format,
});
/// What is being stepped; names the buttons for screen readers and
@ -71,9 +73,17 @@ class _Stepper extends StatelessWidget {
final int max;
final ValueChanged<int> onChanged;
/// How far one press moves [value].
final int step;
/// Renders [value]; plain digits when null. A formatted value gets a
/// wider box so `10:00` fits.
final String Function(int)? format;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final fmt = format;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -81,12 +91,12 @@ class _Stepper extends StatelessWidget {
context,
'$name down',
Icons.remove,
value > min ? value - 1 : null,
value > min ? value - step : null,
),
SizedBox(
width: 28,
width: fmt == null ? 28 : 44,
child: Text(
'$value',
fmt == null ? '$value' : fmt(value),
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.onSurface,
@ -95,7 +105,7 @@ class _Stepper extends StatelessWidget {
),
),
),
_btn(context, '$name up', Icons.add, value < max ? value + 1 : null),
_btn(context, '$name up', Icons.add, value < max ? value + step : null),
],
);
}

View File

@ -66,7 +66,7 @@ class _ModeChip extends StatelessWidget {
}
/// The warmup as the first circle of the set row: outlined, the size of a
/// set circle, showing `W×5` over the warmup weight.
/// set circle, showing `W×6` over the warmup weight.
class _WarmupCircle extends StatelessWidget {
const _WarmupCircle({
required this.semanticLabel,
@ -113,7 +113,7 @@ class _WarmupCircle extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
'W×5',
'W×$kWarmupReps',
style: small.copyWith(fontWeight: FontWeight.bold, height: 1.1),
),
Text('$warmupWeight', style: small.copyWith(height: 1.1)),

View File

@ -5,6 +5,7 @@ const _full = BreakSnapshot(
workoutType: 'B',
breakEndMs: 1789432100123,
breakDurationSecs: 180,
breakFailSecs: 300,
breakLabel: 'Rest (3 min — well done!)',
breakForExIdx: 0,
breakForSetIdx: 1,
@ -29,6 +30,7 @@ void main() {
expect(decoded.breakLabel, contains('well done'));
expect(decoded.nextWeight, 42.5);
expect(decoded.setsRemaining, 7);
expect(decoded.breakFailSecs, 300);
expect(decoded.finished, isTrue);
});
@ -50,13 +52,16 @@ void main() {
BreakSnapshot.tryFromMap(_full.toMap()..['workoutType'] = 1),
isNull,
);
expect(BreakSnapshot.tryFromMap(_full.toMap()..['nextExName'] = 1), isNull);
expect(BreakSnapshot.tryFromMap(_full.toMap()..['breakLabel'] = 1), isNull);
expect(
BreakSnapshot.tryFromMap(_full.toMap()..['nextExName'] = 1),
BreakSnapshot.tryFromMap(_full.toMap()..['nextWeight'] = 'x'),
isNull,
);
expect(
BreakSnapshot.tryFromMap(_full.toMap()..['breakEndMs'] = 'x'),
isNull,
);
expect(BreakSnapshot.tryFromMap(_full.toMap()..['breakLabel'] = 1), isNull);
expect(BreakSnapshot.tryFromMap(_full.toMap()..['nextWeight'] = 'x'), isNull);
expect(BreakSnapshot.tryFromMap(_full.toMap()..['breakEndMs'] = 'x'), isNull);
});
test('a missing finished flag reads as not finished', () {
@ -89,6 +94,7 @@ void main() {
expect(same.breakEndMs, _full.breakEndMs);
expect(same.breakLabel, _full.breakLabel);
expect(same.breakDurationSecs, _full.breakDurationSecs);
expect(same.breakFailSecs, _full.breakFailSecs);
expect(same.setsRemaining, _full.setsRemaining);
expect(same.finished, _full.finished);
});
@ -98,6 +104,7 @@ void main() {
workoutType: 'A',
breakEndMs: 0,
breakDurationSecs: 0,
breakFailSecs: 300,
breakLabel: '',
breakForExIdx: -1,
breakForSetIdx: -1,

View File

@ -0,0 +1,70 @@
// ExerciseState's injury-pause and rest-length fields and their formatting
// helpers. Split from progression_test.dart for the 250-line cap.
import 'package:flutter_test/flutter_test.dart';
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/models/exercise_state.dart';
// ExerciseState.initial starts in weight mode, the same as the progression
// tests' default.
final _base = ExerciseState.initial(
const Exercise(name: 'X', sets: 3, reps: 8, weight: 20),
);
void main() {
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 = _base.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(_base.isPausedAt(now), isFalse);
});
test('copyWith keeps a pause, and resume clears it', () {
final paused = _base.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('rests default to the old fixed lengths and survive copyWith', () {
final s = _base;
expect(
(s.restSuccessSecs, s.restFailSecs, s.restWarmupSecs),
(180, 300, 180),
);
final edited = s.copyWith(restFailSecs: 240);
expect(edited.restFailSecs, 240);
expect(edited.copyWith(reps: 3).restFailSecs, 240);
});
test('formatRest', () {
expect(formatRest(30), '0:30');
expect(formatRest(195), '3:15');
expect(formatRest(600), '10:00');
});
test('formatShortDate', () {
expect(formatShortDate(DateTime(2026, 10, 11)), '11 Oct');
expect(formatShortDate(DateTime(2026, 1, 1)), '1 Jan');
expect(formatShortDate(DateTime(2026, 12, 31)), '31 Dec');
});
});
}

View File

@ -24,6 +24,9 @@ ExerciseState _s({
repsLow: repsLow,
hasWarmup: true,
pausedUntil: null,
restSuccessSecs: kDefaultRestSuccessSecs,
restFailSecs: kDefaultRestFailSecs,
restWarmupSecs: kDefaultRestWarmupSecs,
);
void _expectTarget(ProgressionTarget t, double weight, int reps) {
@ -49,7 +52,11 @@ void main() {
group('weight mode (unchanged behaviour)', () {
test('success below the cap adds 2.5 kg', () {
_expectTarget(targetAfterSuccess(_s(mode: ProgressionMode.weight)), 22.5, 8);
_expectTarget(
targetAfterSuccess(_s(mode: ProgressionMode.weight)),
22.5,
8,
);
});
test('success above the cap clamps to it', () {
@ -63,7 +70,11 @@ void main() {
});
test('failure drops 2.5 kg, never below zero', () {
_expectTarget(targetAfterFailure(_s(mode: ProgressionMode.weight)), 17.5, 8);
_expectTarget(
targetAfterFailure(_s(mode: ProgressionMode.weight)),
17.5,
8,
);
final light = _s(mode: ProgressionMode.weight, weight: 1);
_expectTarget(targetAfterFailure(light), 0, 8);
});
@ -116,7 +127,11 @@ void main() {
});
test('failure at m with no weight left stays put', () {
_expectTarget(targetAfterFailure(_s(mode: mode, reps: 6, weight: 0)), 0, 6);
_expectTarget(
targetAfterFailure(_s(mode: mode, reps: 6, weight: 0)),
0,
6,
);
});
});
@ -131,15 +146,24 @@ void main() {
});
test('weight only drops a trailing .0', () {
expect(describeTargetChange(from, const ProgressionTarget(25, 8)), '25 kg');
expect(
describeTargetChange(from, const ProgressionTarget(25, 8)),
'25 kg',
);
});
test('reps only', () {
expect(describeTargetChange(from, const ProgressionTarget(20, 9)), '9 reps');
expect(
describeTargetChange(from, const ProgressionTarget(20, 9)),
'9 reps',
);
});
test('no change', () {
expect(describeTargetChange(from, const ProgressionTarget(20, 8)), 'same');
expect(
describeTargetChange(from, const ProgressionTarget(20, 8)),
'same',
);
});
test('toString names both fields', () {
@ -185,63 +209,15 @@ void main() {
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,
));
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(
(same.weight, same.reps, same.mode, same.hasWarmup),
(20, 8, ProgressionMode.weight, true),
);
expect(s.isPausedAt(now), isTrue);
expect(s.isPausedAt(DateTime(2026, 10, 10, 23, 59)), isTrue);
expect(s.isPausedAt(DateTime(2026, 10, 11)), isFalse);
expect(_s(mode: ProgressionMode.weight).isPausedAt(now), isFalse);
});
test('copyWith keeps a pause, and resume clears it', () {
final paused = _s(mode: ProgressionMode.weight).copyWith(
pausedUntil: DateTime(2026, 10, 11),
);
expect(paused.copyWith(reps: 3).pausedUntil, DateTime(2026, 10, 11));
expect(paused.copyWith(resume: true).pausedUntil, isNull);
});
test('dates round-trip through storage format', () {
final d = DateTime(2026, 1, 5);
expect(formatPauseDate(d), '2026-01-05');
expect(parsePauseDate(formatPauseDate(d)), d);
});
test('null, garbage and non-strings read as not paused', () {
expect(parsePauseDate(null), isNull);
expect(parsePauseDate('soon'), isNull);
expect(parsePauseDate(42), isNull);
});
test('formatShortDate', () {
expect(formatShortDate(DateTime(2026, 10, 11)), '11 Oct');
expect(formatShortDate(DateTime(2026, 1, 1)), '1 Jan');
expect(formatShortDate(DateTime(2026, 12, 31)), '31 Dec');
});
});
}

View File

@ -0,0 +1,153 @@
// The PER EXERCISE section: each row opens the workout tile's settings sheet,
// edits persist as they happen, and closing the sheet pushes to Firebase.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/screens/settings_screen.dart';
import 'package:workout_app/services/progression_sync_service.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/ui/theme.dart';
import 'package:workout_app/widgets/exercise_settings_sheet.dart';
import '../fake_secure_storage.dart';
const _situp = 'Situp exercise settings';
void main() {
late int pushes;
late ProgressionSyncResult pushResult;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
StorageService.resetForTesting();
await StorageService.init();
installFakeSecureStorage();
pushes = 0;
pushResult = const ProgressionSyncResult(changed: true, reason: 'pushed');
});
Future<void> pumpSettings(WidgetTester tester) async {
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: SettingsScreen(
firebaseFactory: () async => null,
accountLoader: () async => null,
sessionProbe: () async => false,
storageChecker: () async => false,
progressionPuller: () async =>
const ProgressionSyncResult(changed: false, reason: 'stub'),
progressionPusher: () async {
pushes++;
return pushResult;
},
),
),
);
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pump();
await tester.scrollUntilVisible(find.bySemanticsLabel(_situp), 200);
await tester.pumpAndSettle();
}
Future<void> openSitup(WidgetTester tester) async {
await tester.tap(find.bySemanticsLabel(_situp));
await tester.pumpAndSettle();
expect(find.byType(ExerciseSettingsSheet), findsOneWidget);
}
/// Dismisses the sheet and lets the push (a real future) complete.
Future<void> closeSheet(WidgetTester tester) async {
Navigator.of(tester.element(find.byType(ExerciseSettingsSheet))).pop();
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 300)),
);
await tester.pumpAndSettle();
}
Future<ExerciseState> storedSitup(WidgetTester tester) async => (await tester
.runAsync(() => StorageService.instance.getExerciseState('Situp')))!;
testWidgets('each row summarises its exercise', (tester) async {
await pumpSettings(tester);
// Situp is the one plan exercise without a warmup.
expect(
find.text('kg · ↑3 ↓2 · rest 3:00 / 5:00 · no warmup'),
findsOneWidget,
);
});
testWidgets('a double-progression row names its rep range', (tester) async {
await tester.runAsync(() async {
final s = (await StorageService.instance.getExerciseState('Situp'))!;
await StorageService.instance.setExerciseSettings(
s.copyWith(
mode: ProgressionMode.doubleProgression,
repsHigh: 40,
repsLow: 25,
),
);
});
await pumpSettings(tester);
expect(find.textContaining('25→40 reps · ↑3 ↓2'), findsOneWidget);
});
testWidgets('edits persist, show on the row, and push once on close', (
tester,
) async {
await pumpSettings(tester);
await openSitup(tester);
await tester.runAsync(() async {
await tester.tap(find.bySemanticsLabel('Success rest up'));
await Future<void>.delayed(const Duration(milliseconds: 200));
});
await tester.pump();
await tester.tap(find.text('Reps'));
await tester.pump();
await tester.tap(find.text('Pause'));
await tester.pump();
await closeSheet(tester);
expect(pushes, 1);
final s = await storedSitup(tester);
expect(s.restSuccessSecs, 195);
expect(s.mode, ProgressionMode.reps);
expect(s.pausedUntil, isNotNull);
expect(find.textContaining('reps · ↑3 ↓2 · rest 3:15 / 5:00'), findsOne);
expect(find.textContaining('Paused — back on'), findsOneWidget);
expect(find.byType(SnackBar), findsNothing);
});
testWidgets('closing without an edit pushes nothing', (tester) async {
await pumpSettings(tester);
await openSitup(tester);
await closeSheet(tester);
expect(pushes, 0);
});
testWidgets('a push that did not happen says so', (tester) async {
pushResult = const ProgressionSyncResult(
changed: false,
reason: 'no Firebase account on this device',
);
await pumpSettings(tester);
await openSitup(tester);
await tester.tap(find.bySemanticsLabel('Fail rest down'));
await tester.pump();
await closeSheet(tester);
expect(pushes, 1);
expect(
find.text(
'Saved on this phone, not synced: no Firebase account on this device',
),
findsOneWidget,
);
});
}

View File

@ -98,61 +98,6 @@ void main() {
},
);
testWidgets('changing a success threshold persists it', (tester) async {
await _pump(tester, _wrap());
// Scroll to the section header first: the threshold rows below it are not
// built at all until then, so a `.first` finder for the label would throw
// "Bad state: No element" before it could match anything.
await tester.scrollUntilVisible(find.text('PROGRESSION THRESHOLDS'), 200);
await tester.pumpAndSettle();
final label = find.text('↑ Increase after N successes').first;
await tester.scrollUntilVisible(
label,
300,
scrollable: find.byType(Scrollable),
);
// The circles (1-5) live in the same Row as the label; tap the "5" circle.
final row = find.ancestor(of: label, matching: find.byType(Row)).first;
final five = find.descendant(of: row, matching: find.text('5')).first;
// The row can sit below the 800x600 test fold — bring the circle fully in.
await tester.ensureVisible(five);
await tester.pumpAndSettle();
// _onThresholdChanged awaits a DB write, so drive it on the real loop.
await tester.runAsync(() async {
await tester.tap(five);
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pump();
final states = await tester.runAsync(
() => StorageService.instance.getAllExerciseStates(),
);
expect(states!.any((s) => s.successThreshold == 5), isTrue);
// Also exercise the fail-threshold path (onFailChanged closure).
final failLabel = find.text('↓ Decrease after N failures').first;
await tester.ensureVisible(failLabel);
await tester.pumpAndSettle();
final failRow = find
.ancestor(of: failLabel, matching: find.byType(Row))
.first;
final failFour = find
.descendant(of: failRow, matching: find.text('4'))
.first;
await tester.ensureVisible(failFour);
await tester.pumpAndSettle();
await tester.runAsync(() async {
await tester.tap(failFour);
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await tester.pump();
final states2 = await tester.runAsync(
() => StorageService.instance.getAllExerciseStates(),
);
expect(states2!.any((s) => s.failThreshold == 4), isTrue);
});
testWidgets('reps change is debounced then written to storage', (
tester,
) async {

View File

@ -79,17 +79,6 @@ void main() {
),
);
testWidgets('threshold circles show values 1-5', (tester) async {
await _pump(tester, _wrap());
// The threshold cards sit below WEIGHTS and TARGET REPS, past the test
// viewport's fold, so they are not built until scrolled to.
await tester.scrollUntilVisible(find.text('PROGRESSION THRESHOLDS'), 200);
await tester.pumpAndSettle();
for (int i = 1; i <= 5; i++) {
expect(find.text('$i'), findsWidgets);
}
});
testWidgets('Reset dialog shows on tap and cancels', (tester) async {
await _pump(tester, _wrap());
await tester.tap(find.text('Reset defaults'));

View File

@ -84,18 +84,16 @@ void main() {
expect(find.text('Settings'), findsOneWidget);
});
testWidgets('shows WEIGHTS, TARGET REPS and PROGRESSION THRESHOLDS', (
tester,
) async {
testWidgets('shows WEIGHTS, TARGET REPS and PER EXERCISE', (tester) async {
await _pump(tester, _wrap());
expect(find.text('WEIGHTS'), findsOneWidget);
// TARGET REPS and the thresholds below it are off-screen at the test
// TARGET REPS and the per-exercise list below it are off-screen at the test
// viewport height, so scroll them into view rather than asserting on
// whatever happens to be built.
await tester.scrollUntilVisible(find.text('TARGET REPS'), 200);
expect(find.text('TARGET REPS'), findsOneWidget);
await tester.scrollUntilVisible(find.text('PROGRESSION THRESHOLDS'), 200);
expect(find.text('PROGRESSION THRESHOLDS'), findsOneWidget);
await tester.scrollUntilVisible(find.text('PER EXERCISE'), 200);
expect(find.text('PER EXERCISE'), findsOneWidget);
});
testWidgets('increment reps button increases the target reps', (
@ -108,10 +106,7 @@ void main() {
expect(find.text('30 reps'), findsOneWidget);
final plus = find.descendant(
of: find
.ancestor(
of: find.text('30 reps'),
matching: find.byType(Row),
)
.ancestor(of: find.text('30 reps'), matching: find.byType(Row))
.first,
matching: find.byIcon(Icons.add),
);
@ -163,9 +158,6 @@ void main() {
await tester.tap(find.byIcon(Icons.remove).first);
await tester.pump();
expect(
find.textContaining('${before - kWeightIncrement}kg'),
findsWidgets,
);
expect(find.textContaining('${before - kWeightIncrement}kg'), findsWidgets);
});
}

View File

@ -144,7 +144,15 @@ void main() {
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);
// The sheet scrolls only as a fallback for a short screen; on the phone
// everything, rest rows included, fits with nothing to scroll.
final sheetScroll = tester.state<ScrollableState>(
find.descendant(
of: find.byType(ExerciseSettingsSheet),
matching: find.byType(Scrollable),
),
);
expect(sheetScroll.position.maxScrollExtent, 0);
await _shot(tester, 'sheet');
Navigator.of(tester.element(find.byType(ExerciseSettingsSheet))).pop();
await tester.pumpAndSettle();
@ -164,12 +172,15 @@ void main() {
if (!circle.tapped) await tapReal(tester, find.byType(RepCircle).at(i));
}
expect(
tester.widgetList<RepCircle>(find.byType(RepCircle)).every(
(c) => c.tapped,
),
tester
.widgetList<RepCircle>(find.byType(RepCircle))
.every((c) => c.tapped),
isTrue,
);
expect(tester.widget<BreakBanner>(find.byType(BreakBanner)).canFinish, true);
expect(
tester.widget<BreakBanner>(find.byType(BreakBanner)).canFinish,
true,
);
expect(tileRects(), idle);
await _shot(tester, 'all_done');
});

View File

@ -0,0 +1,78 @@
// Rest lengths come from the exercise's own settings: warmup, success and
// fail rests, and the fail rest the notification's `−1 rep` re-cuts to.
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:workout_app/models/exercise.dart';
import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/widgets/rep_circle.dart';
import '../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 hold its rests.
const _lunge = Exercise(name: 'Dumbbell Lunge', sets: 3, reps: 5, weight: 10);
const _row = Exercise(name: 'Dumbbell Row', sets: 3, reps: 5, weight: 20);
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> pumpWithLungeRests(WidgetTester tester) async {
await tester.runAsync(() async {
final s = (await StorageService.instance.getExerciseState(_lunge.name))!;
await StorageService.instance.setExerciseSettings(
s.copyWith(restSuccessSecs: 200, restFailSecs: 250, restWarmupSecs: 90),
);
});
await pumpWorkout(
tester,
wrapWorkout(exercises: const [_lunge, _row], breakClient: client),
);
}
testWidgets('the warmup rest is the exercise\'s own', (tester) async {
await pumpWithLungeRests(tester);
await tapReal(tester, find.bySemanticsLabel('Dumbbell Lunge warmup'));
expect(restSecs(tester), inInclusiveRange(85, 90));
});
testWidgets('success and fail rests are the exercise\'s own', (tester) async {
await pumpWithLungeRests(tester);
final circle = find.byType(RepCircle).first;
await tapReal(tester, circle); // target hit -> success rest
expect(restSecs(tester), inInclusiveRange(195, 200));
// The notification carries the fail rest for a `−1 rep` press.
expect(client.pushed.last.breakFailSecs, 250);
await tapReal(tester, circle); // one rep short -> fail rest
expect(restSecs(tester), inInclusiveRange(245, 250));
});
testWidgets('an exercise left at defaults keeps 3:00 and 5:00', (
tester,
) async {
await pumpWithLungeRests(tester);
final rowFirstSet = find.byType(RepCircle).at(_lunge.sets);
await tapReal(tester, rowFirstSet);
expect(restSecs(tester), inInclusiveRange(175, 180));
expect(client.pushed.last.breakFailSecs, 300);
});
}

View File

@ -0,0 +1,56 @@
// Shared harness for the BreakTaskHandler tests: an in-memory intent store
// and a snapshot builder with every field defaulted.
import 'package:workout_app/models/break_snapshot.dart';
import 'package:workout_app/services/break_intent_store.dart';
/// A [BreakIntentStore] backed by two maps.
class MemIntentStore implements BreakIntentStore {
final ints = <String, int>{};
final lists = <String, List<String>>{};
@override
Future<int> readInt(String key) async => ints[key] ?? 0;
@override
Future<void> writeInt(String key, int value) async => ints[key] = value;
@override
Future<List<String>> readStringList(String key) async =>
lists[key] ?? const [];
@override
Future<void> writeStringList(String key, List<String> value) async =>
lists[key] = List.of(value);
}
/// A snapshot map as the UI sends it, with every field defaulted.
Map<String, Object?> snapMap({
int breakEndMs = 0,
int breakDurationSecs = 180,
int breakFailSecs = 300,
int nextExIdx = 0,
int nextSetIdx = 1,
int lastExIdx = 0,
int lastSetIdx = 0,
int breakForExIdx = 0,
int breakForSetIdx = 0,
int setsRemaining = 4,
}) => BreakSnapshot(
workoutType: 'A',
breakEndMs: breakEndMs,
breakDurationSecs: breakDurationSecs,
breakFailSecs: breakFailSecs,
breakLabel: 'Rest (3 min — well done!)',
breakForExIdx: breakForExIdx,
breakForSetIdx: breakForSetIdx,
nextExIdx: nextExIdx,
nextSetIdx: nextSetIdx,
nextExName: 'Squat',
nextSetNumber: nextSetIdx + 1,
nextTotalSets: 5,
nextReps: 5,
nextWeight: 40,
lastExIdx: lastExIdx,
lastSetIdx: lastSetIdx,
setsRemaining: setsRemaining,
).toMap();

View File

@ -18,6 +18,7 @@ BreakSnapshot snap({
workoutType: 'A',
breakEndMs: breakEndMs,
breakDurationSecs: breakDurationSecs,
breakFailSecs: 300,
breakLabel: 'Rest',
breakForExIdx: 0,
breakForSetIdx: 1,
@ -40,7 +41,9 @@ void main() {
test('shows the countdown in the title, which Android shows collapsed', () {
final copy = BreakNotificationText.render(
snap(
breakEndMs: now.add(const Duration(seconds: 107)).millisecondsSinceEpoch,
breakEndMs: now
.add(const Duration(seconds: 107))
.millisecondsSinceEpoch,
breakDurationSecs: 180,
),
now,
@ -52,7 +55,9 @@ void main() {
test('says the break is over once the deadline has passed', () {
final copy = BreakNotificationText.render(
snap(
breakEndMs: now.subtract(const Duration(seconds: 5)).millisecondsSinceEpoch,
breakEndMs: now
.subtract(const Duration(seconds: 5))
.millisecondsSinceEpoch,
breakDurationSecs: 180,
),
now,

View File

@ -31,6 +31,7 @@ const _snapshot = BreakSnapshot(
workoutType: 'A',
breakEndMs: 0,
breakDurationSecs: 0,
breakFailSecs: 300,
breakLabel: '',
breakForExIdx: -1,
breakForSetIdx: -1,
@ -62,12 +63,14 @@ void main() {
Future<BreakServiceStartResult> start() =>
controller.start(_snapshot, onDrainNudge: () {});
test('starts and reports success when the permission is already held',
() async {
expect(await start(), BreakServiceStartResult.started);
expect(controller.isStarted, isTrue);
expect(client.calls, ['init', 'hasPermission', 'listen', 'start']);
});
test(
'starts and reports success when the permission is already held',
() async {
expect(await start(), BreakServiceStartResult.started);
expect(controller.isStarted, isTrue);
expect(client.calls, ['init', 'hasPermission', 'listen', 'start']);
},
);
test('asks for the permission when it is not held yet', () async {
client.permissionGranted = false;
@ -86,21 +89,25 @@ void main() {
expect(controller.isStarted, isTrue);
});
test('reports failure and stops listening when the service refuses to start',
() async {
client.startSucceeds = false;
expect(await start(), BreakServiceStartResult.failed);
expect(controller.isStarted, isFalse);
expect(client.calls.last, 'unlisten');
});
test(
'reports failure and stops listening when the service refuses to start',
() async {
client.startSucceeds = false;
expect(await start(), BreakServiceStartResult.failed);
expect(controller.isStarted, isFalse);
expect(client.calls.last, 'unlisten');
},
);
test('does nothing at all on a platform with no foreground service',
() async {
client.isSupported = false;
expect(await start(), BreakServiceStartResult.unsupported);
expect(client.calls, isEmpty);
expect(controller.isStarted, isFalse);
});
test(
'does nothing at all on a platform with no foreground service',
() async {
client.isSupported = false;
expect(await start(), BreakServiceStartResult.unsupported);
expect(client.calls, isEmpty);
expect(controller.isStarted, isFalse);
},
);
test('push is a no-op until the service has started', () async {
await controller.push(_snapshot);
@ -183,21 +190,26 @@ void main() {
expect(client.calls, contains('stop'));
});
test('leaves a service alone while its workout is still in progress',
() async {
client.running = true;
final stopped = await stopStaleBreakService(
client,
hasActiveWorkout: () async => true,
);
expect(stopped, isFalse);
expect(client.calls, isNot(contains('stop')));
});
test(
'leaves a service alone while its workout is still in progress',
() async {
client.running = true;
final stopped = await stopStaleBreakService(
client,
hasActiveWorkout: () async => true,
);
expect(stopped, isFalse);
expect(client.calls, isNot(contains('stop')));
},
);
test('does nothing when no service is running', () async {
client.running = false;
expect(
await stopStaleBreakService(client, hasActiveWorkout: () async => false),
await stopStaleBreakService(
client,
hasActiveWorkout: () async => false,
),
isFalse,
);
expect(client.calls, isNot(contains('stop')));
@ -208,7 +220,10 @@ void main() {
..isSupported = false
..running = true;
expect(
await stopStaleBreakService(client, hasActiveWorkout: () async => false),
await stopStaleBreakService(
client,
hasActiveWorkout: () async => false,
),
isFalse,
);
expect(client.calls, isEmpty);

View File

@ -7,70 +7,23 @@ import 'package:workout_app/services/break_service_port.dart';
import 'package:workout_app/services/break_task_handler.dart';
import '../fake_break_service.dart';
class _MemStore implements BreakIntentStore {
final ints = <String, int>{};
final lists = <String, List<String>>{};
@override
Future<int> readInt(String key) async => ints[key] ?? 0;
@override
Future<void> writeInt(String key, int value) async => ints[key] = value;
@override
Future<List<String>> readStringList(String key) async =>
lists[key] ?? const [];
@override
Future<void> writeStringList(String key, List<String> value) async =>
lists[key] = List.of(value);
}
import '_break_task_handler_fixtures.dart';
void main() {
final now = DateTime(2026, 9, 12, 14);
late FakeBreakServicePort port;
late _MemStore store;
late MemIntentStore store;
late BreakIntentQueue queue;
late BreakTaskHandler handler;
setUp(() {
port = FakeBreakServicePort();
store = _MemStore();
store = MemIntentStore();
queue = BreakIntentQueue(store);
handler = BreakTaskHandler(port, queue);
});
Map<String, Object?> snapMap({
int breakEndMs = 0,
int breakDurationSecs = 180,
int nextExIdx = 0,
int nextSetIdx = 1,
int lastExIdx = 0,
int lastSetIdx = 0,
int breakForExIdx = 0,
int breakForSetIdx = 0,
int setsRemaining = 4,
}) => BreakSnapshot(
workoutType: 'A',
breakEndMs: breakEndMs,
breakDurationSecs: breakDurationSecs,
breakLabel: 'Rest (3 min — well done!)',
breakForExIdx: breakForExIdx,
breakForSetIdx: breakForSetIdx,
nextExIdx: nextExIdx,
nextSetIdx: nextSetIdx,
nextExName: 'Squat',
nextSetNumber: nextSetIdx + 1,
nextTotalSets: 5,
nextReps: 5,
nextWeight: 40,
lastExIdx: lastExIdx,
lastSetIdx: lastSetIdx,
setsRemaining: setsRemaining,
).toMap();
Future<List<BreakIntent>> queued() async {
final raw = await store.readStringList(BreakIntentQueue.intentsKey);
return [for (final e in raw) BreakIntent.tryDecode(e)!];
@ -140,35 +93,41 @@ void main() {
expect(port.nudges, 1);
});
test('− 1 rep targets the set recorded last, resolved at press time',
() async {
await handler.start(snapMap(lastExIdx: 2, lastSetIdx: 3), now);
await handler.pressButton(BreakNotificationAction.minusRep, now);
test(
'− 1 rep targets the set recorded last, resolved at press time',
() async {
await handler.start(snapMap(lastExIdx: 2, lastSetIdx: 3), now);
await handler.pressButton(BreakNotificationAction.minusRep, now);
final intents = await queued();
expect(intents.single.kind, BreakIntentKind.minusRep);
expect(intents.single.exIdx, 2);
expect(intents.single.setIdx, 3);
});
final intents = await queued();
expect(intents.single.kind, BreakIntentKind.minusRep);
expect(intents.single.exIdx, 2);
expect(intents.single.setIdx, 3);
},
);
test('− 1 rep stretches a running rest to the failure length', () async {
final endMs = now.add(const Duration(seconds: 120)).millisecondsSinceEpoch;
test('− 1 rep stretches a running rest to the exercise\'s failure '
'length', () async {
final endMs = now
.add(const Duration(seconds: 120))
.millisecondsSinceEpoch;
await handler.start(
snapMap(breakEndMs: endMs, breakDurationSecs: 180),
snapMap(breakEndMs: endMs, breakDurationSecs: 180, breakFailSecs: 270),
now,
);
await handler.pressButton(BreakNotificationAction.minusRep, now);
final s = handler.snapshot!;
expect(s.breakDurationSecs, 300);
// The exercise's own fail rest from the snapshot, not a fixed 5 min.
expect(s.breakDurationSecs, 270);
// 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.
// Re-cut from the original start: 60s in, so 210s left of 270.
expect(
DateTime.fromMillisecondsSinceEpoch(s.breakEndMs)
.difference(now)
.inSeconds,
240,
DateTime.fromMillisecondsSinceEpoch(
s.breakEndMs,
).difference(now).inSeconds,
210,
);
});
@ -181,12 +140,14 @@ void main() {
expect(handler.snapshot!.hasBreak, isFalse);
});
test('Done on the last set stops the service instead of carrying on',
() async {
await handler.start(snapMap(setsRemaining: 1), now);
await handler.pressButton(BreakNotificationAction.done, now);
expect(port.stopped, isTrue);
});
test(
'Done on the last set stops the service instead of carrying on',
() async {
await handler.start(snapMap(setsRemaining: 1), now);
await handler.pressButton(BreakNotificationAction.done, now);
expect(port.stopped, isTrue);
},
);
test('ignores presses that no longer refer to anything', () async {
await handler.start(

View File

@ -1,4 +1,4 @@
// The progression-mode, warmup and pause fields on a Firebase progression
// The progression-mode, warmup, pause and rest fields on a Firebase progression
// record: pushed, pulled back, and read leniently from records written
// before they existed.
import 'dart:convert';
@ -34,55 +34,80 @@ void main() {
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);
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();
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);
});
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);
expect(
(situp.restSuccessSecs, situp.restFailSecs, situp.restWarmupSecs),
(180, 300, 180),
);
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();
test(
'mode, rep range, warmup, pause and rests 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),
restSuccessSecs: 150,
restFailSecs: 240,
restWarmupSecs: 90,
),
);
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);
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(
(
pushed['rest_success_secs'],
pushed['rest_fail_secs'],
pushed['rest_warmup_secs'],
),
(150, 240, 90),
);
expect(_payload(store, 'Dumbbell Row')['paused_until'], isNull);
// A reinstall: fresh database, same Firebase.
StorageService.resetForTesting();
await StorageService.init();
await sync.pullProgression();
// 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));
});
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));
expect(
(back.restSuccessSecs, back.restFailSecs, back.restWarmupSecs),
(150, 240, 90),
);
},
);
}

View File

@ -55,6 +55,11 @@ void main() {
expect(st, isNotNull);
expect(st!.successThreshold, 3);
expect(st.failThreshold, 2);
// … and the <6 block added the rest columns at the old fixed lengths.
expect(
(st.restSuccessSecs, st.restFailSecs, st.restWarmupSecs),
(180, 300, 180),
);
await dir.delete(recursive: true);
});

View File

@ -17,7 +17,10 @@ Future<List<ExerciseState>> _pump(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: ExerciseSettingsSheet(state: state ?? _base, onChanged: edits.add),
body: ExerciseSettingsSheet(
state: state ?? _base,
onChanged: edits.add,
),
),
),
);
@ -97,6 +100,44 @@ void main() {
expect(edits.last.hasWarmup, isFalse);
});
testWidgets('rests step by 15 s as m:ss and stop at the bounds', (
tester,
) async {
final edits = await _pump(
tester,
_base.copyWith(restSuccessSecs: kMaxRestSecs, restFailSecs: kMinRestSecs),
);
expect(find.text('10:00'), findsOneWidget);
expect(find.text('0:30'), findsOneWidget);
// Already at the bounds: nothing to report.
await _tapLabel(tester, 'Success rest up');
await _tapLabel(tester, 'Fail rest down');
expect(edits, isEmpty);
await _tapLabel(tester, 'Success rest down');
expect(edits.last.restSuccessSecs, kMaxRestSecs - kRestStepSecs);
expect(find.text('9:45'), findsOneWidget);
await _tapLabel(tester, 'Fail rest up');
expect(edits.last.restFailSecs, kMinRestSecs + kRestStepSecs);
await _tapLabel(tester, 'Warmup rest down');
expect(edits.last.restWarmupSecs, kDefaultRestWarmupSecs - kRestStepSecs);
// Every edit carries the earlier ones.
expect(edits.last.restSuccessSecs, kMaxRestSecs - kRestStepSecs);
});
testWidgets('the warmup rest hides with the warmup, keeping the height', (
tester,
) async {
await _pump(tester);
final before = tester.getSize(find.byType(ExerciseSettingsSheet));
final warmupRest = find.bySemanticsLabel('Warmup rest up').hitTestable();
expect(warmupRest, findsOneWidget);
await tester.tap(find.text('Warmup set'));
await tester.pump();
expect(warmupRest, findsNothing);
expect(tester.getSize(find.byType(ExerciseSettingsSheet)), before);
});
testWidgets('pause length steps from 14 and sets the end date', (
tester,
) async {

View File

@ -51,7 +51,7 @@ void main() {
testWidgets('warmup is the first circle, with its weight', (tester) async {
await tester.pumpWidget(_wrap(_tile()));
expect(find.text('W×5'), findsOneWidget);
expect(find.text('W×6'), findsOneWidget);
expect(find.text('${_exercise.warmupWeight}'), findsOneWidget);
final warmup = tester.getRect(find.bySemanticsLabel('Squat warmup'));
final firstSet = tester.getRect(find.byType(RepCircle).first);
@ -95,10 +95,7 @@ void main() {
await tester.pumpWidget(
_wrap(_tile(state: _state.copyWith(hasWarmup: false))),
);
expect(
find.bySemanticsLabel('Squat warmup').hitTestable(),
findsNothing,
);
expect(find.bySemanticsLabel('Squat warmup').hitTestable(), findsNothing);
expect(tester.getRect(find.byType(RepCircle).first), setsWith);
expect(tester.getSize(find.byType(ExerciseTile)), tileWith);
});