feat: add timer to timebased Exercises

This commit is contained in:
Patryk Hegenberg 2026-01-21 14:55:09 +01:00
parent cdc5e44bb3
commit d4be30cf74
10 changed files with 567 additions and 58 deletions

View file

@ -123,6 +123,8 @@
},
"battleWeight": "GEWICHT",
"battleReps": "WH",
"battleTime": "ZEIT",
"battleStartTimer": "Timer starten",
"battleAssistance": "UNTERSTÜTZUNG",
"battleCompleteSet": "SATZ ABSCHLIESSEN",
"battleRest": "PAUSE",

View file

@ -123,6 +123,8 @@
},
"battleWeight": "WEIGHT",
"battleReps": "REPS",
"battleTime": "TIME",
"battleStartTimer": "Start Timer",
"battleAssistance": "ASSISTANCE",
"battleCompleteSet": "COMPLETE SET",
"battleRest": "REST",

View file

@ -104,7 +104,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(),
Container(
width: 100,
height: 100,
@ -127,7 +126,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
),
),
const SizedBox(height: 32),
Text(
l10n.loginWelcomeBack,
style: Theme.of(context).textTheme.displayMedium,
@ -140,7 +138,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(16),
@ -173,7 +170,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
],
),
),
TextFormField(
controller: _emailController,
focusNode: _emailFocusNode,
@ -198,8 +194,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
},
),
const SizedBox(height: 16),
// Password Field
TextFormField(
controller: _passwordController,
focusNode: _passwordFocusNode,
@ -234,7 +228,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
},
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
@ -262,7 +255,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -278,7 +270,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
),
],
),
const Spacer(),
],
),

View file

