diff --git a/eme-frontend/lib/demo_identity.dart b/eme-frontend/lib/demo_identity.dart index db9ec0a..287bb99 100644 --- a/eme-frontend/lib/demo_identity.dart +++ b/eme-frontend/lib/demo_identity.dart @@ -1,10 +1,18 @@ -/// Identité simulée utilisée tant que l'authentification Azure AD n'est pas branchée. +import "services/auth_service.dart"; + +/// Identité de secours utilisée lorsque aucun profil de session n'est chargé. class DemoIdentity { DemoIdentity._(); - static const userName = "Lucas Martin"; - static const userInitials = "LM"; - static const userSubtitle = "Étudiant · B2 Informatique · Ensitech Cergy"; - static const campusTag = "Saint-Christophe · Cergy"; + static String get userName => + AuthService.currentProfile?.fullName ?? "Lucas Martin"; + static String get userInitials => + AuthService.currentProfile?.initials ?? "LM"; + static String get userSubtitle => + AuthService.currentProfile?.subtitle ?? + "Étudiant · B2 Informatique · Ensitech Cergy"; + static String get campusTag => + AuthService.currentProfile?.campusTag ?? "Saint-Christophe · Cergy"; + static const loginCampusTag = "Ensitech · Cergy"; } diff --git a/eme-frontend/lib/models/authenticated_profile.dart b/eme-frontend/lib/models/authenticated_profile.dart new file mode 100644 index 0000000..95a1dbe --- /dev/null +++ b/eme-frontend/lib/models/authenticated_profile.dart @@ -0,0 +1,90 @@ +class AuthenticatedProfile { + const AuthenticatedProfile({ + required this.id, + required this.nom, + required this.prenom, + required this.email, + required this.classe, + required this.roleCode, + required this.roleLabel, + required this.campusId, + required this.campusNom, + required this.campusVille, + }); + + final int id; + final String nom; + final String prenom; + final String email; + final String? classe; + final String roleCode; + final String roleLabel; + final int campusId; + final String campusNom; + final String campusVille; + + factory AuthenticatedProfile.fromApi(dynamic reponse) { + if (reponse is! Map || reponse["data"] is! Map) { + throw const FormatException("Profil utilisateur invalide."); + } + + final data = reponse["data"] as Map; + final role = data["role"]; + final campus = data["campus"]; + if (role is! Map || campus is! Map) { + throw const FormatException("Role ou campus utilisateur invalide."); + } + + final id = data["id"]; + final campusId = campus["id"]; + if (id is! int || campusId is! int) { + throw const FormatException("Identifiant utilisateur invalide."); + } + + final classe = data["classe"]; + if (classe != null && classe is! String) { + throw const FormatException("Classe utilisateur invalide."); + } + + return AuthenticatedProfile( + id: id, + nom: _requiredString(data, "nom"), + prenom: _requiredString(data, "prenom"), + email: _requiredString(data, "email"), + classe: classe as String?, + roleCode: _requiredString(role, "code"), + roleLabel: _requiredString(role, "libelle"), + campusId: campusId, + campusNom: _requiredString(campus, "nom"), + campusVille: _requiredString(campus, "ville"), + ); + } + + String get fullName => "$prenom $nom"; + + String get initials { + final first = prenom.trim().isEmpty ? "" : prenom.trim()[0]; + final last = nom.trim().isEmpty ? "" : nom.trim()[0]; + return "$first$last".toUpperCase(); + } + + String get campusTag => "$campusNom · $campusVille"; + + String get subtitle { + final details = [roleLabel]; + final studentClass = classe?.trim(); + if (studentClass != null && studentClass.isNotEmpty) { + details.add(studentClass); + } + details.add(campusTag); + return details.join(" · "); + } + + static String _requiredString(Map source, String key) { + final value = source[key]; + if (value is! String || value.trim().isEmpty) { + throw FormatException("Champ utilisateur invalide : $key."); + } + return value.trim(); + } +} diff --git a/eme-frontend/lib/screens/confirmation_screen.dart b/eme-frontend/lib/screens/confirmation_screen.dart index ebc7199..53277c8 100644 --- a/eme-frontend/lib/screens/confirmation_screen.dart +++ b/eme-frontend/lib/screens/confirmation_screen.dart @@ -23,7 +23,8 @@ class ConfirmationScreen extends StatelessWidget { final dateEmprunt = emprunt.dateEmprunt; final date = "${_deuxChiffres(dateEmprunt.day)}/${_deuxChiffres(dateEmprunt.month)}/${dateEmprunt.year}"; - final heure = "${_deuxChiffres(dateEmprunt.hour)}:${_deuxChiffres(dateEmprunt.minute)}"; + final heure = + "${_deuxChiffres(dateEmprunt.hour)}:${_deuxChiffres(dateEmprunt.minute)}"; return Scaffold( backgroundColor: EnsupColors.soft, @@ -48,7 +49,7 @@ class ConfirmationScreen extends StatelessWidget { clipBehavior: Clip.antiAlias, child: Column( children: [ - const EnsupTopBar( + EnsupTopBar( brandText: "EME · Confirmation", campusTag: DemoIdentity.campusTag, ), @@ -63,7 +64,10 @@ class ConfirmationScreen extends StatelessWidget { ), child: Center( child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32), + padding: const EdgeInsets.symmetric( + horizontal: 28, + vertical: 32, + ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: Column( @@ -75,10 +79,15 @@ class ConfirmationScreen extends StatelessWidget { SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: () => Navigator.of(context).popUntil( - (route) => route.settings.name == "home", + onPressed: () => + Navigator.of(context).popUntil( + (route) => + route.settings.name == "home", + ), + icon: const Icon( + Icons.home_outlined, + size: 18, ), - icon: const Icon(Icons.home_outlined, size: 18), label: Text( "Retour à l'accueil", style: GoogleFonts.darkerGrotesque( @@ -88,9 +97,13 @@ class ConfirmationScreen extends StatelessWidget { ), style: FilledButton.styleFrom( backgroundColor: EnsupColors.cyan, - padding: const EdgeInsets.symmetric(vertical: 16), + padding: const EdgeInsets.symmetric( + vertical: 16, + ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular( + 10, + ), ), ), ), @@ -118,7 +131,9 @@ class ConfirmationScreen extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.all(36), decoration: BoxDecoration( - gradient: const LinearGradient(colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)]), + gradient: const LinearGradient( + colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)], + ), borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFBBF7D0), width: 2), ), @@ -129,7 +144,9 @@ class ConfirmationScreen extends StatelessWidget { height: 72, decoration: BoxDecoration( shape: BoxShape.circle, - gradient: const LinearGradient(colors: [Color(0xFF16A34A), Color(0xFF22C55E)]), + gradient: const LinearGradient( + colors: [Color(0xFF16A34A), Color(0xFF22C55E)], + ), boxShadow: [ BoxShadow( color: const Color(0xFF16A34A).withValues(alpha: 0.3), @@ -153,7 +170,10 @@ class ConfirmationScreen extends StatelessWidget { Text( "Votre emprunt a bien été enregistré. Vous pouvez récupérer le matériel.", textAlign: TextAlign.center, - style: GoogleFonts.titilliumWeb(fontSize: 14, color: const Color(0xFF166534)), + style: GoogleFonts.titilliumWeb( + fontSize: 14, + color: const Color(0xFF166534), + ), ), ], ), @@ -191,7 +211,8 @@ class ConfirmationScreen extends StatelessWidget { _rrow("Date d'emprunt", date), _rrow("Heure", heure), _rrow("Campus", item.campus), - if (note != null) _rrow("Note", note, valueColor: const Color(0xFFD97706)), + if (note != null) + _rrow("Note", note, valueColor: const Color(0xFFD97706)), ], ), ); @@ -203,7 +224,13 @@ class ConfirmationScreen extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(cle, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)), + Text( + cle, + style: GoogleFonts.titilliumWeb( + fontSize: 13, + color: EnsupColors.muted, + ), + ), const SizedBox(width: 16), Expanded( child: Text( diff --git a/eme-frontend/lib/screens/home_screen.dart b/eme-frontend/lib/screens/home_screen.dart index 7cda671..01bf392 100644 --- a/eme-frontend/lib/screens/home_screen.dart +++ b/eme-frontend/lib/screens/home_screen.dart @@ -71,7 +71,7 @@ class HomeScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const ProfileCard( + ProfileCard( name: DemoIdentity.userName, subtitle: DemoIdentity.userSubtitle, initials: DemoIdentity.userInitials, diff --git a/eme-frontend/lib/screens/login_screen.dart b/eme-frontend/lib/screens/login_screen.dart index b4b8395..d51d19e 100644 --- a/eme-frontend/lib/screens/login_screen.dart +++ b/eme-frontend/lib/screens/login_screen.dart @@ -21,6 +21,14 @@ class LoginScreen extends StatefulWidget { class _LoginScreenState extends State { bool _connexionEnCours = false; + @override + void initState() { + super.initState(); + if (AuthService.mode == AuthenticationMode.azure) { + WidgetsBinding.instance.addPostFrameCallback((_) => _restaurerSession()); + } + } + void _ouvrirEtudiant() { Navigator.of(context).push( MaterialPageRoute( @@ -39,7 +47,44 @@ class _LoginScreenState extends State { ); } - void _identifierParQr() { + void _ouvrirPourRole(String roleCode) { + switch (roleCode) { + case "ETUDIANT": + _ouvrirEtudiant(); + case "RESPONSABLE": + _ouvrirResponsable(); + default: + throw StateError("Role utilisateur non autorise."); + } + } + + Future _restaurerSession() async { + setState(() => _connexionEnCours = true); + try { + if (!await AuthService.hasMicrosoftAccount()) { + return; + } + + final reponse = await ApiClient.get("/auth/me"); + final profile = AuthService.setProfileFromApi(reponse); + if (mounted) { + _ouvrirPourRole(profile.roleCode); + } + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("La session Microsoft doit etre renouvelee."), + ), + ); + } finally { + if (mounted) { + setState(() => _connexionEnCours = false); + } + } + } + + Future _identifierParQr() async { if (_connexionEnCours) return; if (AuthService.mode == AuthenticationMode.azure) { ScaffoldMessenger.of(context).showSnackBar( @@ -52,8 +97,7 @@ class _LoginScreenState extends State { return; } - AuthService.selectDemoStudent(); - _ouvrirEtudiant(); + await _connecterMicrosoft(responsableDemo: false); } Future _connecterMicrosoft({required bool responsableDemo}) async { @@ -64,27 +108,17 @@ class _LoginScreenState extends State { if (AuthService.mode == AuthenticationMode.demo) { if (responsableDemo) { AuthService.selectDemoResponsable(); - _ouvrirResponsable(); } else { AuthService.selectDemoStudent(); - _ouvrirEtudiant(); } - return; + } else { + await AuthService.signInWithMicrosoft(); } - await AuthService.signInWithMicrosoft(); final reponse = await ApiClient.get("/auth/me"); - final roleCode = _roleCode(reponse); + final profile = AuthService.setProfileFromApi(reponse); if (!mounted) return; - - switch (roleCode) { - case "ETUDIANT": - _ouvrirEtudiant(); - case "RESPONSABLE": - _ouvrirResponsable(); - default: - throw StateError("Role utilisateur non autorise."); - } + _ouvrirPourRole(profile.roleCode); } catch (error) { if (!mounted) return; ScaffoldMessenger.of( @@ -97,25 +131,6 @@ class _LoginScreenState extends State { } } - String _roleCode(dynamic reponse) { - if (reponse is! Map || reponse["data"] is! Map) { - throw StateError("Profil utilisateur invalide."); - } - - final data = reponse["data"] as Map; - if (data["role"] is! Map) { - throw StateError("Role utilisateur absent."); - } - - final role = data["role"] as Map; - final code = role["code"]; - if (code is! String) { - throw StateError("Role utilisateur invalide."); - } - - return code; - } - @override Widget build(BuildContext context) { return Scaffold( diff --git a/eme-frontend/lib/screens/responsable_screen.dart b/eme-frontend/lib/screens/responsable_screen.dart index 1e8617a..2d95577 100644 --- a/eme-frontend/lib/screens/responsable_screen.dart +++ b/eme-frontend/lib/screens/responsable_screen.dart @@ -1,7 +1,6 @@ import "package:flutter/material.dart"; import "package:google_fonts/google_fonts.dart"; -import "../demo_identity.dart"; import "../services/auth_service.dart"; import "../services/responsable_service.dart"; import "../theme/ensup_colors.dart"; @@ -463,6 +462,12 @@ class _ResponsableTopBar extends StatelessWidget { @override Widget build(BuildContext context) { + final profile = AuthService.currentProfile; + final campusTag = profile?.campusTag ?? "Ensitech · Cergy"; + final initials = profile?.initials ?? "KB"; + final userName = profile?.fullName ?? "Karim Benali"; + final roleLabel = profile?.roleLabel ?? "Responsable matériel"; + return Container( height: 68, padding: const EdgeInsets.symmetric(horizontal: 20), @@ -533,7 +538,7 @@ class _ResponsableTopBar extends StatelessWidget { ), const SizedBox(width: 6), Text( - DemoIdentity.loginCampusTag, + campusTag, style: GoogleFonts.titilliumWeb( fontSize: 12, fontWeight: FontWeight.w600, @@ -565,7 +570,7 @@ class _ResponsableTopBar extends StatelessWidget { shape: BoxShape.circle, ), child: Text( - "KB", + initials, style: GoogleFonts.darkerGrotesque( color: Colors.white, fontSize: 13, @@ -580,7 +585,7 @@ class _ResponsableTopBar extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "Karim Benali", + userName, style: GoogleFonts.titilliumWeb( fontSize: 13, fontWeight: FontWeight.w700, @@ -588,7 +593,7 @@ class _ResponsableTopBar extends StatelessWidget { ), ), Text( - "Responsable matériel", + roleLabel, style: GoogleFonts.titilliumWeb( fontSize: 11, color: EnsupColors.muted, diff --git a/eme-frontend/lib/screens/resultat_retour_screen.dart b/eme-frontend/lib/screens/resultat_retour_screen.dart index bd9bd4b..aa21d36 100644 --- a/eme-frontend/lib/screens/resultat_retour_screen.dart +++ b/eme-frontend/lib/screens/resultat_retour_screen.dart @@ -10,7 +10,11 @@ import "../widgets/brand_corners.dart"; /// Résultat de la restitution (RG17-RG20) : conforme (succès) ou non conforme /// (anomalie détectée + notification au responsable). class ResultatRetourScreen extends StatelessWidget { - const ResultatRetourScreen({super.key, required this.emprunt, required this.anomalies}); + const ResultatRetourScreen({ + super.key, + required this.emprunt, + required this.anomalies, + }); final EmpruntItem emprunt; final List anomalies; @@ -48,7 +52,7 @@ class ResultatRetourScreen extends StatelessWidget { clipBehavior: Clip.antiAlias, child: Column( children: [ - const EnsupTopBar( + EnsupTopBar( brandText: "EME · Résultat du contrôle", campusTag: DemoIdentity.campusTag, ), @@ -63,7 +67,10 @@ class ResultatRetourScreen extends StatelessWidget { ), child: Center( child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32), + padding: const EdgeInsets.symmetric( + horizontal: 28, + vertical: 32, + ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: Column( @@ -73,10 +80,15 @@ class ResultatRetourScreen extends StatelessWidget { SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: () => Navigator.of(context).popUntil( - (route) => route.settings.name == "home", + onPressed: () => + Navigator.of(context).popUntil( + (route) => + route.settings.name == "home", + ), + icon: const Icon( + Icons.home_outlined, + size: 18, ), - icon: const Icon(Icons.home_outlined, size: 18), label: Text( "Retour à l'accueil", style: GoogleFonts.darkerGrotesque( @@ -86,9 +98,13 @@ class ResultatRetourScreen extends StatelessWidget { ), style: FilledButton.styleFrom( backgroundColor: EnsupColors.cyan, - padding: const EdgeInsets.symmetric(vertical: 16), + padding: const EdgeInsets.symmetric( + vertical: 16, + ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular( + 10, + ), ), ), ), @@ -116,7 +132,9 @@ class ResultatRetourScreen extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.all(36), decoration: BoxDecoration( - gradient: const LinearGradient(colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)]), + gradient: const LinearGradient( + colors: [Color(0xFFF0FDF4), Color(0xFFECFDF5)], + ), borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFBBF7D0), width: 2), ), @@ -127,7 +145,9 @@ class ResultatRetourScreen extends StatelessWidget { height: 72, decoration: const BoxDecoration( shape: BoxShape.circle, - gradient: LinearGradient(colors: [Color(0xFF16A34A), Color(0xFF22C55E)]), + gradient: LinearGradient( + colors: [Color(0xFF16A34A), Color(0xFF22C55E)], + ), ), child: const Icon(Icons.check, color: Colors.white, size: 36), ), @@ -144,7 +164,10 @@ class ResultatRetourScreen extends StatelessWidget { Text( "L'emprunt a été clôturé et le matériel est de nouveau disponible.", textAlign: TextAlign.center, - style: GoogleFonts.titilliumWeb(fontSize: 14, color: const Color(0xFF166534)), + style: GoogleFonts.titilliumWeb( + fontSize: 14, + color: const Color(0xFF166534), + ), ), ], ), @@ -164,12 +187,19 @@ class ResultatRetourScreen extends StatelessWidget { decoration: BoxDecoration( color: EnsupColors.red.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(12), - border: Border.all(color: EnsupColors.red.withValues(alpha: 0.2), width: 1.5), + border: Border.all( + color: EnsupColors.red.withValues(alpha: 0.2), + width: 1.5, + ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.warning_amber_rounded, color: EnsupColors.red, size: 24), + const Icon( + Icons.warning_amber_rounded, + color: EnsupColors.red, + size: 24, + ), const SizedBox(width: 12), Expanded( child: Column( @@ -227,7 +257,10 @@ class ResultatRetourScreen extends StatelessWidget { anomalie.etatRetour, valeurCouleur: EnsupColors.red, ), - _drow("Matériel", "${emprunt.materiel.nom} · ${emprunt.materiel.reference}"), + _drow( + "Matériel", + "${emprunt.materiel.nom} · ${emprunt.materiel.reference}", + ), _drow("Emprunteur", DemoIdentity.userName), _drow("Date détection", date), ], @@ -237,7 +270,11 @@ class ResultatRetourScreen extends StatelessWidget { Text( "Le responsable matériel traitera cette anomalie. Vous pouvez retourner à l'accueil.", textAlign: TextAlign.center, - style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted, height: 1.6), + style: GoogleFonts.titilliumWeb( + fontSize: 13, + color: EnsupColors.muted, + height: 1.6, + ), ), ], ); @@ -249,7 +286,13 @@ class ResultatRetourScreen extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(cle, style: GoogleFonts.titilliumWeb(fontSize: 13, color: EnsupColors.muted)), + Text( + cle, + style: GoogleFonts.titilliumWeb( + fontSize: 13, + color: EnsupColors.muted, + ), + ), const SizedBox(width: 16), Expanded( child: Text( diff --git a/eme-frontend/lib/services/auth_service.dart b/eme-frontend/lib/services/auth_service.dart index 6c18ea8..b136158 100644 --- a/eme-frontend/lib/services/auth_service.dart +++ b/eme-frontend/lib/services/auth_service.dart @@ -1,3 +1,4 @@ +import "../models/authenticated_profile.dart"; import "msal_bridge.dart"; enum AuthenticationMode { demo, azure } @@ -19,8 +20,11 @@ class AuthService { static const String demoResponsableEmail = "karim.benali@ensup.eu"; static String? _demoEmail; + static AuthenticatedProfile? _currentProfile; static bool _azureInitialized = false; + static AuthenticatedProfile? get currentProfile => _currentProfile; + static AuthenticationMode get mode { return switch (_configuredMode) { "demo" => AuthenticationMode.demo, @@ -41,6 +45,13 @@ class AuthService { static void clearSession() { _demoEmail = null; + _currentProfile = null; + } + + static AuthenticatedProfile setProfileFromApi(dynamic reponse) { + final profile = AuthenticatedProfile.fromApi(reponse); + _currentProfile = profile; + return profile; } static Future signInWithMicrosoft() async { @@ -57,6 +68,7 @@ class AuthService { if (mode == AuthenticationMode.azure) { await _initializeAzure(); await MsalBridge.signOut(); + _currentProfile = null; return; }