Merge branch 'dev/feature-add-multiplayer-leaderboard'

* dev/feature-add-multiplayer-leaderboard:
  feat: add leaderboard
This commit is contained in:
Patryk Hegenberg 2026-01-12 15:22:53 +01:00
commit 83619f31c5
15 changed files with 266 additions and 44 deletions

View file

@ -306,5 +306,9 @@
"enemyPressurePhantomName": "Druck-Phantom",
"enemyPressurePhantomTitle": "Der unsichtbare Zermalmer",
"enemyPressurePhantomDesc": "Eine ätherische Entität, die die Luft um dich herum komprimiert. Es versucht, Brust und Schultern derer kollabieren zu lassen, die es wagen, dagegen zu drücken.\n\nBesiege es, indem du mit explosiver Dip-Kraft durch den Schmerz drückst.",
"enemyPressurePhantomNemesis": "Dip-Nemesis"
"enemyPressurePhantomNemesis": "Dip-Nemesis",
"usernameLabel": "Heldenname",
"usernameEmptyError": "Bitte wähle einen Heldennamen",
"usernameShortError": "Name zu kurz"
}

View file

@ -306,5 +306,9 @@
"enemyPressurePhantomName": "Pressure Phantom",
"enemyPressurePhantomTitle": "The Invisible Crusher",
"enemyPressurePhantomDesc": "An ethereal entity that compresses the very air around you. It seeks to collapse the chest and shoulders of any who dare to push against it.\n\nDefeat it by pushing through the pain with explosive dipping power.",
"enemyPressurePhantomNemesis": "Dip Nemesis"
"enemyPressurePhantomNemesis": "Dip Nemesis",
"usernameLabel": "Hero Name",
"usernameEmptyError": "Please choose a hero name",
"usernameShortError": "Name too short"
}

View file

@ -1,6 +1,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 '../../features/authentication/presentation/screens/login_screen.dart';
import '../../features/authentication/presentation/screens/profile_screen.dart';
@ -135,6 +136,10 @@ final routerProvider = Provider<GoRouter>((ref) {
name: 'quests',
builder: (context, state) => const QuestLogScreen(),
),
GoRoute(
path: '/leaderboard',
builder: (context, state) => const LeaderboardScreen(),
),
],
);
});

View file

@ -15,11 +15,13 @@ class RegisterScreen extends ConsumerStatefulWidget {
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _emailController = TextEditingController();
final _emailFocusNode = FocusNode();
@override
void dispose() {
_usernameController.dispose();
_emailController.dispose();
_emailFocusNode.dispose();
super.dispose();
@ -34,6 +36,7 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
ref.read(onboardingDataProvider.notifier).updateData({
'email': _emailController.text.trim(),
'username': _usernameController.text.trim(),
});
context.go('/onboarding/welcome');
@ -81,6 +84,24 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextFormField(
controller: _usernameController,
decoration: InputDecoration(
labelText: l10n.usernameLabel,
prefixIcon: const Icon(Icons.person),
border: const OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return l10n.usernameEmptyError;
}
if (value.length < 3) {
return l10n.usernameShortError;
}
return null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: _emailController,
focusNode: _emailFocusNode,

View file

@ -329,6 +329,11 @@ class _HubScreenState extends ConsumerState<HubScreen> {
),
onPressed: _runSync,
),
IconButton(
icon: const Icon(Icons.leaderboard,
color: AppTheme.secondaryColor),
onPressed: () => context.go('/leaderboard'),
),
],
),
),

View file

