mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
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:
parent
38b56e1e2b
commit
d35edfc10c
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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')}';
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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();
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user