From d35edfc10c73db1ae9699ebe28de5a872ec929cf Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 27 Sep 2026 18:55:56 +0200 Subject: [PATCH] =?UTF-8?q?workout=5Fapp:=20warmup=20=C3=976,=20per-exerci?= =?UTF-8?q?se=20rests,=20exercise=20sheet=20in=20Settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../lib/models/break_snapshot.dart | 9 ++ .../workout_app/lib/models/exercise.dart | 3 + .../lib/models/exercise_state.dart | 43 +++++ .../lib/screens/settings_screen.dart | 53 +++--- .../settings_screen_exercise_list.dart | 136 ++++++++++++++++ .../settings_screen_exercise_sections.dart | 58 +------ .../screens/settings_screen_thresholds.dart | 123 -------------- .../lib/screens/settings_screen_widget.dart | 9 +- .../lib/screens/workout_screen_breaks.dart | 18 ++- .../lib/screens/workout_screen_intents.dart | 5 + .../lib/screens/workout_screen_session.dart | 13 +- .../lib/screens/workout_screen_taps.dart | 2 +- .../lib/services/break_task_handler.dart | 5 +- .../services/progression_sync_service.dart | 13 +- .../lib/services/storage_service.dart | 2 +- .../services/storage_service_exercises.dart | 14 +- .../lib/services/storage_service_schema.dart | 19 ++- .../lib/widgets/exercise_settings_sheet.dart | 13 +- .../widgets/exercise_settings_sheet_rest.dart | 76 +++++++++ .../widgets/exercise_settings_sheet_rows.dart | 18 ++- .../lib/widgets/exercise_tile_rows.dart | 4 +- .../test/models/break_snapshot_test.dart | 15 +- .../test/models/exercise_state_test.dart | 70 ++++++++ .../test/models/progression_test.dart | 98 +++++------ .../settings_screen_exercises_test.dart | 153 ++++++++++++++++++ .../screens/settings_screen_reset_test.dart | 55 ------- .../screens/settings_screen_sync_test.dart | 11 -- .../test/screens/settings_screen_test.dart | 20 +-- .../test/screens/workout_layout_test.dart | 21 ++- .../screens/workout_rest_lengths_test.dart | 78 +++++++++ .../_break_task_handler_fixtures.dart | 56 +++++++ .../break_notification_text_test.dart | 9 +- .../break_service_controller_test.dart | 79 +++++---- .../services/break_task_handler_test.dart | 109 ++++--------- .../progression_sync_fields_test.dart | 117 ++++++++------ .../storage_service_restore_test.dart | 5 + .../widgets/exercise_settings_sheet_test.dart | 43 ++++- .../test/widgets/exercise_tile_test.dart | 7 +- 38 files changed, 1042 insertions(+), 540 deletions(-) create mode 100644 stronglift_replacement/workout_app/lib/screens/settings_screen_exercise_list.dart delete mode 100644 stronglift_replacement/workout_app/lib/screens/settings_screen_thresholds.dart create mode 100644 stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_rest.dart create mode 100644 stronglift_replacement/workout_app/test/models/exercise_state_test.dart create mode 100644 stronglift_replacement/workout_app/test/screens/settings_screen_exercises_test.dart create mode 100644 stronglift_replacement/workout_app/test/screens/workout_rest_lengths_test.dart create mode 100644 stronglift_replacement/workout_app/test/services/_break_task_handler_fixtures.dart diff --git a/stronglift_replacement/workout_app/lib/models/break_snapshot.dart b/stronglift_replacement/workout_app/lib/models/break_snapshot.dart index 90fbd79..624deca 100644 --- a/stronglift_replacement/workout_app/lib/models/break_snapshot.dart +++ b/stronglift_replacement/workout_app/lib/models/break_snapshot.dart @@ -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, diff --git a/stronglift_replacement/workout_app/lib/models/exercise.dart b/stronglift_replacement/workout_app/lib/models/exercise.dart index cb677da..8008dad 100644 --- a/stronglift_replacement/workout_app/lib/models/exercise.dart +++ b/stronglift_replacement/workout_app/lib/models/exercise.dart @@ -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. diff --git a/stronglift_replacement/workout_app/lib/models/exercise_state.dart b/stronglift_replacement/workout_app/lib/models/exercise_state.dart index fd6058e..adc3bfe 100644 --- a/stronglift_replacement/workout_app/lib/models/exercise_state.dart +++ b/stronglift_replacement/workout_app/lib/models/exercise_state.dart @@ -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')}'; diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart index 2e5e4b7..f65584f 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart @@ -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 { bool _loading = true; - final Map _successThresholds = {}; - final Map _failThresholds = {}; + final Map _states = {}; final Map _weights = {}; final Map _reps = {}; @@ -97,8 +97,7 @@ class _SettingsScreenState extends State { 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 { }); } - Future _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 _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 { 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( diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_exercise_list.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_exercise_list.dart new file mode 100644 index 0000000..3f8b0a8 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_exercise_list.dart @@ -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 orderedNames; + + /// Stored state per exercise; a missing entry renders nothing. + final Map states; + + /// Opens the settings sheet for the tapped exercise. + final ValueChanged 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), + ], + ), + ), + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_exercise_sections.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_exercise_sections.dart index 35d3507..0a79806 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen_exercise_sections.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_exercise_sections.dart @@ -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 orderedNames; - - /// Successes before a weight increase, per exercise; defaults to 3. - final Map successThresholds; - - /// Failures before a weight decrease, per exercise; defaults to 2. - final Map 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), - ); - }), - ], - ); - } -} diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_thresholds.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_thresholds.dart deleted file mode 100644 index 7f5acc8..0000000 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen_thresholds.dart +++ /dev/null @@ -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 onSuccessChanged; - final ValueChanged onFailChanged; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final status = Theme.of(context).extension()!; - 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 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, - ), - ), - ), - ), - ), - ], - ); - } -} diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_widget.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_widget.dart index accfa04..d51873b 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen_widget.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_widget.dart @@ -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 Function()? progressionPuller; + /// Pushes progression after an exercise's settings sheet closes with edits. + /// Injected so tests never reach Firebase. + final Future Function()? progressionPusher; + @override State createState() => _SettingsScreenState(); } diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_breaks.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_breaks.dart index ead378f..ef7a66b 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_breaks.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_breaks.dart @@ -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; diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart index d4640d7..681335d 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_intents.dart @@ -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, diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart index f62a461..be0b36e 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart @@ -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. diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_taps.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_taps.dart index e80cbd1..38f79f6 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_taps.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_taps.dart @@ -52,7 +52,7 @@ extension _WorkoutScreenTaps on _WorkoutScreenState { if (_finished || _warmupTapped[exIdx]) return; _applyBreakState(() => _warmupTapped[exIdx] = true); if (!_inBreak) { - _startBreak(_warmupBreakSecs, 'Warmup rest', exIdx, -1); + _startBreak(_stateOf(exIdx).restWarmupSecs, 'Warmup rest', exIdx, -1); } unawaited(_saveActiveSession(toFirebase: true)); } diff --git a/stronglift_replacement/workout_app/lib/services/break_task_handler.dart b/stronglift_replacement/workout_app/lib/services/break_task_handler.dart index aa16c6e..62079a5 100644 --- a/stronglift_replacement/workout_app/lib/services/break_task_handler.dart +++ b/stronglift_replacement/workout_app/lib/services/break_task_handler.dart @@ -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, diff --git a/stronglift_replacement/workout_app/lib/services/progression_sync_service.dart b/stronglift_replacement/workout_app/lib/services/progression_sync_service.dart index 1a3a3e4..d1193cc 100644 --- a/stronglift_replacement/workout_app/lib/services/progression_sync_service.dart +++ b/stronglift_replacement/workout_app/lib/services/progression_sync_service.dart @@ -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 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. diff --git a/stronglift_replacement/workout_app/lib/services/storage_service.dart b/stronglift_replacement/workout_app/lib/services/storage_service.dart index e8b946b..7ad566d 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service.dart @@ -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 diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart b/stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart index ea534d7..a3d81bb 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_exercises.dart @@ -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 _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); } diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart b/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart index 2b1f1dc..49a2317 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service_schema.dart @@ -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 _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 _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, }); } } diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet.dart index 9d35357..b061d7a 100644 --- a/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet.dart +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet.dart @@ -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 showExerciseSettingsSheet( required ValueChanged onChanged, }) => showModalBottomSheet( 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 { ); 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 { 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 diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_rest.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_rest.dart new file mode 100644 index 0000000..fe81cee --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_rest.dart @@ -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 onChanged; + + _Stepper _stepper(String name, int value, ValueChanged 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)), + ), + ], + ), + ), + ], + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_rows.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_rows.dart index fca6fb9..050d021 100644 --- a/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_rows.dart +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_settings_sheet_rows.dart @@ -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 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), ], ); } diff --git a/stronglift_replacement/workout_app/lib/widgets/exercise_tile_rows.dart b/stronglift_replacement/workout_app/lib/widgets/exercise_tile_rows.dart index dbb7b89..6eba3a1 100644 --- a/stronglift_replacement/workout_app/lib/widgets/exercise_tile_rows.dart +++ b/stronglift_replacement/workout_app/lib/widgets/exercise_tile_rows.dart @@ -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)), diff --git a/stronglift_replacement/workout_app/test/models/break_snapshot_test.dart b/stronglift_replacement/workout_app/test/models/break_snapshot_test.dart index 73c4c9b..af592a3 100644 --- a/stronglift_replacement/workout_app/test/models/break_snapshot_test.dart +++ b/stronglift_replacement/workout_app/test/models/break_snapshot_test.dart @@ -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, diff --git a/stronglift_replacement/workout_app/test/models/exercise_state_test.dart b/stronglift_replacement/workout_app/test/models/exercise_state_test.dart new file mode 100644 index 0000000..881c213 --- /dev/null +++ b/stronglift_replacement/workout_app/test/models/exercise_state_test.dart @@ -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'); + }); + }); +} diff --git a/stronglift_replacement/workout_app/test/models/progression_test.dart b/stronglift_replacement/workout_app/test/models/progression_test.dart index 291c00f..46389b0 100644 --- a/stronglift_replacement/workout_app/test/models/progression_test.dart +++ b/stronglift_replacement/workout_app/test/models/progression_test.dart @@ -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'); }); }); } diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_exercises_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_exercises_test.dart new file mode 100644 index 0000000..670e0ec --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_exercises_test.dart @@ -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 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.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + await tester.scrollUntilVisible(find.bySemanticsLabel(_situp), 200); + await tester.pumpAndSettle(); + } + + Future 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 closeSheet(WidgetTester tester) async { + Navigator.of(tester.element(find.byType(ExerciseSettingsSheet))).pop(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 300)), + ); + await tester.pumpAndSettle(); + } + + Future 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.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, + ); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_reset_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_reset_test.dart index bcf11d3..f7f7e3c 100644 --- a/stronglift_replacement/workout_app/test/screens/settings_screen_reset_test.dart +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_reset_test.dart @@ -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.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.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 { diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_sync_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_sync_test.dart index 2c52702..a908bd3 100644 --- a/stronglift_replacement/workout_app/test/screens/settings_screen_sync_test.dart +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_sync_test.dart @@ -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')); diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_test.dart index 275cd00..2fc377a 100644 --- a/stronglift_replacement/workout_app/test/screens/settings_screen_test.dart +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_test.dart @@ -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); }); } diff --git a/stronglift_replacement/workout_app/test/screens/workout_layout_test.dart b/stronglift_replacement/workout_app/test/screens/workout_layout_test.dart index 3777125..209b359 100644 --- a/stronglift_replacement/workout_app/test/screens/workout_layout_test.dart +++ b/stronglift_replacement/workout_app/test/screens/workout_layout_test.dart @@ -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( + 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(find.byType(RepCircle)).every( - (c) => c.tapped, - ), + tester + .widgetList(find.byType(RepCircle)) + .every((c) => c.tapped), isTrue, ); - expect(tester.widget(find.byType(BreakBanner)).canFinish, true); + expect( + tester.widget(find.byType(BreakBanner)).canFinish, + true, + ); expect(tileRects(), idle); await _shot(tester, 'all_done'); }); diff --git a/stronglift_replacement/workout_app/test/screens/workout_rest_lengths_test.dart b/stronglift_replacement/workout_app/test/screens/workout_rest_lengths_test.dart new file mode 100644 index 0000000..b2d1521 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/workout_rest_lengths_test.dart @@ -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 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); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/_break_task_handler_fixtures.dart b/stronglift_replacement/workout_app/test/services/_break_task_handler_fixtures.dart new file mode 100644 index 0000000..a7eafb7 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/_break_task_handler_fixtures.dart @@ -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 = {}; + final lists = >{}; + + @override + Future readInt(String key) async => ints[key] ?? 0; + + @override + Future writeInt(String key, int value) async => ints[key] = value; + + @override + Future> readStringList(String key) async => + lists[key] ?? const []; + + @override + Future writeStringList(String key, List value) async => + lists[key] = List.of(value); +} + +/// A snapshot map as the UI sends it, with every field defaulted. +Map 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(); diff --git a/stronglift_replacement/workout_app/test/services/break_notification_text_test.dart b/stronglift_replacement/workout_app/test/services/break_notification_text_test.dart index d764c2b..d715c31 100644 --- a/stronglift_replacement/workout_app/test/services/break_notification_text_test.dart +++ b/stronglift_replacement/workout_app/test/services/break_notification_text_test.dart @@ -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, diff --git a/stronglift_replacement/workout_app/test/services/break_service_controller_test.dart b/stronglift_replacement/workout_app/test/services/break_service_controller_test.dart index e24d6de..de4a18a 100644 --- a/stronglift_replacement/workout_app/test/services/break_service_controller_test.dart +++ b/stronglift_replacement/workout_app/test/services/break_service_controller_test.dart @@ -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 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); diff --git a/stronglift_replacement/workout_app/test/services/break_task_handler_test.dart b/stronglift_replacement/workout_app/test/services/break_task_handler_test.dart index 5f1a75a..87eed8a 100644 --- a/stronglift_replacement/workout_app/test/services/break_task_handler_test.dart +++ b/stronglift_replacement/workout_app/test/services/break_task_handler_test.dart @@ -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 = {}; - final lists = >{}; - - @override - Future readInt(String key) async => ints[key] ?? 0; - - @override - Future writeInt(String key, int value) async => ints[key] = value; - - @override - Future> readStringList(String key) async => - lists[key] ?? const []; - - @override - Future writeStringList(String key, List 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 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> 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( diff --git a/stronglift_replacement/workout_app/test/services/progression_sync_fields_test.dart b/stronglift_replacement/workout_app/test/services/progression_sync_fields_test.dart index 3a7ce59..c0cd65d 100644 --- a/stronglift_replacement/workout_app/test/services/progression_sync_fields_test.dart +++ b/stronglift_replacement/workout_app/test/services/progression_sync_fields_test.dart @@ -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), + ); + }, + ); } diff --git a/stronglift_replacement/workout_app/test/services/storage_service_restore_test.dart b/stronglift_replacement/workout_app/test/services/storage_service_restore_test.dart index b95feb1..480ff3a 100644 --- a/stronglift_replacement/workout_app/test/services/storage_service_restore_test.dart +++ b/stronglift_replacement/workout_app/test/services/storage_service_restore_test.dart @@ -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); }); diff --git a/stronglift_replacement/workout_app/test/widgets/exercise_settings_sheet_test.dart b/stronglift_replacement/workout_app/test/widgets/exercise_settings_sheet_test.dart index 6ef27ad..98badb0 100644 --- a/stronglift_replacement/workout_app/test/widgets/exercise_settings_sheet_test.dart +++ b/stronglift_replacement/workout_app/test/widgets/exercise_settings_sheet_test.dart @@ -17,7 +17,10 @@ Future> _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 { diff --git a/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart b/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart index 5dffdaf..d30cf95 100644 --- a/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart +++ b/stronglift_replacement/workout_app/test/widgets/exercise_tile_test.dart @@ -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); });