@ -494,21 +494,6 @@ class _HubScreenState extends ConsumerState<HubScreen> {
),
),
const SizedBox(height: 24),
// if (cycle != null)
// Padding(
// padding: const EdgeInsets.symmetric(horizontal: 32),
// child: Row(
// mainAxisAlignment: MainAxisAlignment.spaceEvenly,
// children: [
// _StatBox(
// label: l10n.hubCycleLabel,
// value: '#${cycle.cycleNumber}'),
// _StatBox(
// label: l10n.hubActiveLabel,
// value: l10n.hubActiveYes),
// ],
// ),
// ),
const Spacer(flex: 1),
Container(
padding: const EdgeInsets.symmetric(

View file

@ -185,9 +185,6 @@ class _LobbyScreenState extends ConsumerState<LobbyScreen> {
await ref
.read(partyRepositoryProvider)
.startRaid(party.id, customHp: raidHp);
// ref
// .read(partyRepositoryProvider)
// .startRaid(party.id);
},
child: Text(
l10n.lobbyStartRaid,

View file

@ -13,6 +13,8 @@ class BattleState {
final bool isResting;
final int restSeconds;
final String? error;
final bool isExerciseTimerActive;
final int exerciseTimerSeconds;
const BattleState({
this.exercises = const [],
@ -22,6 +24,8 @@ class BattleState {
this.isLoading = true,
this.isResting = false,
this.restSeconds = 0,
this.isExerciseTimerActive = false,
this.exerciseTimerSeconds = 0,
this.error,
});
@ -33,6 +37,8 @@ class BattleState {
bool? isLoading,
bool? isResting,
int? restSeconds,
bool? isExerciseTimerActive,
int? exerciseTimerSeconds,
String? error,
}) {
return BattleState(
@ -43,6 +49,9 @@ class BattleState {
isLoading: isLoading ?? this.isLoading,
isResting: isResting ?? this.isResting,
restSeconds: restSeconds ?? this.restSeconds,
isExerciseTimerActive:
isExerciseTimerActive ?? this.isExerciseTimerActive,
exerciseTimerSeconds: exerciseTimerSeconds ?? this.exerciseTimerSeconds,
error: error ?? this.error,
);
}
@ -93,7 +102,30 @@ class BattleController extends _$BattleController {
state = state.copyWith(isResting: false, restSeconds: 0);
}
void startExerciseTimer(int seconds) {
state = state.copyWith(
isExerciseTimerActive: true, exerciseTimerSeconds: seconds);
}
void tickExerciseTimer() {
if (state.exerciseTimerSeconds > 0) {
state =
state.copyWith(exerciseTimerSeconds: state.exerciseTimerSeconds - 1);
} else {
state = state.copyWith(isExerciseTimerActive: false);
}
}
void stopExerciseTimer() {
state =
state.copyWith(isExerciseTimerActive: false, exerciseTimerSeconds: 0);
}
void completeSet({required int repsActual}) {
if (state.isExerciseTimerActive) {
state = state.copyWith(isExerciseTimerActive: false);
}
final currentExercise = state.exercises[state.currentExerciseIndex];
final currentSet = currentExercise.sets[state.currentSetIndex];

View file

@ -169,7 +169,7 @@ class WorkoutGeneratorService {
accessories.add(createSimple('curl', 'Barbell Curl', 3, 10,
weight: calculateWeight(pullupTm, 0.2)));
accessories.add(createSimple('plank', 'Plank (30s)', 3, 1));
accessories.add(createSimple('plank', 'Plank (30s)', 3, 30));
accessories.add(_createIntervalExercise(
id: 'kb_swing',
@ -217,7 +217,7 @@ class WorkoutGeneratorService {
ExerciseType.scapularPull, 3, 10));
exercises.add(createAccessory(
'plank', 'Core Plank (45s)', ExerciseType.plank, 3, 1));
'plank', 'Core Plank (45s)', ExerciseType.plank, 3, 45));
break;
case 2:

View file

@ -8,6 +8,7 @@ import 'package:slrpg_app/src/features/multiplayer/presentation/screens/lobby_sc
import 'package:slrpg_app/src/features/workout_runner/application/battle_controller.dart';
import 'package:slrpg_app/src/features/workout_runner/application/workout_completion_service.dart';
import 'package:slrpg_app/src/features/workout_runner/application/workout_loader_service.dart';
import 'package:slrpg_app/src/features/workout_runner/presentation/widgets/timer_widget.dart';
import 'package:slrpg_app/src/shared/domain/entities/exercise.dart';
import 'package:slrpg_app/src/shared/domain/entities/workout_set.dart';
@ -68,6 +69,11 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
}
}
bool _isTimeBasedExercise(String exerciseId) {
final id = exerciseId.toLowerCase();
return id.contains('plank') || id.contains('hold') || id.contains('static');
}
String _getEnemyAsset(String exerciseId) {
switch (exerciseId) {
case 'squat':
@ -560,6 +566,356 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
);
}
Widget _buildTimeBasedView(
Exercise currentExercise,
WorkoutSet currentSet,
PlateLoadResult plateResult,
int completedHP,
int totalHP,
) {
final l10n = AppLocalizations.of(context)!;
final state = ref.watch(battleControllerProvider);
final controller = ref.read(battleControllerProvider.notifier);
return Column(
children: [
Flexible(
flex: 3,
child: Stack(
alignment: Alignment.center,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Image.asset(
_getEnemyAsset(currentExercise.exerciseId),
fit: BoxFit.contain,
color: Colors.white.withValues(alpha: 0.9),
colorBlendMode: BlendMode.modulate,
),
),
Positioned(
top: 16,
right: 16,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white24),
),
child: Text(
l10n.battleWave(
state.currentExerciseIndex + 1,
state.exercises.length,
),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
Positioned(
bottom: 10,
left: 32,
right: 32,
child: Column(
children: [
const Icon(Icons.favorite,
color: AppTheme.errorColor, size: 24),
const SizedBox(height: 4),
widget.partyId != null
? _buildMultiplayerHpBar()
: EnemyHPBar(
current: totalHP - completedHP,
max: totalHP,
),
],
),
),
],
),
),
Expanded(
flex: 7,
child: Container(
decoration: BoxDecoration(
color: AppTheme.surfaceColor.withValues(alpha: 0.95),
borderRadius:
const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
blurRadius: 20,
offset: const Offset(0, -5),
)
],
),
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 100),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
currentExercise.exerciseName,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
IconButton(
icon: const Icon(Icons.info_outline,
color: Colors.white54),
onPressed: () => _showExerciseGuide(
currentExercise.exerciseId),
),
],
),
Text(
l10n.battleSet(
state.currentSetIndex + 1,
currentExercise.sets.length,
),
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white70,
),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color:
AppTheme.backgroundColor.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color:
AppTheme.primaryColor.withValues(alpha: 0.3),
width: 2,
),
),
child: TimerWidget(
key: ValueKey(
'${currentExercise.exerciseId}_${state.currentExerciseIndex}_${state.currentSetIndex}',
),
durationSeconds: currentSet.repsTarget,
onComplete: () {
controller.stopExerciseTimer();
_showTimeBasedCompleteDialog(currentSet);
},
),
),
const SizedBox(height: 24),
if (currentSet.targetWeightTotal > 0) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.surfaceColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.fitness_center,
color: AppTheme.primaryColor),
const SizedBox(width: 12),
Text(
'${currentSet.targetWeightTotal} kg',
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 16),
],
// // Reps Info (falls vorhanden)
// if (currentSet.repsTarget > 0)
// Container(
// padding: const EdgeInsets.symmetric(
// horizontal: 16,
// vertical: 12,
// ),
// decoration: BoxDecoration(
// color: AppTheme.secondaryColor
// .withValues(alpha: 0.1),
// borderRadius: BorderRadius.circular(8),
// border:
// Border.all(color: AppTheme.secondaryColor),
// ),
// child: Text(
// '${currentSet.repsTarget} ${currentSet.isAmrap ? "+" : ""} Reps',
// style: const TextStyle(
// color: AppTheme.secondaryColor,
// fontSize: 18,
// fontWeight: FontWeight.bold,
// ),
// ),
// ),
],
),
),
),
Container(
color: AppTheme.surfaceColor,
padding: const EdgeInsets.all(16),
child: SafeArea(
top: false,
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
controller.stopExerciseTimer();
_showTimeBasedCompleteDialog(currentSet);
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: AppTheme.successColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
l10n.battleCompleteSet,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
),
),
),
),
],
),
),
),
],
);
}
void _showTimeBasedCompleteDialog(WorkoutSet currentSet) {
final l10n = AppLocalizations.of(context)!;
if (currentSet.repsTarget > 0) {
int tempReps = currentSet.repsTarget;
showModalBottomSheet(
context: context,
backgroundColor: AppTheme.surfaceColor,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) {
return StatefulBuilder(
builder: (context, setModalState) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.timer_off,
size: 48, color: AppTheme.successColor),
const SizedBox(height: 16),
Text(
'Time Complete!',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'How many reps did you complete?',
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_CounterButton(
icon: Icons.remove,
onTap: tempReps > 0
? () => setModalState(() => tempReps--)
: null,
),
Container(
width: 120,
alignment: Alignment.center,
child: Text(
'$tempReps',
style: const TextStyle(
fontSize: 72,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
_CounterButton(
icon: Icons.add,
onTap: () => setModalState(() => tempReps++),
),
],
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: () {
Navigator.pop(context);
_completeSetAndProgress(tempReps);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.successColor,
foregroundColor: Colors.white,
),
child: const Text(
'CONFIRM',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 16),
],
),
);
},
);
},
);
} else {
_completeSetAndProgress(0);
}
}
Widget _buildWorkoutScreen(
Exercise currentExercise,
WorkoutSet currentSet,
@ -572,6 +928,16 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
return _buildEmomView(currentExercise, currentSet, completedHP, totalHP);
}
if (_isTimeBasedExercise(currentExercise.exerciseId)) {
return _buildTimeBasedView(
currentExercise,
currentSet,
plateResult,
completedHP,
totalHP,
);
}
final l10n = AppLocalizations.of(context)!;
final state = ref.watch(battleControllerProvider);

