Split workout_screen.dart (633) into seven part files

The largest lib/ file on the 250-line list and the one never attempted. It
has no private widget classes to lift out (pattern A did not apply), so the
cut is by responsibility:

  workout_screen.dart        250  state fields, lifecycle, setState methods
  workout_screen_session.dart 182  save/restore, break maths, threshold write
  workout_screen_finish.dart  110  the write-everything finish step
  workout_screen_body.dart    105  exercise list + break banner
  workout_screen_dialogs.dart  73  finish/reset confirmations
  workout_screen_appbar.dart   68  clock + Reset/Finish actions
  workout_screen_breaks.dart   67  rest timers, threshold edits

Two constraints shaped it. `setState` is `@protected` and unreachable from an
extension, so methods that call it either stay in the class or split into a
class-side wrapper plus a setState-free body — that is why `_finishWorkout`
keeps only its three lines and delegates to `_persistFinishedWorkout`. The
breaks extension needs real state writes, so the class exposes one named
`_applyBreakState` shim rather than scattering the seam.

The extensions are named `_Foo`, not `Foo`: extending the private
`_WorkoutScreenState` from a public extension trips
`library_private_types_in_public_api`.

Coverage caught one real regression mid-way. Restructuring the threshold
update as a map-returning helper with an `if (s == null) return` guard added
a branch no test exercises and dropped the gate to 2753/2754. Rewritten as
`_writeThresholds`, mutating in place with the original `if (s != null)`
shape, so the extraction stays a pure move.

dart analyze clean; 402 tests pass with a byte-identical test-name set
(captured via --reporter json and diffed, since a count alone cannot tell a
lost test from a duplicated one); flutter coverage back to 100%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 22:02:53 +02:00
parent 99302c2be7
commit 78db299086
7 changed files with 656 additions and 433 deletions

View File

