feat: add leaderboard

This commit is contained in:
Patryk Hegenberg 2026-01-12 15:22:03 +01:00
parent 246672b24d
commit 6e90cf606d
15 changed files with 266 additions and 44 deletions

View file

@ -306,5 +306,9 @@
"enemyPressurePhantomName": "Druck-Phantom", "enemyPressurePhantomName": "Druck-Phantom",
"enemyPressurePhantomTitle": "Der unsichtbare Zermalmer", "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.", "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", "enemyPressurePhantomName": "Pressure Phantom",
"enemyPressurePhantomTitle": "The Invisible Crusher", "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.", "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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/login_screen.dart';
import '../../features/authentication/presentation/screens/profile_screen.dart'; import '../../features/authentication/presentation/screens/profile_screen.dart';
@ -135,6 +136,10 @@ final routerProvider = Provider<GoRouter>((ref) {
name: 'quests', name: 'quests',
builder: (context, state) => const QuestLogScreen(), 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> { class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _emailFocusNode = FocusNode(); final _emailFocusNode = FocusNode();
@override @override
void dispose() { void dispose() {
_usernameController.dispose();
_emailController.dispose(); _emailController.dispose();
_emailFocusNode.dispose(); _emailFocusNode.dispose();
super.dispose(); super.dispose();
@ -34,6 +36,7 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
ref.read(onboardingDataProvider.notifier).updateData({ ref.read(onboardingDataProvider.notifier).updateData({
'email': _emailController.text.trim(), 'email': _emailController.text.trim(),
'username': _usernameController.text.trim(),
}); });
context.go('/onboarding/welcome'); context.go('/onboarding/welcome');
@ -81,6 +84,24 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 48), 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( TextFormField(
controller: _emailController, controller: _emailController,
focusNode: _emailFocusNode, focusNode: _emailFocusNode,

View file

@ -329,6 +329,11 @@ class _HubScreenState extends ConsumerState<HubScreen> {
), ),
onPressed: _runSync, 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'; import '../../../../core/constants/asset_paths.dart';
class AvatarConfig { class AvatarConfig {
final String gender; final String gender;
final int variant; final int variant;
final String selectedBackground; // NEU final String selectedBackground;
const AvatarConfig({ const AvatarConfig({
this.gender = 'male', this.gender = 'male',
this.variant = 1, this.variant = 1,
this.selectedBackground = 'bg_street_day', // Default this.selectedBackground = 'bg_street_day',
}); });
factory AvatarConfig.fromJson(Map<String, dynamic> json) { factory AvatarConfig.fromJson(Map<String, dynamic> json) {
return AvatarConfig( return AvatarConfig(
gender: json['gender'] ?? 'male', gender: json['gender'] ?? 'male',
variant: json['variant'] ?? 1, 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 { return {
'gender': gender, 'gender': gender,
'variant': variant, '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 = final exerciseVariants =
onboardingData['exercise_variants'] as Map<String, dynamic>?; onboardingData['exercise_variants'] as Map<String, dynamic>?;
var user = await userRepo.getLocalUser(); var user = await userRepo.getLocalUser();
final avatarJson = _config.toJson();
if (user == null) { if (user == null) {
final email = onboardingData['email'] as String? ?? ''; final email = onboardingData['email'] as String? ?? '';
final String username = onboardingData['username'] as String;
final bodyweight = final bodyweight =
(onboardingData['bodyweight'] as num?)?.toDouble() ?? 80.0; (onboardingData['bodyweight'] as num?)?.toDouble() ?? 80.0;
@ -52,10 +54,12 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
user = await userRepo.register( user = await userRepo.register(
email: email, email: email,
username: username,
password: password, password: password,
bodyweight: bodyweight, bodyweight: bodyweight,
inventorySettings: inventorySettings, inventorySettings: inventorySettings,
exerciseVariants: exerciseVariants, exerciseVariants: exerciseVariants,
avatarConfig: avatarJson,
); );
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
user = await userRepo.getLocalUser(); user = await userRepo.getLocalUser();
@ -75,8 +79,6 @@ class _AvatarSetupScreenState extends ConsumerState<AvatarSetupScreen> {
await userRepo.saveLocalUser(user); await userRepo.saveLocalUser(user);
} }
final avatarJson = _config.toJson();
user = user.copyWith( user = user.copyWith(
avatarConfig: Value(avatarJson), avatarConfig: Value(avatarJson),
isDirty: true, isDirty: true,

View file

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

View file

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

View file

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

View file

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

View file

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