View file

@ -3,16 +3,35 @@ import 'dart:async';
import '../../../../core/theme/app_theme.dart';
class TimerWidget extends StatefulWidget {
const TimerWidget({super.key});
final int durationSeconds;
final VoidCallback? onComplete;
final bool autoStart;
const TimerWidget({
super.key,
required this.durationSeconds,
this.onComplete,
this.autoStart = true,
});
@override
State<TimerWidget> createState() => _TimerWidgetState();
}
class _TimerWidgetState extends State<TimerWidget> {
int _seconds = 0;
late int _secondsRemaining;
Timer? _timer;
bool _isRunning = false;
bool _isCompleted = false;
@override
void initState() {
super.initState();
_secondsRemaining = widget.durationSeconds;
if (widget.autoStart) {
_start();
}
}
@override
void dispose() {
@ -21,8 +40,19 @@ class _TimerWidgetState extends State<TimerWidget> {
}
void _start() {
if (_isCompleted) return;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
setState(() => _seconds++);
setState(() {
if (_secondsRemaining > 0) {
_secondsRemaining--;
} else {
_timer?.cancel();
_isRunning = false;
_isCompleted = true;
widget.onComplete?.call();
}
});
});
setState(() => _isRunning = true);
}
@ -35,42 +65,151 @@ class _TimerWidgetState extends State<TimerWidget> {
void _reset() {
_timer?.cancel();
setState(() {
_seconds = 0;
_secondsRemaining = widget.durationSeconds;
_isRunning = false;
_isCompleted = false;
});
}
void _skip() {
_timer?.cancel();
setState(() {
_secondsRemaining = 0;
_isRunning = false;
_isCompleted = true;
});
widget.onComplete?.call();
}
String _formatTime() {
final minutes = _seconds ~/ 60;
final secs = _seconds % 60;
final minutes = _secondsRemaining ~/ 60;
final secs = _secondsRemaining % 60;
return '${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
}
Color _getTimerColor() {
if (_isCompleted) return AppTheme.successColor;
if (_secondsRemaining <= 10) return AppTheme.errorColor;
if (_secondsRemaining <= 30) return Colors.orange;
return AppTheme.primaryColor;
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
final progress = widget.durationSeconds > 0
? _secondsRemaining / widget.durationSeconds
: 0.0;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: AppTheme.primaryColor,
fontFamily: 'monospace',
Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 200,
height: 200,
child: CircularProgressIndicator(
value: progress,
strokeWidth: 12,
backgroundColor: AppTheme.xpBarBackground,
color: _getTimerColor(),
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(),
style: Theme.of(context).textTheme.displayLarge?.copyWith(
fontSize: 48,
fontWeight: FontWeight.bold,
color: _getTimerColor(),
fontFamily: 'monospace',
),
),
if (_isCompleted)
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'COMPLETE!',
style: TextStyle(
color: AppTheme.successColor,
fontSize: 16,
fontWeight: FontWeight.bold,
letterSpacing: 1.5,
),
),
),
],
),
],
),
const SizedBox(width: 16),
IconButton(
icon: Icon(_isRunning ? Icons.pause : Icons.play_arrow),
onPressed: _isRunning ? _pause : _start,
color: AppTheme.primaryColor,
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _reset,
color: AppTheme.primaryColor,
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!_isCompleted) ...[
ElevatedButton.icon(
onPressed: _isRunning ? _pause : _start,
icon: Icon(_isRunning ? Icons.pause : Icons.play_arrow),
label: Text(_isRunning ? 'PAUSE' : 'START'),
style: ElevatedButton.styleFrom(
backgroundColor:
_isRunning ? Colors.orange : AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: _reset,
icon: const Icon(Icons.refresh),
label: const Text('RESET'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.primaryColor,
side: const BorderSide(color: AppTheme.primaryColor),
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: _skip,
icon: const Icon(Icons.skip_next),
label: const Text('SKIP'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.grey,
side: const BorderSide(color: Colors.grey),
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
] else ...[
ElevatedButton.icon(
onPressed: _reset,
icon: const Icon(Icons.replay),
label: const Text('RESTART'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
),
),
],
],
),
],
);
}
}

View file

@ -104,11 +104,6 @@ class ApiClient {
return handler.next(error);
}
}
// onError: (error, handler) async {
// if (error.response?.statusCode == 401) {
// _logger.w('Unauthorized - clearing token');
// await _storage.delete(key: AppConstants.keyAuthToken);
// }
return handler.next(error);
},
),