Merge branch 'dev/refactor-battlescreen'

* dev/refactor-battlescreen:
  refactor: refactor battlescreen and split it into seperate services and controllers
This commit is contained in:
Patryk Hegenberg 2026-01-13 10:38:50 +01:00
commit ab3d0e9c15
8 changed files with 1640 additions and 1086 deletions

View file

@ -72,10 +72,6 @@ class PartyRepository {
await _pb.collection('parties').update(partyId, body: body);
}
// Future<void> startRaid(String partyId) async {
// await _pb.collection('parties').update(partyId, body: {'status': 'active'});
// }
Stream<Party> subscribeToParty(String partyId) async* {
await _syncAuth();

View file

@ -223,7 +223,7 @@ class _LobbyScreenState extends ConsumerState<LobbyScreen> {
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);

View file

@ -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<Exercise> 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<Exercise>? 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<Exercise> 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<WorkoutSet>.from(currentExercise.sets);
updatedSets[state.currentSetIndex] = updatedSet;
final updatedExercise = currentExercise.copyWith(sets: updatedSets);
final updatedExercises = List<Exercise>.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<WorkoutSet> 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<Exercise>.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<int>(
0,
(sum, ex) => sum + ex.sets.fold<int>(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<int>(
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;
}
}

View file

@ -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;
}
}

View file

@ -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<WorkoutCompletionResult> completeWorkout({
required List<Exercise> 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<void> _reportQuestEvents(List<Exercise> 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<void> _saveWorkout({
required List<Exercise> 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,
);
}
}
}

View file

@ -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<List<Exercise>> 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<Exercise> 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<Exercise> _generateExercises(
int week, int day, UserCollection user, Map<String, double> tms) {
final exercises = <Exercise>[];
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<WorkoutSet> 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;
}
}

View file

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