diff --git a/mobile_app/lib/core/di/dependency_injection.dart b/mobile_app/lib/core/di/dependency_injection.dart index 7d2c56c..b196c97 100644 --- a/mobile_app/lib/core/di/dependency_injection.dart +++ b/mobile_app/lib/core/di/dependency_injection.dart @@ -17,6 +17,8 @@ import '../../features/profile/ui/cubit/profile_cubit.dart'; import '../../features/quiz/data/repositories/quiz_repository.dart'; import '../../features/quiz/data/services/quiz_api_service.dart'; import '../../features/quiz/ui/cubit/quiz_cubit.dart'; +import '../../features/ranking/data/repositories/ranking_repository.dart'; +import '../../features/ranking/data/services/ranking_api_service.dart'; import '../../features/ranking/ui/cubit/ranking_cubit.dart'; import '../../features/home/ui/cubit/home_cubit.dart'; import '../../features/battle/ui/cubit/battle_cubit.dart'; @@ -49,7 +51,9 @@ Future setupGetIt() async { getIt.registerFactory(() => CategoriesRepository(getIt())); getIt.registerFactory(() => CategoriesCubit(getIt())); - getIt.registerFactory(() => RankingCubit()); + getIt.registerFactory(() => RankingApiService(dio)); + getIt.registerFactory(() => RankingRepository(getIt())); + getIt.registerFactory(() => RankingCubit(getIt())); getIt.registerFactory(() => ProfileCubit()); diff --git a/mobile_app/lib/core/networking/api_constants.dart b/mobile_app/lib/core/networking/api_constants.dart index 905a7f2..e175a5a 100644 --- a/mobile_app/lib/core/networking/api_constants.dart +++ b/mobile_app/lib/core/networking/api_constants.dart @@ -12,6 +12,8 @@ class ApiConstants { static const String gameSessionStart = "game/session/start"; static const String gameSessionAnswer = "game/session/answer"; static const String gameSessionComplete = "game/session/complete"; + + static const String leaderboard = "progression/leaderboard"; } enum ApiErrorType { diff --git a/mobile_app/lib/features/onboarding/presentation/onboarding_screen.dart b/mobile_app/lib/features/onboarding/presentation/onboarding_screen.dart index b6a433c..8299c2f 100644 --- a/mobile_app/lib/features/onboarding/presentation/onboarding_screen.dart +++ b/mobile_app/lib/features/onboarding/presentation/onboarding_screen.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../../../core/helpers/spacing.dart'; import '../../../core/theming/theming.dart'; +import 'cubit/onboarding_cubit.dart'; import 'widgets/widgets.dart'; class OnboardingScreen extends StatelessWidget { @@ -47,7 +49,17 @@ class OnboardingScreen extends StatelessWidget { ), bottomNavigationBar: Padding( padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 24.h), - child: ChooseYourCountryBottomButton(), + child: ElevatedButton( + onPressed: () => context.read().navigateToSignUpPage(), + style: ElevatedButton.styleFrom( + minimumSize: Size(double.infinity, 48.h), + backgroundColor: ColorsManager.primaryBlack, + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 12.h), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.r)), + ), + child: Text('Get Started →', style: InterFontStyle.font16W600White), + ), ), ); } diff --git a/mobile_app/lib/features/onboarding/presentation/widgets/choose_your_country_bottom_button.dart b/mobile_app/lib/features/onboarding/presentation/widgets/choose_your_country_bottom_button.dart deleted file mode 100644 index 24eda9f..0000000 --- a/mobile_app/lib/features/onboarding/presentation/widgets/choose_your_country_bottom_button.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; - -import '../../../../core/helpers/spacing.dart'; -import '../../../../core/theming/colors_manager.dart'; -import '../../../../core/theming/inter_font_style.dart'; -import '../cubit/onboarding_cubit.dart'; - -class ChooseYourCountryBottomButton extends StatelessWidget { - const ChooseYourCountryBottomButton({super.key}); - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: .min, - children: [ - ElevatedButton( - onPressed: () => context.read().navigateToSignUpPage(), - style: ElevatedButton.styleFrom( - minimumSize: Size(double.infinity, 48.h), - backgroundColor: ColorsManager.primaryBlack, - foregroundColor: Colors.white, - padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 12.h), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.r)), - ), - child: Text('Choose Your Country →', style: InterFontStyle.font16W600White), - ), - verticalSpace(16), - RichText( - text: TextSpan( - text: 'Already have an account?', - style: InterFontStyle.font12W600GreyGreen, - children: [ - WidgetSpan(child: horizontalSpace(6)), - TextSpan( - text: 'Sign In', - style: InterFontStyle.font12W600PrimaryBlack.copyWith(decoration: TextDecoration.underline), - ), - ], - ), - ), - ], - ); - } -} diff --git a/mobile_app/lib/features/onboarding/presentation/widgets/widgets.dart b/mobile_app/lib/features/onboarding/presentation/widgets/widgets.dart index 8f85fba..c44fc08 100644 --- a/mobile_app/lib/features/onboarding/presentation/widgets/widgets.dart +++ b/mobile_app/lib/features/onboarding/presentation/widgets/widgets.dart @@ -1,4 +1,3 @@ -export 'choose_your_country_bottom_button.dart'; export 'country_quiz.dart'; export 'do_you_really_now_text.dart'; export 'onboarding_bloc_listener.dart'; diff --git a/mobile_app/lib/features/ranking/data/dtos/leaderboard_response_dto.dart b/mobile_app/lib/features/ranking/data/dtos/leaderboard_response_dto.dart new file mode 100644 index 0000000..120b600 --- /dev/null +++ b/mobile_app/lib/features/ranking/data/dtos/leaderboard_response_dto.dart @@ -0,0 +1,41 @@ +import '../../domain/models/leaderboard_player_model.dart'; + +class LeaderboardPlayerDto { + final String userId; + final String username; + final String color; + final int level; + final int xp; + + const LeaderboardPlayerDto({ + required this.userId, + required this.username, + required this.color, + required this.level, + required this.xp, + }); + + factory LeaderboardPlayerDto.fromJson(Map json) => LeaderboardPlayerDto( + userId: json['userId'] as String, + username: json['username'] as String, + color: (json['color'] as String?) ?? '', + level: (json['level'] as int?) ?? 1, + xp: (json['xp'] as int?) ?? 0, + ); + + LeaderboardPlayerModel toDomain() => + LeaderboardPlayerModel(userId: userId, username: username, color: color, level: level, xp: xp); +} + +class LeaderboardResponseDto { + final List topByXP; + + const LeaderboardResponseDto({required this.topByXP}); + + factory LeaderboardResponseDto.fromJson(Map json) { + final list = json['topByXP'] as List? ?? []; + return LeaderboardResponseDto( + topByXP: list.map((e) => LeaderboardPlayerDto.fromJson(e as Map)).toList(), + ); + } +} diff --git a/mobile_app/lib/features/ranking/data/repositories/ranking_repository.dart b/mobile_app/lib/features/ranking/data/repositories/ranking_repository.dart new file mode 100644 index 0000000..c593b6c --- /dev/null +++ b/mobile_app/lib/features/ranking/data/repositories/ranking_repository.dart @@ -0,0 +1,19 @@ +import '../../../../core/networking/api_error_handler.dart'; +import '../../../../core/networking/api_result.dart'; +import '../../domain/models/leaderboard_player_model.dart'; +import '../services/ranking_api_service.dart'; + +class RankingRepository { + final RankingApiService _api; + + const RankingRepository(this._api); + + Future>> getLeaderboard() async { + try { + final dto = await _api.getLeaderboard(); + return ApiSuccess(dto.topByXP.map((e) => e.toDomain()).toList()); + } catch (e) { + return ApiFailure(ApiErrorHandler.handle(e)); + } + } +} diff --git a/mobile_app/lib/features/ranking/data/services/ranking_api_service.dart b/mobile_app/lib/features/ranking/data/services/ranking_api_service.dart new file mode 100644 index 0000000..2916798 --- /dev/null +++ b/mobile_app/lib/features/ranking/data/services/ranking_api_service.dart @@ -0,0 +1,15 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/networking/api_constants.dart'; +import '../dtos/leaderboard_response_dto.dart'; + +class RankingApiService { + final Dio _dio; + + const RankingApiService(this._dio); + + Future getLeaderboard() async { + final response = await _dio.get(ApiConstants.leaderboard); + return LeaderboardResponseDto.fromJson(response.data as Map); + } +} diff --git a/mobile_app/lib/features/ranking/domain/models/leaderboard_player_model.dart b/mobile_app/lib/features/ranking/domain/models/leaderboard_player_model.dart new file mode 100644 index 0000000..d8850f5 --- /dev/null +++ b/mobile_app/lib/features/ranking/domain/models/leaderboard_player_model.dart @@ -0,0 +1,15 @@ +class LeaderboardPlayerModel { + final String userId; + final String username; + final String color; + final int level; + final int xp; + + const LeaderboardPlayerModel({ + required this.userId, + required this.username, + required this.color, + required this.level, + required this.xp, + }); +} diff --git a/mobile_app/lib/features/ranking/domain/models/ranking_entry_model.dart b/mobile_app/lib/features/ranking/domain/models/ranking_entry_model.dart index ffafaa9..ada9de2 100644 --- a/mobile_app/lib/features/ranking/domain/models/ranking_entry_model.dart +++ b/mobile_app/lib/features/ranking/domain/models/ranking_entry_model.dart @@ -4,26 +4,18 @@ class RankingEntryModel { final int rank; final String username; final String initial; - final String country; - final String countryCode; + final int level; final String ptsFormatted; final String scoreFormatted; - final String deltaFormatted; - final Color deltaColor; - final bool isVerified; final Color avatarBg; const RankingEntryModel({ required this.rank, required this.username, required this.initial, - required this.country, - required this.countryCode, + required this.level, required this.ptsFormatted, required this.scoreFormatted, - required this.deltaFormatted, - required this.deltaColor, - this.isVerified = false, required this.avatarBg, }); } diff --git a/mobile_app/lib/features/ranking/ui/cubit/ranking_cubit.dart b/mobile_app/lib/features/ranking/ui/cubit/ranking_cubit.dart index b65a4d3..d00ce47 100644 --- a/mobile_app/lib/features/ranking/ui/cubit/ranking_cubit.dart +++ b/mobile_app/lib/features/ranking/ui/cubit/ranking_cubit.dart @@ -1,116 +1,79 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../core/theming/colors_manager.dart'; +import '../../../../core/networking/api_result.dart'; +import '../../data/repositories/ranking_repository.dart'; +import '../../domain/models/leaderboard_player_model.dart'; import '../../domain/models/ranking_entry_model.dart'; part 'ranking_state.dart'; class RankingCubit extends Cubit { - RankingCubit() : super(RankingInitial()); + final RankingRepository _repository; - static const _totalPlayersLabel = '184 320 joueurs'; + RankingCubit(this._repository) : super(RankingInitial()); - static const _entries = [ - RankingEntryModel( - rank: 1, - username: 'leila_oran', - initial: 'L', - country: "Algérie", - countryCode: 'DZ', - ptsFormatted: '14 820', - scoreFormatted: '14,8k', - deltaFormatted: '+1', - deltaColor: SemanticColors.success, - isVerified: true, - avatarBg: Color(0xFF2A6B4A), - ), - RankingEntryModel( - rank: 2, - username: 'k.benz', - initial: 'K', - country: 'France', - countryCode: 'FR', - ptsFormatted: '14 200', - scoreFormatted: '14,2k', - deltaFormatted: '0', - deltaColor: ColorsManager.lightGrey, - isVerified: true, - avatarBg: Color(0xFF1A4B35), - ), - RankingEntryModel( - rank: 3, - username: 'amir_casa', - initial: 'A', - country: 'Maroc', - countryCode: 'MA', - ptsFormatted: '14 000', - scoreFormatted: '14,0k', - deltaFormatted: '+4', - deltaColor: SemanticColors.success, - avatarBg: Color(0xFF3A5530), - ), - RankingEntryModel( - rank: 4, - username: 'wassim.t', - initial: 'W', - country: 'Tunisie', - countryCode: 'TN', - ptsFormatted: '13 600', - scoreFormatted: '13,6k', - deltaFormatted: '−1', - deltaColor: SemanticColors.danger, - isVerified: true, - avatarBg: Color(0xFF1E3D38), - ), - RankingEntryModel( - rank: 5, - username: 'rania.k', - initial: 'R', - country: 'Algérie', - countryCode: 'DZ', - ptsFormatted: '13 100', - scoreFormatted: '13,1k', - deltaFormatted: '+2', - deltaColor: SemanticColors.success, - isVerified: true, - avatarBg: Color(0xFF2E5A3A), - ), - RankingEntryModel( - rank: 6, - username: 'youssef.cai', - initial: 'Y', - country: 'Égypte', - countryCode: 'EG', - ptsFormatted: '12 900', - scoreFormatted: '12,9k', - deltaFormatted: '0', - deltaColor: ColorsManager.lightGrey, - avatarBg: Color(0xFF6B6A30), - ), - RankingEntryModel( - rank: 7, - username: 'noor.b', - initial: 'N', - country: 'Liban', - countryCode: 'LB', - ptsFormatted: '12 400', - scoreFormatted: '12,4k', - deltaFormatted: '−3', - deltaColor: SemanticColors.danger, - avatarBg: Color(0xFF404035), - ), - ]; + Future loadRanking() async { + emit(RankingInitial()); - void loadRanking() { - emit( - RankingLoaded( - weekLabel: '22 — 28 avril', - scopePrefix: 'Monde entier', - totalPlayersLabel: _totalPlayersLabel, - topEntry: _entries.first, - entries: _entries, - ), + final result = await _repository.getLeaderboard(); + + result.when( + success: (List players) { + if (players.isEmpty) { + emit(RankingError('Aucun joueur classé pour le moment.')); + return; + } + final entries = players.indexed.map((e) => _toDisplayModel(rank: e.$1 + 1, player: e.$2)).toList(); + emit( + RankingLoaded( + weekLabel: '', + scopePrefix: 'Monde entier', + totalPlayersLabel: '${players.length}', + topEntry: entries.first, + entries: entries, + ), + ); + }, + failure: (error) => emit(RankingError(error.message)), + ); + } + + RankingEntryModel _toDisplayModel({required int rank, required LeaderboardPlayerModel player}) { + final initial = player.username.isNotEmpty ? player.username[0].toUpperCase() : '?'; + final xp = player.xp; + final ptsFormatted = _formatPts(xp); + final scoreFormatted = xp >= 1000 ? '${(xp / 1000.0).toStringAsFixed(1).replaceAll('.', ',')}k' : '$xp'; + + return RankingEntryModel( + rank: rank, + username: player.username, + initial: initial, + level: player.level, + ptsFormatted: ptsFormatted, + scoreFormatted: scoreFormatted, + avatarBg: _parseColor(player.color), ); } + + String _formatPts(int xp) { + if (xp < 1000) return '$xp'; + final s = xp.toString(); + final buf = StringBuffer(); + for (int i = 0; i < s.length; i++) { + if (i > 0 && (s.length - i) % 3 == 0) buf.write(' '); + buf.write(s[i]); + } + return buf.toString(); + } + + Color _parseColor(String hex) { + try { + final cleaned = hex.replaceAll('#', ''); + final padded = cleaned.padLeft(6, '0'); + return Color(int.parse('FF$padded', radix: 16)); + } catch (_) { + return const Color(0xFF2A6B4A); + } + } } diff --git a/mobile_app/lib/features/ranking/ui/cubit/ranking_state.dart b/mobile_app/lib/features/ranking/ui/cubit/ranking_state.dart index 43bee2c..f3b4d53 100644 --- a/mobile_app/lib/features/ranking/ui/cubit/ranking_state.dart +++ b/mobile_app/lib/features/ranking/ui/cubit/ranking_state.dart @@ -4,6 +4,12 @@ sealed class RankingState {} final class RankingInitial extends RankingState {} +final class RankingError extends RankingState { + final String message; + + RankingError(this.message); +} + final class RankingLoaded extends RankingState { final String weekLabel; final String scopePrefix; diff --git a/mobile_app/lib/features/ranking/ui/ranking_screen.dart b/mobile_app/lib/features/ranking/ui/ranking_screen.dart index 65a5c50..eb6b034 100644 --- a/mobile_app/lib/features/ranking/ui/ranking_screen.dart +++ b/mobile_app/lib/features/ranking/ui/ranking_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../../../core/helpers/spacing.dart'; import '../../../core/theming/colors_manager.dart'; +import '../../../core/theming/inter_font_style.dart'; import '../../../core/widgets/app_circular_progress_indicator.dart'; import 'cubit/ranking_cubit.dart'; import 'widgets/widgets.dart'; @@ -19,6 +20,10 @@ class RankingScreen extends StatelessWidget { body: BlocBuilder( builder: (context, state) => switch (state) { RankingInitial() => const AppCircularProgressIndicator(), + RankingError(:final message) => _RankingErrorView( + message: message, + onRetry: () => context.read().loadRanking(), + ), RankingLoaded() => SafeArea( child: Padding( padding: EdgeInsets.only(left: 18.w, top: 12.h, right: 18.w), @@ -46,3 +51,37 @@ class RankingScreen extends StatelessWidget { ); } } + +class _RankingErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const _RankingErrorView({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: DesertColors.bg, + body: Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 32.w), + child: Column( + mainAxisAlignment: .center, + children: [ + Text(message, style: InterFontStyle.font14W500Ink, textAlign: .center), + verticalSpace(20), + ElevatedButton( + onPressed: onRetry, + style: ElevatedButton.styleFrom( + backgroundColor: DesertColors.ink, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.r)), + ), + child: Text('Réessayer', style: InterFontStyle.font16W600White), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile_app/lib/features/ranking/ui/widgets/ranking_row.dart b/mobile_app/lib/features/ranking/ui/widgets/ranking_row.dart index 9d12dbd..6f27bc0 100644 --- a/mobile_app/lib/features/ranking/ui/widgets/ranking_row.dart +++ b/mobile_app/lib/features/ranking/ui/widgets/ranking_row.dart @@ -6,7 +6,6 @@ import '../../../../core/helpers/spacing.dart'; import '../../../../core/theming/colors_manager.dart'; import '../../../../core/theming/instrument_serif_font_style.dart'; import '../../../../core/theming/inter_font_style.dart'; -import '../../../../core/theming/jet_brains_mono_font_style.dart'; import '../../domain/models/ranking_entry_model.dart'; import '../../../../core/widgets/app_avatar.dart'; @@ -38,16 +37,8 @@ class RankingRow extends StatelessWidget { Column( crossAxisAlignment: .start, children: [ - Row( - children: [ - Text(entry.username, style: InterFontStyle.font14W600Ink, overflow: .ellipsis), - if (entry.isVerified) ...[ - horizontalSpace(4), - Icon(Icons.diamond, size: 10.sp, color: DesertColors.primary), - ], - ], - ), - Text(entry.country, style: InterFontStyle.font11W500LightGrey), + Text(entry.username, style: InterFontStyle.font14W600Ink, overflow: .ellipsis), + Text('Niv. ${entry.level}', style: InterFontStyle.font11W500LightGrey), ], ), Spacer(), @@ -55,7 +46,7 @@ class RankingRow extends StatelessWidget { crossAxisAlignment: .end, children: [ Text(entry.scoreFormatted, style: InstrumentSerifFontStyle.font16W400Ink), - Text(entry.deltaFormatted, style: JetBrainsMonoFontStyle.font11W600(entry.deltaColor)), + Text('XP', style: InterFontStyle.font11W500LightGrey), ], ), ], diff --git a/mobile_app/lib/features/ranking/ui/widgets/top_entry_card.dart b/mobile_app/lib/features/ranking/ui/widgets/top_entry_card.dart index fcbd8c9..c677112 100644 --- a/mobile_app/lib/features/ranking/ui/widgets/top_entry_card.dart +++ b/mobile_app/lib/features/ranking/ui/widgets/top_entry_card.dart @@ -43,17 +43,9 @@ class TopEntryCard extends StatelessWidget { color: ColorsManager.vanilla.withValues(alpha: 0.6), ), ), - Text.rich( - TextSpan( - children: [ - TextSpan(text: entry.username, style: InstrumentSerifFontStyle.font24W400ItalicVanilla), - WidgetSpan(child: horizontalSpace(6)), - TextSpan(text: entry.countryCode, style: InterFontStyle.font12W500Vanilla), - ], - ), - ), + Text(entry.username, style: InstrumentSerifFontStyle.font24W400ItalicVanilla), Text( - '${entry.ptsFormatted} pts cette saison', + 'Niveau ${entry.level} · ${entry.ptsFormatted} XP', style: InterFontStyle.font12W500Vanilla.copyWith( color: ColorsManager.vanilla.withValues(alpha: 0.6), ), @@ -62,7 +54,6 @@ class TopEntryCard extends StatelessWidget { ), ], ), - Text( '★', style: TextStyle(color: DesertColors.gold, fontSize: 32.sp, fontStyle: FontStyle.italic),