From 3613e22d499d58faf6d825662ac49404129a0765 Mon Sep 17 00:00:00 2001 From: aliakrem Date: Fri, 28 Nov 2025 21:19:41 +0100 Subject: [PATCH 1/3] feat: allow student to see their debts --- lib/app.dart | 2 + lib/config/routes/app_router.dart | 8 + lib/core/di/injector.dart | 12 +- .../presentation/widgets/dashboard.dart | 8 + .../debts/data/models/academic_year_debt.dart | 35 +++ .../debts/data/models/debt_course.dart | 63 ++++ .../repositories/debts_repository_impl.dart | 27 ++ .../data/services/debts_cache_service.dart | 80 +++++ .../debts/presentation/bloc/debts_bloc.dart | 70 +++++ .../debts/presentation/bloc/debts_event.dart | 21 ++ .../debts/presentation/bloc/debts_state.dart | 34 +++ .../debts/presentation/pages/debts_page.dart | 285 ++++++++++++++++++ .../widgets/debt_course_card.dart | 220 ++++++++++++++ .../widgets/year_summary_card.dart | 93 ++++++ lib/l10n/app_ar.arb | 46 +++ lib/l10n/app_en.arb | 46 +++ lib/l10n/app_localizations.dart | 60 ++++ lib/l10n/app_localizations_ar.dart | 39 +++ lib/l10n/app_localizations_en.dart | 39 +++ 19 files changed, 1187 insertions(+), 1 deletion(-) create mode 100644 lib/features/debts/data/models/academic_year_debt.dart create mode 100644 lib/features/debts/data/models/debt_course.dart create mode 100644 lib/features/debts/data/repositories/debts_repository_impl.dart create mode 100644 lib/features/debts/data/services/debts_cache_service.dart create mode 100644 lib/features/debts/presentation/bloc/debts_bloc.dart create mode 100644 lib/features/debts/presentation/bloc/debts_event.dart create mode 100644 lib/features/debts/presentation/bloc/debts_state.dart create mode 100644 lib/features/debts/presentation/pages/debts_page.dart create mode 100644 lib/features/debts/presentation/widgets/debt_course_card.dart create mode 100644 lib/features/debts/presentation/widgets/year_summary_card.dart diff --git a/lib/app.dart b/lib/app.dart index 9adbf3e..ed4a337 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -8,6 +8,7 @@ import 'package:progres/core/di/injector.dart'; import 'package:progres/core/theme/theme_bloc.dart'; import 'package:progres/features/academics/presentation/bloc/academics_bloc.dart'; import 'package:progres/features/auth/presentation/bloc/auth_bloc.dart'; +import 'package:progres/features/debts/presentation/bloc/debts_bloc.dart'; import 'package:progres/features/enrollment/presentation/bloc/enrollment_bloc.dart'; import 'package:progres/features/groups/presentation/bloc/groups_bloc.dart'; import 'package:progres/features/profile/presentation/bloc/profile_bloc.dart'; @@ -34,6 +35,7 @@ class ProgresApp extends StatelessWidget { BlocProvider(create: (context) => injector()), BlocProvider(create: (context) => injector()), BlocProvider(create: (context) => injector()), + BlocProvider(create: (context) => injector()), ], child: CalendarControllerProvider( controller: EventController(), diff --git a/lib/config/routes/app_router.dart b/lib/config/routes/app_router.dart index 5f439f3..4a7a1fb 100644 --- a/lib/config/routes/app_router.dart +++ b/lib/config/routes/app_router.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:progres/core/splash_screen/splash_screen.dart'; import 'package:progres/features/academics/presentation/pages/academic_performance_page.dart'; +import 'package:progres/features/debts/presentation/pages/debts_page.dart'; import 'package:progres/features/groups/presentation/pages/groups_page.dart'; import 'package:progres/features/discharge/presentation/pages/discharge_page.dart'; import 'package:progres/features/subject/presentation/pages/subject_page.dart'; @@ -32,6 +33,7 @@ class AppRouter { static const String transcripts = 'transcripts'; static const String discharge = 'discharge'; static const String about = 'about'; + static const String debts = 'debts'; // Route paths static const String splashPath = '/'; @@ -48,6 +50,7 @@ class AppRouter { static const String transcriptsPath = 'transcripts'; static const String dischargePath = 'discharge'; static const String aboutPath = 'about'; + static const String debtsPath = 'debts'; late final GoRouter router; @@ -129,6 +132,11 @@ class AppRouter { name: discharge, builder: (context, state) => const DischargePage(), ), + GoRoute( + path: debtsPath, + name: debts, + builder: (context, state) => const DebtsPage(), + ), ], ), GoRoute( diff --git a/lib/core/di/injector.dart b/lib/core/di/injector.dart index a29646a..a764cc2 100644 --- a/lib/core/di/injector.dart +++ b/lib/core/di/injector.dart @@ -25,11 +25,14 @@ import 'package:progres/features/transcript/data/services/transcript_cache_servi import 'package:progres/features/transcript/presentation/bloc/transcript_bloc.dart'; import 'package:progres/features/discharge/data/repository/discharge_repository_impl.dart'; import 'package:progres/features/discharge/presentation/bloc/discharge_bloc.dart'; +import 'package:progres/features/debts/data/repositories/debts_repository_impl.dart'; +import 'package:progres/features/debts/data/services/debts_cache_service.dart'; +import 'package:progres/features/debts/presentation/bloc/debts_bloc.dart'; final injector = GetIt.instance; Future initDependencies() async { - injector.registerLazySingleton(() => ApiClient()); + injector.registerLazySingleton(() => ApiClient()); injector.registerLazySingleton( () => AuthRepositoryImpl(apiClient: injector()), ); @@ -55,11 +58,15 @@ Future initDependencies() async { () => AcademicPerformanceRepositoryImpl(apiClient: injector()), ); injector.registerLazySingleton(() => StudentDischargeRepositoryImpl()); + injector.registerLazySingleton( + () => DebtsRepositoryImpl(apiClient: injector()), + ); injector.registerLazySingleton(() => TimelineCacheService()); injector.registerLazySingleton(() => EnrollmentCacheService()); injector.registerLazySingleton(() => TranscriptCacheService()); injector.registerLazySingleton(() => GroupsCacheService()); injector.registerLazySingleton(() => SubjectCacheService()); + injector.registerLazySingleton(() => DebtsCacheService()); // Register BLoCs injector.registerFactory(() => ThemeBloc()..add(LoadTheme())); @@ -110,4 +117,7 @@ Future initDependencies() async { injector.registerFactory( () => StudentDischargeBloc(studentDischargeRepository: injector()), ); + injector.registerFactory( + () => DebtsBloc(debtsRepository: injector(), debtsCacheService: injector()), + ); } diff --git a/lib/features/dashboard/presentation/widgets/dashboard.dart b/lib/features/dashboard/presentation/widgets/dashboard.dart index 821abf8..66f3af2 100644 --- a/lib/features/dashboard/presentation/widgets/dashboard.dart +++ b/lib/features/dashboard/presentation/widgets/dashboard.dart @@ -139,6 +139,14 @@ Widget buildDashboard(ProfileLoaded state, BuildContext context) { color: AppTheme.AppPrimary, onTap: () => context.goNamed(AppRouter.discharge), ), + + buildGridCard( + context, + title: AppLocalizations.of(context)!.academicDebts, + icon: Icons.assignment_late, + color: AppTheme.AppPrimary, + onTap: () => context.goNamed(AppRouter.debts), + ), ], ), diff --git a/lib/features/debts/data/models/academic_year_debt.dart b/lib/features/debts/data/models/academic_year_debt.dart new file mode 100644 index 0000000..471cb60 --- /dev/null +++ b/lib/features/debts/data/models/academic_year_debt.dart @@ -0,0 +1,35 @@ +import 'package:progres/features/debts/data/models/debt_course.dart'; + +class AcademicYearDebt { + final String annee; + final int idAnneeAcademique; + final int rang; + final List dette; + + AcademicYearDebt({ + required this.annee, + required this.idAnneeAcademique, + required this.rang, + required this.dette, + }); + + factory AcademicYearDebt.fromJson(Map json) { + return AcademicYearDebt( + annee: json['annee'] as String, + idAnneeAcademique: json['id_annee_academique'] as int, + rang: json['rang'] as int, + dette: (json['dette'] as List) + .map((e) => DebtCourse.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'annee': annee, + 'id_annee_academique': idAnneeAcademique, + 'rang': rang, + 'dette': dette.map((e) => e.toJson()).toList(), + }; + } +} diff --git a/lib/features/debts/data/models/debt_course.dart b/lib/features/debts/data/models/debt_course.dart new file mode 100644 index 0000000..fc1be57 --- /dev/null +++ b/lib/features/debts/data/models/debt_course.dart @@ -0,0 +1,63 @@ +class DebtCourse { + final String mcFr; + final String mcAr; + final String nfr; + final String nar; + final String pfr; + final String par; + final double m; + final double md; + final double cc; + final double ccd; + final double ex; + final double exd; + + DebtCourse({ + required this.mcFr, + required this.mcAr, + required this.nfr, + required this.nar, + required this.pfr, + required this.par, + required this.m, + required this.md, + required this.cc, + required this.ccd, + required this.ex, + required this.exd, + }); + + factory DebtCourse.fromJson(Map json) { + return DebtCourse( + mcFr: json['mcFr'] as String, + mcAr: json['mcAr'] as String, + nfr: json['nfr'] as String, + nar: json['nar'] as String, + pfr: json['pfr'] as String, + par: json['par'] as String, + m: (json['m'] as num).toDouble(), + md: (json['md'] as num).toDouble(), + cc: (json['cc'] as num).toDouble(), + ccd: (json['ccd'] as num).toDouble(), + ex: (json['ex'] as num).toDouble(), + exd: (json['exd'] as num).toDouble(), + ); + } + + Map toJson() { + return { + 'mcFr': mcFr, + 'mcAr': mcAr, + 'nfr': nfr, + 'nar': nar, + 'pfr': pfr, + 'par': par, + 'm': m, + 'md': md, + 'cc': cc, + 'ccd': ccd, + 'ex': ex, + 'exd': exd, + }; + } +} diff --git a/lib/features/debts/data/repositories/debts_repository_impl.dart b/lib/features/debts/data/repositories/debts_repository_impl.dart new file mode 100644 index 0000000..2c2e804 --- /dev/null +++ b/lib/features/debts/data/repositories/debts_repository_impl.dart @@ -0,0 +1,27 @@ +import 'package:progres/core/network/api_client.dart'; +import 'package:progres/features/debts/data/models/academic_year_debt.dart'; + +class DebtsRepositoryImpl { + final ApiClient _apiClient; + + DebtsRepositoryImpl({ApiClient? apiClient}) + : _apiClient = apiClient ?? ApiClient(); + + Future> getStudentDebts() async { + try { + final uuid = await _apiClient.getUuid(); + if (uuid == null) { + throw Exception('UUID not found, please login again'); + } + + final response = await _apiClient.get('/infos/dettes/$uuid'); + + final List debtsJson = response.data; + return debtsJson + .map((debtJson) => AcademicYearDebt.fromJson(debtJson)) + .toList(); + } catch (e) { + rethrow; + } + } +} diff --git a/lib/features/debts/data/services/debts_cache_service.dart b/lib/features/debts/data/services/debts_cache_service.dart new file mode 100644 index 0000000..4b5992c --- /dev/null +++ b/lib/features/debts/data/services/debts_cache_service.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:progres/features/debts/data/models/academic_year_debt.dart'; + +class DebtsCacheService { + // Keys for SharedPreferences + static const String _debtsKey = 'cached_debts'; + static const String _lastUpdatedKey = 'last_updated_debts'; + + // Save debts + Future cacheDebts(List debts) async { + try { + final prefs = await SharedPreferences.getInstance(); + final debtsJson = debts.map((d) => d.toJson()).toList(); + await prefs.setString(_debtsKey, jsonEncode(debtsJson)); + await prefs.setString(_lastUpdatedKey, DateTime.now().toIso8601String()); + return true; + } catch (e) { + debugPrint('Error caching debts: $e'); + return false; + } + } + + // Retrieve debts + Future?> getCachedDebts() async { + try { + final prefs = await SharedPreferences.getInstance(); + final debtsString = prefs.getString(_debtsKey); + + if (debtsString == null) return null; + + final List decodedJson = jsonDecode(debtsString); + return decodedJson + .map((json) => AcademicYearDebt.fromJson(json)) + .toList(); + } catch (e) { + debugPrint('Error retrieving cached debts: $e'); + return null; + } + } + + // Get last update timestamp + Future getLastUpdated() async { + try { + final prefs = await SharedPreferences.getInstance(); + final timestamp = prefs.getString(_lastUpdatedKey); + if (timestamp == null) return null; + + return DateTime.parse(timestamp); + } catch (e) { + debugPrint('Error getting last updated time: $e'); + return null; + } + } + + // Clear all debts cached data + Future clearCache() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_debtsKey); + await prefs.remove(_lastUpdatedKey); + return true; + } catch (e) { + debugPrint('Error clearing cache: $e'); + return false; + } + } + + // Check if data is stale (older than specified duration) + Future isDataStale({ + Duration staleDuration = const Duration(hours: 12), + }) async { + final lastUpdated = await getLastUpdated(); + if (lastUpdated == null) return true; + + final now = DateTime.now(); + return now.difference(lastUpdated) > staleDuration; + } +} diff --git a/lib/features/debts/presentation/bloc/debts_bloc.dart b/lib/features/debts/presentation/bloc/debts_bloc.dart new file mode 100644 index 0000000..b60b64c --- /dev/null +++ b/lib/features/debts/presentation/bloc/debts_bloc.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:progres/features/debts/data/repositories/debts_repository_impl.dart'; +import 'package:progres/features/debts/data/services/debts_cache_service.dart'; +import 'package:progres/features/debts/presentation/bloc/debts_event.dart'; +import 'package:progres/features/debts/presentation/bloc/debts_state.dart'; + +class DebtsBloc extends Bloc { + final DebtsRepositoryImpl debtsRepository; + final DebtsCacheService debtsCacheService; + + DebtsBloc({required this.debtsRepository, required this.debtsCacheService}) + : super(DebtsInitial()) { + on(_onLoadDebts); + on(_onClearCache); + } + + Future _onLoadDebts(LoadDebts event, Emitter emit) async { + try { + emit(DebtsLoading()); + + // If not forcing refresh, try to get from cache first + if (!event.forceRefresh) { + final isStale = await debtsCacheService.isDataStale(); + if (!isStale) { + final cachedDebts = await debtsCacheService.getCachedDebts(); + if (cachedDebts != null) { + if (cachedDebts.isEmpty) { + emit(DebtsEmpty()); + } else { + emit(DebtsLoaded(debts: cachedDebts, fromCache: true)); + } + return; + } + } + } + + final debts = await debtsRepository.getStudentDebts(); + + // Cache the results + await debtsCacheService.cacheDebts(debts); + + if (debts.isEmpty) { + emit(DebtsEmpty()); + } else { + emit(DebtsLoaded(debts: debts, fromCache: false)); + } + } catch (e) { + debugPrint('Error loading debts: $e'); + + final cachedDebts = await debtsCacheService.getCachedDebts(); + if (cachedDebts != null) { + if (cachedDebts.isEmpty) { + emit(DebtsEmpty()); + } else { + emit(DebtsLoaded(debts: cachedDebts, fromCache: true)); + } + } else { + emit(DebtsError(message: e.toString())); + } + } + } + + Future _onClearCache( + ClearDebtsCache event, + Emitter emit, + ) async { + await debtsCacheService.clearCache(); + } +} diff --git a/lib/features/debts/presentation/bloc/debts_event.dart b/lib/features/debts/presentation/bloc/debts_event.dart new file mode 100644 index 0000000..bc568c3 --- /dev/null +++ b/lib/features/debts/presentation/bloc/debts_event.dart @@ -0,0 +1,21 @@ +import 'package:equatable/equatable.dart'; + +abstract class DebtsEvent extends Equatable { + const DebtsEvent(); + + @override + List get props => []; +} + +class LoadDebts extends DebtsEvent { + final bool forceRefresh; + + const LoadDebts({this.forceRefresh = false}); + + @override + List get props => [forceRefresh]; +} + +class ClearDebtsCache extends DebtsEvent { + const ClearDebtsCache(); +} diff --git a/lib/features/debts/presentation/bloc/debts_state.dart b/lib/features/debts/presentation/bloc/debts_state.dart new file mode 100644 index 0000000..6616c74 --- /dev/null +++ b/lib/features/debts/presentation/bloc/debts_state.dart @@ -0,0 +1,34 @@ +import 'package:equatable/equatable.dart'; +import 'package:progres/features/debts/data/models/academic_year_debt.dart'; + +abstract class DebtsState extends Equatable { + const DebtsState(); + + @override + List get props => []; +} + +class DebtsInitial extends DebtsState {} + +class DebtsLoading extends DebtsState {} + +class DebtsLoaded extends DebtsState { + final List debts; + final bool fromCache; + + const DebtsLoaded({required this.debts, this.fromCache = false}); + + @override + List get props => [debts, fromCache]; +} + +class DebtsEmpty extends DebtsState {} + +class DebtsError extends DebtsState { + final String message; + + const DebtsError({required this.message}); + + @override + List get props => [message]; +} diff --git a/lib/features/debts/presentation/pages/debts_page.dart b/lib/features/debts/presentation/pages/debts_page.dart new file mode 100644 index 0000000..5eebf56 --- /dev/null +++ b/lib/features/debts/presentation/pages/debts_page.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:progres/config/theme/app_theme.dart'; +import 'package:progres/features/debts/data/models/academic_year_debt.dart'; +import 'package:progres/features/debts/presentation/bloc/debts_bloc.dart'; +import 'package:progres/features/debts/presentation/bloc/debts_event.dart'; +import 'package:progres/features/debts/presentation/bloc/debts_state.dart'; +import 'package:progres/features/debts/presentation/widgets/debt_course_card.dart'; +import 'package:progres/features/debts/presentation/widgets/year_summary_card.dart'; +import 'package:progres/l10n/app_localizations.dart'; + +class DebtsPage extends StatefulWidget { + const DebtsPage({super.key}); + + @override + State createState() => _DebtsPageState(); +} + +class _DebtsPageState extends State { + @override + void initState() { + super.initState(); + // Load debts when page is opened + BlocProvider.of(context).add(const LoadDebts()); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + ), + title: Text(AppLocalizations.of(context)!.academicDebts), + actions: [ + // Refresh button + IconButton( + icon: const Icon(Icons.refresh), + tooltip: AppLocalizations.of(context)!.refreshData, + onPressed: () { + context.read().add( + const LoadDebts(forceRefresh: true), + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.refreshingData), + duration: const Duration(seconds: 1), + ), + ); + }, + ), + ], + ), + body: BlocBuilder( + builder: (context, state) { + if (state is DebtsInitial || state is DebtsLoading) { + return const Center( + child: CircularProgressIndicator(color: AppTheme.AppPrimary), + ); + } else if (state is DebtsEmpty) { + return _buildEmptyState(theme); + } else if (state is DebtsError) { + return _buildErrorState(theme, state.message); + } else if (state is DebtsLoaded) { + return _buildDebtsView(state, theme); + } + return const SizedBox.shrink(); + }, + ), + ); + } + + Widget _buildEmptyState(ThemeData theme) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.check_circle_outline, + size: 80, + color: AppTheme.accentGreen, + ), + const SizedBox(height: 16), + Text( + AppLocalizations.of(context)!.noDebts, + style: theme.textTheme.titleLarge?.copyWith( + color: AppTheme.accentGreen, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + AppLocalizations.of(context)!.noDebtsDescription, + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Widget _buildErrorState(ThemeData theme, String message) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 80, color: AppTheme.accentRed), + const SizedBox(height: 16), + Text( + AppLocalizations.of(context)!.somthingWentWrong, + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + message, + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } + + Widget _buildDebtsView(DebtsLoaded state, ThemeData theme) { + // Calculate total debts + final totalDebts = state.debts.fold( + 0, + (sum, year) => sum + year.dette.length, + ); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Cached data indicator + if (state.fromCache) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Icon( + Icons.access_time, + size: 14, + color: theme.colorScheme.secondary, + ), + const SizedBox(width: 4), + Text( + AppLocalizations.of(context)!.cachedData, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.secondary, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + + // Summary card + Card( + elevation: 2, + margin: const EdgeInsets.only(bottom: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: theme.brightness == Brightness.light + ? AppTheme.AppBorder + : const Color(0xFF3F3C34), + ), + ), + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppTheme.accentRed.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.warning_rounded, + size: 24, + color: AppTheme.accentRed, + ), + const SizedBox(width: 8), + Text( + AppLocalizations.of(context)!.debtsSummary, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: AppTheme.accentRed, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildSummaryItem( + context, + AppLocalizations.of(context)!.totalDebts, + totalDebts.toString(), + Icons.school_outlined, + ), + _buildSummaryItem( + context, + AppLocalizations.of(context)!.academicYears, + state.debts.length.toString(), + Icons.calendar_today, + ), + ], + ), + ], + ), + ), + ), + ), + + // Debts by year + ...state.debts.map((yearDebt) => _buildYearSection(yearDebt, theme)), + ], + ), + ); + } + + Widget _buildSummaryItem( + BuildContext context, + String label, + String value, + IconData icon, + ) { + return Column( + children: [ + Icon(icon, size: 32, color: AppTheme.accentRed), + const SizedBox(height: 8), + Text( + value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppTheme.accentRed, + ), + ), + Text( + label, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).textTheme.bodySmall?.color, + ), + ), + ], + ); + } + + Widget _buildYearSection(AcademicYearDebt yearDebt, ThemeData theme) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Year header + YearSummaryCard(year: yearDebt.annee, debtCount: yearDebt.dette.length), + + const SizedBox(height: 12), + + // Debt courses + ...yearDebt.dette.map((course) => DebtCourseCard(course: course)), + + const SizedBox(height: 16), + ], + ); + } +} diff --git a/lib/features/debts/presentation/widgets/debt_course_card.dart b/lib/features/debts/presentation/widgets/debt_course_card.dart new file mode 100644 index 0000000..3279a84 --- /dev/null +++ b/lib/features/debts/presentation/widgets/debt_course_card.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:progres/config/theme/app_theme.dart'; +import 'package:progres/features/debts/data/models/debt_course.dart'; +import 'package:progres/l10n/app_localizations.dart'; + +class DebtCourseCard extends StatelessWidget { + final DebtCourse course; + + const DebtCourseCard({super.key, required this.course}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + elevation: 1, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide( + color: theme.brightness == Brightness.light + ? AppTheme.AppBorder + : const Color(0xFF3F3C34), + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Course name and level + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + course.mcFr, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: theme.textTheme.titleMedium?.color, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppTheme.AppPrimary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: AppTheme.AppPrimary), + ), + child: Text( + course.nfr, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppTheme.AppPrimary, + ), + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppTheme.accentBlue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: AppTheme.accentBlue), + ), + child: Text( + course.pfr, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppTheme.accentBlue, + ), + ), + ), + ], + ), + ], + ), + ), + // Final grade badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: AppTheme.accentRed, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Text( + course.md.toStringAsFixed(2), + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + fontSize: 18, + ), + ), + Text( + AppLocalizations.of(context)!.finalGrade, + style: const TextStyle( + fontSize: 10, + color: Colors.white, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 16), + + // Grade breakdown + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.brightness == Brightness.light + ? Colors.grey.shade50 + : Colors.grey.shade900, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.brightness == Brightness.light + ? AppTheme.AppBorder + : const Color(0xFF3F3C34), + ), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + AppLocalizations.of(context)!.gradeBreakdown, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: theme.textTheme.bodySmall?.color, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildGradeItem( + context, + AppLocalizations.of(context)!.continuousControl, + course.ccd, + Icons.assignment_outlined, + ), + _buildGradeItem( + context, + AppLocalizations.of(context)!.exam, + course.exd, + Icons.edit_note_outlined, + ), + _buildGradeItem( + context, + AppLocalizations.of(context)!.average, + course.m, + Icons.bar_chart_rounded, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildGradeItem( + BuildContext context, + String label, + double value, + IconData icon, + ) { + return Column( + children: [ + Icon(icon, size: 20, color: AppTheme.AppTextSecondary), + const SizedBox(height: 4), + Text( + value.toStringAsFixed(2), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppTheme.accentRed, + ), + ), + Text( + label, + style: TextStyle( + fontSize: 10, + color: Theme.of(context).textTheme.bodySmall?.color, + ), + textAlign: TextAlign.center, + ), + ], + ); + } +} diff --git a/lib/features/debts/presentation/widgets/year_summary_card.dart b/lib/features/debts/presentation/widgets/year_summary_card.dart new file mode 100644 index 0000000..509d4a5 --- /dev/null +++ b/lib/features/debts/presentation/widgets/year_summary_card.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:progres/config/theme/app_theme.dart'; +import 'package:progres/l10n/app_localizations.dart'; + +class YearSummaryCard extends StatelessWidget { + final String year; + final int debtCount; + + const YearSummaryCard({ + super.key, + required this.year, + required this.debtCount, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppTheme.accentRed.withValues(alpha: 0.15), + AppTheme.accentRed.withValues(alpha: 0.05), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.accentRed.withValues(alpha: 0.3), + width: 1.5, + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.accentRed.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.calendar_today, + color: AppTheme.accentRed, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.academicYearWrapper(year), + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: theme.textTheme.titleMedium?.color, + ), + ), + const SizedBox(height: 4), + Text( + AppLocalizations.of(context)!.debtCoursesCount(debtCount), + style: TextStyle( + fontSize: 13, + color: theme.textTheme.bodySmall?.color, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppTheme.accentRed, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + debtCount.toString(), + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + fontSize: 16, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 379cc7f..657ab4e 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -688,5 +688,51 @@ "specialization":"التخصص", "@specialization":{ "description": "التخصص" + }, + "academicDebts": "الديون الأكاديمية", + "@academicDebts": { + "description": "عنوان صفحة الديون الأكاديمية" + }, + "noDebts": "لا توجد ديون أكاديمية", + "@noDebts": { + "description": "عنوان عندما لا يكون لدى الطالب ديون" + }, + "noDebtsDescription": "تهانينا! ليس لديك أي مقاييس راسبة لإعادتها.", + "@noDebtsDescription": { + "description": "وصف عندما لا يكون لدى الطالب ديون" + }, + "debtsSummary": "ملخص الديون", + "@debtsSummary": { + "description": "عنوان قسم ملخص الديون" + }, + "totalDebts": "إجمالي الديون", + "@totalDebts": { + "description": "تسمية لإجمالي عدد الديون" + }, + "academicYears": "السنوات الأكاديمية", + "@academicYears": { + "description": "تسمية لعدد السنوات الأكاديمية مع الديون" + }, + "debtCoursesCount": "{count} {count, plural, =1{مقياس راسب} other{مقاييس راسبة}}", + "@debtCoursesCount": { + "description": "عدد المقاييس الراسبة", + "placeholders": { + "count": { + "type": "int", + "example": "3" + } + } + }, + "finalGrade": "النهائي", + "@finalGrade": { + "description": "تسمية للعلامة النهائية" + }, + "gradeBreakdown": "تفصيل العلامات", + "@gradeBreakdown": { + "description": "عنوان قسم تفصيل العلامات" + }, + "continuousControl": "المراقبة المستمرة", + "@continuousControl": { + "description": "تسمية لعلامة المراقبة المستمرة" } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 05994e0..6290a34 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -670,6 +670,52 @@ "specialization":"Specialization", "@specialization":{ "description": "Specialization" + }, + "academicDebts": "Academic Debts", + "@academicDebts": { + "description": "Title for academic debts page" + }, + "noDebts": "No Academic Debts", + "@noDebts": { + "description": "Title when student has no debts" + }, + "noDebtsDescription": "Congratulations! You have no failed courses to retake.", + "@noDebtsDescription": { + "description": "Description when student has no debts" + }, + "debtsSummary": "DEBTS SUMMARY", + "@debtsSummary": { + "description": "Title for debts summary section" + }, + "totalDebts": "Total Debts", + "@totalDebts": { + "description": "Label for total number of debts" + }, + "academicYears": "Academic Years", + "@academicYears": { + "description": "Label for number of academic years with debts" + }, + "debtCoursesCount": "{count} failed {count, plural, =1{course} other{courses}}", + "@debtCoursesCount": { + "description": "Count of debt courses", + "placeholders": { + "count": { + "type": "int", + "example": "3" + } + } + }, + "finalGrade": "Final", + "@finalGrade": { + "description": "Label for final grade" + }, + "gradeBreakdown": "Grade Breakdown", + "@gradeBreakdown": { + "description": "Title for grade breakdown section" + }, + "continuousControl": "CC", + "@continuousControl": { + "description": "Label for continuous control grade" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e56b03e..b95ccfe 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1038,6 +1038,66 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Specialization'** String get specialization; + + /// Title for academic debts page + /// + /// In en, this message translates to: + /// **'Academic Debts'** + String get academicDebts; + + /// Title when student has no debts + /// + /// In en, this message translates to: + /// **'No Academic Debts'** + String get noDebts; + + /// Description when student has no debts + /// + /// In en, this message translates to: + /// **'Congratulations! You have no failed courses to retake.'** + String get noDebtsDescription; + + /// Title for debts summary section + /// + /// In en, this message translates to: + /// **'DEBTS SUMMARY'** + String get debtsSummary; + + /// Label for total number of debts + /// + /// In en, this message translates to: + /// **'Total Debts'** + String get totalDebts; + + /// Label for number of academic years with debts + /// + /// In en, this message translates to: + /// **'Academic Years'** + String get academicYears; + + /// Count of debt courses + /// + /// In en, this message translates to: + /// **'{count} failed {count, plural, =1{course} other{courses}}'** + String debtCoursesCount(int count); + + /// Label for final grade + /// + /// In en, this message translates to: + /// **'Final'** + String get finalGrade; + + /// Title for grade breakdown section + /// + /// In en, this message translates to: + /// **'Grade Breakdown'** + String get gradeBreakdown; + + /// Label for continuous control grade + /// + /// In en, this message translates to: + /// **'CC'** + String get continuousControl; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 855a5ed..b121622 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -508,4 +508,43 @@ class AppLocalizationsAr extends AppLocalizations { @override String get specialization => 'التخصص'; + + @override + String get academicDebts => 'الديون الأكاديمية'; + + @override + String get noDebts => 'لا توجد ديون أكاديمية'; + + @override + String get noDebtsDescription => + 'تهانينا! ليس لديك أي مقاييس راسبة لإعادتها.'; + + @override + String get debtsSummary => 'ملخص الديون'; + + @override + String get totalDebts => 'إجمالي الديون'; + + @override + String get academicYears => 'السنوات الأكاديمية'; + + @override + String debtCoursesCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'مقاييس راسبة', + one: 'مقياس راسب', + ); + return '$count $_temp0'; + } + + @override + String get finalGrade => 'النهائي'; + + @override + String get gradeBreakdown => 'تفصيل العلامات'; + + @override + String get continuousControl => 'المراقبة المستمرة'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 49b0351..80f8ae1 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -513,4 +513,43 @@ class AppLocalizationsEn extends AppLocalizations { @override String get specialization => 'Specialization'; + + @override + String get academicDebts => 'Academic Debts'; + + @override + String get noDebts => 'No Academic Debts'; + + @override + String get noDebtsDescription => + 'Congratulations! You have no failed courses to retake.'; + + @override + String get debtsSummary => 'DEBTS SUMMARY'; + + @override + String get totalDebts => 'Total Debts'; + + @override + String get academicYears => 'Academic Years'; + + @override + String debtCoursesCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'courses', + one: 'course', + ); + return '$count failed $_temp0'; + } + + @override + String get finalGrade => 'Final'; + + @override + String get gradeBreakdown => 'Grade Breakdown'; + + @override + String get continuousControl => 'CC'; } From 7d6e2b2e0e93579f74773a6919853446863b4dfa Mon Sep 17 00:00:00 2001 From: aliakrem Date: Fri, 28 Nov 2025 21:38:59 +0100 Subject: [PATCH 2/3] fix : fix null response --- lib/features/auth/presentation/bloc/auth_bloc.dart | 7 +++++++ .../debts/data/repositories/debts_repository_impl.dart | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/features/auth/presentation/bloc/auth_bloc.dart b/lib/features/auth/presentation/bloc/auth_bloc.dart index a2d0e72..43d4454 100644 --- a/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:progres/features/auth/data/repositories/auth_repository_impl.dart'; import 'package:progres/features/auth/data/models/auth_response.dart'; +import 'package:progres/features/debts/presentation/bloc/debts_bloc.dart'; +import 'package:progres/features/debts/presentation/bloc/debts_event.dart'; import 'package:progres/features/enrollment/presentation/bloc/enrollment_bloc.dart'; import 'package:progres/features/enrollment/presentation/bloc/enrollment_event.dart'; import 'package:progres/features/groups/presentation/bloc/groups_bloc.dart'; @@ -111,6 +113,11 @@ class AuthBloc extends Bloc { } catch (e) { debugPrint('Note: Could not clear profile cache. ${e.toString()}'); } + try { + event.context?.read().add(ClearDebtsCache()); + } catch (e) { + debugPrint('Note: Could not clear debts cache. ${e.toString()}'); + } emit(AuthLoggedOut()); } catch (e) { diff --git a/lib/features/debts/data/repositories/debts_repository_impl.dart b/lib/features/debts/data/repositories/debts_repository_impl.dart index 2c2e804..733df0d 100644 --- a/lib/features/debts/data/repositories/debts_repository_impl.dart +++ b/lib/features/debts/data/repositories/debts_repository_impl.dart @@ -13,9 +13,10 @@ class DebtsRepositoryImpl { if (uuid == null) { throw Exception('UUID not found, please login again'); } - final response = await _apiClient.get('/infos/dettes/$uuid'); - + if (response.data == null) { + return []; + } final List debtsJson = response.data; return debtsJson .map((debtJson) => AcademicYearDebt.fromJson(debtJson)) From a30332b6590c5f18eadce68abab60f58a432e755 Mon Sep 17 00:00:00 2001 From: aliakrem Date: Sat, 20 Dec 2025 11:15:33 +0100 Subject: [PATCH 3/3] fix: sort academic year by most recent --- .../debts/data/repositories/debts_repository_impl.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/features/debts/data/repositories/debts_repository_impl.dart b/lib/features/debts/data/repositories/debts_repository_impl.dart index 733df0d..5e941c0 100644 --- a/lib/features/debts/data/repositories/debts_repository_impl.dart +++ b/lib/features/debts/data/repositories/debts_repository_impl.dart @@ -18,9 +18,11 @@ class DebtsRepositoryImpl { return []; } final List debtsJson = response.data; - return debtsJson + final debts = debtsJson .map((debtJson) => AcademicYearDebt.fromJson(debtJson)) .toList(); + debts.sort((a, b) => b.idAnneeAcademique.compareTo(a.idAnneeAcademique)); + return debts; } catch (e) { rethrow; }