@ -20,6 +20,13 @@ import 'package:workout_app/widgets/break_banner.dart';
import 'package:workout_app/widgets/exercise_tile.dart';
import 'package:workout_app/widgets/workout_summary_dialog.dart';
part 'workout_screen_appbar.dart';
part 'workout_screen_body.dart';
part 'workout_screen_breaks.dart';
part 'workout_screen_dialogs.dart';
part 'workout_screen_finish.dart';
part 'workout_screen_session.dart';
const _successBreakSecs = 180; // 3 min after successful set
const _failBreakSecs = 300; // 5 min after failed set
const _warmupBreakSecs = 180; // 3 min after warmup
@ -100,33 +107,6 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
_warmupTapped = List.filled(widget.exercises.length, false);
}
void _restoreFromSaved(Map<String, dynamic> s) {
_startTime = DateTime.fromMillisecondsSinceEpoch(s['startTimeMs'] as int);
_tapped = (s['tapped'] as List)
.map((row) => (row as List).cast<bool>())
.toList();
_doneReps = (s['doneReps'] as List)
.map((row) => (row as List).cast<int>())
.toList();
_warmupTapped = (s['warmupTapped'] as List).cast<bool>();
final breakEndMs = s['breakEndMs'] as int? ?? 0;
final breakDur = s['breakDurationSecs'] as int? ?? 0;
if (breakEndMs > 0 && breakDur > 0) {
final endTime = DateTime.fromMillisecondsSinceEpoch(breakEndMs);
final remaining = endTime.difference(DateTime.now()).inSeconds;
if (remaining > 0) {
_breakForExIdx = s['breakForExIdx'] as int? ?? -1;
_breakForSetIdx = s['breakForSetIdx'] as int? ?? -1;
_breakLabel = s['breakLabel'] as String? ?? 'Rest';
_breakDurationSecs = breakDur;
_breakStartTime = endTime.subtract(Duration(seconds: breakDur));
_breakRemaining = remaining;
_breakTimer = Timer.periodic(const Duration(seconds: 1), _tickBreak);
}
}
}
Future<void> _loadExerciseStates() async {
final states = await StorageService.instance.getAllExerciseStates();
if (mounted) {
@ -150,67 +130,10 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
/// be ordered after it instead of racing it.
Future<void> _lastActiveSessionPush = Future.value();
/// Persists the active session locally, and to Firebase when [toFirebase].
///
/// [toFirebase] is false on the per-tap paths (rep decrements, break
/// bookkeeping) and true only on set/warmup completion. `saveActiveSession`
/// runs on every tap, and a Firebase write per tap is exactly the traffic the
/// sync revision cache exists to avoid — so the remote copy is debounced to
/// the events that actually change which set the user is standing on.
Future<void> _saveActiveSession({bool toFirebase = false}) async {
final data = _activeSessionData();
await StorageService.instance.saveActiveSession(data);
if (!toFirebase) return;
_lastActiveSessionPush = ProgressionSyncService()
.pushActiveSession(data)
.then((result) {
if (!result.changed) {
debugPrint(
'WorkoutScreen: active session not shared — ${result.reason}',
);
}
});
await _lastActiveSessionPush;
}
Map<String, dynamic> _activeSessionData() {
return {
'workoutType': widget.workoutType,
'startTimeMs': _startTime.millisecondsSinceEpoch,
'tapped': _tapped,
'doneReps': _doneReps,
'warmupTapped': _warmupTapped,
'breakForExIdx': _breakForExIdx,
'breakForSetIdx': _breakForSetIdx,
'breakLabel': _breakLabel,
'breakDurationSecs': _breakDurationSecs,
'breakEndMs': _breakStartTime != null
? _breakStartTime!
.add(Duration(seconds: _breakDurationSecs))
.millisecondsSinceEpoch
: 0,
};
}
// ── Helpers ────────────────────────────────────────────────────────────────
String _formatDuration(Duration d) {
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '${d.inHours > 0 ? '${d.inHours}:' : ''}$m:$s';
}
bool get _allSetsCompleted => _tapped.every((row) => row.every((t) => t));
/// True when [setIdx] is the last untapped set of exercise [exIdx].
bool _isLastSetOfExercise(int exIdx, int setIdx) {
final sets = widget.exercises[exIdx].sets;
for (var s = 0; s < sets; s++) {
if (s != setIdx && !_tapped[exIdx][s]) return false;
}
return true;
}
// ── Interaction ────────────────────────────────────────────────────────────
void _tapCircle(int exIdx, int setIdx) {
@ -229,18 +152,9 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
});
if (wasNotTapped) {
final isLastSet = _isLastSetOfExercise(exIdx, setIdx);
if (!isLastSet) {
final succeeded =
_doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps;
_startBreak(
succeeded ? _successBreakSecs : _failBreakSecs,
succeeded
? 'Rest (3 min — well done!)'
: 'Rest (5 min — keep going!)',
exIdx,
setIdx,
);
final rest = _restAfterSet(exIdx, setIdx);
if (rest != null) {
_startBreak(rest.seconds, rest.label, exIdx, setIdx);
}
}
@ -270,362 +184,59 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
unawaited(_saveActiveSession());
}
// ── Break management ───────────────────────────────────────────────────────
void _startBreak(int secs, String label, int exIdx, int setIdx) {
_breakTimer?.cancel();
setState(() {
_breakDurationSecs = secs;
_breakRemaining = secs;
_breakLabel = label;
_breakForExIdx = exIdx;
_breakForSetIdx = setIdx;
_breakStartTime = DateTime.now();
});
_breakTimer = Timer.periodic(const Duration(seconds: 1), _tickBreak);
}
void _tickBreak(Timer t) {
setState(() => _breakRemaining--);
if (_breakRemaining <= 0) {
t.cancel();
unawaited(_onBreakFinished());
}
}
void _cancelBreak() {
_breakTimer?.cancel();
setState(() {
_breakRemaining = 0;
_breakForExIdx = -1;
_breakForSetIdx = -1;
_breakStartTime = null;
});
}
void _skipBreak() {
_cancelBreak();
unawaited(_saveActiveSession());
}
/// When the user decrements reps on the set that triggered the current break,
/// switch between 3-min (success) and 5-min (fail) durations.
void _recomputeBreakIfNeeded(int exIdx, int setIdx) {
if (!_inBreak) return;
if (_breakForExIdx != exIdx || _breakForSetIdx != setIdx) return;
if (_breakForSetIdx == -1) return; // warmup break, never recompute
final succeeded = _doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps;
final newDuration = succeeded ? _successBreakSecs : _failBreakSecs;
if (newDuration == _breakDurationSecs) return;
final elapsed = DateTime.now().difference(_breakStartTime!).inSeconds;
final newRemaining = (newDuration - elapsed).clamp(0, newDuration);
_breakDurationSecs = newDuration;
_breakRemaining = newRemaining;
_breakLabel = succeeded
? 'Rest (3 min — well done!)'
: 'Rest (5 min — keep going!)';
}
Future<void> _onBreakFinished() async {
await _audio
.play(AssetSource('sounds/break_end.mp3'))
.catchError((Object error) {
// Never fatal: a missing audio route must not interrupt the workout.
// But it is the break-end cue, so a silent failure looks like the
// timer itself is broken.
debugPrint('WorkoutApp: break-end sound failed to play ($error).');
});
if (await Vibration.hasVibrator()) {
// Android/iOS-only: hasVibrator() returns false on the Linux test host
// (no Platform.isAndroid/isIOS), so this body never runs there.
// coverage:ignore-start
unawaited(Vibration.vibrate(duration: 800));
// coverage:ignore-end
}
setState(() {
_breakForExIdx = -1;
_breakForSetIdx = -1;
_breakStartTime = null;
});
unawaited(_saveActiveSession());
}
Future<void> _onThresholdChanged(
String name,
int success,
int fail,
) async {
await StorageService.instance.setExerciseThresholds(
name,
successThreshold: success,
failThreshold: fail,
);
if (mounted) {
setState(() {
final s = _exerciseStates[name];
if (s != null) {
_exerciseStates[name] = ExerciseState(
name: s.name,
weight: s.weight,
reps: s.reps,
successStreak: s.successStreak,
failStreak: s.failStreak,
maxWeight: s.maxWeight,
successThreshold: success,
failThreshold: fail,
);
}
});
}
}
/// Runs [fn] inside `setState` on behalf of the break/threshold extensions.
///
/// `setState` is `@protected`, so an extension cannot call it directly. This
/// shim is the one seam through which `workout_screen_breaks.dart` mutates
/// state; keeping it named makes those writes greppable from here.
void _applyBreakState(VoidCallback fn) => setState(fn);
// ── Finish / Reset ─────────────────────────────────────────────────────────
Future<void> _confirmFinish() async {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Finish workout?',
style: TextStyle(color: colorScheme.onSurface),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(
'Cancel',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Finish', style: TextStyle(color: status.success)),
),
],
),
);
if (ok == true) await _finishWorkout();
}
Future<void> _confirmReset() async {
final colorScheme = Theme.of(context).colorScheme;
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Reset workout?',
style: TextStyle(color: colorScheme.onSurface),
),
content: Text(
'All progress will be lost.',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(
'Cancel',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Reset', style: TextStyle(color: colorScheme.error)),
),
],
),
);
if (ok == true) {
await StorageService.instance.clearActiveSession();
if (mounted) Navigator.of(context).pop();
}
}
/// Stops the timers, marks the workout finished, and persists everything.
///
/// Only the `setState` lives here — `setState` is `@protected` and cannot be
/// called from an extension, so the rest is in [_persistFinishedWorkout].
Future<void> _finishWorkout() async {
_elapsedTimer.cancel();
_breakTimer?.cancel();
setState(() => _finished = true);
final endTime = DateTime.now();
final results = <ExerciseResult>[];
for (var i = 0; i < widget.exercises.length; i++) {
final ex = widget.exercises[i];
results.add(
ExerciseResult(
exercise: ex,
warmupDone: _warmupTapped[i],
sets: List.generate(
ex.sets,
(s) => SetResult(
targetReps: ex.reps,
doneReps: _tapped[i][s] ? _doneReps[i][s] : 0,
weight: ex.weight,
),
),
),
);
}
final session = WorkoutSession(
workoutType: widget.workoutType,
startTime: _startTime,
endTime: endTime,
exercises: results,
);
final storage = StorageService.instance;
await storage.saveSession(
date: _startTime.toIso8601String().substring(0, 10),
workoutType: widget.workoutType,
durationSeconds: session.duration.inSeconds,
succeeded: session.fullySucceeded,
json: session.toJsonString(),
);
final lastDate = await storage.getLastWorkoutDate() ?? _startTime;
await storage.applyProgression(
succeededExercises: {
for (int i = 0; i < widget.exercises.length; i++)
widget.exercises[i].name: results[i].succeeded,
},
lastWorkoutDate: lastDate,
);
await storage.setLastWorkoutType(widget.workoutType);
await storage.clearActiveSession();
// applyProgression just moved weights/reps/streaks, so this is the moment
// the remote copy goes stale. Pushed here (not on every set) because a
// finished workout is the only thing that changes progression.
unawaited(
ProgressionSyncService().pushProgression().then((result) {
if (!result.changed) {
log('Progression not synced: ${result.reason}', level: 1000);
}
}),
);
// The workout is over: retract the shared in-progress session so another
// device cannot resume a session that no longer exists.
//
// Chained onto the in-flight publish rather than fired alongside it: the
// last set's `_saveActiveSession(toFirebase: true)` is unawaited, so two
// concurrent PUTs to the same path can land out of order and strand a
// finished session that the next install would faithfully restore.
unawaited(
_lastActiveSessionPush.then(
(_) => ProgressionSyncService().pushActiveSession(null),
),
);
final syncResult = await _sync.writeWorkoutResult(session);
// Not awaited: a slow or unreachable backend must not delay the summary
// dialog. But the result is no longer discarded -- a failed push logs at
// error level inside push(), so an unpushed workout is diagnosable
// instead of silently absent from every other device.
unawaited(
WorkoutSyncService().push(session).then((result) {
if (!result.pushed) {
log('Workout not synced: ${result.reason}', level: 1000);
}
}),
);
if (!mounted) return;
unawaited(
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => WorkoutSummaryDialog(
session: session,
syncResult: syncResult,
),
),
);
await _persistFinishedWorkout();
}
// ── Build ────────────────────────────────────────────────────────────────
// ── Build ──────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
return PopScope(
// Explicit `canPop: true` makes it clear this scope never blocks the back
// button — a future reader must not assume the default silently.
// ignore: avoid_redundant_argument_values
canPop: true,
child: Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Workout ${widget.workoutType} · ${_formatDuration(_elapsed)}',
style: TextStyle(color: colorScheme.onSurface),
),
actions: [
if (!_finished)
TextButton(
onPressed: _confirmReset,
child: Text(
'Reset',
style: TextStyle(color: colorScheme.error),
),
),
if (!_finished)
TextButton(
onPressed: _allSetsCompleted ? _confirmFinish : null,
child: Text(
'Finish',
style: TextStyle(
color: _allSetsCompleted
? status.success
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
],
appBar: _WorkoutAppBar(
title:
'Workout ${widget.workoutType} · ${_formatDuration(_elapsed)}',
finished: _finished,
allSetsCompleted: _allSetsCompleted,
onReset: () => unawaited(_confirmReset()),
onFinish: () => unawaited(_confirmFinish()),
),
body: Column(
children: [
if (_inBreak)
BreakBanner(
breakRemaining: _breakRemaining,
breakLabel: _breakLabel,
onSkip: _skipBreak,
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: widget.exercises.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (_, i) {
final exName = widget.exercises[i].name;
final state = _exerciseStates[exName];
return ExerciseTile(
exercise: widget.exercises[i],
tapped: _tapped[i],
doneReps: _doneReps[i],
warmupTapped: _warmupTapped[i],
successThreshold: state?.successThreshold ?? 3,
failThreshold: state?.failThreshold ?? 2,
onTapCircle: (s) => _tapCircle(i, s),
onLongPressCircle: (s) => _resetCircle(i, s),
onTapWarmup: () => _tapWarmup(i),
onThresholdChanged: (success, fail) =>
_onThresholdChanged(exName, success, fail),
);
},
),
),
],
body: _WorkoutBody(
exercises: widget.exercises,
exerciseStates: _exerciseStates,
tapped: _tapped,
doneReps: _doneReps,
warmupTapped: _warmupTapped,
inBreak: _inBreak,
breakRemaining: _breakRemaining,
breakLabel: _breakLabel,
onSkipBreak: _skipBreak,
onTapCircle: _tapCircle,
onLongPressCircle: _resetCircle,
onTapWarmup: _tapWarmup,
onThresholdChanged: (name, success, fail) =>
unawaited(_onThresholdChanged(name, success, fail)),
),
),
);

View File

@ -0,0 +1,68 @@
// The active-workout app bar: elapsed clock plus Reset / Finish actions.
//
// See workout_screen_session.dart for why this is a `part`.
part of 'workout_screen.dart';
/// App bar for [WorkoutScreen], showing the running clock and the two
/// destructive actions.
///
/// A widget rather than a method so the state class stays under the file-length
/// cap; it reads only what it is given and never calls `setState` itself.
class _WorkoutAppBar extends StatelessWidget implements PreferredSizeWidget {
const _WorkoutAppBar({
required this.title,
required this.finished,
required this.allSetsCompleted,
required this.onReset,
required this.onFinish,
});
/// Full app-bar title, already including the formatted elapsed time.
final String title;
/// Whether the workout is over — hides both actions once true.
final bool finished;
/// Whether every set is tapped; Finish stays disabled until it is.
final bool allSetsCompleted;
/// Invoked when the user taps Reset.
final VoidCallback onReset;
/// Invoked when the user taps Finish.
final VoidCallback onFinish;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
return AppBar(
automaticallyImplyLeading: false,
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(title, style: TextStyle(color: colorScheme.onSurface)),
actions: [
if (!finished)
TextButton(
onPressed: onReset,
child: Text('Reset', style: TextStyle(color: colorScheme.error)),
),
if (!finished)
TextButton(
onPressed: allSetsCompleted ? onFinish : null,
child: Text(
'Finish',
style: TextStyle(
color: allSetsCompleted
? status.success
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
],
);
}
}

View File

@ -0,0 +1,105 @@
// The active-workout body: optional break banner over the exercise list.
//
// See workout_screen_session.dart for why this is a `part`.
part of 'workout_screen.dart';
/// Scrolling exercise list for [WorkoutScreen], with the rest banner above it.
///
/// A widget rather than a method so the state class stays under the
/// file-length cap. It holds no state of its own — every value and callback is
/// passed in, and the `setState` that drives them stays in the state class.
class _WorkoutBody extends StatelessWidget {
const _WorkoutBody({
required this.exercises,
required this.exerciseStates,
required this.tapped,
required this.doneReps,
required this.warmupTapped,
required this.inBreak,
required this.breakRemaining,
required this.breakLabel,
required this.onSkipBreak,
required this.onTapCircle,
required this.onLongPressCircle,
required this.onTapWarmup,
required this.onThresholdChanged,
});
/// The exercises in this session, in display order.
final List<Exercise> exercises;
/// Progression state per exercise name; a missing entry falls back to
/// the tile's default thresholds.
final Map<String, ExerciseState> exerciseStates;
/// Per-exercise, per-set completion flags.
final List<List<bool>> tapped;
/// Per-exercise, per-set completed rep counts.
final List<List<int>> doneReps;
/// Per-exercise warmup completion flags.
final List<bool> warmupTapped;
/// Whether a rest period is running; shows the banner when true.
final bool inBreak;
/// Seconds left in the current rest period.
final int breakRemaining;
/// Human-readable label for the current rest period.
final String breakLabel;
/// Invoked when the user skips the rest period.
final VoidCallback onSkipBreak;
/// Invoked with (exerciseIndex, setIndex) when a set circle is tapped.
final void Function(int exIdx, int setIdx) onTapCircle;
/// Invoked with (exerciseIndex, setIndex) when a set circle is long-pressed.
final void Function(int exIdx, int setIdx) onLongPressCircle;
/// Invoked with the exercise index when its warmup is tapped.
final void Function(int exIdx) onTapWarmup;
/// Invoked with (exerciseName, successThreshold, failThreshold) on edit.
final void Function(String name, int success, int fail) onThresholdChanged;
@override
Widget build(BuildContext context) {
return Column(
children: [
if (inBreak)
BreakBanner(
breakRemaining: breakRemaining,
breakLabel: breakLabel,
onSkip: onSkipBreak,
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: exercises.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (_, i) {
final exName = exercises[i].name;
final state = exerciseStates[exName];
return ExerciseTile(
exercise: exercises[i],
tapped: tapped[i],
doneReps: doneReps[i],
warmupTapped: warmupTapped[i],
successThreshold: state?.successThreshold ?? 3,
failThreshold: state?.failThreshold ?? 2,
onTapCircle: (s) => onTapCircle(i, s),
onLongPressCircle: (s) => onLongPressCircle(i, s),
onTapWarmup: () => onTapWarmup(i),
onThresholdChanged: (success, fail) =>
onThresholdChanged(exName, success, fail),
);
},
),
),
],
);
}
}

View File

@ -0,0 +1,67 @@
// Rest-period timers and per-exercise threshold edits.
//
// See workout_screen_session.dart for why this is a `part`. `setState` is
// `@protected` and unreachable from an extension, so these mutate through the
// state class's `_applyBreakState` shim instead.
part of 'workout_screen.dart';
/// Starting, ticking, cancelling and finishing the rest period between sets.
extension _WorkoutScreenBreaks on _WorkoutScreenState {
void _startBreak(int secs, String label, int exIdx, int setIdx) {
_breakTimer?.cancel();
_applyBreakState(() {
_breakDurationSecs = secs;
_breakRemaining = secs;
_breakLabel = label;
_breakForExIdx = exIdx;
_breakForSetIdx = setIdx;
_breakStartTime = DateTime.now();
});
_breakTimer = Timer.periodic(const Duration(seconds: 1), _tickBreak);
}
void _tickBreak(Timer t) {
_applyBreakState(() => _breakRemaining--);
if (_breakRemaining <= 0) {
t.cancel();
unawaited(_onBreakFinished());
}
}
void _cancelBreak() {
_breakTimer?.cancel();
_applyBreakState(() {
_breakRemaining = 0;
_breakForExIdx = -1;
_breakForSetIdx = -1;
_breakStartTime = null;
});
}
Future<void> _onBreakFinished() async {
await _playBreakEndCue();
_applyBreakState(() {
_breakForExIdx = -1;
_breakForSetIdx = -1;
_breakStartTime = null;
});
unawaited(_saveActiveSession());
}
Future<void> _onThresholdChanged(
String name,
int success,
int fail,
) async {
await StorageService.instance.setExerciseThresholds(
name,
successThreshold: success,
failThreshold: fail,
);
if (mounted) {
_applyBreakState(() {
_writeThresholds(name, success, fail);
});
}
}
}

View File

@ -0,0 +1,73 @@
// Confirmation dialogs for finishing and resetting an active workout.
//
// See workout_screen_session.dart for why these are a `part`.
part of 'workout_screen.dart';
/// The destructive-action confirmations shown from the workout app bar.
extension _WorkoutScreenDialogs on _WorkoutScreenState {
/// Asks for confirmation, then finishes the workout.
Future<void> _confirmFinish() async {
final colorScheme = Theme.of(context).colorScheme;
final status = Theme.of(context).extension<AppStatusColors>()!;
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Finish workout?',
style: TextStyle(color: colorScheme.onSurface),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(
'Cancel',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Finish', style: TextStyle(color: status.success)),
),
],
),
);
if (ok == true) await _finishWorkout();
}
/// Asks for confirmation, then discards the session and leaves the screen.
Future<void> _confirmReset() async {
final colorScheme = Theme.of(context).colorScheme;
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
backgroundColor: colorScheme.surfaceContainerHigh,
title: Text(
'Reset workout?',
style: TextStyle(color: colorScheme.onSurface),
),
content: Text(
'All progress will be lost.',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(
'Cancel',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Reset', style: TextStyle(color: colorScheme.error)),
),
],
),
);
if (ok == true) {
await StorageService.instance.clearActiveSession();
if (mounted) Navigator.of(context).pop();
}
}
}