@ -1,50 +1,21 @@
// import '../../../../core/constants/asset_paths.dart';
// class AvatarConfig {
// final String gender; // 'male' or 'female'
// final int variant; // 1 to 8
// final String selectedBackground;
// const AvatarConfig({
// this.gender = 'male',
// this.variant = 1,
// this.selectedBackground = 'bg_street_day',
// });
// factory AvatarConfig.fromJson(Map<String, dynamic> json) {
// return AvatarConfig(
// gender: json['gender'] ?? 'male',
// variant: json['variant'] ?? 1,
// );
// }
// Map<String, dynamic> toJson() {
// return {
// 'gender': gender,
// 'variant': variant,
// };
// }
// String get assetPath => AssetPaths.getAvatarPath(gender, variant);
// }
import '../../../../core/constants/asset_paths.dart';
class AvatarConfig {
final String gender;
final int variant;
final String selectedBackground; // NEU
final String selectedBackground;
const AvatarConfig({
this.gender = 'male',
this.variant = 1,
this.selectedBackground = 'bg_street_day', // Default
this.selectedBackground = 'bg_street_day',
});
factory AvatarConfig.fromJson(Map<String, dynamic> json) {
return AvatarConfig(
gender: json['gender'] ?? 'male',
variant: json['variant'] ?? 1,
selectedBackground: json['selected_background'] ?? 'bg_street_day', // NEU
selectedBackground: json['selected_background'] ?? 'bg_street_day',
);
}
@ -52,7 +23,7 @@ class AvatarConfig {
return {
'gender': gender,
'variant': variant,
'selected_background': selectedBackground, // NEU
'selected_background': selectedBackground,
};
}

View file

@ -0,0 +1,42 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:slrpg_app/src/features/multiplayer/domain/entities/leaderboard_entry.dart';
import 'package:slrpg_app/src/shared/data/repositories/user_repository.dart';
import '../../../../shared/data/remote/api_client.dart';
final leaderboardRepositoryProvider = Provider((ref) {
return LeaderboardRepository(ref.read(apiClientProvider));
});
class LeaderboardRepository {
final ApiClient _api;
LeaderboardRepository(this._api);
Future<List<LeaderboardEntry>> getGlobalLeaderboard() async {
try {
final response = await _api.dio.get(
'/api/collections/leaderboard/records',
queryParameters: {
'sort': '-level,-xp',
'perPage': 50,
},
);
final items = (response.data['items'] as List);
return items.asMap().entries.map((entry) {
final index = entry.key;
final data = entry.value as Map<String, dynamic>;
if (data['name'] == null || data['name'].toString().isEmpty) {
data['name'] = 'Unknown Hero';
}
return LeaderboardEntry.fromJson(data).copyWith(rank: index + 1);
}).toList();
} catch (e) {
throw Exception('Failed to load leaderboard: $e');
}
}
}

View file

@ -0,0 +1,19 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'leaderboard_entry.freezed.dart';
part 'leaderboard_entry.g.dart';
@freezed
abstract class LeaderboardEntry with _$LeaderboardEntry {
const factory LeaderboardEntry({
required String id,
required String name,
required int level,
required int xp,
@Default(0) int rank,
@JsonKey(name: 'avatar_config') Map<String, dynamic>? avatar,
}) = _LeaderboardEntry;
factory LeaderboardEntry.fromJson(Map<String, dynamic> json) =>
_$LeaderboardEntryFromJson(json);
}

View file

@ -0,0 +1,139 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:slrpg_app/src/core/constants/app_constants.dart';
import 'package:slrpg_app/src/features/multiplayer/domain/entities/leaderboard_entry.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../shared/data/repositories/user_repository.dart';
import '../../data/repositories/leaderboard_repository.dart';
import '../../../gamification/domain/entities/avatar_config.dart';
import '../../../gamification/presentation/widgets/avatar_renderer.dart';
class LeaderboardScreen extends ConsumerStatefulWidget {
const LeaderboardScreen({super.key});
@override
ConsumerState<LeaderboardScreen> createState() => _LeaderboardScreenState();
}
class _LeaderboardScreenState extends ConsumerState<LeaderboardScreen> {
@override
Widget build(BuildContext context) {
final leaderboardAsync = ref.watch(leaderboardProvider);
final currentUserAsync = ref.watch(userRepositoryProvider).getLocalUser();
return Scaffold(
appBar: AppBar(
title: const Text('HALL OF FAME'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/hub'),
),
),
body: leaderboardAsync.when(
data: (entries) {
return FutureBuilder(
future: currentUserAsync,
builder: (context, snapshot) {
final currentUserId = snapshot.data?.serverId ?? '';
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: entries.length,
itemBuilder: (context, index) {
final entry = entries[index];
final isMe = entry.id == currentUserId;
return Card(
color: isMe
? AppTheme.primaryColor.withValues(alpha: 0.1)
: null,
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
leading: _buildRankBadge(entry.rank),
title: Text(
entry.name.isEmpty
? 'Hero #${entry.id.substring(0, 5)}'
: entry.name,
style: TextStyle(
fontWeight:
isMe ? FontWeight.bold : FontWeight.normal,
color: isMe ? AppTheme.primaryColor : Colors.white,
),
),
subtitle: Text('Level ${entry.level}${entry.xp} XP'),
trailing: _buildAvatarPreview(entry),
),
);
},
);
});
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
),
);
}
Widget _buildRankBadge(int rank) {
Color color;
double size = 24;
switch (rank) {
case 1:
color = Colors.amber;
size = 32;
break;
case 2:
color = Colors.grey.shade400;
size = 28;
break;
case 3:
color = Colors.brown.shade400;
size = 26;
break;
default:
color = Colors.grey.shade700;
}
return SizedBox(
width: 40,
child: Center(
child: rank <= 3
? Icon(Icons.emoji_events, color: color, size: size)
: Text('#$rank',
style: const TextStyle(
fontWeight: FontWeight.bold, color: Colors.grey)),
),
);
}
Widget _buildAvatarPreview(LeaderboardEntry entry) {
if (entry.avatar != null && entry.avatar!.isNotEmpty) {
try {
final config = AvatarConfig.fromJson(entry.avatar!);
return SizedBox(
width: 40,
height: 40,
child: AvatarRenderer(
config: config,
size: 40,
),
);
} catch (e) {}
}
return CircleAvatar(
radius: 20,
backgroundColor: Colors.grey.shade800,
child: const Icon(Icons.person, color: Colors.white70),
);
}
}
final leaderboardProvider = FutureProvider((ref) async {
return ref.read(leaderboardRepositoryProvider).getGlobalLeaderboard();
});

