feat: add multiplayer workout

Added a lobbyScreen to generate a unique Code and invite other Players
and the possibility to workout together, by using the pocketbase
realtime feature.
This commit is contained in:
Patryk Hegenberg 2026-01-13 07:56:08 +01:00
parent 83619f31c5
commit a2067b5f9b
11 changed files with 792 additions and 33 deletions

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:slrpg_app/src/features/multiplayer/presentation/screens/leaderboard_screen.dart';
import 'package:slrpg_app/src/features/multiplayer/presentation/screens/lobby_screen.dart';
import '../../features/authentication/presentation/screens/login_screen.dart';
import '../../features/authentication/presentation/screens/profile_screen.dart';
@ -140,6 +141,26 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/leaderboard',
builder: (context, state) => const LeaderboardScreen(),
),
GoRoute(
path: '/lobby/:partyId',
builder: (context, state) {
final partyId = state.pathParameters['partyId']!;
return LobbyScreen(partyId: partyId);
},
),
GoRoute(
path: '/battle/:partyId',
builder: (context, state) {
final partyId = state.pathParameters['partyId']!;
final extra = state.extra as Map<String, dynamic>?;
return BattleScreen(
week: extra?['week'] ?? 1,
day: extra?['day'] ?? 1,
workoutId: extra?['workoutId'],
partyId: partyId);
},
),
],
);
});

View file