View File

@ -0,0 +1,110 @@
// Finishing a workout: persisting the session, progression, and the summary.
//
// See workout_screen_session.dart for why this is a `part`.
part of 'workout_screen.dart';
/// The write-everything step that runs once a workout is marked finished.
extension _WorkoutScreenFinish on _WorkoutScreenState {
/// Writes the finished session, applies progression, and shows the summary.
///
/// Called by [_WorkoutScreenState._finishWorkout], which owns the `setState`
/// that marks the workout finished before this runs.
Future<void> _persistFinishedWorkout() async {
final endTime = DateTime.now();
final results = <ExerciseResult>[];
for (var i = 0; i < widget.exercises.length; i++) {
final ex = widget.exercises[i];
results.add(
ExerciseResult(
exercise: ex,
warmupDone: _warmupTapped[i],
sets: List.generate(
ex.sets,
(s) => SetResult(
targetReps: ex.reps,
doneReps: _tapped[i][s] ? _doneReps[i][s] : 0,
weight: ex.weight,
),
),
),
);
}
final session = WorkoutSession(
workoutType: widget.workoutType,
startTime: _startTime,
endTime: endTime,
exercises: results,
);
final storage = StorageService.instance;
await storage.saveSession(
date: _startTime.toIso8601String().substring(0, 10),
workoutType: widget.workoutType,
durationSeconds: session.duration.inSeconds,
succeeded: session.fullySucceeded,
json: session.toJsonString(),
);
final lastDate = await storage.getLastWorkoutDate() ?? _startTime;
await storage.applyProgression(
succeededExercises: {
for (int i = 0; i < widget.exercises.length; i++)
widget.exercises[i].name: results[i].succeeded,
},
lastWorkoutDate: lastDate,
);
await storage.setLastWorkoutType(widget.workoutType);
await storage.clearActiveSession();
// applyProgression just moved weights/reps/streaks, so this is the moment
// the remote copy goes stale. Pushed here (not on every set) because a
// finished workout is the only thing that changes progression.
unawaited(
ProgressionSyncService().pushProgression().then((result) {
if (!result.changed) {
log('Progression not synced: ${result.reason}', level: 1000);
}
}),
);
// The workout is over: retract the shared in-progress session so another
// device cannot resume a session that no longer exists.
//
// Chained onto the in-flight publish rather than fired alongside it: the
// last set's `_saveActiveSession(toFirebase: true)` is unawaited, so two
// concurrent PUTs to the same path can land out of order and strand a
// finished session that the next install would faithfully restore.
unawaited(
_lastActiveSessionPush.then(
(_) => ProgressionSyncService().pushActiveSession(null),
),
);
final syncResult = await _sync.writeWorkoutResult(session);
// Not awaited: a slow or unreachable backend must not delay the summary
// dialog. But the result is no longer discarded -- a failed push logs at
// error level inside push(), so an unpushed workout is diagnosable
// instead of silently absent from every other device.
unawaited(
WorkoutSyncService().push(session).then((result) {
if (!result.pushed) {
log('Workout not synced: ${result.reason}', level: 1000);
}
}),
);
if (!mounted) return;
unawaited(
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => WorkoutSummaryDialog(
session: session,
syncResult: syncResult,
),
),
);
}
}

