diff --git a/stronglift_replacement/workout_app/assets/sounds/break_end.mp3 b/stronglift_replacement/workout_app/assets/sounds/break_end.mp3 new file mode 100644 index 0000000..19034e9 Binary files /dev/null and b/stronglift_replacement/workout_app/assets/sounds/break_end.mp3 differ diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart index 812ebb6..e657934 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart @@ -1,18 +1,25 @@ -/// Active workout screen: warmup, back-button protection, +/// Active workout screen: per-rep breaks, warmup, back-button protection, /// and crash-safe session persistence. library; import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:vibration/vibration.dart'; import 'package:workout_app/models/exercise.dart'; import 'package:workout_app/models/exercise_result.dart'; import 'package:workout_app/models/set_result.dart'; import 'package:workout_app/models/workout_session.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/sync_service.dart'; +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'; +const _successBreakSecs = 180; // 3 min after successful rep +const _failBreakSecs = 300; // 5 min after failed rep +const _warmupBreakSecs = 180; // 3 min after warmup + class WorkoutScreen extends StatefulWidget { const WorkoutScreen({ super.key, @@ -39,6 +46,18 @@ class _WorkoutScreenState extends State { late Timer _elapsedTimer; Duration _elapsed = Duration.zero; + // Break state + int _breakRemaining = 0; + int _breakDurationSecs = 0; + DateTime? _breakStartTime; + Timer? _breakTimer; + String _breakLabel = ''; + int _breakForExIdx = -1; + int _breakForRepIdx = -1; // -1 = warmup break + + bool get _inBreak => _breakRemaining > 0; + + final _audio = AudioPlayer(); final _sync = SyncService(); bool _finished = false; @@ -78,11 +97,29 @@ class _WorkoutScreenState extends State { .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; + _breakForRepIdx = s['breakForRepIdx'] 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); + } + } } @override void dispose() { _elapsedTimer.cancel(); + _breakTimer?.cancel(); + _audio.dispose(); super.dispose(); } @@ -95,6 +132,15 @@ class _WorkoutScreenState extends State { 'tapped': _tapped, 'doneReps': _doneReps, 'warmupTapped': _warmupTapped, + 'breakForExIdx': _breakForExIdx, + 'breakForRepIdx': _breakForRepIdx, + 'breakLabel': _breakLabel, + 'breakDurationSecs': _breakDurationSecs, + 'breakEndMs': _breakStartTime != null + ? _breakStartTime! + .add(Duration(seconds: _breakDurationSecs)) + .millisecondsSinceEpoch + : 0, }); } @@ -108,33 +154,142 @@ class _WorkoutScreenState extends State { bool get _allSetsCompleted => _tapped.every((row) => row.every((t) => t)); + bool _isLastUntappedCircle(int exIdx, int repIdx) { + int remaining = 0; + for (int i = 0; i < widget.exercises.length; i++) { + for (int s = 0; s < widget.exercises[i].sets; s++) { + if (!_tapped[i][s]) remaining++; + } + } + return remaining == 1; + } + // ── Interaction ──────────────────────────────────────────────────────────── - void _tapCircle(int exIdx, int setIdx) { + void _tapCircle(int exIdx, int repIdx) { if (_finished) return; + + final wasNotTapped = !_tapped[exIdx][repIdx]; + if (wasNotTapped && _inBreak) return; + setState(() { - if (!_tapped[exIdx][setIdx]) { - _tapped[exIdx][setIdx] = true; + if (wasNotTapped) { + _tapped[exIdx][repIdx] = true; } else { // Subsequent taps decrement reps (records actual reps done). - _doneReps[exIdx][setIdx] = - (_doneReps[exIdx][setIdx] - 1).clamp(0, 999); + _doneReps[exIdx][repIdx] = + (_doneReps[exIdx][repIdx] - 1).clamp(0, 999); + _recomputeBreakIfNeeded(exIdx, repIdx); } }); + + if (wasNotTapped) { + final isLast = _isLastUntappedCircle(exIdx, repIdx); + if (!isLast) { + final succeeded = + _doneReps[exIdx][repIdx] >= widget.exercises[exIdx].reps; + _startBreak( + succeeded ? _successBreakSecs : _failBreakSecs, + succeeded + ? 'Rest (3 min — well done!)' + : 'Rest (5 min — keep going!)', + exIdx, + repIdx, + ); + } + } + _saveActiveSession(); } void _tapWarmup(int exIdx) { if (_finished || _warmupTapped[exIdx]) return; setState(() => _warmupTapped[exIdx] = true); + if (!_inBreak) { + _startBreak(_warmupBreakSecs, 'Warmup rest (3 min)', exIdx, -1); + } _saveActiveSession(); } - void _resetCircle(int exIdx, int setIdx) { + void _resetCircle(int exIdx, int repIdx) { if (_finished) return; setState(() { - _tapped[exIdx][setIdx] = false; - _doneReps[exIdx][setIdx] = widget.exercises[exIdx].reps; + _tapped[exIdx][repIdx] = false; + _doneReps[exIdx][repIdx] = widget.exercises[exIdx].reps; + }); + if (_breakForExIdx == exIdx && _breakForRepIdx == repIdx) { + _cancelBreak(); + } + _saveActiveSession(); + } + + // ── Break management ─────────────────────────────────────────────────────── + + void _startBreak(int secs, String label, int exIdx, int repIdx) { + _breakTimer?.cancel(); + setState(() { + _breakDurationSecs = secs; + _breakRemaining = secs; + _breakLabel = label; + _breakForExIdx = exIdx; + _breakForRepIdx = repIdx; + _breakStartTime = DateTime.now(); + }); + _breakTimer = Timer.periodic(const Duration(seconds: 1), _tickBreak); + } + + void _tickBreak(Timer t) { + setState(() => _breakRemaining--); + if (_breakRemaining <= 0) { + t.cancel(); + _onBreakFinished(); + } + } + + void _cancelBreak() { + _breakTimer?.cancel(); + setState(() { + _breakRemaining = 0; + _breakForExIdx = -1; + _breakForRepIdx = -1; + _breakStartTime = null; + }); + } + + void _skipBreak() { + _cancelBreak(); + _saveActiveSession(); + } + + /// If the user reduces reps on the rep that triggered the current break, + /// switch from 3-min to 5-min (or vice versa). + void _recomputeBreakIfNeeded(int exIdx, int repIdx) { + if (!_inBreak) return; + if (_breakForExIdx != exIdx || _breakForRepIdx != repIdx) return; + if (_breakForRepIdx == -1) return; // warmup break, never recompute + + final succeeded = + _doneReps[exIdx][repIdx] >= 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((_) {}); + final hasVibrator = await Vibration.hasVibrator() == true; + if (hasVibrator) Vibration.vibrate(duration: 800); + setState(() { + _breakForExIdx = -1; + _breakForRepIdx = -1; + _breakStartTime = null; }); _saveActiveSession(); } @@ -153,12 +308,15 @@ class _WorkoutScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel', style: TextStyle(color: Colors.white70)), + child: + const Text('Cancel', style: TextStyle(color: Colors.white70)), ), TextButton( onPressed: () => Navigator.pop(context, true), - child: - const Text('Finish', style: TextStyle(color: Colors.greenAccent)), + child: const Text( + 'Finish', + style: TextStyle(color: Colors.greenAccent), + ), ), ], ), @@ -201,6 +359,7 @@ class _WorkoutScreenState extends State { Future _finishWorkout() async { _elapsedTimer.cancel(); + _breakTimer?.cancel(); setState(() => _finished = true); final endTime = DateTime.now(); @@ -301,19 +460,31 @@ class _WorkoutScreenState extends State { ), ], ), - body: ListView.separated( - padding: const EdgeInsets.all(12), - itemCount: widget.exercises.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (_, i) => ExerciseTile( - exercise: widget.exercises[i], - tapped: _tapped[i], - doneReps: _doneReps[i], - warmupTapped: _warmupTapped[i], - onTapCircle: (s) => _tapCircle(i, s), - onLongPressCircle: (s) => _resetCircle(i, s), - onTapWarmup: () => _tapWarmup(i), - ), + 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) => ExerciseTile( + exercise: widget.exercises[i], + tapped: _tapped[i], + doneReps: _doneReps[i], + warmupTapped: _warmupTapped[i], + onTapCircle: (s) => _tapCircle(i, s), + onLongPressCircle: (s) => _resetCircle(i, s), + onTapWarmup: () => _tapWarmup(i), + ), + ), + ), + ], ), ), ); diff --git a/stronglift_replacement/workout_app/lib/widgets/break_banner.dart b/stronglift_replacement/workout_app/lib/widgets/break_banner.dart new file mode 100644 index 0000000..7185d8f --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/break_banner.dart @@ -0,0 +1,63 @@ +/// Countdown banner displayed at the top of the workout screen during a rest. +library; + +import 'package:flutter/material.dart'; + +class BreakBanner extends StatelessWidget { + const BreakBanner({ + super.key, + required this.breakRemaining, + required this.breakLabel, + required this.onSkip, + }); + + final int breakRemaining; + final String breakLabel; + final VoidCallback onSkip; + + String _fmt(int secs) { + final m = (secs ~/ 60).toString().padLeft(2, '0'); + final s = (secs % 60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.indigo.shade900, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + breakLabel, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + Text( + _fmt(breakRemaining), + style: const TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.bold, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + TextButton( + onPressed: onSkip, + child: const Text( + 'Skip', + style: TextStyle(color: Colors.white70), + ), + ), + ], + ), + ); + } +} diff --git a/stronglift_replacement/workout_app/pubspec.lock b/stronglift_replacement/workout_app/pubspec.lock index 1b0a0c0..58561ad 100644 --- a/stronglift_replacement/workout_app/pubspec.lock +++ b/stronglift_replacement/workout_app/pubspec.lock @@ -17,6 +17,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "1d0c1b1f2095e59080e2d5046639096417a86687d89778da41b0c9a06d683dfd" + url: "https://pub.dev" + source: hosted + version: "6.7.0" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 + url: "https://pub.dev" + source: hosted + version: "6.4.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "24a6f258062bd7da8cb2157e83fccb9816a08dd306cbaaa24f9813d071470545" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "95f875a96c88c3dbbcb608d4f8288e300b0113d256a81d0b3197fcc18f0dc91a" + url: "https://pub.dev" + source: hosted + version: "4.3.1" boolean_selector: dependency: transitive description: @@ -65,6 +121,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65" + url: "https://pub.dev" + source: hosted + version: "13.1.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46" + url: "https://pub.dev" + source: hosted + version: "8.1.0" fake_async: dependency: transitive description: @@ -81,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -89,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -120,6 +208,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" jni: dependency: transitive description: @@ -517,6 +621,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: @@ -525,6 +637,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + vibration: + dependency: "direct main" + description: + name: vibration + sha256: "9bb06614c69260f8bd11c80fe01ed7988905cf00e3417d656c2647e41f261d87" + url: "https://pub.dev" + source: hosted + version: "3.1.8" + vibration_platform_interface: + dependency: transitive + description: + name: vibration_platform_interface + sha256: "258c273268f8aa40c88d29741137c536874a738779b92ddb8aa32ed093721ec5" + url: "https://pub.dev" + source: hosted + version: "0.1.2" vm_service: dependency: transitive description: @@ -541,6 +669,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904" + url: "https://pub.dev" + source: hosted + version: "3.0.3" xdg_directories: dependency: transitive description: diff --git a/stronglift_replacement/workout_app/pubspec.yaml b/stronglift_replacement/workout_app/pubspec.yaml index 8fb3aab..07dcc94 100644 --- a/stronglift_replacement/workout_app/pubspec.yaml +++ b/stronglift_replacement/workout_app/pubspec.yaml @@ -14,6 +14,8 @@ dependencies: sqflite: ^2.4.2 path_provider: ^2.1.5 shared_preferences: ^2.5.3 + audioplayers: ^6.4.0 + vibration: ^3.1.0 permission_handler: ^12.0.0 dev_dependencies: @@ -23,3 +25,5 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - assets/sounds/break_end.mp3