diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart index 9c8a5b2..30d0f68 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart @@ -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 { _warmupTapped = List.filled(widget.exercises.length, false); } - void _restoreFromSaved(Map s) { - _startTime = DateTime.fromMillisecondsSinceEpoch(s['startTimeMs'] as int); - _tapped = (s['tapped'] as List) - .map((row) => (row as List).cast()) - .toList(); - _doneReps = (s['doneReps'] as List) - .map((row) => (row as List).cast()) - .toList(); - _warmupTapped = (s['warmupTapped'] as List).cast(); - - 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 _loadExerciseStates() async { final states = await StorageService.instance.getAllExerciseStates(); if (mounted) { @@ -150,67 +130,10 @@ class _WorkoutScreenState extends State { /// be ordered after it instead of racing it. Future _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 _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 _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 { }); 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 { 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 _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 _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 _confirmFinish() async { - final colorScheme = Theme.of(context).colorScheme; - final status = Theme.of(context).extension()!; - final ok = await showDialog( - 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 _confirmReset() async { - final colorScheme = Theme.of(context).colorScheme; - final ok = await showDialog( - 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 _finishWorkout() async { _elapsedTimer.cancel(); _breakTimer?.cancel(); setState(() => _finished = true); - - final endTime = DateTime.now(); - final results = []; - - 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( - 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()!; 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)), ), ), ); diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_appbar.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_appbar.dart new file mode 100644 index 0000000..bba7ed4 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_appbar.dart @@ -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()!; + 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, + ), + ), + ), + ], + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_body.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_body.dart new file mode 100644 index 0000000..58bff75 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_body.dart @@ -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 exercises; + + /// Progression state per exercise name; a missing entry falls back to + /// the tile's default thresholds. + final Map exerciseStates; + + /// Per-exercise, per-set completion flags. + final List> tapped; + + /// Per-exercise, per-set completed rep counts. + final List> doneReps; + + /// Per-exercise warmup completion flags. + final List 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), + ); + }, + ), + ), + ], + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_breaks.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_breaks.dart new file mode 100644 index 0000000..698797f --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_breaks.dart @@ -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 _onBreakFinished() async { + await _playBreakEndCue(); + _applyBreakState(() { + _breakForExIdx = -1; + _breakForSetIdx = -1; + _breakStartTime = null; + }); + unawaited(_saveActiveSession()); + } + + Future _onThresholdChanged( + String name, + int success, + int fail, + ) async { + await StorageService.instance.setExerciseThresholds( + name, + successThreshold: success, + failThreshold: fail, + ); + if (mounted) { + _applyBreakState(() { + _writeThresholds(name, success, fail); + }); + } + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_dialogs.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_dialogs.dart new file mode 100644 index 0000000..33bee3c --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_dialogs.dart @@ -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 _confirmFinish() async { + final colorScheme = Theme.of(context).colorScheme; + final status = Theme.of(context).extension()!; + final ok = await showDialog( + 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 _confirmReset() async { + final colorScheme = Theme.of(context).colorScheme; + final ok = await showDialog( + 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(); + } + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_finish.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_finish.dart new file mode 100644 index 0000000..7068dca --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_finish.dart @@ -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 _persistFinishedWorkout() async { + final endTime = DateTime.now(); + final results = []; + + 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( + context: context, + barrierDismissible: false, + builder: (_) => WorkoutSummaryDialog( + session: session, + syncResult: syncResult, + ), + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart new file mode 100644 index 0000000..8884824 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_session.dart @@ -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 s) { + _startTime = DateTime.fromMillisecondsSinceEpoch(s['startTimeMs'] as int); + _tapped = (s['tapped'] as List) + .map((row) => (row as List).cast()) + .toList(); + _doneReps = (s['doneReps'] as List) + .map((row) => (row as List).cast()) + .toList(); + _warmupTapped = (s['warmupTapped'] as List).cast(); + + 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 _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 _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 _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; +}