View File

@ -0,0 +1,189 @@
// Session persistence and restore for the active workout screen.
//
// A `part` rather than a separate library so the extension keeps reaching the
// private `_WorkoutScreenState` fields. Everything here is deliberately
// setState-free: `setState` is `@protected` and cannot be called from an
// extension, so the state-mutating wrappers stay in the class.
part of 'workout_screen.dart';
/// Save/restore of the crash-recovery session blob and break bookkeeping.
extension _WorkoutScreenSession on _WorkoutScreenState {
/// Rebuilds in-memory session state from a persisted [s] blob.
///
/// Restores a break only when its recorded end time is still in the future,
/// so a session resumed after the rest period simply has no break running.
void _restoreFromSaved(Map<String, dynamic> s) {
_startTime = DateTime.fromMillisecondsSinceEpoch(s['startTimeMs'] as int);
_tapped = (s['tapped'] as List)
.map((row) => (row as List).cast<bool>())
.toList();
_doneReps = (s['doneReps'] as List)
.map((row) => (row as List).cast<int>())
.toList();
_warmupTapped = (s['warmupTapped'] as List).cast<bool>();
final breakEndMs = s['breakEndMs'] as int? ?? 0;
final breakDur = s['breakDurationSecs'] as int? ?? 0;
if (breakEndMs > 0 && breakDur > 0) {
final endTime = DateTime.fromMillisecondsSinceEpoch(breakEndMs);
final remaining = endTime.difference(DateTime.now()).inSeconds;
if (remaining > 0) {
_breakForExIdx = s['breakForExIdx'] as int? ?? -1;
_breakForSetIdx = s['breakForSetIdx'] as int? ?? -1;
_breakLabel = s['breakLabel'] as String? ?? 'Rest';
_breakDurationSecs = breakDur;
_breakStartTime = endTime.subtract(Duration(seconds: breakDur));
_breakRemaining = remaining;
_breakTimer = Timer.periodic(const Duration(seconds: 1), _tickBreak);
}
}
}
/// Persists the active session locally, and to Firebase when [toFirebase].
///
/// [toFirebase] is false on the per-tap paths (rep decrements, break
/// bookkeeping) and true only on set/warmup completion. `saveActiveSession`
/// runs on every tap, and a Firebase write per tap is exactly the traffic the
/// sync revision cache exists to avoid — so the remote copy is debounced to
/// the events that actually change which set the user is standing on.
Future<void> _saveActiveSession({bool toFirebase = false}) async {
final data = _activeSessionData();
await StorageService.instance.saveActiveSession(data);
if (!toFirebase) return;
_lastActiveSessionPush = ProgressionSyncService()
.pushActiveSession(data)
.then((result) {
if (!result.changed) {
debugPrint(
'WorkoutScreen: active session not shared — ${result.reason}',
);
}
});
await _lastActiveSessionPush;
}
/// The serializable snapshot of the in-progress workout.
Map<String, dynamic> _activeSessionData() {
return {
'workoutType': widget.workoutType,
'startTimeMs': _startTime.millisecondsSinceEpoch,
'tapped': _tapped,
'doneReps': _doneReps,
'warmupTapped': _warmupTapped,
'breakForExIdx': _breakForExIdx,
'breakForSetIdx': _breakForSetIdx,
'breakLabel': _breakLabel,
'breakDurationSecs': _breakDurationSecs,
'breakEndMs': _breakStartTime != null
? _breakStartTime!
.add(Duration(seconds: _breakDurationSecs))
.millisecondsSinceEpoch
: 0,
};
}
/// When the user decrements reps on the set that triggered the current break,
/// switch between 3-min (success) and 5-min (fail) durations.
void _recomputeBreakIfNeeded(int exIdx, int setIdx) {
if (!_inBreak) return;
if (_breakForExIdx != exIdx || _breakForSetIdx != setIdx) return;
if (_breakForSetIdx == -1) return; // warmup break, never recompute
final succeeded = _doneReps[exIdx][setIdx] >= widget.exercises[exIdx].reps;
final newDuration = succeeded ? _successBreakSecs : _failBreakSecs;
if (newDuration == _breakDurationSecs) return;
final elapsed = DateTime.now().difference(_breakStartTime!).inSeconds;
final newRemaining = (newDuration - elapsed).clamp(0, newDuration);
_breakDurationSecs = newDuration;
_breakRemaining = newRemaining;
_breakLabel = succeeded
? 'Rest (3 min — well done!)'
: 'Rest (5 min — keep going!)';
}
/// True when [setIdx] is the last untapped set of exercise [exIdx].
bool _isLastSetOfExercise(int exIdx, int setIdx) {
final sets = widget.exercises[exIdx].sets;
for (var s = 0; s < sets; s++) {
if (s != setIdx && !_tapped[exIdx][s]) return false;
}
return true;
}
/// The rest period earned by just completing set [setIdx] of [exIdx].
///
/// Null on the exercise's final set — the user moves straight on rather than
/// resting inside an exercise they have finished.
_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 (3 min — well done!)')
: const _Rest(_failBreakSecs, 'Rest (5 min — keep going!)');
}
/// Plays the sound and haptic that tell the user the rest period is over.
Future<void> _playBreakEndCue() async {
await _audio
.play(AssetSource('sounds/break_end.mp3'))
.catchError((Object error) {
// Never fatal: a missing audio route must not interrupt the workout.
// But it is the break-end cue, so a silent failure looks like the
// timer itself is broken.
debugPrint('WorkoutApp: break-end sound failed to play ($error).');
});
if (await Vibration.hasVibrator()) {
// Android/iOS-only: hasVibrator() returns false on the Linux test host
// (no Platform.isAndroid/isIOS), so this body never runs there.
// coverage:ignore-start
unawaited(Vibration.vibrate(duration: 800));
// coverage:ignore-end
}
}
/// Rewrites [name]'s thresholds in `_exerciseStates`, in place.
///
/// A no-op when the screen holds no state for [name], so editing an unloaded
/// exercise cannot insert a half-built entry.
void _writeThresholds(String name, int success, int fail) {
final s = _exerciseStates[name];
if (s != null) {
_exerciseStates[name] = ExerciseState(
name: s.name,
weight: s.weight,
reps: s.reps,
successStreak: s.successStreak,
failStreak: s.failStreak,
maxWeight: s.maxWeight,
successThreshold: success,
failThreshold: fail,
);
}
}
/// Ends the break early at the user's request and re-persists the session.
void _skipBreak() {
_cancelBreak();
unawaited(_saveActiveSession());
}
}
/// Formats [d] as `h:mm:ss`, dropping the hours part under an hour.
String _formatDuration(Duration d) {
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '${d.inHours > 0 ? '${d.inHours}:' : ''}$m:$s';
}
/// A rest period's duration and the label shown on the break banner.
class _Rest {
const _Rest(this.seconds, this.label);
/// How long the rest lasts.
final int seconds;
/// Banner text explaining why this rest length was chosen.
final String label;
}