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 <noreply@anthropic.com>
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-31 16:32:37 +02:00
parent da269c537a
commit 735f900bf8
5 changed files with 407 additions and 25 deletions

View File

@ -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. /// and crash-safe session persistence.
library; library;
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; 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.dart';
import 'package:workout_app/models/exercise_result.dart'; import 'package:workout_app/models/exercise_result.dart';
import 'package:workout_app/models/set_result.dart'; import 'package:workout_app/models/set_result.dart';
import 'package:workout_app/models/workout_session.dart'; import 'package:workout_app/models/workout_session.dart';
import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/storage_service.dart';
import 'package:workout_app/services/sync_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/exercise_tile.dart';
import 'package:workout_app/widgets/workout_summary_dialog.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 { class WorkoutScreen extends StatefulWidget {
const WorkoutScreen({ const WorkoutScreen({
super.key, super.key,
@ -39,6 +46,18 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
late Timer _elapsedTimer; late Timer _elapsedTimer;
Duration _elapsed = Duration.zero; 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(); final _sync = SyncService();
bool _finished = false; bool _finished = false;
@ -78,11 +97,29 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
.map((row) => (row as List).cast<int>()) .map((row) => (row as List).cast<int>())
.toList(); .toList();
_warmupTapped = (s['warmupTapped'] as List).cast<bool>(); _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;
_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 @override
void dispose() { void dispose() {
_elapsedTimer.cancel(); _elapsedTimer.cancel();
_breakTimer?.cancel();
_audio.dispose();
super.dispose(); super.dispose();
} }
@ -95,6 +132,15 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
'tapped': _tapped, 'tapped': _tapped,
'doneReps': _doneReps, 'doneReps': _doneReps,
'warmupTapped': _warmupTapped, '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<WorkoutScreen> {
bool get _allSetsCompleted => _tapped.every((row) => row.every((t) => t)); 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 // Interaction
void _tapCircle(int exIdx, int setIdx) { void _tapCircle(int exIdx, int repIdx) {
if (_finished) return; if (_finished) return;
final wasNotTapped = !_tapped[exIdx][repIdx];
if (wasNotTapped && _inBreak) return;
setState(() { setState(() {
if (!_tapped[exIdx][setIdx]) { if (wasNotTapped) {
_tapped[exIdx][setIdx] = true; _tapped[exIdx][repIdx] = true;
} else { } else {
// Subsequent taps decrement reps (records actual reps done). // Subsequent taps decrement reps (records actual reps done).
_doneReps[exIdx][setIdx] = _doneReps[exIdx][repIdx] =
(_doneReps[exIdx][setIdx] - 1).clamp(0, 999); (_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(); _saveActiveSession();
} }
void _tapWarmup(int exIdx) { void _tapWarmup(int exIdx) {
if (_finished || _warmupTapped[exIdx]) return; if (_finished || _warmupTapped[exIdx]) return;
setState(() => _warmupTapped[exIdx] = true); setState(() => _warmupTapped[exIdx] = true);
if (!_inBreak) {
_startBreak(_warmupBreakSecs, 'Warmup rest (3 min)', exIdx, -1);
}
_saveActiveSession(); _saveActiveSession();
} }
void _resetCircle(int exIdx, int setIdx) { void _resetCircle(int exIdx, int repIdx) {
if (_finished) return; if (_finished) return;
setState(() { setState(() {
_tapped[exIdx][setIdx] = false; _tapped[exIdx][repIdx] = false;
_doneReps[exIdx][setIdx] = widget.exercises[exIdx].reps; _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<void> _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(); _saveActiveSession();
} }
@ -153,12 +308,15 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel', style: TextStyle(color: Colors.white70)), child:
const Text('Cancel', style: TextStyle(color: Colors.white70)),
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
child: child: const Text(
const Text('Finish', style: TextStyle(color: Colors.greenAccent)), 'Finish',
style: TextStyle(color: Colors.greenAccent),
),
), ),
], ],
), ),
@ -201,6 +359,7 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
Future<void> _finishWorkout() async { Future<void> _finishWorkout() async {
_elapsedTimer.cancel(); _elapsedTimer.cancel();
_breakTimer?.cancel();
setState(() => _finished = true); setState(() => _finished = true);
final endTime = DateTime.now(); final endTime = DateTime.now();
@ -301,19 +460,31 @@ class _WorkoutScreenState extends State<WorkoutScreen> {
), ),
], ],
), ),
body: ListView.separated( body: Column(
padding: const EdgeInsets.all(12), children: [
itemCount: widget.exercises.length, if (_inBreak)
separatorBuilder: (_, _) => const SizedBox(height: 8), BreakBanner(
itemBuilder: (_, i) => ExerciseTile( breakRemaining: _breakRemaining,
exercise: widget.exercises[i], breakLabel: _breakLabel,
tapped: _tapped[i], onSkip: _skipBreak,
doneReps: _doneReps[i], ),
warmupTapped: _warmupTapped[i], Expanded(
onTapCircle: (s) => _tapCircle(i, s), child: ListView.separated(
onLongPressCircle: (s) => _resetCircle(i, s), padding: const EdgeInsets.all(12),
onTapWarmup: () => _tapWarmup(i), 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),
),
),
),
],
), ),
), ),
); );

View File

@ -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),
),
),
],
),
);
}
}

View File

@ -17,6 +17,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" 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: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -65,6 +121,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -81,6 +153,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: file:
dependency: transitive dependency: transitive
description: description:
@ -89,6 +169,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -120,6 +208,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" 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: jni:
dependency: transitive dependency: transitive
description: description:
@ -517,6 +621,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -525,6 +637,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: vm_service:
dependency: transitive dependency: transitive
description: description:
@ -541,6 +669,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" 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: xdg_directories:
dependency: transitive dependency: transitive
description: description:

View File

@ -14,6 +14,8 @@ dependencies:
sqflite: ^2.4.2 sqflite: ^2.4.2
path_provider: ^2.1.5 path_provider: ^2.1.5
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
audioplayers: ^6.4.0
vibration: ^3.1.0
permission_handler: ^12.0.0 permission_handler: ^12.0.0
dev_dependencies: dev_dependencies:
@ -23,3 +25,5 @@ dev_dependencies:
flutter: flutter:
uses-material-design: true uses-material-design: true
assets:
- assets/sounds/break_end.mp3