Merge branch 'dev/feature-add-multiplayer-partymode'
* dev/feature-add-multiplayer-partymode: feat: add multiplayer workout
This commit is contained in:
commit
ce6c479e92
11 changed files with 792 additions and 33 deletions
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
18
lib/src/features/multiplayer/domain/entities/party.dart
Normal file
18
lib/src/features/multiplayer/domain/entities/party.dart
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue