From d6a50cd862d1af3c31e184596ba15e4dfd053330 Mon Sep 17 00:00:00 2001 From: aliakrem Date: Sat, 20 Dec 2025 12:11:30 +0100 Subject: [PATCH 1/2] feat(academic year): add academic year selection feature --- lib/config/routes/app_router.dart | 27 +- lib/core/services/year_selection_service.dart | 33 +++ .../auth/presentation/bloc/auth_bloc.dart | 9 + .../profile/data/models/academic_year.dart | 4 + .../data/models/student_detailed_info.dart | 2 +- .../repositories/student_repository_impl.dart | 60 +++- .../data/services/profile_cache_service.dart | 58 +++- .../presentation/bloc/profile_bloc.dart | 109 +++---- .../presentation/pages/profile_page.dart | 53 ++-- .../presentation/pages/settings_page.dart | 56 +++- .../pages/year_selection_page.dart | 276 ++++++++++++++++++ lib/l10n/app_ar.arb | 20 ++ lib/l10n/app_en.arb | 20 ++ lib/l10n/app_localizations.dart | 30 ++ lib/l10n/app_localizations_ar.dart | 16 + lib/l10n/app_localizations_en.dart | 16 + 16 files changed, 692 insertions(+), 97 deletions(-) create mode 100644 lib/core/services/year_selection_service.dart create mode 100644 lib/features/settings/presentation/pages/year_selection_page.dart diff --git a/lib/config/routes/app_router.dart b/lib/config/routes/app_router.dart index 4a7a1fb..e3b7b8d 100644 --- a/lib/config/routes/app_router.dart +++ b/lib/config/routes/app_router.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; 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/core/services/year_selection_service.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'; @@ -15,12 +16,14 @@ import 'package:progres/features/dashboard/presentation/pages/dashboard_page.dar import 'package:progres/features/enrollment/presentation/pages/enrollments_page.dart'; import 'package:progres/features/profile/presentation/pages/profile_page.dart'; import 'package:progres/features/settings/presentation/pages/settings_page.dart'; +import 'package:progres/features/settings/presentation/pages/year_selection_page.dart'; import 'package:progres/layouts/main_shell.dart'; class AppRouter { // Route names as static constants static const String splash = 'splash'; static const String login = 'login'; + static const String yearSelection = 'year_selection'; static const String dashboard = 'dashboard'; static const String profile = 'profile'; static const String settings = 'settings'; @@ -38,6 +41,7 @@ class AppRouter { // Route paths static const String splashPath = '/'; static const String loginPath = '/login'; + static const String yearSelectionPath = '/year-selection'; static const String dashboardPath = '/dashboard'; static const String profilePath = '/profile'; static const String settingsPath = '/settings'; @@ -57,7 +61,7 @@ class AppRouter { AppRouter({required BuildContext context}) { router = GoRouter( initialLocation: splashPath, - redirect: (context, state) { + redirect: (context, state) async { // Skip redirection logic for splash screen if (state.matchedLocation == splashPath) { return null; @@ -65,13 +69,27 @@ class AppRouter { final authState = context.read().state; final isLoginRoute = state.matchedLocation == loginPath; + final isYearSelectionRoute = state.matchedLocation == yearSelectionPath; + // Not authenticated - redirect to login if (authState is! AuthSuccess && !isLoginRoute) { return loginPath; } + // Authenticated but on login page - check year selection if (authState is AuthSuccess && isLoginRoute) { - return dashboardPath; + final yearService = YearSelectionService(); + final hasSelectedYear = await yearService.hasSelectedYear(); + return hasSelectedYear ? dashboardPath : yearSelectionPath; + } + + // Authenticated - check if year is selected for protected routes + if (authState is AuthSuccess && !isYearSelectionRoute) { + final yearService = YearSelectionService(); + final hasSelectedYear = await yearService.hasSelectedYear(); + if (!hasSelectedYear) { + return yearSelectionPath; + } } return null; @@ -87,6 +105,11 @@ class AppRouter { name: login, builder: (context, state) => const LoginPage(), ), + GoRoute( + path: yearSelectionPath, + name: yearSelection, + builder: (context, state) => const YearSelectionPage(), + ), ShellRoute( builder: (context, state, child) { return MainShell(child: child); diff --git a/lib/core/services/year_selection_service.dart b/lib/core/services/year_selection_service.dart new file mode 100644 index 0000000..d3bc8dc --- /dev/null +++ b/lib/core/services/year_selection_service.dart @@ -0,0 +1,33 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class YearSelectionService { + static const String _selectedYearIdKey = 'selected_academic_year_id'; + static const String _selectedYearCodeKey = 'selected_academic_year_code'; + + Future saveSelectedYear(int yearId, String yearCode) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_selectedYearIdKey, yearId); + await prefs.setString(_selectedYearCodeKey, yearCode); + } + + Future getSelectedYearId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt(_selectedYearIdKey); + } + + Future getSelectedYearCode() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_selectedYearCodeKey); + } + + Future hasSelectedYear() async { + final yearId = await getSelectedYearId(); + return yearId != null; + } + + Future clearSelectedYear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_selectedYearIdKey); + await prefs.remove(_selectedYearCodeKey); + } +} diff --git a/lib/features/auth/presentation/bloc/auth_bloc.dart b/lib/features/auth/presentation/bloc/auth_bloc.dart index 43d4454..f9731b5 100644 --- a/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:progres/core/services/year_selection_service.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'; @@ -80,6 +81,14 @@ class AuthBloc extends Bloc { emit(AuthLoading()); await authRepository.logout(); + // Clear selected year + try { + final yearService = YearSelectionService(); + await yearService.clearSelectedYear(); + } catch (e) { + debugPrint('Note: Could not clear selected year. ${e.toString()}'); + } + try { event.context?.read().add(const ClearTranscriptCache()); } catch (e) { diff --git a/lib/features/profile/data/models/academic_year.dart b/lib/features/profile/data/models/academic_year.dart index 8fe4c89..eb32171 100644 --- a/lib/features/profile/data/models/academic_year.dart +++ b/lib/features/profile/data/models/academic_year.dart @@ -8,6 +8,10 @@ class AcademicYear { return AcademicYear(id: json['id'] as int, code: json['code'] as String); } + AcademicYear copyWith({id, code}) { + return new AcademicYear(id: id, code: code); + } + Map toJson() { return {'id': id, 'code': code}; } diff --git a/lib/features/profile/data/models/student_detailed_info.dart b/lib/features/profile/data/models/student_detailed_info.dart index 905aba0..347aba5 100644 --- a/lib/features/profile/data/models/student_detailed_info.dart +++ b/lib/features/profile/data/models/student_detailed_info.dart @@ -55,7 +55,7 @@ class StudentDetailedInfo { refLibelleCycle: json['refLibelleCycle'] as String, refLibelleCycleAr: json['refLibelleCycleAr'] as String, situationId: json['situationId'] as int, - transportPaye: json['transportPaye'] as bool, + transportPaye: (json['transportPaye'] ?? false) as bool, ); } diff --git a/lib/features/profile/data/repositories/student_repository_impl.dart b/lib/features/profile/data/repositories/student_repository_impl.dart index 3609350..3e2207e 100644 --- a/lib/features/profile/data/repositories/student_repository_impl.dart +++ b/lib/features/profile/data/repositories/student_repository_impl.dart @@ -1,4 +1,6 @@ import 'package:progres/core/network/api_client.dart'; +import 'package:progres/core/services/year_selection_service.dart'; +import 'package:progres/features/enrollment/data/models/enrollment.dart'; import 'package:progres/features/profile/data/models/academic_period.dart'; import 'package:progres/features/profile/data/models/academic_year.dart'; import 'package:progres/features/profile/data/models/student_basic_info.dart'; @@ -6,9 +8,13 @@ import 'package:progres/features/profile/data/models/student_detailed_info.dart' class StudentRepositoryImpl { final ApiClient _apiClient; + final YearSelectionService _yearSelectionService; - StudentRepositoryImpl({ApiClient? apiClient}) - : _apiClient = apiClient ?? ApiClient(); + StudentRepositoryImpl({ + ApiClient? apiClient, + YearSelectionService? yearSelectionService, + }) : _apiClient = apiClient ?? ApiClient(), + _yearSelectionService = yearSelectionService ?? YearSelectionService(); Future getStudentBasicInfo() async { try { @@ -26,8 +32,54 @@ class StudentRepositoryImpl { Future getCurrentAcademicYear() async { try { - final response = await _apiClient.get('/infos/AnneeAcademiqueEncours'); - return AcademicYear.fromJson(response.data); + // Check if student has manually selected a year + final selectedYearId = await _yearSelectionService.getSelectedYearId(); + final selectedYearCode = await _yearSelectionService.getSelectedYearCode(); + + if (selectedYearId != null && selectedYearCode != null) { + // Return the manually selected year + return AcademicYear(id: selectedYearId, code: selectedYearCode); + } + + // If no manual selection, proceed with automatic logic + final uuid = await _apiClient.getUuid(); + if (uuid == null) { + throw Exception('UUID not found, please login again'); + } + + final enrollmentRes = await _apiClient.get('/infos/bac/$uuid/dias'); + + final List enrollmentsJson = enrollmentRes.data; + final enrollments = enrollmentsJson + .map((enrollmentJson) => Enrollment.fromJson(enrollmentJson)) + .toList(); + + final currentYearRes = await _apiClient.get( + '/infos/AnneeAcademiqueEncours', + ); + + final currentAcademicYear = AcademicYear.fromJson(currentYearRes.data); + + // Find the biggest year ID from enrollments + int maxEnrollmentYearId = 0; + String maxEnrollmentCode = ""; + for (var enrollment in enrollments) { + if (enrollment.anneeAcademiqueId > maxEnrollmentYearId) { + maxEnrollmentYearId = enrollment.anneeAcademiqueId; + maxEnrollmentCode = enrollment.anneeAcademiqueCode; + } + } + + // If current year is bigger than the biggest enrollment year, + // it means student has graduated or left college, so fall back to max enrollment year + if (currentAcademicYear.id > maxEnrollmentYearId) { + return currentAcademicYear.copyWith( + id: maxEnrollmentYearId, + code: maxEnrollmentCode, + ); + } + + return currentAcademicYear; } catch (e) { rethrow; } diff --git a/lib/features/profile/data/services/profile_cache_service.dart b/lib/features/profile/data/services/profile_cache_service.dart index c471843..8c22870 100644 --- a/lib/features/profile/data/services/profile_cache_service.dart +++ b/lib/features/profile/data/services/profile_cache_service.dart @@ -4,15 +4,27 @@ import 'package:shared_preferences/shared_preferences.dart'; class ProfileCacheService { // Keys for SharedPreferences - static const String _profileKey = 'cached_profile_data'; - static const String _lastUpdatedKey = 'last_updated_profile'; + static const String _profileKeyPrefix = 'cached_profile_data'; + static const String _lastUpdatedKeyPrefix = 'last_updated_profile'; + static const String _currentYearKey = 'cached_year_id'; - // Save profile data to cache - Future cacheProfileData(Map profileData) async { + // Get cache key for specific year + String _getProfileKey(int yearId) => '${_profileKeyPrefix}_$yearId'; + String _getLastUpdatedKey(int yearId) => '${_lastUpdatedKeyPrefix}_$yearId'; + + // Save profile data to cache with year ID + Future cacheProfileData( + Map profileData, + int yearId, + ) async { try { final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_profileKey, jsonEncode(profileData)); - await prefs.setString(_lastUpdatedKey, DateTime.now().toIso8601String()); + await prefs.setString(_getProfileKey(yearId), jsonEncode(profileData)); + await prefs.setString( + _getLastUpdatedKey(yearId), + DateTime.now().toIso8601String(), + ); + await prefs.setInt(_currentYearKey, yearId); return true; } catch (e) { debugPrint('Error caching profile data: $e'); @@ -20,11 +32,11 @@ class ProfileCacheService { } } - // Retrieve profile data from cache - Future?> getCachedProfileData() async { + // Retrieve profile data from cache for specific year + Future?> getCachedProfileData(int yearId) async { try { final prefs = await SharedPreferences.getInstance(); - final profileDataString = prefs.getString(_profileKey); + final profileDataString = prefs.getString(_getProfileKey(yearId)); if (profileDataString == null) return null; @@ -35,11 +47,11 @@ class ProfileCacheService { } } - // Get last update timestamp for profile data - Future getLastUpdated() async { + // Get last update timestamp for profile data of specific year + Future getLastUpdated(int yearId) async { try { final prefs = await SharedPreferences.getInstance(); - final timestamp = prefs.getString(_lastUpdatedKey); + final timestamp = prefs.getString(_getLastUpdatedKey(yearId)); if (timestamp == null) return null; return DateTime.parse(timestamp); @@ -49,12 +61,26 @@ class ProfileCacheService { } } - // Clear profile data cache - Future clearCache() async { + // Clear profile data cache for specific year + Future clearCache([int? yearId]) async { try { final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_profileKey); - await prefs.remove(_lastUpdatedKey); + + if (yearId != null) { + // Clear specific year cache + await prefs.remove(_getProfileKey(yearId)); + await prefs.remove(_getLastUpdatedKey(yearId)); + } else { + // Clear all profile caches + final keys = prefs.getKeys(); + for (final key in keys) { + if (key.startsWith(_profileKeyPrefix) || + key.startsWith(_lastUpdatedKeyPrefix)) { + await prefs.remove(key); + } + } + await prefs.remove(_currentYearKey); + } return true; } catch (e) { debugPrint('Error clearing profile cache: $e'); diff --git a/lib/features/profile/presentation/bloc/profile_bloc.dart b/lib/features/profile/presentation/bloc/profile_bloc.dart index f4b3023..ad97fb3 100644 --- a/lib/features/profile/presentation/bloc/profile_bloc.dart +++ b/lib/features/profile/presentation/bloc/profile_bloc.dart @@ -109,16 +109,18 @@ class ProfileBloc extends Bloc { Emitter emit, ) async { try { - // Try to load cached profile first - final cachedProfileData = await cacheService.getCachedProfileData(); + // Fetch current academic year first to know which cache to check + final academicYear = await studentRepository.getCurrentAcademicYear(); + + // Try to load cached profile for this specific year + final cachedProfileData = await cacheService.getCachedProfileData( + academicYear.id, + ); if (cachedProfileData != null) { - // Create dummy models from cached data or adapt as needed - // Here we do simple null checks and assign cached JSON to minimal model fields for demo - // Adjust according to your real model constructors for proper deserialization final basicInfo = StudentBasicInfo.fromJson( cachedProfileData['basicInfo'], ); - final academicYear = AcademicYear.fromJson( + final cachedAcademicYear = AcademicYear.fromJson( cachedProfileData['academicYear'], ); final detailedInfo = StudentDetailedInfo.fromJson( @@ -134,7 +136,7 @@ class ProfileBloc extends Bloc { emit( ProfileLoaded( basicInfo: basicInfo, - academicYear: academicYear, + academicYear: cachedAcademicYear, detailedInfo: detailedInfo, academicPeriods: academicPeriods, profileImage: profileImage, @@ -145,9 +147,6 @@ class ProfileBloc extends Bloc { emit(ProfileLoading()); - // Fetch current academic year - final academicYear = await studentRepository.getCurrentAcademicYear(); - // Fetch basic info final basicInfo = await studentRepository.getStudentBasicInfo(); @@ -184,15 +183,18 @@ class ProfileBloc extends Bloc { // Institution logo not available, continue without it } - // Cache latest profile data - await cacheService.cacheProfileData({ - 'basicInfo': basicInfo.toJson(), - 'academicYear': academicYear.toJson(), - 'detailedInfo': detailedInfo.toJson(), - 'academicPeriods': academicPeriods.map((e) => e.toJson()).toList(), - 'profileImage': profileImage, - 'institutionLogo': institutionLogo, - }); + // Cache latest profile data with year ID + await cacheService.cacheProfileData( + { + 'basicInfo': basicInfo.toJson(), + 'academicYear': academicYear.toJson(), + 'detailedInfo': detailedInfo.toJson(), + 'academicPeriods': academicPeriods.map((e) => e.toJson()).toList(), + 'profileImage': profileImage, + 'institutionLogo': institutionLogo, + }, + academicYear.id, + ); emit( ProfileLoaded( @@ -205,38 +207,47 @@ class ProfileBloc extends Bloc { ), ); } catch (e) { - // On error, fallback to cache one last time - final cachedProfileData = await cacheService.getCachedProfileData(); - if (cachedProfileData != null) { - final basicInfo = StudentBasicInfo.fromJson( - cachedProfileData['basicInfo'], - ); - final academicYear = AcademicYear.fromJson( - cachedProfileData['academicYear'], - ); - final detailedInfo = StudentDetailedInfo.fromJson( - cachedProfileData['detailedInfo'], - ); - final academicPeriodsJson = cachedProfileData['academicPeriods'] ?? []; - final academicPeriods = academicPeriodsJson - .map((item) => AcademicPeriod.fromJson(item)) - .toList(); - final profileImage = cachedProfileData['profileImage'] as String?; - final institutionLogo = cachedProfileData['institutionLogo'] as String?; - - emit( - ProfileLoaded( - basicInfo: basicInfo, - academicYear: academicYear, - detailedInfo: detailedInfo, - academicPeriods: academicPeriods, - profileImage: profileImage, - institutionLogo: institutionLogo, - ), + // On error, try to get academic year and fallback to cache + try { + final academicYear = await studentRepository.getCurrentAcademicYear(); + final cachedProfileData = await cacheService.getCachedProfileData( + academicYear.id, ); - } else { - emit(ProfileError(e.toString())); + if (cachedProfileData != null) { + final basicInfo = StudentBasicInfo.fromJson( + cachedProfileData['basicInfo'], + ); + final cachedAcademicYear = AcademicYear.fromJson( + cachedProfileData['academicYear'], + ); + final detailedInfo = StudentDetailedInfo.fromJson( + cachedProfileData['detailedInfo'], + ); + final academicPeriodsJson = + cachedProfileData['academicPeriods'] ?? []; + final academicPeriods = academicPeriodsJson + .map((item) => AcademicPeriod.fromJson(item)) + .toList(); + final profileImage = cachedProfileData['profileImage'] as String?; + final institutionLogo = + cachedProfileData['institutionLogo'] as String?; + + emit( + ProfileLoaded( + basicInfo: basicInfo, + academicYear: cachedAcademicYear, + detailedInfo: detailedInfo, + academicPeriods: academicPeriods, + profileImage: profileImage, + institutionLogo: institutionLogo, + ), + ); + return; + } + } catch (_) { + // If we can't get year or cache, emit error } + emit(ProfileError(e.toString())); } } } diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index 7c84344..64794c7 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -49,17 +49,22 @@ class _ProfilePageState extends State { builder: (context, profileState) { return BlocBuilder( builder: (context, enrollmentState) { - // Get latest enrollment if available - Enrollment? latestEnrollment; - if (enrollmentState is EnrollmentsLoaded && + // Get enrollment matching the current academic year + Enrollment? currentEnrollment; + if (profileState is ProfileLoaded && + enrollmentState is EnrollmentsLoaded && enrollmentState.enrollments.isNotEmpty) { - // Sort enrollments by academic year (newest first) - final sortedEnrollments = - List.from(enrollmentState.enrollments)..sort( - (a, b) => - b.anneeAcademiqueId.compareTo(a.anneeAcademiqueId), - ); - latestEnrollment = sortedEnrollments.first; + // Find enrollment that matches the current academic year + try { + currentEnrollment = enrollmentState.enrollments.firstWhere( + (enrollment) => + enrollment.anneeAcademiqueId == + profileState.academicYear.id, + ); + } catch (e) { + // If no matching enrollment found, use null + currentEnrollment = null; + } } if (profileState is ProfileLoading) { @@ -67,7 +72,7 @@ class _ProfilePageState extends State { } else if (profileState is ProfileError) { return _buildErrorState(profileState); } else if (profileState is ProfileLoaded) { - return _buildProfileContent(profileState, latestEnrollment); + return _buildProfileContent(profileState, currentEnrollment); } else { return _buildLoadingState(); } @@ -136,7 +141,7 @@ class _ProfilePageState extends State { Widget _buildProfileContent( ProfileLoaded state, - Enrollment? latestEnrollment, + Enrollment? currentEnrollment, ) { // Make sure we have the required imports and parameters final theme = Theme.of(context); @@ -155,7 +160,7 @@ class _ProfilePageState extends State { SizedBox(height: isSmallScreen ? 16 : 24), // Academic Information - if (latestEnrollment != null) ...[ + if (currentEnrollment != null) ...[ Padding( padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: _buildInfoSection( @@ -163,36 +168,36 @@ class _ProfilePageState extends State { children: [ _buildInfoRow( AppLocalizations.of(context)!.academicYear, - latestEnrollment.anneeAcademiqueCode, + currentEnrollment.anneeAcademiqueCode, ), _buildInfoRow( AppLocalizations.of(context)!.institution, deviceLocale.languageCode == 'ar' - ? latestEnrollment.llEtablissementArabe ?? '' - : latestEnrollment.llEtablissementLatin ?? '', + ? currentEnrollment.llEtablissementArabe ?? '' + : currentEnrollment.llEtablissementLatin ?? '', ), _buildInfoRow( AppLocalizations.of(context)!.level, deviceLocale.languageCode == 'ar' - ? latestEnrollment.niveauLibelleLongAr ?? '' - : latestEnrollment.niveauLibelleLongLt ?? '', + ? currentEnrollment.niveauLibelleLongAr ?? '' + : currentEnrollment.niveauLibelleLongLt ?? '', ), _buildInfoRow( AppLocalizations.of(context)!.program, deviceLocale.languageCode == 'ar' - ? latestEnrollment.ofLlDomaineArabe ?? '' - : latestEnrollment.ofLlDomaine ?? '', + ? currentEnrollment.ofLlDomaineArabe ?? '' + : currentEnrollment.ofLlDomaine ?? '', ), _buildInfoRow( AppLocalizations.of(context)!.specialization, deviceLocale.languageCode == 'ar' - ? latestEnrollment.ofLlSpecialiteArabe ?? '' - : latestEnrollment.ofLlSpecialite ?? '', + ? currentEnrollment.ofLlSpecialiteArabe ?? '' + : currentEnrollment.ofLlSpecialite ?? '', ), - if (latestEnrollment.numeroInscription != null) + if (currentEnrollment.numeroInscription != null) _buildInfoRow( AppLocalizations.of(context)!.registrationNumber, - latestEnrollment.numeroInscription!, + currentEnrollment.numeroInscription!, ), ], ), diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 3c7aeaa..a3e80cf 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:progres/config/routes/app_router.dart'; +import 'package:progres/core/services/year_selection_service.dart'; import 'package:progres/features/about/presentation/pages/about.dart'; import 'package:progres/features/auth/presentation/bloc/auth_bloc.dart'; import 'package:progres/config/theme/app_theme.dart'; @@ -9,9 +10,30 @@ import 'package:progres/core/theme/theme_bloc.dart'; import 'package:progres/l10n/app_localizations.dart'; import 'package:progres/features/settings/presentation/pages/switch_lang_modal.dart'; -class SettingsPage extends StatelessWidget { +class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + final YearSelectionService _yearService = YearSelectionService(); + String? _selectedYearCode; + + @override + void initState() { + super.initState(); + _loadSelectedYear(); + } + + Future _loadSelectedYear() async { + final yearCode = await _yearService.getSelectedYearCode(); + setState(() { + _selectedYearCode = yearCode; + }); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -93,6 +115,38 @@ class SettingsPage extends StatelessWidget { ); }, ), + ListTile( + leading: Icon( + Icons.calendar_today, + color: theme.iconTheme.color, + size: isSmallScreen ? 22 : 24, + ), + title: Text( + AppLocalizations.of(context)!.academicYear, + style: theme.textTheme.titleMedium?.copyWith( + fontSize: isSmallScreen ? 14 : 16, + ), + ), + subtitle: Text( + _selectedYearCode ?? AppLocalizations.of(context)!.notSelected, + style: theme.textTheme.bodyMedium?.copyWith( + fontSize: isSmallScreen ? 12 : 14, + ), + ), + trailing: Icon( + Icons.arrow_forward_ios, + size: isSmallScreen ? 14 : 16, + color: theme.iconTheme.color, + ), + contentPadding: EdgeInsets.symmetric( + horizontal: isSmallScreen ? 16 : 24, + vertical: isSmallScreen ? 8 : 12, + ), + onTap: () async { + await context.pushNamed(AppRouter.yearSelection); + _loadSelectedYear(); + }, + ), ListTile( leading: Icon( Icons.notifications, diff --git a/lib/features/settings/presentation/pages/year_selection_page.dart b/lib/features/settings/presentation/pages/year_selection_page.dart new file mode 100644 index 0000000..84ad78d --- /dev/null +++ b/lib/features/settings/presentation/pages/year_selection_page.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart'; +import 'package:progres/core/services/year_selection_service.dart'; +import 'package:progres/features/enrollment/data/models/enrollment.dart'; +import 'package:progres/features/enrollment/data/repositories/enrollment_repository_impl.dart'; +import 'package:progres/core/network/api_client.dart'; +import 'package:progres/features/profile/data/services/profile_cache_service.dart'; +import 'package:progres/l10n/app_localizations.dart'; +import 'package:restart_app/restart_app.dart'; + +class YearSelectionPage extends StatefulWidget { + const YearSelectionPage({super.key}); + + @override + State createState() => _YearSelectionPageState(); +} + +class _YearSelectionPageState extends State { + final YearSelectionService _yearService = YearSelectionService(); + final ProfileCacheService _cacheService = ProfileCacheService(); + final EnrollmentRepositoryImpl _enrollmentRepo = EnrollmentRepositoryImpl( + apiClient: ApiClient(), + ); + + List? _enrollments; + bool _isLoading = true; + String? _error; + int? _selectedYearId; + + @override + void initState() { + super.initState(); + _loadEnrollments(); + } + + Future _loadEnrollments() async { + try { + setState(() { + _isLoading = true; + _error = null; + }); + + // Get currently selected year if any + final currentSelectedYearId = await _yearService.getSelectedYearId(); + + final enrollments = await _enrollmentRepo.getStudentEnrollments(); + + // Sort by year ID descending (most recent first) + enrollments.sort( + (a, b) => b.anneeAcademiqueId.compareTo(a.anneeAcademiqueId), + ); + + setState(() { + _enrollments = enrollments; + // Pre-select the currently selected year + _selectedYearId = currentSelectedYearId; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + Future _confirmSelection() async { + if (_selectedYearId == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.pleaseSelectYear)), + ); + return; + } + + final selectedEnrollment = _enrollments!.firstWhere( + (e) => e.anneeAcademiqueId == _selectedYearId, + ); + + await _yearService.saveSelectedYear( + selectedEnrollment.anneeAcademiqueId, + selectedEnrollment.anneeAcademiqueCode, + ); + + await _cacheService.clearCache(); + + await Restart.restartApp(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final screenSize = MediaQuery.of(context).size; + final isSmallScreen = screenSize.width < 360; + + return SafeArea( + child: Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.selectAcademicYear), + automaticallyImplyLeading: false, + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: theme.colorScheme.error, + ), + const SizedBox(height: 16), + Text( + AppLocalizations.of(context)!.errorLoadingData, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + _error!, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall, + ), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _loadEnrollments, + icon: const Icon(Icons.refresh), + label: Text(AppLocalizations.of(context)!.retry), + ), + ], + ), + ) + : _enrollments == null || _enrollments!.isEmpty + ? Center( + child: Text( + AppLocalizations.of(context)!.noEnrollmentsFound, + style: theme.textTheme.titleMedium, + ), + ) + : Column( + children: [ + Padding( + padding: EdgeInsets.all(isSmallScreen ? 16 : 24), + child: Text( + AppLocalizations.of(context)!.selectYearDescription, + style: theme.textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ), + Expanded( + child: ListView.builder( + padding: EdgeInsets.symmetric( + horizontal: isSmallScreen ? 16 : 24, + ), + itemCount: _enrollments!.length, + itemBuilder: (context, index) { + final enrollment = _enrollments![index]; + final isSelected = + _selectedYearId == enrollment.anneeAcademiqueId; + final locale = Localizations.localeOf(context); + final localizedEnrollment = LocalizedEnrollment( + deviceLocale: locale, + enrollment: enrollment, + ); + + return Card( + margin: EdgeInsets.only( + bottom: isSmallScreen ? 12 : 16, + ), + elevation: isSelected ? 4 : 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: isSelected + ? theme.colorScheme.primary + : Colors.transparent, + width: 2, + ), + ), + child: InkWell( + onTap: () { + setState(() { + _selectedYearId = enrollment.anneeAcademiqueId; + }); + }, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: EdgeInsets.all(isSmallScreen ? 12 : 16), + child: Row( + children: [ + Radio( + value: enrollment.anneeAcademiqueId, + groupValue: _selectedYearId, + onChanged: (value) { + setState(() { + _selectedYearId = value; + }); + }, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + enrollment.anneeAcademiqueCode, + style: theme.textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.bold, + fontSize: isSmallScreen + ? 14 + : 16, + ), + ), + const SizedBox(height: 4), + Text( + localizedEnrollment.niveauLibelleLong, + style: theme.textTheme.bodyMedium + ?.copyWith( + fontSize: isSmallScreen + ? 12 + : 14, + ), + ), + if (localizedEnrollment + .ofLlSpecialite + .isNotEmpty) + Text( + localizedEnrollment.ofLlSpecialite, + style: theme.textTheme.bodySmall + ?.copyWith( + fontSize: isSmallScreen + ? 11 + : 13, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + Padding( + padding: EdgeInsets.all(isSmallScreen ? 16 : 24), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _selectedYearId != null + ? _confirmSelection + : null, + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric( + vertical: isSmallScreen ? 12 : 16, + ), + ), + child: Text( + AppLocalizations.of(context)!.confirm, + style: TextStyle(fontSize: isSmallScreen ? 14 : 16), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 657ab4e..265a271 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -734,5 +734,25 @@ "continuousControl": "المراقبة المستمرة", "@continuousControl": { "description": "تسمية لعلامة المراقبة المستمرة" + }, + "selectYearDescription": "يرجى اختيار السنة الأكاديمية التي تريد عرضها", + "@selectYearDescription": { + "description": "وصف صفحة اختيار السنة" + }, + "pleaseSelectYear": "يرجى اختيار سنة أكاديمية", + "@pleaseSelectYear": { + "description": "رسالة خطأ عند عدم اختيار سنة" + }, + "noEnrollmentsFound": "لم يتم العثور على تسجيلات", + "@noEnrollmentsFound": { + "description": "رسالة عند عدم توفر تسجيلات" + }, + "errorLoadingData": "خطأ في تحميل البيانات", + "@errorLoadingData": { + "description": "رسالة خطأ عامة لتحميل البيانات" + }, + "notSelected": "غير محدد", + "@notSelected": { + "description": "حالة عندما لا يتم تحديد شيء" } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6290a34..482b421 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -716,6 +716,26 @@ "continuousControl": "CC", "@continuousControl": { "description": "Label for continuous control grade" + }, + "selectYearDescription": "Please select the academic year you want to view", + "@selectYearDescription": { + "description": "Description for year selection page" + }, + "pleaseSelectYear": "Please select an academic year", + "@pleaseSelectYear": { + "description": "Error message when no year is selected" + }, + "noEnrollmentsFound": "No enrollments found", + "@noEnrollmentsFound": { + "description": "Message when no enrollments are available" + }, + "errorLoadingData": "Error loading data", + "@errorLoadingData": { + "description": "Generic error message for data loading" + }, + "notSelected": "Not selected", + "@notSelected": { + "description": "Status when something is not selected" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b95ccfe..6df940d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1098,6 +1098,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'CC'** String get continuousControl; + + /// Description for year selection page + /// + /// In en, this message translates to: + /// **'Please select the academic year you want to view'** + String get selectYearDescription; + + /// Error message when no year is selected + /// + /// In en, this message translates to: + /// **'Please select an academic year'** + String get pleaseSelectYear; + + /// Message when no enrollments are available + /// + /// In en, this message translates to: + /// **'No enrollments found'** + String get noEnrollmentsFound; + + /// Generic error message for data loading + /// + /// In en, this message translates to: + /// **'Error loading data'** + String get errorLoadingData; + + /// Status when something is not selected + /// + /// In en, this message translates to: + /// **'Not selected'** + String get notSelected; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index b121622..d8c0719 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -547,4 +547,20 @@ class AppLocalizationsAr extends AppLocalizations { @override String get continuousControl => 'المراقبة المستمرة'; + + @override + String get selectYearDescription => + 'يرجى اختيار السنة الأكاديمية التي تريد عرضها'; + + @override + String get pleaseSelectYear => 'يرجى اختيار سنة أكاديمية'; + + @override + String get noEnrollmentsFound => 'لم يتم العثور على تسجيلات'; + + @override + String get errorLoadingData => 'خطأ في تحميل البيانات'; + + @override + String get notSelected => 'غير محدد'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 80f8ae1..c7c57b6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -552,4 +552,20 @@ class AppLocalizationsEn extends AppLocalizations { @override String get continuousControl => 'CC'; + + @override + String get selectYearDescription => + 'Please select the academic year you want to view'; + + @override + String get pleaseSelectYear => 'Please select an academic year'; + + @override + String get noEnrollmentsFound => 'No enrollments found'; + + @override + String get errorLoadingData => 'Error loading data'; + + @override + String get notSelected => 'Not selected'; } From be4ea63f64c3111e4ced9c970b6a99571e8acdad Mon Sep 17 00:00:00 2001 From: aliakrem Date: Sat, 20 Dec 2025 14:04:35 +0100 Subject: [PATCH 2/2] refactor(profile): improve year selection logic and code quality --- lib/config/routes/app_router.dart | 19 +----- .../profile/data/models/academic_year.dart | 4 +- .../data/models/student_detailed_info.dart | 2 +- .../repositories/student_repository_impl.dart | 21 +++++-- .../presentation/pages/settings_page.dart | 60 ++++++++++--------- .../pages/year_selection_page.dart | 1 - 6 files changed, 52 insertions(+), 55 deletions(-) diff --git a/lib/config/routes/app_router.dart b/lib/config/routes/app_router.dart index e3b7b8d..20a541a 100644 --- a/lib/config/routes/app_router.dart +++ b/lib/config/routes/app_router.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; 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/core/services/year_selection_service.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'; @@ -61,7 +60,7 @@ class AppRouter { AppRouter({required BuildContext context}) { router = GoRouter( initialLocation: splashPath, - redirect: (context, state) async { + redirect: (context, state) { // Skip redirection logic for splash screen if (state.matchedLocation == splashPath) { return null; @@ -69,27 +68,13 @@ class AppRouter { final authState = context.read().state; final isLoginRoute = state.matchedLocation == loginPath; - final isYearSelectionRoute = state.matchedLocation == yearSelectionPath; - // Not authenticated - redirect to login if (authState is! AuthSuccess && !isLoginRoute) { return loginPath; } - // Authenticated but on login page - check year selection if (authState is AuthSuccess && isLoginRoute) { - final yearService = YearSelectionService(); - final hasSelectedYear = await yearService.hasSelectedYear(); - return hasSelectedYear ? dashboardPath : yearSelectionPath; - } - - // Authenticated - check if year is selected for protected routes - if (authState is AuthSuccess && !isYearSelectionRoute) { - final yearService = YearSelectionService(); - final hasSelectedYear = await yearService.hasSelectedYear(); - if (!hasSelectedYear) { - return yearSelectionPath; - } + return dashboardPath; } return null; diff --git a/lib/features/profile/data/models/academic_year.dart b/lib/features/profile/data/models/academic_year.dart index eb32171..fb09a0a 100644 --- a/lib/features/profile/data/models/academic_year.dart +++ b/lib/features/profile/data/models/academic_year.dart @@ -8,8 +8,8 @@ class AcademicYear { return AcademicYear(id: json['id'] as int, code: json['code'] as String); } - AcademicYear copyWith({id, code}) { - return new AcademicYear(id: id, code: code); + AcademicYear copyWith({int? id, String? code}) { + return AcademicYear(id: id ?? this.id, code: code ?? this.code); } Map toJson() { diff --git a/lib/features/profile/data/models/student_detailed_info.dart b/lib/features/profile/data/models/student_detailed_info.dart index 347aba5..e5e2bba 100644 --- a/lib/features/profile/data/models/student_detailed_info.dart +++ b/lib/features/profile/data/models/student_detailed_info.dart @@ -55,7 +55,7 @@ class StudentDetailedInfo { refLibelleCycle: json['refLibelleCycle'] as String, refLibelleCycleAr: json['refLibelleCycleAr'] as String, situationId: json['situationId'] as int, - transportPaye: (json['transportPaye'] ?? false) as bool, + transportPaye: json['transportPaye'] as bool? ?? false, ); } diff --git a/lib/features/profile/data/repositories/student_repository_impl.dart b/lib/features/profile/data/repositories/student_repository_impl.dart index 3e2207e..0b1e636 100644 --- a/lib/features/profile/data/repositories/student_repository_impl.dart +++ b/lib/features/profile/data/repositories/student_repository_impl.dart @@ -13,8 +13,8 @@ class StudentRepositoryImpl { StudentRepositoryImpl({ ApiClient? apiClient, YearSelectionService? yearSelectionService, - }) : _apiClient = apiClient ?? ApiClient(), - _yearSelectionService = yearSelectionService ?? YearSelectionService(); + }) : _apiClient = apiClient ?? ApiClient(), + _yearSelectionService = yearSelectionService ?? YearSelectionService(); Future getStudentBasicInfo() async { try { @@ -34,7 +34,8 @@ class StudentRepositoryImpl { try { // Check if student has manually selected a year final selectedYearId = await _yearSelectionService.getSelectedYearId(); - final selectedYearCode = await _yearSelectionService.getSelectedYearCode(); + final selectedYearCode = await _yearSelectionService + .getSelectedYearCode(); if (selectedYearId != null && selectedYearCode != null) { // Return the manually selected year @@ -58,11 +59,16 @@ class StudentRepositoryImpl { '/infos/AnneeAcademiqueEncours', ); - final currentAcademicYear = AcademicYear.fromJson(currentYearRes.data); + var currentAcademicYear = AcademicYear.fromJson(currentYearRes.data); // Find the biggest year ID from enrollments int maxEnrollmentYearId = 0; String maxEnrollmentCode = ""; + + if (enrollments.isEmpty) { + return currentAcademicYear; + } + for (var enrollment in enrollments) { if (enrollment.anneeAcademiqueId > maxEnrollmentYearId) { maxEnrollmentYearId = enrollment.anneeAcademiqueId; @@ -73,12 +79,17 @@ class StudentRepositoryImpl { // If current year is bigger than the biggest enrollment year, // it means student has graduated or left college, so fall back to max enrollment year if (currentAcademicYear.id > maxEnrollmentYearId) { - return currentAcademicYear.copyWith( + currentAcademicYear = currentAcademicYear.copyWith( id: maxEnrollmentYearId, code: maxEnrollmentCode, ); } + await _yearSelectionService.saveSelectedYear( + currentAcademicYear.id, + currentAcademicYear.code, + ); + return currentAcademicYear; } catch (e) { rethrow; diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index a3e80cf..0a02930 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -115,38 +115,40 @@ class _SettingsPageState extends State { ); }, ), - ListTile( - leading: Icon( - Icons.calendar_today, - color: theme.iconTheme.color, - size: isSmallScreen ? 22 : 24, - ), - title: Text( - AppLocalizations.of(context)!.academicYear, - style: theme.textTheme.titleMedium?.copyWith( - fontSize: isSmallScreen ? 14 : 16, + if (authState is AuthSuccess) + ListTile( + leading: Icon( + Icons.calendar_today, + color: theme.iconTheme.color, + size: isSmallScreen ? 22 : 24, ), - ), - subtitle: Text( - _selectedYearCode ?? AppLocalizations.of(context)!.notSelected, - style: theme.textTheme.bodyMedium?.copyWith( - fontSize: isSmallScreen ? 12 : 14, + title: Text( + AppLocalizations.of(context)!.academicYear, + style: theme.textTheme.titleMedium?.copyWith( + fontSize: isSmallScreen ? 14 : 16, + ), ), + subtitle: Text( + _selectedYearCode ?? AppLocalizations.of(context)!.notSelected, + style: theme.textTheme.bodyMedium?.copyWith( + fontSize: isSmallScreen ? 12 : 14, + ), + ), + trailing: Icon( + Icons.arrow_forward_ios, + size: isSmallScreen ? 14 : 16, + color: theme.iconTheme.color, + ), + contentPadding: EdgeInsets.symmetric( + horizontal: isSmallScreen ? 16 : 24, + vertical: isSmallScreen ? 8 : 12, + ), + onTap: () async { + await context.pushNamed(AppRouter.yearSelection); + _loadSelectedYear(); + }, ), - trailing: Icon( - Icons.arrow_forward_ios, - size: isSmallScreen ? 14 : 16, - color: theme.iconTheme.color, - ), - contentPadding: EdgeInsets.symmetric( - horizontal: isSmallScreen ? 16 : 24, - vertical: isSmallScreen ? 8 : 12, - ), - onTap: () async { - await context.pushNamed(AppRouter.yearSelection); - _loadSelectedYear(); - }, - ), + ListTile( leading: Icon( Icons.notifications, diff --git a/lib/features/settings/presentation/pages/year_selection_page.dart b/lib/features/settings/presentation/pages/year_selection_page.dart index 84ad78d..e96eaf1 100644 --- a/lib/features/settings/presentation/pages/year_selection_page.dart +++ b/lib/features/settings/presentation/pages/year_selection_page.dart @@ -95,7 +95,6 @@ class _YearSelectionPageState extends State { child: Scaffold( appBar: AppBar( title: Text(AppLocalizations.of(context)!.selectAcademicYear), - automaticallyImplyLeading: false, ), body: _isLoading ? const Center(child: CircularProgressIndicator())