From a2067b5f9bb72df8658e3b4920bbd7cd7d929016 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Tue, 13 Jan 2026 07:56:08 +0100 Subject: [PATCH] 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. --- lib/src/core/routing/app_router.dart | 21 ++ .../presentation/screens/hub_screen.dart | 88 ++++++ .../data/repositories/party_repository.dart | 143 +++++++++ .../multiplayer/domain/entities/party.dart | 18 ++ .../domain/entities/party_member.dart | 40 +++ .../presentation/screens/lobby_screen.dart | 276 ++++++++++++++++++ .../presentation/screens/battle_screen.dart | 182 +++++++++--- lib/src/shared/data/remote/api_client.dart | 25 ++ .../data/repositories/workout_repository.dart | 23 ++ pubspec.lock | 8 + pubspec.yaml | 1 + 11 files changed, 792 insertions(+), 33 deletions(-) create mode 100644 lib/src/features/multiplayer/data/repositories/party_repository.dart create mode 100644 lib/src/features/multiplayer/domain/entities/party.dart create mode 100644 lib/src/features/multiplayer/domain/entities/party_member.dart create mode 100644 lib/src/features/multiplayer/presentation/screens/lobby_screen.dart diff --git a/lib/src/core/routing/app_router.dart b/lib/src/core/routing/app_router.dart index 275d902..2dd6c42 100644 --- a/lib/src/core/routing/app_router.dart +++ b/lib/src/core/routing/app_router.dart @@ -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((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?; + return BattleScreen( + week: extra?['week'] ?? 1, + day: extra?['day'] ?? 1, + workoutId: extra?['workoutId'], + partyId: partyId); + }, + ), ], ); }); diff --git a/lib/src/features/dashboard/presentation/screens/hub_screen.dart b/lib/src/features/dashboard/presentation/screens/hub_screen.dart index 5b93c34..60fb332 100644 --- a/lib/src/features/dashboard/presentation/screens/hub_screen.dart +++ b/lib/src/features/dashboard/presentation/screens/hub_screen.dart @@ -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 { ); } + 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 { color: AppTheme.secondaryColor), onPressed: () => context.go('/leaderboard'), ), + IconButton( + icon: const Icon(Icons.groups, + color: AppTheme.secondaryColor), + tooltip: 'Multiplayer Lobby', + onPressed: _showMultiplayerDialog, + ), ], ), ), diff --git a/lib/src/features/multiplayer/data/repositories/party_repository.dart b/lib/src/features/multiplayer/data/repositories/party_repository.dart new file mode 100644 index 0000000..47ec87b --- /dev/null +++ b/lib/src/features/multiplayer/data/repositories/party_repository.dart @@ -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 _syncAuth() async { + final token = await _api.getToken(); + if (token != null) { + _pb.authStore.save(token, null); + } + } + + Future createParty() async { + final response = await _api.dio.post('/api/v1/party/create'); + final partyId = response.data['party_id']; + return getPartyDetails(partyId); + } + + Future 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 getPartyDetails(String partyId) async { + final response = + await _api.dio.get('/api/collections/parties/records/$partyId'); + return Party.fromJson(response.data); + } + + Future 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 startRaid(String partyId, {int? customHp}) async { + final body = {'status': 'active'}; + + if (customHp != null) { + body['current_hp'] = customHp; + body['max_hp'] = customHp; + } + + 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(); + + yield await getPartyDetails(partyId); + + final controller = StreamController(); + + _pb.collection('parties').subscribe(partyId, (e) { + if (e.action == 'update') { + controller.add(Party.fromJson(e.record!.toJson())); + } + }); + + yield* controller.stream; + } + + Stream> subscribeToMembers(String partyId) async* { + await _syncAuth(); + + Future> 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>(); + + _pb.collection('party_members').subscribe('*', (e) async { + if (e.record!.getStringValue('party_id') == partyId) { + controller.add(await fetchMembers()); + } + }); + + yield* controller.stream; + } + + Future dealDamage(String partyId, int damage) async { + await _api.dio.post( + '/api/v1/party/damage', + data: { + 'party_id': partyId, + 'damage': damage, + }, + ); + } + + Future 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'); + } + } +} diff --git a/lib/src/features/multiplayer/domain/entities/party.dart b/lib/src/features/multiplayer/domain/entities/party.dart new file mode 100644 index 0000000..2ad2d0f --- /dev/null +++ b/lib/src/features/multiplayer/domain/entities/party.dart @@ -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 json) => _$PartyFromJson(json); +} diff --git a/lib/src/features/multiplayer/domain/entities/party_member.dart b/lib/src/features/multiplayer/domain/entities/party_member.dart new file mode 100644 index 0000000..0aa2c0e --- /dev/null +++ b/lib/src/features/multiplayer/domain/entities/party_member.dart @@ -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 json) => + _$PartyMemberFromJson(json); + + factory PartyMember.fromRecord(Map json) { + var member = PartyMember.fromJson(json); + + if (json['expand'] != null && json['expand']['user_id'] != null) { + final userMap = json['expand']['user_id'] as Map; + + 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; + } +} diff --git a/lib/src/features/multiplayer/presentation/screens/lobby_screen.dart b/lib/src/features/multiplayer/presentation/screens/lobby_screen.dart new file mode 100644 index 0000000..de57c39 --- /dev/null +++ b/lib/src/features/multiplayer/presentation/screens/lobby_screen.dart @@ -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 createState() => _LobbyScreenState(); +} + +class _LobbyScreenState extends ConsumerState { + Future _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; + + 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> _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((ref, partyId) { + return ref.watch(partyRepositoryProvider).subscribeToParty(partyId); +}); + +final partyMembersStreamProvider = StreamProvider.autoDispose + .family, String>((ref, partyId) { + return ref.watch(partyRepositoryProvider).subscribeToMembers(partyId); +}); 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 407f11f..f3bfcf6 100644 --- a/lib/src/features/workout_runner/presentation/screens/battle_screen.dart +++ b/lib/src/features/workout_runner/presentation/screens/battle_screen.dart @@ -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 { @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 { 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 { } List> _getExerciseConfig(int day, UserCollection user) { + // ... (Code bleibt identisch) ... final variants = user.exerciseVariants ?? {}; Map getVariant(String slot, String defaultId, @@ -188,6 +209,7 @@ class _BattleScreenState extends ConsumerState { } Future _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 { } 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 { 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 { 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 { } 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 { 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 { }); } + // --- 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 inventory) { final nextExerciseInfo = _exercises[_currentExerciseIndex]; final nextSet = nextExerciseInfo.sets[_currentSetIndex]; @@ -841,10 +937,14 @@ class _BattleScreenState extends ConsumerState { 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 { } 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 { 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 { } 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 { } 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 { } } +// ... _InfoBox und _CounterButton bleiben identisch ... class _InfoBox extends StatelessWidget { final String label; final String value; diff --git a/lib/src/shared/data/remote/api_client.dart b/lib/src/shared/data/remote/api_client.dart index 5c13957..8d6c705 100644 --- a/lib/src/shared/data/remote/api_client.dart +++ b/lib/src/shared/data/remote/api_client.dart @@ -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 getToken() async { + return await _storage.read(key: AppConstants.keyAuthToken); + } + + Future getUserId() async { + return await _storage.read(key: AppConstants.keyUserId); + } } diff --git a/lib/src/shared/data/repositories/workout_repository.dart b/lib/src/shared/data/repositories/workout_repository.dart index a16904c..632793f 100644 --- a/lib/src/shared/data/repositories/workout_repository.dart +++ b/lib/src/shared/data/repositories/workout_repository.dart @@ -84,6 +84,29 @@ class WorkoutRepository { .write(companion); } + Future createCompletedWorkout({ + required String cycleId, + required int week, + required int day, + required List> 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 getWorkoutByWeekDay({ required String cycleId, String? localCycleId, diff --git a/pubspec.lock b/pubspec.lock index 81b91ef..9fb39b8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -797,6 +797,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pocketbase: + dependency: "direct main" + description: + name: pocketbase + sha256: e6aca23d3181a23d367c6650fc4ebe6855bfba4d18b3746ceb1974fb152d8cf9 + url: "https://pub.dev" + source: hosted + version: "0.19.1" pool: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 963ae21..0c3f2c5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: # Networking dio: ^5.4.3+1 pretty_dio_logger: ^1.3.1 + pocketbase: ^0.19.0 # Storage flutter_secure_storage: ^10.0.0