View file

@ -40,9 +40,11 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
final exerciseVariants =
onboardingData['exercise_variants'] as Map<String, dynamic>?;
var user = await userRepo.getLocalUser();
final avatarJson = _config.toJson();
if (user == null) {
final email = onboardingData['email'] as String? ?? '';
final String username = onboardingData['username'] as String;
final bodyweight =
(onboardingData['bodyweight'] as num?)?.toDouble() ?? 80.0;
@ -52,10 +54,12 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
user = await userRepo.register(
email: email,
username: username,
password: password,
bodyweight: bodyweight,
inventorySettings: inventorySettings,
exerciseVariants: exerciseVariants,
avatarConfig: avatarJson,
);
await Future.delayed(const Duration(milliseconds: 100));
user = await userRepo.getLocalUser();
@ -75,8 +79,6 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
await userRepo.saveLocalUser(user);
}
final avatarJson = _config.toJson();
user = user.copyWith(
avatarConfig: Value(avatarJson),
isDirty: true,

View file

@ -145,6 +145,7 @@ class _InventorySetupScreenState extends ConsumerState<InventorySetupScreen> {
final user = await userRepo.register(
email: onboardingData['email'] ?? '',
username: onboardingData['username'] ?? '',
password: onboardingData['password'] ?? '',
bodyweight: onboardingData['bodyweight'] ?? 80.0,
inventorySettings: inventorySettings,

View file

@ -13,6 +13,8 @@ class ApiClient {
bool _isRefreshing = false;
final List<Function> _requestsQueue = [];
Dio get dio => _dio;
ApiClient({
FlutterSecureStorage? storage,
Logger? logger,
@ -187,16 +189,19 @@ class ApiClient {
Future<Map<String, dynamic>> register({
required String email,
required String username,
required String password,
required double bodyweight,
required Map<String, dynamic> inventorySettings,
Map<String, dynamic>? exerciseVariants,
Map<String, dynamic>? avatarConfig,
}) async {
try {
final response = await _dio.post(
ApiEndpoints.register,
data: {
'email': email,
'name': username,
'password': password,
'passwordConfirm': password,
'xp': 0,
@ -204,12 +209,7 @@ class ApiClient {
'current_bodyweight': bodyweight,
'inventory_settings': inventorySettings,
'exercise_variants': exerciseVariants ?? {},
'avatar_config': {
'skin_tone': 'medium',
'hair_style': 'short_01',
'clothing': 'basic_tee',
'unlocked_items': ['basic_tee'],
},
'avatar_config': avatarConfig ?? {},
},
);
return response.data;

View file

@ -102,18 +102,22 @@ class UserRepository {
Future<UserCollection> register({
required String email,
required String username,
required String password,
required double bodyweight,
required Map<String, dynamic> inventorySettings,
Map<String, dynamic>? exerciseVariants,
Map<String, dynamic>? avatarConfig,
}) async {
try {
final response = await apiClient.register(
email: email,
username: username,
password: password,
bodyweight: bodyweight,
inventorySettings: inventorySettings,
exerciseVariants: exerciseVariants,
avatarConfig: avatarConfig,
);
final record = response['record'] ?? response;

View file

@ -398,6 +398,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_riverpod:
dependency: "direct main"
description:

View file

@ -36,7 +36,7 @@ dependencies:
cupertino_icons: ^1.0.6
google_fonts: ^6.2.1
flutter_svg: ^2.0.10+1
cached_network_image: ^3.3.1
cached_network_image: ^3.4.1
shimmer: ^3.0.0
# Utilities