diff --git a/mobile_app/lib/core/di/dependency_injection.dart b/mobile_app/lib/core/di/dependency_injection.dart index b196c97..6734604 100644 --- a/mobile_app/lib/core/di/dependency_injection.dart +++ b/mobile_app/lib/core/di/dependency_injection.dart @@ -13,6 +13,8 @@ import '../../features/signup/data/services/sign_up_api_service.dart'; import '../../features/signup/ui/cubit/sign_up_cubit.dart'; import '../../features/signup/ui/cubit/enter_email_otp_cubit.dart'; import '../../features/categories/ui/cubit/categories_cubit.dart'; +import '../../features/profile/data/repositories/profile_repository.dart'; +import '../../features/profile/data/services/profile_api_service.dart'; 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'; @@ -55,7 +57,9 @@ Future setupGetIt() async { getIt.registerFactory(() => RankingRepository(getIt())); getIt.registerFactory(() => RankingCubit(getIt())); - getIt.registerFactory(() => ProfileCubit()); + getIt.registerFactory(() => ProfileApiService(dio)); + getIt.registerFactory(() => ProfileRepository(getIt())); + getIt.registerFactory(() => ProfileCubit(getIt())); getIt.registerFactory(() => QuizApiService(dio)); getIt.registerFactory(() => QuizRepository(getIt())); diff --git a/mobile_app/lib/features/home/domain/models/user_profile_domain_model.dart b/mobile_app/lib/features/home/domain/models/user_profile_domain_model.dart index 8054a53..a0722fb 100644 --- a/mobile_app/lib/features/home/domain/models/user_profile_domain_model.dart +++ b/mobile_app/lib/features/home/domain/models/user_profile_domain_model.dart @@ -30,7 +30,12 @@ class UserProfileDomainModel { streakCount: response.userLevel.level, avatarBgColor: avatarBgColor, ), - userStats: UserStatsDomainModel(xp: response.userLevel.xp, level: response.userLevel.level, rank: 0), + userStats: UserStatsDomainModel( + xp: response.userLevel.xp, + level: response.userLevel.level, + rank: 0, + isBattleUnlocked: response.userLevel.isBattleUnlocked, + ), dailyChallenge: DailyChallengeDomainModel( date: "2023-09-15", resetCountdown: "12:34:56", diff --git a/mobile_app/lib/features/home/domain/models/user_stats_domain_model.dart b/mobile_app/lib/features/home/domain/models/user_stats_domain_model.dart index f3df478..b5f750a 100644 --- a/mobile_app/lib/features/home/domain/models/user_stats_domain_model.dart +++ b/mobile_app/lib/features/home/domain/models/user_stats_domain_model.dart @@ -2,6 +2,12 @@ class UserStatsDomainModel { final int xp; final int level; final int rank; + final bool isBattleUnlocked; - const UserStatsDomainModel({required this.xp, required this.level, required this.rank}); + const UserStatsDomainModel({ + required this.xp, + required this.level, + required this.rank, + required this.isBattleUnlocked, + }); } diff --git a/mobile_app/lib/features/home/ui/widgets/stats_row.dart b/mobile_app/lib/features/home/ui/widgets/stats_row.dart index 1758472..3b1f892 100644 --- a/mobile_app/lib/features/home/ui/widgets/stats_row.dart +++ b/mobile_app/lib/features/home/ui/widgets/stats_row.dart @@ -21,7 +21,21 @@ class StatsRow extends StatelessWidget { horizontalSpace(10), _StatCard(value: '${stats.level}', label: 'LEVEL'), horizontalSpace(10), - _StatCard(value: '#${stats.rank}', label: 'RANG'), + _StatCard( + value: stats.isBattleUnlocked ? 'Actif' : 'Bloqué', + label: 'BATTLES', + valueWidget: Row( + children: [ + Icon( + stats.isBattleUnlocked ? Icons.lock_open_rounded : Icons.lock_rounded, + size: 20.sp, + color: stats.isBattleUnlocked ? SemanticColors.success : ColorsManager.lightGrey, + ), + horizontalSpace(6), + Text(stats.isBattleUnlocked ? 'Actif' : 'Bloqué', style: InstrumentSerifFontStyle.font22W400Ink), + ], + ), + ), ], ); } @@ -30,12 +44,14 @@ class StatsRow extends StatelessWidget { class _StatCard extends StatelessWidget { final String value; final String label; + final Widget? valueWidget; - const _StatCard({required this.value, required this.label}); + const _StatCard({required this.value, required this.label, this.valueWidget}); @override Widget build(BuildContext context) { return Container( + height: 76.h, width: 106.w, padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), decoration: BoxDecoration( @@ -46,7 +62,7 @@ class _StatCard extends StatelessWidget { child: Column( crossAxisAlignment: .start, children: [ - Text(value, style: InstrumentSerifFontStyle.font26W500Ink), + valueWidget ?? Text(value, style: InstrumentSerifFontStyle.font26W500Ink), verticalSpace(1), Text(label, style: JetBrainsMonoFontStyle.font10W500MediumGrey), ], diff --git a/mobile_app/lib/features/profile/data/dtos/user_profile_dto.dart b/mobile_app/lib/features/profile/data/dtos/user_profile_dto.dart new file mode 100644 index 0000000..66a26e7 --- /dev/null +++ b/mobile_app/lib/features/profile/data/dtos/user_profile_dto.dart @@ -0,0 +1,48 @@ +import '../../domain/models/user_profile_model.dart'; + +class UserProfileDto { + final String id; + final String username; + final String email; + final String color; + final String createdAt; + final int xp; + final int level; + final bool isBattleUnlocked; + + const UserProfileDto({ + required this.id, + required this.username, + required this.email, + required this.color, + required this.createdAt, + required this.xp, + required this.level, + required this.isBattleUnlocked, + }); + + factory UserProfileDto.fromJson(Map json) { + final userLevel = json['userLevel'] as Map? ?? {}; + return UserProfileDto( + id: json['id'] as String, + username: json['username'] as String, + email: json['email'] as String, + color: (json['color'] as String?) ?? '', + createdAt: (json['createdAt'] as String?) ?? '', + xp: (userLevel['xp'] as int?) ?? 0, + level: (userLevel['level'] as int?) ?? 1, + isBattleUnlocked: (userLevel['isBattleUnlocked'] as bool?) ?? false, + ); + } + + UserProfileModel toDomain() => UserProfileModel( + id: id, + username: username, + email: email, + color: color, + createdAt: createdAt, + xp: xp, + level: level, + isBattleUnlocked: isBattleUnlocked, + ); +} diff --git a/mobile_app/lib/features/profile/data/repositories/profile_repository.dart b/mobile_app/lib/features/profile/data/repositories/profile_repository.dart new file mode 100644 index 0000000..e249144 --- /dev/null +++ b/mobile_app/lib/features/profile/data/repositories/profile_repository.dart @@ -0,0 +1,19 @@ +import '../../../../core/networking/api_error_handler.dart'; +import '../../../../core/networking/api_result.dart'; +import '../../domain/models/user_profile_model.dart'; +import '../services/profile_api_service.dart'; + +class ProfileRepository { + final ProfileApiService _api; + + const ProfileRepository(this._api); + + Future> getProfile() async { + try { + final dto = await _api.getProfile(); + return ApiSuccess(dto.toDomain()); + } catch (e) { + return ApiFailure(ApiErrorHandler.handle(e)); + } + } +} diff --git a/mobile_app/lib/features/profile/data/services/profile_api_service.dart b/mobile_app/lib/features/profile/data/services/profile_api_service.dart new file mode 100644 index 0000000..8d1cb3f --- /dev/null +++ b/mobile_app/lib/features/profile/data/services/profile_api_service.dart @@ -0,0 +1,15 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/networking/api_constants.dart'; +import '../dtos/user_profile_dto.dart'; + +class ProfileApiService { + final Dio _dio; + + const ProfileApiService(this._dio); + + Future getProfile() async { + final response = await _dio.get(ApiConstants.usersGetMe); + return UserProfileDto.fromJson(response.data as Map); + } +} diff --git a/mobile_app/lib/features/profile/domain/models/profile_model.dart b/mobile_app/lib/features/profile/domain/models/profile_model.dart index ebb8185..3e5d690 100644 --- a/mobile_app/lib/features/profile/domain/models/profile_model.dart +++ b/mobile_app/lib/features/profile/domain/models/profile_model.dart @@ -1,36 +1,23 @@ import 'package:flutter/material.dart'; -import 'category_accuracy_model.dart'; - class ProfileModel { - final String name; - final String handle; + final String username; + final String email; final String initial; final Color avatarBgColor; - final bool isPro; - final String subtitleLabel; - final int streakDays; - final int precisionPercent; - final String precisionSublabel; - final String battlesLabel; - final int battleWinRatePercent; - // [week][day], values 0–4 (intensity), 5 weeks × 7 days - final List> activityGrid; - final List categoryAccuracies; + final String memberSinceLabel; + final int xp; + final int level; + final bool isBattleUnlocked; const ProfileModel({ - required this.name, - required this.handle, + required this.username, + required this.email, required this.initial, required this.avatarBgColor, - required this.isPro, - required this.subtitleLabel, - required this.streakDays, - required this.precisionPercent, - required this.precisionSublabel, - required this.battlesLabel, - required this.battleWinRatePercent, - required this.activityGrid, - required this.categoryAccuracies, + required this.memberSinceLabel, + required this.xp, + required this.level, + required this.isBattleUnlocked, }); } diff --git a/mobile_app/lib/features/profile/domain/models/user_profile_model.dart b/mobile_app/lib/features/profile/domain/models/user_profile_model.dart new file mode 100644 index 0000000..9dd25fb --- /dev/null +++ b/mobile_app/lib/features/profile/domain/models/user_profile_model.dart @@ -0,0 +1,21 @@ +class UserProfileModel { + final String id; + final String username; + final String email; + final String color; + final String createdAt; + final int xp; + final int level; + final bool isBattleUnlocked; + + const UserProfileModel({ + required this.id, + required this.username, + required this.email, + required this.color, + required this.createdAt, + required this.xp, + required this.level, + required this.isBattleUnlocked, + }); +} diff --git a/mobile_app/lib/features/profile/ui/cubit/profile_cubit.dart b/mobile_app/lib/features/profile/ui/cubit/profile_cubit.dart index 880d6f0..e1b3010 100644 --- a/mobile_app/lib/features/profile/ui/cubit/profile_cubit.dart +++ b/mobile_app/lib/features/profile/ui/cubit/profile_cubit.dart @@ -1,48 +1,58 @@ import 'package:flutter/material.dart'; - import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../core/theming/colors_manager.dart'; -import '../../domain/models/category_accuracy_model.dart'; +import '../../../../core/networking/api_result.dart'; +import '../../data/repositories/profile_repository.dart'; import '../../domain/models/profile_model.dart'; +import '../../domain/models/user_profile_model.dart'; part 'profile_state.dart'; class ProfileCubit extends Cubit { - ProfileCubit() : super(ProfileInitial()); - - void loadProfile() { - emit( - ProfileLoaded( - profile: ProfileModel( - name: 'Amine B.', - handle: '@amine.dz', - initial: 'A', - avatarBgColor: const Color(0xFF2D6A4F), - isPro: true, - subtitleLabel: 'Alger (16) · membre depuis fév. 2026', - streakDays: 12, - precisionPercent: 78, - precisionSublabel: '% 1 240 Q', - battlesLabel: '38/22', - battleWinRatePercent: 63, - activityGrid: const [ - [2, 4, 1, 3, 0], - [1, 0, 3, 4, 2], - [3, 2, 4, 1, 3], - [0, 3, 2, 4, 1], - [4, 2, 3, 1, 4], - [4, 2, 3, 1, 4], - ], - categoryAccuracies: const [ - CategoryAccuracyModel(name: 'Histoire', percent: 86, barColor: SemanticColors.success), - CategoryAccuracyModel(name: 'Darja', percent: 74, barColor: SemanticColors.success), - CategoryAccuracyModel(name: 'Géographie', percent: 91, barColor: SemanticColors.success), - CategoryAccuracyModel(name: 'Football', percent: 52, barColor: SemanticColors.danger), - CategoryAccuracyModel(name: 'Musique', percent: 68, barColor: SemanticColors.success), - ], - ), - ), + final ProfileRepository _repository; + + ProfileCubit(this._repository) : super(ProfileInitial()); + + Future loadProfile() async { + emit(ProfileInitial()); + + final result = await _repository.getProfile(); + + result.when( + success: (UserProfileModel raw) => emit(ProfileLoaded(profile: _toDisplayModel(raw))), + failure: (error) => emit(ProfileError(error.message)), ); } + + ProfileModel _toDisplayModel(UserProfileModel raw) { + final initial = raw.username.isNotEmpty ? raw.username[0].toUpperCase() : '?'; + + return ProfileModel( + username: raw.username, + email: raw.email, + initial: initial, + avatarBgColor: _parseColor(raw.color), + memberSinceLabel: _formatMemberSince(raw.createdAt), + xp: raw.xp, + level: raw.level, + isBattleUnlocked: raw.isBattleUnlocked, + ); + } + + String _formatMemberSince(String createdAt) { + final dt = DateTime.tryParse(createdAt); + if (dt == null) return ''; + const months = ['janv.', 'fév.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.']; + return 'membre depuis ${months[dt.month - 1]} ${dt.year}'; + } + + 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(0xFF2D6A4F); + } + } } diff --git a/mobile_app/lib/features/profile/ui/cubit/profile_state.dart b/mobile_app/lib/features/profile/ui/cubit/profile_state.dart index 41f8124..b1b8381 100644 --- a/mobile_app/lib/features/profile/ui/cubit/profile_state.dart +++ b/mobile_app/lib/features/profile/ui/cubit/profile_state.dart @@ -4,6 +4,12 @@ sealed class ProfileState {} final class ProfileInitial extends ProfileState {} +final class ProfileError extends ProfileState { + final String message; + + ProfileError(this.message); +} + final class ProfileLoaded extends ProfileState { final ProfileModel profile; diff --git a/mobile_app/lib/features/profile/ui/profile_screen.dart b/mobile_app/lib/features/profile/ui/profile_screen.dart index 04ba9f8..7ac845a 100644 --- a/mobile_app/lib/features/profile/ui/profile_screen.dart +++ b/mobile_app/lib/features/profile/ui/profile_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/profile_cubit.dart'; import 'widgets/widgets.dart'; @@ -19,6 +20,10 @@ class ProfileScreen extends StatelessWidget { body: BlocBuilder( builder: (context, state) => switch (state) { ProfileInitial() => const AppCircularProgressIndicator(), + ProfileError(:final message) => _ProfileErrorView( + message: message, + onRetry: () => context.read().loadProfile(), + ), ProfileLoaded() => SafeArea( child: SingleChildScrollView( padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 20.h), @@ -28,10 +33,6 @@ class ProfileScreen extends StatelessWidget { ProfileHeader(profile: state.profile), verticalSpace(20), ProfileStatsRow(profile: state.profile), - verticalSpace(24), - ActivityHeatmap(activityGrid: state.profile.activityGrid), - verticalSpace(24), - CategoryAccuracySection(items: state.profile.categoryAccuracies), verticalSpace(16), ], ), @@ -42,3 +43,37 @@ class ProfileScreen extends StatelessWidget { ); } } + +class _ProfileErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const _ProfileErrorView({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/profile/ui/widgets/profile_header.dart b/mobile_app/lib/features/profile/ui/widgets/profile_header.dart index 08e527b..d516d28 100644 --- a/mobile_app/lib/features/profile/ui/widgets/profile_header.dart +++ b/mobile_app/lib/features/profile/ui/widgets/profile_header.dart @@ -1,11 +1,9 @@ -import 'package:culturo/core/theming/instrument_serif_font_style.dart'; -import 'package:culturo/core/theming/jet_brains_mono_font_style.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; 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 '../../domain/models/profile_model.dart'; @@ -29,15 +27,10 @@ class ProfileHeader extends StatelessWidget { child: Column( crossAxisAlignment: .start, children: [ - Row( - children: [ - Text(profile.name, style: InstrumentSerifFontStyle.font22W400Ink), - if (profile.isPro) ...[horizontalSpace(6), _ProBadge()], - ], - ), - - Text(profile.handle, style: InterFontStyle.font12W500MediumGrey), - Text(profile.subtitleLabel, style: InterFontStyle.font12W500MediumGrey), + Text(profile.username, style: InstrumentSerifFontStyle.font22W400Ink), + Text(profile.email, style: InterFontStyle.font12W500MediumGrey), + if (profile.memberSinceLabel.isNotEmpty) + Text(profile.memberSinceLabel, style: InterFontStyle.font12W500MediumGrey), ], ), ), @@ -45,24 +38,3 @@ class ProfileHeader extends StatelessWidget { ); } } - -class _ProBadge extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h), - decoration: BoxDecoration( - color: DesertColors.gold.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8.r), - ), - child: Row( - mainAxisSize: .min, - children: [ - Icon(Icons.diamond, size: 10.sp, color: DesertColors.gold), - horizontalSpace(3), - Text('PRO', style: JetBrainsMonoFontStyle.font10W700Gold), - ], - ), - ); - } -} diff --git a/mobile_app/lib/features/profile/ui/widgets/profile_stats_row.dart b/mobile_app/lib/features/profile/ui/widgets/profile_stats_row.dart index 849fd63..fbc5320 100644 --- a/mobile_app/lib/features/profile/ui/widgets/profile_stats_row.dart +++ b/mobile_app/lib/features/profile/ui/widgets/profile_stats_row.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -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'; @@ -15,34 +14,42 @@ class ProfileStatsRow extends StatelessWidget { @override Widget build(BuildContext context) { + final xpFormatted = _formatXp(profile.xp); + return Row( mainAxisAlignment: .spaceBetween, children: [ + _StatCard(label: 'NIVEAU', value: '${profile.level}', sublabel: 'actuel'), + _StatCard(label: 'XP TOTAL', value: xpFormatted, sublabel: 'points'), _StatCard( - label: 'SÉRIE\nACTUELLE', - value: '${profile.streakDays}', - sublabelWidget: Row( - children: [ - Text('jours', style: InterFontStyle.font11W500LightGrey), - horizontalSpace(4), - Icon(Icons.whatshot, size: 14, color: DesertColors.accent), - ], + label: 'BATTLES', + value: profile.isBattleUnlocked ? 'Actif' : 'Bloqué', + sublabelWidget: Icon( + profile.isBattleUnlocked ? Icons.lock_open_rounded : Icons.lock_rounded, + size: 14, + color: profile.isBattleUnlocked ? SemanticColors.success : ColorsManager.lightGrey, ), ), - - _StatCard(label: 'PRÉCISION', value: '${profile.precisionPercent}', sublabel: profile.precisionSublabel), - - _StatCard(label: 'BATTLES\nN/L', value: profile.battlesLabel, sublabel: '${profile.battleWinRatePercent}%'), ], ); } + + String _formatXp(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(); + } } class _StatCard extends StatelessWidget { final String label; final String value; final String? sublabel; - final Widget? sublabelWidget; const _StatCard({required this.label, required this.value, this.sublabel, this.sublabelWidget}); @@ -64,7 +71,10 @@ class _StatCard extends StatelessWidget { children: [ Text(label, style: InterFontStyle.font10W600MediumGrey, maxLines: 2), Text(value, style: InstrumentSerifFontStyle.font22W400Ink), - if (sublabelWidget != null) sublabelWidget! else Text(sublabel!, style: InterFontStyle.font11W500LightGrey), + if (sublabelWidget != null) + sublabelWidget! + else + Text(sublabel ?? '', style: InterFontStyle.font11W500LightGrey), ], ), );