From 6e90cf606d41dda1fa910bd5cd2ae764b4c4e8de Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Mon, 12 Jan 2026 15:22:03 +0100 Subject: [PATCH] feat: add leaderboard --- lib/l10n/app_de.arb | 6 +- lib/l10n/app_en.arb | 6 +- lib/src/core/routing/app_router.dart | 5 + .../presentation/screens/register_screen.dart | 21 +++ .../presentation/screens/hub_screen.dart | 5 + .../domain/entities/avatar_config.dart | 37 +---- .../repositories/leaderboard_repository.dart | 42 ++++++ .../domain/entities/leaderboard_entry.dart | 19 +++ .../screens/leaderboard_screen.dart | 139 ++++++++++++++++++ .../screens/avatar_setup_screen.dart | 6 +- .../screens/inventory_setup_screen.dart | 1 + lib/src/shared/data/remote/api_client.dart | 12 +- .../data/repositories/user_repository.dart | 4 + pubspec.lock | 5 + pubspec.yaml | 2 +- 15 files changed, 266 insertions(+), 44 deletions(-) create mode 100644 lib/src/features/multiplayer/data/repositories/leaderboard_repository.dart create mode 100644 lib/src/features/multiplayer/domain/entities/leaderboard_entry.dart create mode 100644 lib/src/features/multiplayer/presentation/screens/leaderboard_screen.dart diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 1318757..8c123c1 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 63dc211..9a5df12 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" } diff --git a/lib/src/core/routing/app_router.dart b/lib/src/core/routing/app_router.dart index 8deb418..275d902 100644 --- a/lib/src/core/routing/app_router.dart +++ b/lib/src/core/routing/app_router.dart @@ -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((ref) { name: 'quests', builder: (context, state) => const QuestLogScreen(), ), + GoRoute( + path: '/leaderboard', + builder: (context, state) => const LeaderboardScreen(), + ), ], ); }); diff --git a/lib/src/features/authentication/presentation/screens/register_screen.dart b/lib/src/features/authentication/presentation/screens/register_screen.dart index c846d8f..637d64d 100644 --- a/lib/src/features/authentication/presentation/screens/register_screen.dart +++ b/lib/src/features/authentication/presentation/screens/register_screen.dart @@ -15,11 +15,13 @@ class RegisterScreen extends ConsumerStatefulWidget { class _RegisterScreenState extends ConsumerState { final _formKey = GlobalKey(); + 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 { 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 { 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, diff --git a/lib/src/features/dashboard/presentation/screens/hub_screen.dart b/lib/src/features/dashboard/presentation/screens/hub_screen.dart index 8b73e84..5b93c34 100644 --- a/lib/src/features/dashboard/presentation/screens/hub_screen.dart +++ b/lib/src/features/dashboard/presentation/screens/hub_screen.dart @@ -329,6 +329,11 @@ class _HubScreenState extends ConsumerState { ), onPressed: _runSync, ), + IconButton( + icon: const Icon(Icons.leaderboard, + color: AppTheme.secondaryColor), + onPressed: () => context.go('/leaderboard'), + ), ], ), ), diff --git a/lib/src/features/gamification/domain/entities/avatar_config.dart b/lib/src/features/gamification/domain/entities/avatar_config.dart index 09a5f37..9e80453 100644 --- a/lib/src/features/gamification/domain/entities/avatar_config.dart +++ b/lib/src/features/gamification/domain/entities/avatar_config.dart @@ -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 json) { -// return AvatarConfig( -// gender: json['gender'] ?? 'male', -// variant: json['variant'] ?? 1, -// ); -// } - -// Map 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 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, }; } diff --git a/lib/src/features/multiplayer/data/repositories/leaderboard_repository.dart b/lib/src/features/multiplayer/data/repositories/leaderboard_repository.dart new file mode 100644 index 0000000..227b048 --- /dev/null +++ b/lib/src/features/multiplayer/data/repositories/leaderboard_repository.dart @@ -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> 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; + + 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'); + } + } +} diff --git a/lib/src/features/multiplayer/domain/entities/leaderboard_entry.dart b/lib/src/features/multiplayer/domain/entities/leaderboard_entry.dart new file mode 100644 index 0000000..2b45616 --- /dev/null +++ b/lib/src/features/multiplayer/domain/entities/leaderboard_entry.dart @@ -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? avatar, + }) = _LeaderboardEntry; + + factory LeaderboardEntry.fromJson(Map json) => + _$LeaderboardEntryFromJson(json); +} diff --git a/lib/src/features/multiplayer/presentation/screens/leaderboard_screen.dart b/lib/src/features/multiplayer/presentation/screens/leaderboard_screen.dart new file mode 100644 index 0000000..34ae10b --- /dev/null +++ b/lib/src/features/multiplayer/presentation/screens/leaderboard_screen.dart @@ -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 createState() => _LeaderboardScreenState(); +} + +class _LeaderboardScreenState extends ConsumerState { + @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(); +}); diff --git a/lib/src/features/onboarding/presentation/screens/avatar_setup_screen.dart b/lib/src/features/onboarding/presentation/screens/avatar_setup_screen.dart index 9be13ce..451fcdb 100644 --- a/lib/src/features/onboarding/presentation/screens/avatar_setup_screen.dart +++ b/lib/src/features/onboarding/presentation/screens/avatar_setup_screen.dart @@ -40,9 +40,11 @@ class _AvatarSetupScreenState extends ConsumerState { final exerciseVariants = onboardingData['exercise_variants'] as Map?; 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 { 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 { await userRepo.saveLocalUser(user); } - final avatarJson = _config.toJson(); - user = user.copyWith( avatarConfig: Value(avatarJson), isDirty: true, diff --git a/lib/src/features/onboarding/presentation/screens/inventory_setup_screen.dart b/lib/src/features/onboarding/presentation/screens/inventory_setup_screen.dart index abb9ae9..1ff6e42 100644 --- a/lib/src/features/onboarding/presentation/screens/inventory_setup_screen.dart +++ b/lib/src/features/onboarding/presentation/screens/inventory_setup_screen.dart @@ -145,6 +145,7 @@ class _InventorySetupScreenState extends ConsumerState { final user = await userRepo.register( email: onboardingData['email'] ?? '', + username: onboardingData['username'] ?? '', password: onboardingData['password'] ?? '', bodyweight: onboardingData['bodyweight'] ?? 80.0, inventorySettings: inventorySettings, diff --git a/lib/src/shared/data/remote/api_client.dart b/lib/src/shared/data/remote/api_client.dart index 46a4ac7..5c13957 100644 --- a/lib/src/shared/data/remote/api_client.dart +++ b/lib/src/shared/data/remote/api_client.dart @@ -13,6 +13,8 @@ class ApiClient { bool _isRefreshing = false; final List _requestsQueue = []; + Dio get dio => _dio; + ApiClient({ FlutterSecureStorage? storage, Logger? logger, @@ -187,16 +189,19 @@ class ApiClient { Future> register({ required String email, + required String username, required String password, required double bodyweight, required Map inventorySettings, Map? exerciseVariants, + Map? 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; diff --git a/lib/src/shared/data/repositories/user_repository.dart b/lib/src/shared/data/repositories/user_repository.dart index 12c53b9..6318f49 100644 --- a/lib/src/shared/data/repositories/user_repository.dart +++ b/lib/src/shared/data/repositories/user_repository.dart @@ -102,18 +102,22 @@ class UserRepository { Future register({ required String email, + required String username, required String password, required double bodyweight, required Map inventorySettings, Map? exerciseVariants, + Map? 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; diff --git a/pubspec.lock b/pubspec.lock index c5e912b..81b91ef 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index eb7bccb..963ae21 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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