Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion mobile_app/lib/core/di/dependency_injection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -55,7 +57,9 @@ Future<void> setupGetIt() async {
getIt.registerFactory<RankingRepository>(() => RankingRepository(getIt()));
getIt.registerFactory<RankingCubit>(() => RankingCubit(getIt()));

getIt.registerFactory<ProfileCubit>(() => ProfileCubit());
getIt.registerFactory<ProfileApiService>(() => ProfileApiService(dio));
getIt.registerFactory<ProfileRepository>(() => ProfileRepository(getIt()));
getIt.registerFactory<ProfileCubit>(() => ProfileCubit(getIt()));

getIt.registerFactory<QuizApiService>(() => QuizApiService(dio));
getIt.registerFactory<QuizRepository>(() => QuizRepository(getIt()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
22 changes: 19 additions & 3 deletions mobile_app/lib/features/home/ui/widgets/stats_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
],
),
),
],
);
}
Expand All @@ -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(
Expand All @@ -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),
],
Expand Down
48 changes: 48 additions & 0 deletions mobile_app/lib/features/profile/data/dtos/user_profile_dto.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> json) {
final userLevel = json['userLevel'] as Map<String, dynamic>? ?? {};
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,
);
}
Original file line number Diff line number Diff line change
@@ -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<ApiResult<UserProfileModel>> getProfile() async {
try {
final dto = await _api.getProfile();
return ApiSuccess(dto.toDomain());
} catch (e) {
return ApiFailure(ApiErrorHandler.handle(e));
}
}
}
Original file line number Diff line number Diff line change
@@ -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<UserProfileDto> getProfile() async {
final response = await _dio.get(ApiConstants.usersGetMe);
return UserProfileDto.fromJson(response.data as Map<String, dynamic>);
}
}
37 changes: 12 additions & 25 deletions mobile_app/lib/features/profile/domain/models/profile_model.dart
Original file line number Diff line number Diff line change
@@ -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<List<int>> activityGrid;
final List<CategoryAccuracyModel> 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,
});
}
Original file line number Diff line number Diff line change
@@ -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,
});
}
84 changes: 47 additions & 37 deletions mobile_app/lib/features/profile/ui/cubit/profile_cubit.dart
Original file line number Diff line number Diff line change
@@ -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<ProfileState> {
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<void> 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);
}
}
}
6 changes: 6 additions & 0 deletions mobile_app/lib/features/profile/ui/cubit/profile_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading
Loading