From 735f900bf87e0d966d83cd280be664488e913e9d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 31 May 2026 16:32:37 +0200 Subject: [PATCH] fix: restore per-rep breaks with audio/vibration Breaks belong after each rep (circle tap), not removed entirely. Restored break_banner, audioplayers, vibration, and break_end.mp3 asset. Break triggers on every circle tap except the last one; 3 min on success, 5 min on failure; sound + vibration fires when the countdown ends. Co-Authored-By: Claude Sonnet 4.6 --- .../workout_app/assets/sounds/break_end.mp3 | Bin 0 -> 8586 bytes .../lib/screens/workout_screen.dart | 221 ++++++++++++++++-- .../workout_app/lib/widgets/break_banner.dart | 63 +++++ .../workout_app/pubspec.lock | 144 ++++++++++++ .../workout_app/pubspec.yaml | 4 + 5 files changed, 407 insertions(+), 25 deletions(-) create mode 100644 stronglift_replacement/workout_app/assets/sounds/break_end.mp3 create mode 100644 stronglift_replacement/workout_app/lib/widgets/break_banner.dart 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 0000000000000000000000000000000000000000..19034e9e80f7b258166a5348842757123da11b37 GIT binary patch literal 8586 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 literal 0 HcmV?d00001 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