@ -2,6 +2,7 @@ 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 'package:slrpg_app/src/features/multiplayer/data/repositories/party_repository.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../core/debug/debug_config_screen.dart';
@ -227,6 +228,87 @@ class _HubScreenState extends ConsumerState<HubScreen> {
);
}
void _showMultiplayerDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('MULTIPLAYER RAID'),
content:
const Text('Join forces with other heroes to defeat epic bosses!'),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
_showJoinCodeDialog();
},
child: const Text('JOIN PARTY'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor),
onPressed: () async {
Navigator.pop(context);
try {
final party =
await ref.read(partyRepositoryProvider).createParty();
if (mounted) context.go('/lobby/${party.id}');
} catch (e) {
if (mounted)
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Error: $e')));
}
},
child: const Text('CREATE PARTY',
style: TextStyle(
color: Colors.black, fontWeight: FontWeight.bold)),
),
],
),
);
}
void _showJoinCodeDialog() {
final controller = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('ENTER PARTY CODE'),
content: TextField(
controller: controller,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(
hintText: 'e.g. A1B2',
border: OutlineInputBorder(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('CANCEL'),
),
ElevatedButton(
onPressed: () async {
final code = controller.text.trim().toUpperCase();
if (code.isNotEmpty) {
Navigator.pop(context);
try {
final party =
await ref.read(partyRepositoryProvider).joinParty(code);
if (mounted) context.go('/lobby/${party.id}');
} catch (e) {
if (mounted)
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
},
child: const Text('JOIN'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final userRepo = ref.watch(userRepositoryProvider);
@ -334,6 +416,12 @@ class _HubScreenState extends ConsumerState<HubScreen> {
color: AppTheme.secondaryColor),
onPressed: () => context.go('/leaderboard'),
),
IconButton(
icon: const Icon(Icons.groups,
color: AppTheme.secondaryColor),
tooltip: 'Multiplayer Lobby',
onPressed: _showMultiplayerDialog,
),
],
),
),

View file

@ -0,0 +1,143 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:slrpg_app/src/features/multiplayer/domain/entities/party.dart';
import 'package:slrpg_app/src/features/multiplayer/domain/entities/party_member.dart';
import 'package:slrpg_app/src/shared/data/repositories/user_repository.dart';
import '../../../../core/constants/app_constants.dart';
import '../../../../shared/data/remote/api_client.dart';
final partyRepositoryProvider = Provider((ref) {
return PartyRepository(ref.read(apiClientProvider));
});
class PartyRepository {
final ApiClient _api;
final PocketBase _pb;
PartyRepository(this._api) : _pb = PocketBase(AppConstants.apiBaseUrl);
Future<void> _syncAuth() async {
final token = await _api.getToken();
if (token != null) {
_pb.authStore.save(token, null);
}
}
Future<Party> createParty() async {
final response = await _api.dio.post('/api/v1/party/create');
final partyId = response.data['party_id'];
return getPartyDetails(partyId);
}
Future<Party> joinParty(String code) async {
final response = await _api.dio.post(
'/api/v1/party/join',
data: {'code': code},
);
final partyId = response.data['party_id'];
return getPartyDetails(partyId);
}
Future<Party> getPartyDetails(String partyId) async {
final response =
await _api.dio.get('/api/collections/parties/records/$partyId');
return Party.fromJson(response.data);
}
Future<void> setReady(String partyId, bool isReady) async {
final userId = (await _api.getUserId())!;
final members = await _pb.collection('party_members').getList(
filter: 'party_id="$partyId" && user_id="$userId"',
);
if (members.items.isNotEmpty) {
await _pb.collection('party_members').update(
members.items.first.id,
body: {'is_ready': isReady},
);
}
}
Future<void> startRaid(String partyId, {int? customHp}) async {
final body = <String, dynamic>{'status': 'active'};
if (customHp != null) {
body['current_hp'] = customHp;
body['max_hp'] = customHp;
}
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();
yield await getPartyDetails(partyId);
final controller = StreamController<Party>();
_pb.collection('parties').subscribe(partyId, (e) {
if (e.action == 'update') {
controller.add(Party.fromJson(e.record!.toJson()));
}
});
yield* controller.stream;
}
Stream<List<PartyMember>> subscribeToMembers(String partyId) async* {
await _syncAuth();
Future<List<PartyMember>> fetchMembers() async {
final records = await _pb.collection('party_members').getFullList(
filter: 'party_id="$partyId"',
expand: 'user_id',
);
return records.map((r) => PartyMember.fromRecord(r.toJson())).toList();
}
yield await fetchMembers();
final controller = StreamController<List<PartyMember>>();
_pb.collection('party_members').subscribe('*', (e) async {
if (e.record!.getStringValue('party_id') == partyId) {
controller.add(await fetchMembers());
}
});
yield* controller.stream;
}
Future<void> dealDamage(String partyId, int damage) async {
await _api.dio.post(
'/api/v1/party/damage',
data: {
'party_id': partyId,
'damage': damage,
},
);
}
Future<void> leaveParty(String partyId, String userId) async {
try {
final result = await _pb.collection('party_members').getList(
filter: 'party_id="$partyId" && user_id="$userId"',
);
if (result.items.isNotEmpty) {
final memberId = result.items.first.id;
await _pb.collection('party_members').delete(memberId);
}
} catch (e) {
debugPrint('Error leaving party: $e');
}
}
}

View file

@ -0,0 +1,18 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'party.freezed.dart';
part 'party.g.dart';
@freezed
abstract class Party with _$Party {
const factory Party({
required String id,
required String code,
@JsonKey(name: 'host_id') required String hostId,
required String status, // 'waiting', 'active', 'finished'
@Default(0) @JsonKey(name: 'current_hp') int currentHp,
@Default(0) @JsonKey(name: 'max_hp') int maxHp,
}) = _Party;
factory Party.fromJson(Map<String, dynamic> json) => _$PartyFromJson(json);
}

View file

@ -0,0 +1,40 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:slrpg_app/src/features/gamification/domain/entities/avatar_config.dart';
part 'party_member.freezed.dart';
part 'party_member.g.dart';
@freezed
abstract class PartyMember with _$PartyMember {
const factory PartyMember({
required String id,
@JsonKey(name: 'user_id') required String userId,
@JsonKey(name: 'is_ready') required bool isReady,
@JsonKey(includeFromJson: false) String? username,
@JsonKey(includeFromJson: false) AvatarConfig? avatar,
}) = _PartyMember;
factory PartyMember.fromJson(Map<String, dynamic> json) =>
_$PartyMemberFromJson(json);
factory PartyMember.fromRecord(Map<String, dynamic> json) {
var member = PartyMember.fromJson(json);
if (json['expand'] != null && json['expand']['user_id'] != null) {
final userMap = json['expand']['user_id'] as Map<String, dynamic>;
AvatarConfig? avatarConfig;
if (userMap['avatar_config'] != null) {
try {
avatarConfig = AvatarConfig.fromJson(userMap['avatar_config']);
} catch (_) {}
}
return member.copyWith(
username: userMap['name'] ?? userMap['username'] ?? 'Unknown',
avatar: avatarConfig,
);
}
return member;
}
}

View file

@ -0,0 +1,276 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart';
import 'package:slrpg_app/src/features/workout_runner/application/workout_generator_service.dart';
import 'package:slrpg_app/src/shared/data/repositories/workout_repository.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../shared/data/repositories/user_repository.dart';
import '../../data/repositories/party_repository.dart';
import '../../domain/entities/party.dart';
import '../../domain/entities/party_member.dart';
import '../../../gamification/presentation/widgets/avatar_renderer.dart';
import '../../../../shared/data/repositories/cycle_repository.dart';
class LobbyScreen extends ConsumerStatefulWidget {
final String partyId;
const LobbyScreen({super.key, required this.partyId});
@override
ConsumerState<LobbyScreen> createState() => _LobbyScreenState();
}
class _LobbyScreenState extends ConsumerState<LobbyScreen> {
Future<void> _leaveParty() async {
final currentUser = await ref.read(userRepositoryProvider).getLocalUser();
if (currentUser != null) {
await ref
.read(partyRepositoryProvider)
.leaveParty(widget.partyId, currentUser.serverId!);
}
if (mounted) context.go('/hub');
}
int _calculateRaidHp(int memberCount) {
const int baseVolumePerPlayer = 50;
return (baseVolumePerPlayer * memberCount * 1.1).round();
}
@override
Widget build(BuildContext context) {
final partyAsync = ref.watch(partyStreamProvider(widget.partyId));
final membersAsync = ref.watch(partyMembersStreamProvider(widget.partyId));
final currentUser = ref.watch(userRepositoryProvider).getLocalUser();
return Scaffold(
appBar: AppBar(
title: const Text('RAID LOBBY'),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _leaveParty,
),
),
body: partyAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(child: Text('Error: $err')),
data: (party) {
if (party.status == 'active') {
return FutureBuilder(
future: _findNextWorkout(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done &&
snapshot.hasData) {
final next = snapshot.data as Map<String, int>;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
context.go('/battle/${party.id}',
extra: {'week': next['week'], 'day': next['day']});
}
});
return const Center(child: Text('Entering Battle...'));
}
return const Center(
child: Text('Raid is starting... Preparing workout...'));
},
);
}
return Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
color: AppTheme.surfaceColor,
child: Column(
children: [
const Text('PARTY CODE',
style: TextStyle(color: Colors.grey)),
const SizedBox(height: 8),
InkWell(
onTap: () {
Clipboard.setData(ClipboardData(text: party.code));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Code copied!')),
);
},
child: Text(
party.code,
style: const TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
letterSpacing: 8,
color: AppTheme.primaryColor,
),
),
),
const Text('(Tap to copy)',
style: TextStyle(fontSize: 12, color: Colors.grey)),
],
),
),
Expanded(
child: membersAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (e, _) =>
Center(child: Text('Error loading members: $e')),
data: (members) {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: members.length,
itemBuilder: (context, index) {
final member = members[index];
return _MemberCard(member: member);
},
);
},
),
),
FutureBuilder(
future: currentUser,
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox();
final myId = snapshot.data!.serverId;
final isHost = party.hostId == myId;
final myMember = membersAsync.value
?.where((m) => m.userId == myId)
.firstOrNull;
final isReady = myMember?.isReady ?? false;
return Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: AppTheme.surfaceColor,
border: Border(top: BorderSide(color: Colors.white10)),
),
child: Row(
children: [
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor:
isReady ? Colors.grey : AppTheme.primaryColor,
padding: const EdgeInsets.symmetric(vertical: 16),
),
onPressed: () {
ref
.read(partyRepositoryProvider)
.setReady(party.id, !isReady);
},
child: Text(
isReady ? 'READY' : 'NOT READY',
style:
const TextStyle(fontWeight: FontWeight.bold),
),
),
),
if (isHost) ...[
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
padding:
const EdgeInsets.symmetric(vertical: 16),
),
onPressed: () async {
final members = membersAsync.value ?? [];
final memberCount = members.length;
final raidHp = _calculateRaidHp(memberCount);
await ref
.read(partyRepositoryProvider)
.startRaid(party.id, customHp: raidHp);
// ref
// .read(partyRepositoryProvider)
// .startRaid(party.id);
},
child: const Text(
'START RAID',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white),
),
),
),
],
],
),
);
},
),
],
);
},
),
);
}
Future<Map<String, int>> _findNextWorkout() async {
final cycleRepo = ref.read(cycleRepositoryProvider);
final workoutRepo = ref.read(workoutRepositoryProvider);
final activeCycle = await cycleRepo.getCurrentCycle();
int targetWeek = 1;
int targetDay = 1;
if (activeCycle != null) {
final cycleId = activeCycle.serverId ?? activeCycle.id.toString();
for (int w = 1; w <= 4; w++) {
for (int d = 1; d <= 4; d++) {
final workout = await workoutRepo.getWorkoutByWeekDay(
cycleId: cycleId, week: w, day: d);
if (workout == null || workout.completedAt == null) {
return {'week': w, 'day': d};
}
}
}
return {'week': 1, 'day': 1};
}
return {'week': 1, 'day': 1};
}
}
class _MemberCard extends StatelessWidget {
final PartyMember member;
const _MemberCard({required this.member});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: SizedBox(
width: 40,
height: 40,
child: member.avatar != null
? AvatarRenderer(config: member.avatar!, size: 40)
: const CircleAvatar(child: Icon(Icons.person)),
),
title: Text(member.username ?? 'Unknown'),
trailing: member.isReady
? const Icon(Icons.check_circle, color: Colors.green)
: const Icon(Icons.circle_outlined, color: Colors.grey),
),
);
}
}
final partyStreamProvider =
StreamProvider.autoDispose.family<Party, String>((ref, partyId) {
return ref.watch(partyRepositoryProvider).subscribeToParty(partyId);
});
final partyMembersStreamProvider = StreamProvider.autoDispose
.family<List<PartyMember>, String>((ref, partyId) {
return ref.watch(partyRepositoryProvider).subscribeToMembers(partyId);
});

