mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:27:15 +01:00
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:
parent
99302c2be7
commit
78db299086
@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user