From 92ab969831e1ef7ecaa07fbe2a3ddfc80fb86194 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Tue, 13 Jan 2026 10:38:42 +0100 Subject: [PATCH] refactor: refactor battlescreen and split it into seperate services and controllers --- .../data/repositories/party_repository.dart | 4 - .../presentation/screens/lobby_screen.dart | 2 +- .../application/battle_controller.dart | 203 ++ .../application/rest_timer_service.dart | 90 + .../workout_completion_service.dart | 151 ++ .../application/workout_loader_service.dart | 129 ++ .../presentation/screens/battle_screen.dart | 1903 +++++++---------- .../widgets/workout_content_widget.dart | 244 +++ 8 files changed, 1640 insertions(+), 1086 deletions(-) create mode 100644 lib/src/features/workout_runner/application/battle_controller.dart create mode 100644 lib/src/features/workout_runner/application/rest_timer_service.dart create mode 100644 lib/src/features/workout_runner/application/workout_completion_service.dart create mode 100644 lib/src/features/workout_runner/application/workout_loader_service.dart create mode 100644 lib/src/features/workout_runner/presentation/widgets/workout_content_widget.dart diff --git a/lib/src/features/multiplayer/data/repositories/party_repository.dart b/lib/src/features/multiplayer/data/repositories/party_repository.dart index 47ec87b..846949b 100644 --- a/lib/src/features/multiplayer/data/repositories/party_repository.dart +++ b/lib/src/features/multiplayer/data/repositories/party_repository.dart @@ -72,10 +72,6 @@ class PartyRepository { await _pb.collection('parties').update(partyId, body: body); } - // Future startRaid(String partyId) async { - // await _pb.collection('parties').update(partyId, body: {'status': 'active'}); - // } - Stream subscribeToParty(String partyId) async* { await _syncAuth(); diff --git a/lib/src/features/multiplayer/presentation/screens/lobby_screen.dart b/lib/src/features/multiplayer/presentation/screens/lobby_screen.dart index de57c39..747bb3b 100644 --- a/lib/src/features/multiplayer/presentation/screens/lobby_screen.dart +++ b/lib/src/features/multiplayer/presentation/screens/lobby_screen.dart @@ -223,7 +223,7 @@ class _LobbyScreenState extends ConsumerState { final cycleId = activeCycle.serverId ?? activeCycle.id.toString(); for (int w = 1; w <= 4; w++) { - for (int d = 1; d <= 4; d++) { + for (int d = 1; d <= 3; d++) { final workout = await workoutRepo.getWorkoutByWeekDay( cycleId: cycleId, week: w, day: d); diff --git a/lib/src/features/workout_runner/application/battle_controller.dart b/lib/src/features/workout_runner/application/battle_controller.dart new file mode 100644 index 0000000..0dc8de2 --- /dev/null +++ b/lib/src/features/workout_runner/application/battle_controller.dart @@ -0,0 +1,203 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:slrpg_app/src/shared/domain/entities/exercise.dart'; +import 'package:slrpg_app/src/shared/domain/entities/workout_set.dart'; + +part 'battle_controller.g.dart'; + +class BattleState { + final List exercises; + final int currentExerciseIndex; + final int currentSetIndex; + final int repsCompleted; + final bool isLoading; + final bool isResting; + final int restSeconds; + final String? error; + + const BattleState({ + this.exercises = const [], + this.currentExerciseIndex = 0, + this.currentSetIndex = 0, + this.repsCompleted = 0, + this.isLoading = true, + this.isResting = false, + this.restSeconds = 0, + this.error, + }); + + BattleState copyWith({ + List? exercises, + int? currentExerciseIndex, + int? currentSetIndex, + int? repsCompleted, + bool? isLoading, + bool? isResting, + int? restSeconds, + String? error, + }) { + return BattleState( + exercises: exercises ?? this.exercises, + currentExerciseIndex: currentExerciseIndex ?? this.currentExerciseIndex, + currentSetIndex: currentSetIndex ?? this.currentSetIndex, + repsCompleted: repsCompleted ?? this.repsCompleted, + isLoading: isLoading ?? this.isLoading, + isResting: isResting ?? this.isResting, + restSeconds: restSeconds ?? this.restSeconds, + error: error ?? this.error, + ); + } +} + +@riverpod +class BattleController extends _$BattleController { + @override + BattleState build() { + return const BattleState(); + } + + void setExercises(List exercises) { + state = state.copyWith( + exercises: exercises, + isLoading: false, + repsCompleted: exercises.isNotEmpty && exercises.first.sets.isNotEmpty + ? exercises.first.sets.first.repsTarget + : 0, + ); + } + + void setLoading(bool loading) { + state = state.copyWith(isLoading: loading); + } + + void setError(String error) { + state = state.copyWith(error: error, isLoading: false); + } + + void updateRepsCompleted(int reps) { + state = state.copyWith(repsCompleted: reps); + } + + void startRest(int seconds) { + state = state.copyWith(isResting: true, restSeconds: seconds); + } + + void tickRest() { + if (state.restSeconds > 0) { + state = state.copyWith(restSeconds: state.restSeconds - 1); + } else { + state = state.copyWith(isResting: false); + } + } + + void skipRest() { + state = state.copyWith(isResting: false, restSeconds: 0); + } + + void completeSet({required int repsActual}) { + final currentExercise = state.exercises[state.currentExerciseIndex]; + final currentSet = currentExercise.sets[state.currentSetIndex]; + + final updatedSet = currentSet.copyWith( + repsActual: repsActual, + completed: true, + ); + + final updatedSets = List.from(currentExercise.sets); + updatedSets[state.currentSetIndex] = updatedSet; + + final updatedExercise = currentExercise.copyWith(sets: updatedSets); + final updatedExercises = List.from(state.exercises); + updatedExercises[state.currentExerciseIndex] = updatedExercise; + + state = state.copyWith(exercises: updatedExercises); + } + + void moveToNextSet() { + final currentExercise = state.exercises[state.currentExerciseIndex]; + + if (state.currentSetIndex < currentExercise.sets.length - 1) { + final nextSet = currentExercise.sets[state.currentSetIndex + 1]; + state = state.copyWith( + currentSetIndex: state.currentSetIndex + 1, + repsCompleted: nextSet.repsTarget, + ); + } + } + + void moveToNextExercise() { + if (state.currentExerciseIndex < state.exercises.length - 1) { + final nextExercise = state.exercises[state.currentExerciseIndex + 1]; + final nextReps = + nextExercise.sets.isNotEmpty ? nextExercise.sets.first.repsTarget : 0; + + state = state.copyWith( + currentExerciseIndex: state.currentExerciseIndex + 1, + currentSetIndex: 0, + repsCompleted: nextReps, + ); + } + } + + void adjustEmomSets(int newTotalSets) { + final currentEx = state.exercises[state.currentExerciseIndex]; + + if (newTotalSets == currentEx.sets.length) return; + + List currentSets = List.from(currentEx.sets); + + if (newTotalSets > currentSets.length) { + final templateSet = currentSets.last; + for (int i = currentSets.length; i < newTotalSets; i++) { + currentSets.add(templateSet.copyWith( + setNumber: i + 1, + completed: true, + repsActual: templateSet.repsTarget, + )); + } + } else { + currentSets = currentSets.sublist(0, newTotalSets); + } + + final updatedEx = currentEx.copyWith(sets: currentSets); + final updatedExercises = List.from(state.exercises); + updatedExercises[state.currentExerciseIndex] = updatedEx; + + state = state.copyWith( + exercises: updatedExercises, + currentSetIndex: newTotalSets - 1, + repsCompleted: updatedEx.sets.last.repsTarget, + ); + } + + Exercise get currentExercise => state.exercises[state.currentExerciseIndex]; + WorkoutSet get currentSet => currentExercise.sets[state.currentSetIndex]; + + bool get isLastSet => + state.currentSetIndex >= currentExercise.sets.length - 1; + + bool get isLastExercise => + state.currentExerciseIndex >= state.exercises.length - 1; + + int get totalHP => state.exercises.fold( + 0, + (sum, ex) => sum + ex.sets.fold(0, (s, set) => s + set.repsTarget), + ); + + int get completedHP { + int hp = 0; + + for (int i = 0; i < state.currentExerciseIndex; i++) { + hp += state.exercises[i].sets.fold( + 0, + (sum, set) => sum + set.repsActual, + ); + } + + final currentExercise = state.exercises[state.currentExerciseIndex]; + for (int i = 0; i < state.currentSetIndex; i++) { + hp += currentExercise.sets[i].repsActual; + } + + return hp; + } +} diff --git a/lib/src/features/workout_runner/application/rest_timer_service.dart b/lib/src/features/workout_runner/application/rest_timer_service.dart new file mode 100644 index 0000000..e6903a8 --- /dev/null +++ b/lib/src/features/workout_runner/application/rest_timer_service.dart @@ -0,0 +1,90 @@ +import 'dart:async'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'rest_timer_service.g.dart'; + +class RestTimerState { + final bool isActive; + final int remainingSeconds; + final int totalSeconds; + + const RestTimerState({ + required this.isActive, + required this.remainingSeconds, + required this.totalSeconds, + }); + + double get progress => + totalSeconds > 0 ? remainingSeconds / totalSeconds : 0.0; + + RestTimerState copyWith({ + bool? isActive, + int? remainingSeconds, + int? totalSeconds, + }) { + return RestTimerState( + isActive: isActive ?? this.isActive, + remainingSeconds: remainingSeconds ?? this.remainingSeconds, + totalSeconds: totalSeconds ?? this.totalSeconds, + ); + } +} + +@riverpod +class RestTimer extends _$RestTimer { + Timer? _timer; + + @override + RestTimerState build() { + ref.onDispose(() { + _timer?.cancel(); + }); + + return const RestTimerState( + isActive: false, + remainingSeconds: 0, + totalSeconds: 0, + ); + } + + void start(int seconds) { + cancel(); + + state = RestTimerState( + isActive: true, + remainingSeconds: seconds, + totalSeconds: seconds, + ); + + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (state.remainingSeconds > 0) { + state = state.copyWith( + remainingSeconds: state.remainingSeconds - 1, + ); + } else { + complete(); + } + }); + } + + void skip() { + cancel(); + state = state.copyWith( + isActive: false, + remainingSeconds: 0, + ); + } + + void complete() { + cancel(); + state = state.copyWith( + isActive: false, + remainingSeconds: 0, + ); + } + + void cancel() { + _timer?.cancel(); + _timer = null; + } +} diff --git a/lib/src/features/workout_runner/application/workout_completion_service.dart b/lib/src/features/workout_runner/application/workout_completion_service.dart new file mode 100644 index 0000000..7606de6 --- /dev/null +++ b/lib/src/features/workout_runner/application/workout_completion_service.dart @@ -0,0 +1,151 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:slrpg_app/src/features/gamification/application/quest_service.dart'; +import 'package:slrpg_app/src/shared/data/remote/sync_service.dart'; +import 'package:slrpg_app/src/shared/data/repositories/cycle_repository.dart'; +import 'package:slrpg_app/src/shared/data/repositories/user_repository.dart'; +import 'package:slrpg_app/src/shared/data/repositories/workout_repository.dart'; +import 'package:slrpg_app/src/shared/domain/entities/exercise.dart'; +import 'package:slrpg_app/src/shared/domain/logic/xp_calculator.dart'; + +part 'workout_completion_service.g.dart'; + +class WorkoutCompletionResult { + final int xpEarned; + final int? oldLevel; + final int? newLevel; + + const WorkoutCompletionResult({ + required this.xpEarned, + this.oldLevel, + this.newLevel, + }); + + bool get hasLevelUp => + oldLevel != null && newLevel != null && newLevel! > oldLevel!; +} + +@riverpod +WorkoutCompletionService workoutCompletionService(Ref ref) { + return WorkoutCompletionService( + userRepo: ref.watch(userRepositoryProvider), + workoutRepo: ref.watch(workoutRepositoryProvider), + cycleRepo: ref.watch(cycleRepositoryProvider), + questService: ref.watch(questServiceProvider), + syncService: ref.watch(syncServiceProvider), + ); +} + +class WorkoutCompletionService { + final UserRepository userRepo; + final WorkoutRepository workoutRepo; + final CycleRepository cycleRepo; + final QuestService questService; + final SyncService syncService; + + WorkoutCompletionService({ + required this.userRepo, + required this.workoutRepo, + required this.cycleRepo, + required this.questService, + required this.syncService, + }); + + Future completeWorkout({ + required List exercises, + required int week, + required int day, + int? workoutId, + }) async { + final xpEarned = XPCalculator.calculateWorkoutXP(exercises); + + await userRepo.updateXP(xpEarned); + + int? oldLevel; + int? newLevel; + final user = await userRepo.getLocalUser(); + if (user != null) { + final calculatedLevel = XPCalculator.calculateLevelFromXP(user.xp); + if (calculatedLevel > user.level) { + oldLevel = user.level; + newLevel = calculatedLevel; + await userRepo.updateLevel(calculatedLevel); + } + } + + await _reportQuestEvents(exercises); + + await _saveWorkout( + exercises: exercises, + week: week, + day: day, + workoutId: workoutId, + xpEarned: xpEarned, + ); + + syncService.sync(); + + return WorkoutCompletionResult( + xpEarned: xpEarned, + oldLevel: oldLevel, + newLevel: newLevel, + ); + } + + Future _reportQuestEvents(List exercises) async { + await questService.reportEvent(QuestTrigger.workoutComplete); + + int totalVolume = 0; + int totalReps = 0; + + for (var ex in exercises) { + for (var set in ex.sets) { + if (set.completed) { + totalVolume += (set.targetWeightTotal * set.repsActual).round(); + totalReps += set.repsActual; + } + } + } + + if (totalVolume > 0) { + await questService.reportEvent(QuestTrigger.volume, data: totalVolume); + } + if (totalReps > 0) { + await questService.reportEvent(QuestTrigger.repCount, data: totalReps); + } + } + + Future _saveWorkout({ + required List exercises, + required int week, + required int day, + int? workoutId, + required int xpEarned, + }) async { + final cycle = await cycleRepo.getCurrentCycle(); + final cycleIdRef = cycle?.serverId ?? cycle?.id.toString() ?? ''; + + if (workoutId != null) { + var workout = await workoutRepo.getWorkoutByWeekDay( + cycleId: cycleIdRef, + week: week, + day: day, + ); + + if (workout != null) { + final updatedExercises = exercises.map((e) => e.toJson()).toList(); + final updatedWorkout = workout.copyWith(exercises: updatedExercises); + await workoutRepo.completeWorkout(updatedWorkout, xpEarned: xpEarned); + } + } else { + final exercisesJson = exercises.map((e) => e.toJson()).toList(); + await workoutRepo.createCompletedWorkout( + cycleId: cycleIdRef, + week: week, + day: day, + exercises: exercisesJson, + xpEarned: xpEarned, + durationSeconds: 0, + ); + } + } +} diff --git a/lib/src/features/workout_runner/application/workout_loader_service.dart b/lib/src/features/workout_runner/application/workout_loader_service.dart new file mode 100644 index 0000000..613e047 --- /dev/null +++ b/lib/src/features/workout_runner/application/workout_loader_service.dart @@ -0,0 +1,129 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:slrpg_app/src/shared/data/local/app_database.dart'; +import 'package:slrpg_app/src/shared/data/repositories/cycle_repository.dart'; +import 'package:slrpg_app/src/shared/data/repositories/user_repository.dart'; +import 'package:slrpg_app/src/shared/data/repositories/workout_repository.dart'; +import 'package:slrpg_app/src/shared/domain/entities/exercise.dart'; +import 'package:slrpg_app/src/shared/domain/entities/workout_set.dart'; +import 'package:slrpg_app/src/shared/domain/logic/wendler_calculator.dart'; + +final workoutLoaderServiceProvider = + Provider((ref) => WorkoutLoaderService(ref)); + +class WorkoutLoaderService { + final Ref _ref; + + WorkoutLoaderService(this._ref); + + Future> loadWorkout({ + required int week, + required int day, + int? workoutId, + }) async { + final userRepo = _ref.read(userRepositoryProvider); + final workoutRepo = _ref.read(workoutRepositoryProvider); + final cycleRepo = _ref.read(cycleRepositoryProvider); + + final user = await userRepo.getLocalUser(); + if (user == null) throw Exception('User not found'); + + List exercises = []; + + if (workoutId != null) { + try { + final allWorkouts = await workoutRepo.getAllWorkouts(); + final loaded = allWorkouts.where((w) => w.id == workoutId).firstOrNull; + if (loaded != null && loaded.exercises.isNotEmpty) { + exercises = + loaded.exercises.map((e) => Exercise.fromJson(e)).toList(); + } + } catch (e) { + // Fallback: Generieren + } + } + + if (exercises.isEmpty) { + final tms = await cycleRepo.getCurrentTrainingMaxesAsync(); + exercises = _generateExercises(week, day, user, tms); + } + + if (exercises.isEmpty) { + throw Exception('No exercises configured'); + } + + return exercises; + } + + List _generateExercises( + int week, int day, UserCollection user, Map tms) { + final exercises = []; + final variants = user.exerciseVariants ?? {}; + + (String, String, ExerciseType) resolveVariant(String slot, String defaultId, + String defaultName, ExerciseType defaultType) { + final variant = variants[slot]; + if (slot == 'pull') { + if (variant == 'row') return ('row', 'Pendlay Row', ExerciseType.row); + return ('pullup', 'Weighted Pull-up', ExerciseType.pullup); + } + if (slot == 'push') { + if (variant == 'bench') { + return ('bench', 'Bench Press', ExerciseType.bench); + } + return ('dip', 'Weighted Dip', ExerciseType.dip); + } + return (defaultId, defaultName, defaultType); + } + + void addEx(String slot, String defId, String defName, ExerciseType defType, + bool isMain) { + final (id, name, type) = resolveVariant(slot, defId, defName, defType); + + String tmKey = id; + if (id == 'bench') tmKey = 'dip'; + if (id == 'row') tmKey = 'pullup'; + + final tm = tms[tmKey] ?? 0.0; + List sets = []; + + if (isMain) { + sets = WendlerCalculator.generateSets( + week: week, + trainingMax: tm, + exerciseType: type, + currentBodyweight: user.currentBodyweight, + ); + } else { + if (week != 4) { + sets = WendlerCalculator.generateFSLSets( + trainingMax: tm, + exerciseType: type, + currentBodyweight: user.currentBodyweight, + ); + } + } + + if (sets.isNotEmpty) { + exercises.add(Exercise( + exerciseId: id, + exerciseName: isMain ? name : '$name (FSL)', + bodyweightAtSession: user.currentBodyweight, + sets: sets, + )); + } + } + + if (day == 1) { + addEx('legs', 'squat', 'Back Squat', ExerciseType.squat, true); + addEx('pull', 'pullup', 'Weighted Pull-up', ExerciseType.pullup, false); + } else if (day == 2) { + addEx('push', 'dip', 'Weighted Dip', ExerciseType.dip, true); + addEx('legs', 'squat', 'Back Squat', ExerciseType.squat, false); + } else if (day == 3) { + addEx('pull', 'pullup', 'Weighted Pull-up', ExerciseType.pullup, true); + addEx('push', 'dip', 'Weighted Dip', ExerciseType.dip, false); + } + + return exercises; + } +} diff --git a/lib/src/features/workout_runner/presentation/screens/battle_screen.dart b/lib/src/features/workout_runner/presentation/screens/battle_screen.dart index f3bfcf6..1b8d68b 100644 --- a/lib/src/features/workout_runner/presentation/screens/battle_screen.dart +++ b/lib/src/features/workout_runner/presentation/screens/battle_screen.dart @@ -2,37 +2,28 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:slrpg_app/l10n/app_localizations.dart'; -import 'dart:async'; +import 'package:slrpg_app/src/features/multiplayer/presentation/screens/lobby_screen.dart'; +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/shared/domain/entities/exercise.dart'; +import 'package:slrpg_app/src/shared/domain/entities/workout_set.dart'; import '../../../../core/constants/asset_paths.dart'; import '../../../../core/theme/app_theme.dart'; -import '../../../../shared/data/local/app_database.dart'; -import '../../../../shared/domain/entities/exercise.dart'; -import '../../../../shared/domain/entities/workout_set.dart'; -import '../../../../shared/domain/logic/wendler_calculator.dart'; -import '../../../../shared/domain/logic/plate_calculator.dart'; -import '../../../../shared/domain/logic/xp_calculator.dart'; import '../../../../shared/data/repositories/user_repository.dart'; -import '../../../../shared/data/repositories/cycle_repository.dart'; -import '../../../../shared/data/repositories/workout_repository.dart'; -import '../../../../shared/data/remote/sync_service.dart'; -import '../widgets/plate_visualizer.dart'; -import '../widgets/enemy_hp_bar.dart'; -import '../../../gamification/application/quest_service.dart'; -import '../widgets/emom_timer_widget.dart'; -import '../widgets/timer_widget.dart'; -import '../../../wiki/presentation/widgets/exercise_guide_sheet.dart'; - -// --- NEUE IMPORTS FÜR MULTIPLAYER --- +import '../../../../shared/domain/logic/plate_calculator.dart'; import '../../../multiplayer/data/repositories/party_repository.dart'; -import '../../../multiplayer/presentation/screens/lobby_screen.dart'; // Für partyStreamProvider -// ------------------------------------ +import '../../../wiki/presentation/widgets/exercise_guide_sheet.dart'; +import '../widgets/enemy_hp_bar.dart'; +import '../widgets/plate_visualizer.dart'; +import '../widgets/emom_timer_widget.dart'; class BattleScreen extends ConsumerStatefulWidget { final int week; final int day; final int? workoutId; - final String? partyId; // Multiplayer Party ID + final String? partyId; const BattleScreen({ super.key, @@ -47,15 +38,6 @@ class BattleScreen extends ConsumerStatefulWidget { } class _BattleScreenState extends ConsumerState { - List _exercises = []; - int _currentExerciseIndex = 0; - int _currentSetIndex = 0; - int _repsCompleted = 0; - bool _isLoading = true; - Timer? _restTimer; - int _restSeconds = 0; - bool _isResting = false; - @override void initState() { super.initState(); @@ -67,10 +49,21 @@ class _BattleScreenState extends ConsumerState { _loadWorkout(); } - @override - void dispose() { - _restTimer?.cancel(); - super.dispose(); + Future _loadWorkout() async { + try { + final controller = ref.read(battleControllerProvider.notifier); + final loaderService = ref.read(workoutLoaderServiceProvider); + + final exercises = await loaderService.loadWorkout( + week: widget.week, + day: widget.day, + workoutId: widget.workoutId, + ); + + controller.setExercises(exercises); + } catch (e) { + ref.read(battleControllerProvider.notifier).setError(e.toString()); + } } String _getEnemyAsset(String exerciseId) { @@ -88,45 +81,6 @@ class _BattleScreenState extends ConsumerState { } } - void _handleEmomSetComplete() { - final currentExercise = _exercises[_currentExerciseIndex]; - final currentSet = currentExercise.sets[_currentSetIndex]; - - // --- MULTIPLAYER HOOK: Schaden senden --- - if (widget.partyId != null) { - // Bei EMOM ist das Target meist fix, wir nehmen das als Damage - final damage = currentSet.repsTarget; - ref.read(partyRepositoryProvider).dealDamage(widget.partyId!, damage); - } - // ---------------------------------------- - - final updatedSet = currentSet.copyWith( - repsActual: currentSet.repsTarget, - completed: true, - ); - - final updatedSets = List.from(currentExercise.sets); - updatedSets[_currentSetIndex] = updatedSet; - - final updatedExercise = currentExercise.copyWith(sets: updatedSets); - final updatedExercises = List.from(_exercises); - updatedExercises[_currentExerciseIndex] = updatedExercise; - - if (_currentSetIndex < currentExercise.sets.length - 1) { - setState(() { - _exercises = updatedExercises; - _currentSetIndex++; - - _repsCompleted = currentExercise.sets[_currentSetIndex].repsTarget; - }); - } else { - setState(() { - _exercises = updatedExercises; - }); - _showEmomFinishDialog(); - } - } - void _showExerciseGuide(String exerciseId) { showModalBottomSheet( context: context, @@ -136,318 +90,70 @@ class _BattleScreenState extends ConsumerState { ); } - List> _getExerciseConfig(int day, UserCollection user) { - // ... (Code bleibt identisch) ... - final variants = user.exerciseVariants ?? {}; + void _handleCompletePress() { + final controller = ref.read(battleControllerProvider.notifier); + final currentSet = controller.currentSet; - Map getVariant(String slot, String defaultId, - String defaultName, ExerciseType defaultType) { - final variant = variants[slot]; - - if (slot == 'pull') { - if (variant == 'row') { - return {'id': 'row', 'name': 'Pendlay Row', 'type': ExerciseType.row}; - } - return { - 'id': 'pullup', - 'name': 'Weighted Pull-up', - 'type': ExerciseType.pullup - }; - } - - if (slot == 'push') { - if (variant == 'bench') { - return { - 'id': 'bench', - 'name': 'Bench Press', - 'type': ExerciseType.bench - }; - } - return {'id': 'dip', 'name': 'Weighted Dip', 'type': ExerciseType.dip}; - } - - return {'id': defaultId, 'name': defaultName, 'type': defaultType}; - } - - switch (day) { - case 1: - final pull = getVariant( - 'pull', 'pullup', 'Weighted Pull-up', ExerciseType.pullup); - return [ - { - 'id': 'squat', - 'name': 'Back Squat', - 'type': ExerciseType.squat, - 'isMain': true - }, - {...pull, 'isMain': false}, - ]; - case 2: - final push = - getVariant('push', 'dip', 'Weighted Dip', ExerciseType.dip); - return [ - {...push, 'isMain': true}, - { - 'id': 'squat', - 'name': 'Back Squat', - 'type': ExerciseType.squat, - 'isMain': false - }, - ]; - case 3: - final pull = getVariant( - 'pull', 'pullup', 'Weighted Pull-up', ExerciseType.pullup); - final push = - getVariant('push', 'dip', 'Weighted Dip', ExerciseType.dip); - return [ - {...pull, 'isMain': true}, - {...push, 'isMain': false}, - ]; - default: - return []; - } - } - - Future _loadWorkout() async { - // ... (Code bleibt identisch) ... - final userRepo = ref.read(userRepositoryProvider); - final workoutRepo = ref.read(workoutRepositoryProvider); - final cycleRepo = ref.read(cycleRepositoryProvider); - - final user = await userRepo.getLocalUser(); - - if (user == null) { - if (mounted) context.go('/hub'); - return; - } - - List exercises = []; - - if (widget.workoutId != null) { - try { - final allWorkouts = await workoutRepo.getAllWorkouts(); - - final loadedWorkout = - allWorkouts.where((w) => w.id == widget.workoutId).firstOrNull; - - if (loadedWorkout != null && loadedWorkout.exercises.isNotEmpty) { - exercises = loadedWorkout.exercises.map((e) { - return Exercise.fromJson(e as Map); - }).toList(); - } - } catch (e) { - debugPrint('⚠️ Fehler beim Laden des gespeicherten Workouts: $e'); - } - } - - if (exercises.isEmpty) { - final trainingMaxesMap = await cycleRepo.getCurrentTrainingMaxesAsync(); - final exerciseConfigs = _getExerciseConfig(widget.day, user); - - for (final config in exerciseConfigs) { - final id = config['id'] as String; - final name = config['name'] as String; - final type = config['type'] as ExerciseType; - final isMain = config['isMain'] as bool; - - String tmKey = id; - if (id == 'bench') tmKey = 'dip'; - if (id == 'row') tmKey = 'pullup'; - - final tm = trainingMaxesMap[tmKey] ?? 0.0; - List sets = []; - - if (isMain) { - sets = WendlerCalculator.generateSets( - week: widget.week, - trainingMax: tm, - exerciseType: type, - currentBodyweight: user.currentBodyweight, - ); - } else { - if (widget.week != 4) { - sets = WendlerCalculator.generateFSLSets( - trainingMax: tm, - exerciseType: type, - currentBodyweight: user.currentBodyweight, - ); - } - } - - if (sets.isNotEmpty) { - exercises.add(Exercise( - exerciseId: id, - exerciseName: isMain ? name : '$name (FSL)', - bodyweightAtSession: user.currentBodyweight, - sets: sets, - )); - } - } - } - - if (mounted) { - setState(() { - _exercises = exercises; - _isLoading = false; - - if (exercises.isNotEmpty && exercises.first.sets.isNotEmpty) { - _repsCompleted = exercises.first.sets.first.repsTarget; - } - }); - } - } - - void _completeSet() { - // --- MULTIPLAYER HOOK: Schaden senden --- - if (widget.partyId != null) { - // _repsCompleted enthält hier bereits die eingegebene Anzahl (auch bei AMRAP) - ref - .read(partyRepositoryProvider) - .dealDamage(widget.partyId!, _repsCompleted); - } - // ---------------------------------------- - - final currentExercise = _exercises[_currentExerciseIndex]; - final currentSet = currentExercise.sets[_currentSetIndex]; - - final updatedSet = currentSet.copyWith( - repsActual: _repsCompleted, - completed: true, - ); - - final updatedSets = List.from(currentExercise.sets); - updatedSets[_currentSetIndex] = updatedSet; - - final updatedExercise = currentExercise.copyWith(sets: updatedSets); - final updatedExercises = List.from(_exercises); - updatedExercises[_currentExerciseIndex] = updatedExercise; - - int nextRepsTarget = 0; - - if (_currentSetIndex < currentExercise.sets.length - 1) { - nextRepsTarget = currentExercise.sets[_currentSetIndex + 1].repsTarget; - - setState(() { - _exercises = updatedExercises; - _currentSetIndex++; - _repsCompleted = nextRepsTarget; - }); - _startRestTimer(90); - } else if (_currentExerciseIndex < _exercises.length - 1) { - final nextExercise = _exercises[_currentExerciseIndex + 1]; - if (nextExercise.sets.isNotEmpty) { - nextRepsTarget = nextExercise.sets.first.repsTarget; - } - - setState(() { - _exercises = updatedExercises; - _currentExerciseIndex++; - _currentSetIndex = 0; - _repsCompleted = nextRepsTarget; - }); - _startRestTimer(180); + if (currentSet.isAmrap) { + _showAmrapDialog(currentSet.repsTarget); + } else { + _completeSetAndProgress(currentSet.repsTarget); + } + } + + void _completeSetAndProgress(int reps) { + final controller = ref.read(battleControllerProvider.notifier); + + if (widget.partyId != null) { + ref.read(partyRepositoryProvider).dealDamage(widget.partyId!, reps); + } + + controller.completeSet(repsActual: reps); + + if (!controller.isLastSet) { + controller.moveToNextSet(); + _startRest(90); + } else if (!controller.isLastExercise) { + controller.moveToNextExercise(); + _startRest(180); } else { - setState(() { - _exercises = updatedExercises; - }); _completeWorkout(); } } - void _startRestTimer(int seconds) { - setState(() { - _isResting = true; - _restSeconds = seconds; - }); + void _startRest(int seconds) { + ref.read(battleControllerProvider.notifier).startRest(seconds); + _runRestTimer(); + } - _restTimer?.cancel(); - _restTimer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (_restSeconds > 0) { - setState(() => _restSeconds--); - } else { - timer.cancel(); - setState(() => _isResting = false); + void _runRestTimer() { + Future.delayed(const Duration(seconds: 1), () { + if (!mounted) return; + + final controller = ref.read(battleControllerProvider.notifier); + if (controller.state.isResting) { + controller.tickRest(); + if (controller.state.restSeconds > 0) { + _runRestTimer(); + } } }); } void _skipRest() { - _restTimer?.cancel(); - setState(() { - _isResting = false; - _restSeconds = 0; - }); + ref.read(battleControllerProvider.notifier).skipRest(); } Future _completeWorkout() async { - final xpEarned = XPCalculator.calculateWorkoutXP(_exercises); + final state = ref.read(battleControllerProvider); + final completionService = ref.read(workoutCompletionServiceProvider); - final userRepo = ref.read(userRepositoryProvider); - await userRepo.updateXP(xpEarned); - - final user = await userRepo.getLocalUser(); - if (user != null) { - final newLevel = XPCalculator.calculateLevelFromXP(user.xp); - if (newLevel > user.level) { - await userRepo.updateLevel(newLevel); - if (mounted) { - _showLevelUpDialog(user.level, newLevel); - } - } - } - - final questService = ref.read(questServiceProvider); - - await questService.reportEvent(QuestTrigger.workoutComplete); - - int totalVolume = 0; - int totalReps = 0; - for (var ex in _exercises) { - for (var set in ex.sets) { - if (set.completed) { - totalVolume += (set.targetWeightTotal * set.repsActual).round(); - totalReps += set.repsActual; - } - } - } - - if (totalVolume > 0) { - await questService.reportEvent(QuestTrigger.volume, data: totalVolume); - } - if (totalReps > 0) { - await questService.reportEvent(QuestTrigger.repCount, data: totalReps); - } - - final workoutRepo = ref.read(workoutRepositoryProvider); - final cycleRepo = ref.read(cycleRepositoryProvider); - final cycle = await cycleRepo.getCurrentCycle(); - - final cycleIdRef = cycle?.serverId ?? cycle?.id.toString() ?? ''; - - if (widget.workoutId != null) { - var workout = await workoutRepo.getWorkoutByWeekDay( - cycleId: cycleIdRef, week: widget.week, day: widget.day); - - if (workout != null) { - final updatedExercises = _exercises.map((e) => e.toJson()).toList(); - final updatedWorkout = workout.copyWith(exercises: updatedExercises); - - await workoutRepo.completeWorkout(updatedWorkout, xpEarned: xpEarned); - } - } else { - final exercisesJson = _exercises.map((e) => e.toJson()).toList(); - - await workoutRepo.createCompletedWorkout( - cycleId: cycleIdRef, - week: widget.week, - day: widget.day, - exercises: exercisesJson, - xpEarned: xpEarned, - durationSeconds: 0, - ); - } - ref.read(syncServiceProvider).sync(); - final l10n = AppLocalizations.of(context)!; + final result = await completionService.completeWorkout( + exercises: state.exercises, + week: widget.week, + day: widget.day, + workoutId: widget.workoutId, + ); if (widget.partyId != null) { final userId = @@ -459,45 +165,16 @@ class _BattleScreenState extends ConsumerState { } } - if (mounted) { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => AlertDialog( - title: Text(l10n.battleRaidComplete), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.emoji_events, - size: 64, - color: AppTheme.primaryColor, - ), - const SizedBox(height: 16), - Text( - '+$xpEarned XP', - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: AppTheme.primaryColor, - ), - ), - ], - ), - actions: [ - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - context.go('/hub'); - }, - child: Text(l10n.battleBackToHub), - ), - ], - ), - ); + if (!mounted) return; + + if (result.hasLevelUp) { + _showLevelUpDialog(result.oldLevel!, result.newLevel!); } + + _showCompletionDialog(result.xpEarned); } void _showLevelUpDialog(int oldLevel, int newLevel) { - // ... (Code bleibt identisch) ... final l10n = AppLocalizations.of(context)!; showDialog( context: context, @@ -505,7 +182,7 @@ class _BattleScreenState extends ConsumerState { backgroundColor: AppTheme.primaryColor, title: Text( l10n.levelUpTitle, - style: TextStyle(color: Colors.black), + style: const TextStyle(color: Colors.black), ), content: Column( mainAxisSize: MainAxisSize.min, @@ -541,208 +218,673 @@ class _BattleScreenState extends ConsumerState { TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(l10n.continueButton, - style: TextStyle(color: Colors.black)), + style: const TextStyle(color: Colors.black)), ), ], ), ); } - void _handleCompletePress(WorkoutSet currentSet) { - if (currentSet.isAmrap) { - _showAmrapDialog(currentSet); - } else { - setState(() { - _repsCompleted = currentSet.repsTarget; - }); - _completeSet(); + void _showCompletionDialog(int xpEarned) { + final l10n = AppLocalizations.of(context)!; + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: Text(l10n.battleRaidComplete), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.emoji_events, + size: 64, + color: AppTheme.primaryColor, + ), + const SizedBox(height: 16), + Text( + '+$xpEarned XP', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: AppTheme.primaryColor, + ), + ), + ], + ), + actions: [ + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + context.go('/hub'); + }, + child: Text(l10n.battleBackToHub), + ), + ], + ), + ); + } + + void _showAmrapDialog(int minReps) { + int tempReps = minReps; + final l10n = AppLocalizations.of(context)!; + + 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: [ + Text( + l10n.amrapResultTitle, + style: const TextStyle( + color: AppTheme.secondaryColor, + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + const SizedBox(height: 8), + Text( + l10n.amrapResultBody, + style: const 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.secondaryColor, + foregroundColor: Colors.white, + ), + child: Text( + l10n.amrapConfirm, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + }, + ); + } + + void _handleEmomSetComplete() { + final controller = ref.read(battleControllerProvider.notifier); + final currentSet = controller.currentSet; + + if (widget.partyId != null) { + ref + .read(partyRepositoryProvider) + .dealDamage(widget.partyId!, currentSet.repsTarget); } + + controller.completeSet(repsActual: currentSet.repsTarget); + + if (!controller.isLastSet) { + controller.moveToNextSet(); + } else { + _showEmomFinishDialog(); + } + } + + void _showEmomFinishDialog() { + final controller = ref.read(battleControllerProvider.notifier); + final currentExercise = controller.currentExercise; + int setsCount = currentExercise.sets.length; + final l10n = AppLocalizations.of(context)!; + + 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.primaryColor), + const SizedBox(height: 16), + Text( + l10n.emomFinishedTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + letterSpacing: 1.5, + ), + ), + const SizedBox(height: 8), + Text( + l10n.emomFinishedBody, + style: const TextStyle(color: Colors.grey), + ), + const SizedBox(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _CounterButton( + icon: Icons.remove, + onTap: setsCount > 1 + ? () => setModalState(() => setsCount--) + : null, + ), + Container( + width: 140, + alignment: Alignment.center, + child: Column( + children: [ + Text( + '$setsCount', + style: const TextStyle( + fontSize: 64, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Text( + l10n.emomSetsCompleted, + style: const TextStyle( + color: AppTheme.primaryColor, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + _CounterButton( + icon: Icons.add, + onTap: () => setModalState(() => setsCount++), + ), + ], + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); + controller.adjustEmomSets(setsCount); + _completeSetAndProgress( + controller.currentSet.repsTarget); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.successColor, + foregroundColor: Colors.white, + ), + child: Text( + l10n.emomConfirm, + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(height: 16), + ], + ), + ); + }, + ); + }, + ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - if (_isLoading) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); + final state = ref.watch(battleControllerProvider); + + if (state.isLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); } - if (_exercises.isEmpty) { + if (state.error != null) { + return Scaffold( + appBar: AppBar(title: const Text('Battle')), + body: Center(child: Text('Error: ${state.error}')), + ); + } + + if (state.exercises.isEmpty) { return Scaffold( appBar: AppBar(title: const Text('Battle')), body: const Center(child: Text('No exercises configured')), ); } - final currentExercise = _exercises[_currentExerciseIndex]; - final currentSet = currentExercise.sets[_currentSetIndex]; + final controller = ref.read(battleControllerProvider.notifier); + final currentExercise = controller.currentExercise; + final currentSet = controller.currentSet; return FutureBuilder>( - future: ref.read(userRepositoryProvider).getInventorySettingsAsync(), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Scaffold( - body: Center(child: CircularProgressIndicator())); - } - - final inventory = snapshot.data!; - - final totalHP = _exercises.fold( - 0, - (sum, ex) => - sum + ex.sets.fold(0, (s, set) => s + set.repsTarget), + future: ref.read(userRepositoryProvider).getInventorySettingsAsync(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), ); + } - final completedHP = _exercises.take(_currentExerciseIndex).fold( - 0, - (sum, ex) => - sum + - ex.sets.fold(0, (s, set) => s + set.repsActual), - ) + - currentExercise.sets - .take(_currentSetIndex) - .fold(0, (sum, set) => sum + set.repsActual); + final inventory = snapshot.data!; + final plateResult = + _calculatePlates(currentExercise, currentSet, inventory); - final isTwoSided = currentExercise.exerciseId == 'squat' || - currentExercise.exerciseId == 'row' || - currentExercise.exerciseId == 'bench' || - currentExercise.exerciseId == 'rdl' || - currentExercise.exerciseId == 'ohp' || - currentExercise.exerciseId == 'curl'; - final isBodyweight = !isTwoSided; - final barWeight = isBodyweight - ? currentExercise.bodyweightAtSession - : (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0; - - final platesList = (inventory['plates'] as List?) - ?.map((e) => (e as num).toDouble()) - .toList() ?? - []; - - final bandsList = - (inventory['bands'] as List?)?.cast>() ?? []; - final Map availableBands = {}; - for (var band in bandsList) { - final color = band['color'] as String; - final resistance = (band['resistance_kg'] as num).toDouble(); - if (band['count'] as int > 0) { - availableBands[color] = resistance; - } - } - - final plateResult = PlateCalculator.calculate( - targetWeight: currentSet.targetWeightTotal, - barWeight: barWeight, - availablePlates: platesList, - availableBands: availableBands, - isTwoSided: isTwoSided, - ); - - return Scaffold( - appBar: AppBar( - title: Text('Week ${widget.week} - Day ${widget.day}'), - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(l10n.battleAbandonTitle), - content: Text(l10n.battleAbandonBody), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.cancelButton), + return Scaffold( + appBar: _buildAppBar(l10n), + body: Stack( + children: [ + Positioned.fill( + child: Image.asset( + AssetPaths.bgUndergroundGym, + fit: BoxFit.cover, + ), + ), + Positioned.fill( + child: Container( + color: Colors.black.withValues(alpha: 0.7), + ), + ), + Positioned.fill( + child: SafeArea( + child: state.isResting + ? _buildRestScreen(l10n, inventory) + : _buildWorkoutScreen( + currentExercise, + currentSet, + plateResult, + controller.completedHP, + controller.totalHP, ), - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - if (widget.partyId != null) { - final userId = (await ref - .read(userRepositoryProvider) - .getLocalUser()) - ?.serverId; - if (userId != null) { - await ref - .read(partyRepositoryProvider) - .leaveParty(widget.partyId!, userId); - } - } - context.go('/hub'); - }, - style: TextButton.styleFrom( - foregroundColor: AppTheme.errorColor), - child: Text(l10n.abandonButton), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildWorkoutScreen( + Exercise currentExercise, + WorkoutSet currentSet, + PlateLoadResult plateResult, + int completedHP, + int totalHP, + ) { + if (currentExercise.intervalSeconds != null && + currentExercise.intervalSeconds! > 0) { + return _buildEmomView(currentExercise, currentSet, completedHP, totalHP); + } + + final l10n = AppLocalizations.of(context)!; + final state = ref.watch(battleControllerProvider); + + return Column( + children: [ + Flexible( + flex: 4, + child: Stack( + alignment: Alignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 40), + 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: 6, + 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), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _InfoBox( + label: l10n.battleWeight, + value: '${currentSet.targetWeightTotal} kg', + ), + _InfoBox( + label: l10n.battleReps, + value: + '${currentSet.repsTarget}${currentSet.isAmrap ? "+" : ""}', + ), + ], + ), + const SizedBox(height: 24), + if (plateResult.bandAssistance != null) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: + AppTheme.primaryColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.primaryColor), + ), + child: Row( + children: [ + const Icon(Icons.help, + color: AppTheme.primaryColor, size: 32), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + l10n.battleAssistance, + style: const TextStyle( + color: AppTheme.primaryColor, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + Text( + plateResult.bandAssistance!, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + ) + else + PlateVisualizer( + plateConfiguration: plateResult.plateConfiguration, + isTwoSided: currentExercise.exerciseId == 'squat' || + currentExercise.exerciseId == 'row' || + currentExercise.exerciseId == 'bench', + exerciseName: currentExercise.exerciseName, + ), + const SizedBox(height: 32), ], ), - ); - }, - ), - ), - body: Stack( - children: [ - Positioned.fill( - child: Image.asset( - AssetPaths.bgUndergroundGym, - fit: BoxFit.cover, - ), - ), - Positioned.fill( - child: Container( - color: Colors.black.withValues(alpha: 0.7), - ), - ), - Positioned.fill( - child: SafeArea( - child: _isResting - ? _buildRestScreen(inventory) - : _buildWorkoutScreen(currentExercise, currentSet, - plateResult, completedHP, totalHP), ), ), ], ), - ); - }); - } - - // --- NEUES WIDGET: MULTIPLAYER LIVE HP BAR --- - Widget _buildMultiplayerHpBar() { - final partyAsync = ref.watch(partyStreamProvider(widget.partyId!)); - - return partyAsync.when( - data: (party) { - if (party.status == 'finished') { - return Container( - height: 20, - decoration: BoxDecoration( - color: Colors.green, borderRadius: BorderRadius.circular(10)), - child: const Center( - child: Text('BOSS DEFEATED!', - style: - TextStyle(fontWeight: FontWeight.bold, fontSize: 12))), - ); - } - - return EnemyHPBar( - current: party.currentHp, - max: party.maxHp > 0 ? party.maxHp : 1000, - ); - }, - loading: () => - const SizedBox(height: 20, child: LinearProgressIndicator()), - error: (_, __) => const SizedBox( - height: 20, - child: Text('Connection lost', - style: TextStyle(color: Colors.red, fontSize: 10))), + ), + ), + Container( + color: AppTheme.surfaceColor, + padding: const EdgeInsets.all(16), + child: SafeArea( + top: false, + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _handleCompletePress, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + l10n.battleCompleteSet, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + ), + ), + ), + ), + ], ); } - // ... (buildRestScreen und _buildNextSetPlates bleiben identisch) ... - Widget _buildRestScreen(Map inventory) { - final nextExerciseInfo = _exercises[_currentExerciseIndex]; - final nextSet = nextExerciseInfo.sets[_currentSetIndex]; - final l10n = AppLocalizations.of(context)!; + AppBar _buildAppBar(AppLocalizations l10n) { + return AppBar( + title: Text('Week ${widget.week} - Day ${widget.day}'), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => _showAbandonDialog(l10n), + ), + ); + } + + void _showAbandonDialog(AppLocalizations l10n) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.battleAbandonTitle), + content: Text(l10n.battleAbandonBody), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancelButton), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + if (widget.partyId != null) { + final userId = + (await ref.read(userRepositoryProvider).getLocalUser()) + ?.serverId; + if (userId != null) { + await ref + .read(partyRepositoryProvider) + .leaveParty(widget.partyId!, userId); + } + } + if (mounted) context.go('/hub'); + }, + style: TextButton.styleFrom(foregroundColor: AppTheme.errorColor), + child: Text(l10n.abandonButton), + ), + ], + ), + ); + } + + PlateLoadResult _calculatePlates( + dynamic exercise, + dynamic set, + Map inventory, + ) { + final isTwoSided = ['squat', 'row', 'bench', 'rdl', 'ohp', 'curl'] + .contains(exercise.exerciseId); + final isBodyweight = !isTwoSided; + final barWeight = isBodyweight + ? exercise.bodyweightAtSession + : (inventory['bar_weight'] as num?)?.toDouble() ?? 20.0; + + final platesList = (inventory['plates'] as List?) + ?.map((e) => (e as num).toDouble()) + .toList() ?? + []; + + final bandsList = + (inventory['bands'] as List?)?.cast>() ?? []; + final Map availableBands = {}; + for (var band in bandsList) { + final color = band['color'] as String; + final resistance = (band['resistance_kg'] as num).toDouble(); + if (band['count'] as int > 0) { + availableBands[color] = resistance; + } + } + + return PlateCalculator.calculate( + targetWeight: set.targetWeightTotal, + barWeight: barWeight, + availablePlates: platesList, + availableBands: availableBands, + isTwoSided: isTwoSided, + ); + } + + Widget _buildRestScreen( + AppLocalizations l10n, + Map inventory, + ) { + final state = ref.watch(battleControllerProvider); + final controller = ref.read(battleControllerProvider.notifier); + final nextExercise = controller.currentExercise; + final nextSet = controller.currentSet; return Container( decoration: const BoxDecoration( @@ -777,14 +919,14 @@ class _BattleScreenState extends ConsumerState { width: 200, height: 200, child: CircularProgressIndicator( - value: _restSeconds / 180, + value: state.restSeconds / 180, strokeWidth: 12, backgroundColor: AppTheme.xpBarBackground, color: AppTheme.primaryColor, ), ), Text( - _formatTime(_restSeconds), + _formatTime(state.restSeconds), style: Theme.of(context).textTheme.displayLarge?.copyWith( fontSize: 32, color: AppTheme.primaryColor, @@ -798,29 +940,30 @@ class _BattleScreenState extends ConsumerState { onPressed: _skipRest, child: Text(l10n.battleSkipRest), ), - if (nextSet != null && nextExerciseInfo != null) ...[ + if (nextSet != null && nextExercise != null) ...[ const SizedBox(height: 24), const Divider(color: Colors.white10, endIndent: 32, indent: 32), const SizedBox(height: 12), Text( - l10n.battleUpNext( - nextExerciseInfo.exerciseName.toUpperCase()), + l10n.battleUpNext(nextExercise.exerciseName.toUpperCase()), style: const TextStyle( - color: Colors.grey, - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2), + color: Colors.grey, + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), ), const SizedBox(height: 4), Text( '${nextSet.repsTarget} x ${nextSet.targetWeightTotal > 0 ? "${nextSet.targetWeightTotal} kg" : "Bodyweight"}', style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold), + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), ), if (nextSet.targetWeightTotal > 0) - _buildNextSetPlates(nextExerciseInfo, nextSet, inventory), + _buildNextSetPlates(nextExercise, nextSet, inventory), ], ], ), @@ -830,7 +973,10 @@ class _BattleScreenState extends ConsumerState { } Widget _buildNextSetPlates( - Exercise exercise, WorkoutSet set, Map inventory) { + Exercise exercise, + WorkoutSet set, + Map inventory, + ) { final isTwoSided = exercise.exerciseId == 'squat' || exercise.exerciseId == 'row' || exercise.exerciseId == 'bench' || @@ -846,11 +992,22 @@ class _BattleScreenState extends ConsumerState { .toList() ?? []; + final bandsList = + (inventory['bands'] as List?)?.cast>() ?? []; + final Map availableBands = {}; + for (var band in bandsList) { + final color = band['color'] as String; + final resistance = (band['resistance_kg'] as num).toDouble(); + if (band['count'] as int > 0) { + availableBands[color] = resistance; + } + } + final plateResult = PlateCalculator.calculate( targetWeight: set.targetWeightTotal, barWeight: barWeight, availablePlates: platesList, - availableBands: {}, + availableBands: availableBands, isTwoSided: true, ); @@ -864,334 +1021,13 @@ class _BattleScreenState extends ConsumerState { ); } - Widget _buildWorkoutScreen( - Exercise currentExercise, - WorkoutSet currentSet, - PlateLoadResult plateResult, - int completedHP, - int totalHP, - ) { - if (currentExercise.intervalSeconds != null && - currentExercise.intervalSeconds! > 0) { - return _buildEmomView(currentExercise, currentSet, completedHP, totalHP); - } - final readableStyle = Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Colors.white, - shadows: [ - const Shadow(color: Colors.black, blurRadius: 4, offset: Offset(0, 1)) - ], - ); - - final titleStyle = Theme.of(context).textTheme.titleLarge?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - shadows: [ - const Shadow(color: Colors.black, blurRadius: 8, offset: Offset(0, 2)) - ], - ); - final l10n = AppLocalizations.of(context)!; - - return Column( - children: [ - Flexible( - flex: 4, - child: Stack( - alignment: Alignment.center, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 40), - 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( - _currentExerciseIndex + 1, _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), - // --- MULTIPLAYER CHECK: Zeige Live oder Lokale HP --- - widget.partyId != null - ? _buildMultiplayerHpBar() - : EnemyHPBar( - current: totalHP - completedHP, - max: totalHP, - ), - // ---------------------------------------------------- - ], - ), - ), - ], - ), - ), - Expanded( - flex: 6, - 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: [ - Text( - currentExercise.exerciseName, - style: Theme.of(context) - .textTheme - .headlineMedium - ?.copyWith( - color: AppTheme.primaryColor, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.info_outline, - color: Colors.white54), - onPressed: () => - _showExerciseGuide(currentExercise.exerciseId), - ), - Text( - l10n.battleSet(_currentSetIndex + 1, - currentExercise.sets.length), - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(color: Colors.white70), - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _InfoBox( - label: l10n.battleWeight, - value: '${currentSet.targetWeightTotal} kg'), - _InfoBox( - label: l10n.battleReps, - value: - '${currentSet.repsTarget}${currentSet.isAmrap ? "+" : ""}'), - ], - ), - const SizedBox(height: 24), - if (plateResult.bandAssistance != null) - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: - AppTheme.primaryColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppTheme.primaryColor), - ), - child: Row( - children: [ - const Icon(Icons.help, - color: AppTheme.primaryColor, size: 32), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text(l10n.battleAssistance, - style: TextStyle( - color: AppTheme.primaryColor, - fontSize: 12, - fontWeight: FontWeight.bold)), - Text(plateResult.bandAssistance!, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold)), - ], - ), - ), - ], - ), - ) - else - PlateVisualizer( - plateConfiguration: plateResult.plateConfiguration, - isTwoSided: currentExercise.exerciseId == 'squat' || - currentExercise.exerciseId == 'row' || - currentExercise.exerciseId == 'bench', - exerciseName: currentExercise.exerciseName, - ), - const SizedBox(height: 32), - ], - ), - ), - ), - ], - ), - ), - ), - Container( - color: AppTheme.surfaceColor, - padding: const EdgeInsets.all(16), - child: SafeArea( - top: false, - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () => _handleCompletePress(currentSet), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - backgroundColor: AppTheme.primaryColor, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - ), - child: Text(l10n.battleCompleteSet, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - letterSpacing: 1.2)), - ), - ), - ), - ), - ], - ); - } - - String _formatTime(int seconds) { - // ... (Code bleibt identisch) ... - final minutes = seconds ~/ 60; - final secs = seconds % 60; - return '${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}'; - } - - void _showAmrapDialog(WorkoutSet set) { - // ... (Code bleibt identisch) ... - int tempReps = set.repsTarget; - final l10n = AppLocalizations.of(context)!; - - 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: [ - Text( - l10n.amrapResultTitle, - style: TextStyle( - color: AppTheme.secondaryColor, - fontWeight: FontWeight.bold, - fontSize: 20), - ), - const SizedBox(height: 8), - Text( - l10n.amrapResultBody, - 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); - setState(() { - _repsCompleted = tempReps; - }); - _completeSet(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.secondaryColor, - foregroundColor: Colors.white, - ), - child: Text(l10n.amrapConfirm, - style: TextStyle( - fontSize: 18, fontWeight: FontWeight.bold)), - ), - ), - const SizedBox(height: 16), - ], - ), - ); - }); - }, - ); - } - Widget _buildEmomView( - Exercise currentExercise, - WorkoutSet currentSet, + dynamic currentExercise, + dynamic currentSet, int completedHP, int totalHP, ) { + final state = ref.watch(battleControllerProvider); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1211,8 +1047,11 @@ class _BattleScreenState extends ConsumerState { child: Image.asset( _getEnemyAsset(currentExercise.exerciseId), fit: BoxFit.contain, - errorBuilder: (c, o, s) => const Icon(Icons.fitness_center, - size: 40, color: Colors.white), + errorBuilder: (c, o, s) => const Icon( + Icons.fitness_center, + size: 40, + color: Colors.white, + ), ), ), const SizedBox(width: 16), @@ -1223,17 +1062,10 @@ class _BattleScreenState extends ConsumerState { Text( currentExercise.exerciseName, style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: Colors.white), - ), - IconButton( - icon: const Icon(Icons.info_outline, - size: 20, color: AppTheme.primaryColor), - onPressed: () => - _showExerciseGuide(currentExercise.exerciseId), - padding: const EdgeInsets.all(4), - constraints: const BoxConstraints(), + fontWeight: FontWeight.bold, + fontSize: 16, + color: Colors.white, + ), ), Text( '${currentSet.repsTarget} Reps per Round', @@ -1247,20 +1079,17 @@ class _BattleScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - // --- MULTIPLAYER CHECK BEI EMOM --- widget.partyId != null - ? Container( - height: 40, - child: _buildMultiplayerHpBar(), - ) + ? SizedBox(height: 40, child: _buildMultiplayerHpBar()) : Column( children: [ Text( '${totalHP - completedHP}/$totalHP HP', style: const TextStyle( - color: AppTheme.errorColor, - fontWeight: FontWeight.bold, - fontSize: 10), + color: AppTheme.errorColor, + fontWeight: FontWeight.bold, + fontSize: 10, + ), textAlign: TextAlign.center, ), const SizedBox(height: 4), @@ -1277,7 +1106,6 @@ class _BattleScreenState extends ConsumerState { ), ], ), - // ---------------------------------- ], ), ), @@ -1293,10 +1121,10 @@ class _BattleScreenState extends ConsumerState { children: [ EmomTimerWidget( key: ValueKey( - '${currentExercise.exerciseId}_$_currentExerciseIndex'), + '${currentExercise.exerciseId}_${state.currentExerciseIndex}'), intervalSeconds: currentExercise.intervalSeconds!, totalSets: currentExercise.sets.length, - currentSet: _currentSetIndex + 1, + currentSet: state.currentSetIndex + 1, onSetComplete: _handleEmomSetComplete, onWorkoutComplete: _handleEmomSetComplete, ), @@ -1312,8 +1140,9 @@ class _BattleScreenState extends ConsumerState { child: Text( 'WEIGHT: ${currentSet.targetWeightTotal} kg', style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppTheme.primaryColor, - fontWeight: FontWeight.bold), + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), ), ), ], @@ -1325,148 +1154,49 @@ class _BattleScreenState extends ConsumerState { ); } - void _adjustEmomSets(int newTotalSets) { - // ... (Code bleibt identisch) ... - final currentEx = _exercises[_currentExerciseIndex]; - - if (newTotalSets == currentEx.sets.length) return; - - List currentSets = List.from(currentEx.sets); - - if (newTotalSets > currentSets.length) { - final templateSet = currentSets.last; - - for (int i = currentSets.length; i < newTotalSets; i++) { - currentSets.add(templateSet.copyWith( - setNumber: i + 1, - completed: true, - repsActual: templateSet.repsTarget, - )); - } - } else { - currentSets = currentSets.sublist(0, newTotalSets); - } - - final updatedEx = currentEx.copyWith(sets: currentSets); - final updatedExercises = List.from(_exercises); - updatedExercises[_currentExerciseIndex] = updatedEx; - - setState(() { - _exercises = updatedExercises; - - _currentSetIndex = newTotalSets - 1; - - _repsCompleted = updatedEx.sets.last.repsTarget; - }); - } - - void _showEmomFinishDialog() { - // ... (Code bleibt identisch) ... - final currentEx = _exercises[_currentExerciseIndex]; - int setsCount = currentEx.sets.length; - final l10n = AppLocalizations.of(context)!; - - 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.primaryColor), - const SizedBox(height: 16), - Text( - l10n.emomFinishedTitle, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - letterSpacing: 1.5, - ), - ), - const SizedBox(height: 8), - Text( - l10n.emomFinishedBody, - style: TextStyle(color: Colors.grey), - ), - const SizedBox(height: 32), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _CounterButton( - icon: Icons.remove, - onTap: setsCount > 1 - ? () => setModalState(() => setsCount--) - : null, - ), - Container( - width: 140, - alignment: Alignment.center, - child: Column( - children: [ - Text( - '$setsCount', - style: const TextStyle( - fontSize: 64, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - Text(l10n.emomSetsCompleted, - style: TextStyle( - color: AppTheme.primaryColor, - fontSize: 10, - fontWeight: FontWeight.bold)), - ], - ), - ), - _CounterButton( - icon: Icons.add, - onTap: () => setModalState(() => setsCount++), - ), - ], - ), - const SizedBox(height: 32), - SizedBox( - width: double.infinity, - height: 56, - child: ElevatedButton( - onPressed: () { - Navigator.pop(context); - - _adjustEmomSets(setsCount); - - _completeSet(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.successColor, - foregroundColor: Colors.white, - ), - child: Text(l10n.emomConfirm, - style: TextStyle( - fontSize: 18, fontWeight: FontWeight.bold)), - ), - ), - const SizedBox(height: 16), - ], + Widget _buildMultiplayerHpBar() { + final partyAsync = ref.watch(partyStreamProvider(widget.partyId!)); + return partyAsync.when( + data: (party) { + if (party.status == 'finished') { + return Container( + height: 20, + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(10), + ), + child: const Center( + child: Text( + 'BOSS DEFEATED!', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), ), - ); - }, + ), + ); + } + return EnemyHPBar( + current: party.currentHp, + max: party.maxHp > 0 ? party.maxHp : 1000, ); }, + loading: () => + const SizedBox(height: 20, child: LinearProgressIndicator()), + error: (_, __) => const SizedBox( + height: 20, + child: Text( + 'Connection lost', + style: TextStyle(color: Colors.red, fontSize: 10), + ), + ), ); } + + String _formatTime(int seconds) { + final minutes = seconds ~/ 60; + final secs = seconds % 60; + return '${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}'; + } } -// ... _InfoBox und _CounterButton bleiben identisch ... class _InfoBox extends StatelessWidget { final String label; final String value; @@ -1476,15 +1206,23 @@ class _InfoBox extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - Text(label, - style: const TextStyle( - color: Colors.grey, fontSize: 12, fontWeight: FontWeight.bold)), + Text( + label, + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), const SizedBox(height: 4), - Text(value, - style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold)), + Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), ], ); } @@ -1511,8 +1249,11 @@ class _CounterButton extends StatelessWidget { : Colors.grey.withValues(alpha: 0.1), shape: BoxShape.circle, ), - child: Icon(icon, - size: 32, color: onTap != null ? Colors.black : Colors.grey), + child: Icon( + icon, + size: 32, + color: onTap != null ? Colors.black : Colors.grey, + ), ), ), ); diff --git a/lib/src/features/workout_runner/presentation/widgets/workout_content_widget.dart b/lib/src/features/workout_runner/presentation/widgets/workout_content_widget.dart new file mode 100644 index 0000000..5a8a84e --- /dev/null +++ b/lib/src/features/workout_runner/presentation/widgets/workout_content_widget.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:slrpg_app/l10n/app_localizations.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../shared/domain/entities/exercise.dart'; +import '../../../../shared/domain/entities/workout_set.dart'; +import '../../../../shared/domain/logic/plate_calculator.dart'; +import '../widgets/plate_visualizer.dart'; + +class WorkoutContent extends StatelessWidget { + final Exercise exercise; + final WorkoutSet currentSet; + final int currentSetIndex; + final int totalSets; + final PlateLoadResult plateResult; + final VoidCallback onCompletePress; + final VoidCallback onShowGuide; + + const WorkoutContent({ + super.key, + required this.exercise, + required this.currentSet, + required this.currentSetIndex, + required this.totalSets, + required this.plateResult, + required this.onCompletePress, + required this.onShowGuide, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return 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( + exercise.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: onShowGuide, + ), + ], + ), + Text( + l10n.battleSet(currentSetIndex + 1, totalSets), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white70, + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _InfoBox( + label: l10n.battleWeight, + value: '${currentSet.targetWeightTotal} kg', + ), + _InfoBox( + label: l10n.battleReps, + value: + '${currentSet.repsTarget}${currentSet.isAmrap ? "+" : ""}', + ), + ], + ), + const SizedBox(height: 24), + if (plateResult.bandAssistance != null) + _BandAssistanceInfo( + assistance: plateResult.bandAssistance!, + l10n: l10n, + ) + else + PlateVisualizer( + plateConfiguration: plateResult.plateConfiguration, + isTwoSided: _isTwoSided(exercise.exerciseId), + exerciseName: exercise.exerciseName, + ), + const SizedBox(height: 32), + ], + ), + ), + ), + _CompleteButton(onPressed: onCompletePress), + ], + ), + ); + } + + bool _isTwoSided(String exerciseId) { + return ['squat', 'row', 'bench', 'rdl', 'ohp', 'curl'].contains(exerciseId); + } +} + +class _InfoBox extends StatelessWidget { + final String label; + final String value; + + const _InfoBox({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text( + label, + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } +} + +class _BandAssistanceInfo extends StatelessWidget { + final String assistance; + final AppLocalizations l10n; + + const _BandAssistanceInfo({ + required this.assistance, + required this.l10n, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.primaryColor), + ), + child: Row( + children: [ + const Icon(Icons.help, color: AppTheme.primaryColor, size: 32), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.battleAssistance, + style: const TextStyle( + color: AppTheme.primaryColor, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + Text( + assistance, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _CompleteButton extends StatelessWidget { + final VoidCallback onPressed; + + const _CompleteButton({required this.onPressed}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Container( + color: AppTheme.surfaceColor, + padding: const EdgeInsets.all(16), + child: SafeArea( + top: false, + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + l10n.battleCompleteSet, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + ), + ), + ), + ); + } +}