View file

@ -23,16 +23,23 @@ 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 '../../../multiplayer/data/repositories/party_repository.dart';
import '../../../multiplayer/presentation/screens/lobby_screen.dart'; // Für partyStreamProvider
// ------------------------------------
class BattleScreen extends ConsumerStatefulWidget {
final int week;
final int day;
final int? workoutId;
final String? partyId; // Multiplayer Party ID
const BattleScreen({
super.key,
required this.week,
required this.day,
this.workoutId,
this.partyId,
});
@override
@ -52,6 +59,11 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
@override
void initState() {
super.initState();
if (widget.partyId != null) {
debugPrint("⚔️ MULTIPLAYER BATTLE STARTED! Party ID: ${widget.partyId}");
} else {
debugPrint("👤 SINGLEPLAYER BATTLE");
}
_loadWorkout();
}
@ -80,6 +92,14 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
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,
@ -117,6 +137,7 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
}
List<Map<String, dynamic>> _getExerciseConfig(int day, UserCollection user) {
// ... (Code bleibt identisch) ...
final variants = user.exerciseVariants ?? {};
Map<String, dynamic> getVariant(String slot, String defaultId,
@ -188,6 +209,7 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
}
Future<void> _loadWorkout() async {
// ... (Code bleibt identisch) ...
final userRepo = ref.read(userRepositoryProvider);
final workoutRepo = ref.read(workoutRepositoryProvider);
final cycleRepo = ref.read(cycleRepositoryProvider);
@ -276,6 +298,15 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
}
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];
@ -387,13 +418,13 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
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) {
final workoutRepo = ref.read(workoutRepositoryProvider);
final cycleRepo = ref.read(cycleRepositoryProvider);
final cycle = await cycleRepo.getCurrentCycle();
final cycleIdRef = cycle?.serverId ?? cycle?.id.toString() ?? '';
var workout = await workoutRepo.getWorkoutByWeekDay(
cycleId: cycleIdRef, week: widget.week, day: widget.day);
@ -402,11 +433,31 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
final updatedWorkout = workout.copyWith(exercises: updatedExercises);
await workoutRepo.completeWorkout(updatedWorkout, xpEarned: xpEarned);
}
} else {
final exercisesJson = _exercises.map((e) => e.toJson()).toList();
ref.read(syncServiceProvider).sync();
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)!;
if (widget.partyId != null) {
final userId =
(await ref.read(userRepositoryProvider).getLocalUser())?.serverId;
if (userId != null) {
await ref
.read(partyRepositoryProvider)
.leaveParty(widget.partyId!, userId);
}
}
final l10n = AppLocalizations.of(context)!;
if (mounted) {
showDialog(
@ -446,6 +497,7 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
}
void _showLevelUpDialog(int oldLevel, int newLevel) {
// ... (Code bleibt identisch) ...
final l10n = AppLocalizations.of(context)!;
showDialog(
context: context,
@ -602,8 +654,19 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
child: Text(l10n.cancelButton),
),
TextButton(
onPressed: () {
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(
@ -643,6 +706,39 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
});
}
// --- 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))),
);
}
// ... (buildRestScreen und _buildNextSetPlates bleiben identisch) ...
Widget _buildRestScreen(Map<String, dynamic> inventory) {
final nextExerciseInfo = _exercises[_currentExerciseIndex];
final nextSet = nextExerciseInfo.sets[_currentSetIndex];
@ -841,10 +937,14 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
const Icon(Icons.favorite,
color: AppTheme.errorColor, size: 24),
const SizedBox(height: 4),
EnemyHPBar(
current: totalHP - completedHP,
max: totalHP,
),
// --- MULTIPLAYER CHECK: Zeige Live oder Lokale HP ---
widget.partyId != null
? _buildMultiplayerHpBar()
: EnemyHPBar(
current: totalHP - completedHP,
max: totalHP,
),
// ----------------------------------------------------
],
),
),
@ -994,12 +1094,14 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
}
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)!;
@ -1145,26 +1247,37 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${totalHP - completedHP}/$totalHP HP',
style: const TextStyle(
color: AppTheme.errorColor,
fontWeight: FontWeight.bold,
fontSize: 10),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: totalHP > 0
? (totalHP - completedHP) / totalHP
: 0.0,
backgroundColor: Colors.red[900],
color: AppTheme.errorColor,
minHeight: 6,
),
),
// --- MULTIPLAYER CHECK BEI EMOM ---
widget.partyId != null
? Container(
height: 40,
child: _buildMultiplayerHpBar(),
)
: Column(
children: [
Text(
'${totalHP - completedHP}/$totalHP HP',
style: const TextStyle(
color: AppTheme.errorColor,
fontWeight: FontWeight.bold,
fontSize: 10),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: totalHP > 0
? (totalHP - completedHP) / totalHP
: 0.0,
backgroundColor: Colors.red[900],
color: AppTheme.errorColor,
minHeight: 6,
),
),
],
),
// ----------------------------------
],
),
),
@ -1213,6 +1326,7 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
}
void _adjustEmomSets(int newTotalSets) {
// ... (Code bleibt identisch) ...
final currentEx = _exercises[_currentExerciseIndex];
if (newTotalSets == currentEx.sets.length) return;
@ -1247,6 +1361,7 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
}
void _showEmomFinishDialog() {
// ... (Code bleibt identisch) ...
final currentEx = _exercises[_currentExerciseIndex];
int setsCount = currentEx.sets.length;
final l10n = AppLocalizations.of(context)!;
@ -1351,6 +1466,7 @@ class _BattleScreenState extends ConsumerState<BattleScreen> {
}
}
// ... _InfoBox und _CounterButton bleiben identisch ...
class _InfoBox extends StatelessWidget {
final String label;
final String value;

View file

@ -176,10 +176,16 @@ class ApiClient {
);
final token = response.data['token'];
final record = response.data['record'];
if (token != null) {
await _storage.write(key: AppConstants.keyAuthToken, value: token);
}
if (record != null && record['id'] != null) {
await _storage.write(key: AppConstants.keyUserId, value: record['id']);
}
return response.data;
} catch (e) {
_logger.e('Login failed', error: e);
@ -212,6 +218,17 @@ class ApiClient {
'avatar_config': avatarConfig ?? {},
},
);
final token = response.data['token'];
final record = response.data['record'];
if (token != null) {
await _storage.write(key: AppConstants.keyAuthToken, value: token);
}
if (record != null && record['id'] != null) {
await _storage.write(key: AppConstants.keyUserId, value: record['id']);
}
return response.data;
} catch (e) {
_logger.e('Registration failed', error: e);
@ -371,4 +388,12 @@ class ApiClient {
rethrow;
}
}
Future<String?> getToken() async {
return await _storage.read(key: AppConstants.keyAuthToken);
}
Future<String?> getUserId() async {
return await _storage.read(key: AppConstants.keyUserId);
}
}

View file

@ -84,6 +84,29 @@ class WorkoutRepository {
.write(companion);
}
Future<void> createCompletedWorkout({
required String cycleId,
required int week,
required int day,
required List<Map<String, dynamic>> exercises,
required int xpEarned,
required int durationSeconds,
}) async {
final now = DateTime.now();
final companion = WorkoutsCompanion.insert(
userId: (await apiClient.getUserId()) ?? 'local',
cycleId: cycleId,
week: week,
day: day,
exercises: exercises,
xpEarned: Value(xpEarned),
completedAt: Value(now),
updatedAt: Value(now),
);
await db.into(db.workouts).insert(companion);
}
Future<WorkoutCollection?> getWorkoutByWeekDay({
required String cycleId,
String? localCycleId,