diff --git a/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache index 70051b3..f65abf9 100644 --- a/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache +++ b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache @@ -34,16 +34,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63 assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d -version.json,1776853346038,b9cb334972abfae63e76477e574d02e1b3cdf4210fa3edf744a8d33c6250a12e -index.html,1776853378875,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 -flutter_service_worker.js,1776853476352,5524fe7e227e1a0ecdd4c9b14a764638a86fe836ced8d3f80ab0817d043d436a -flutter_bootstrap.js,1776853378859,a9fbceaa97579d418c548aaa1b4fc94284bc33ef5fc2a835d80f9a96d8d6bbd8 -assets/FontManifest.json,1776853472496,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 -assets/AssetManifest.json,1776853472495,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6 -assets/AssetManifest.bin.json,1776853472495,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53 -assets/AssetManifest.bin,1776853472495,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907 -assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1776853475437,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb -assets/shaders/ink_sparkle.frag,1776853472722,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 -assets/fonts/MaterialIcons-Regular.otf,1776853475444,213705f583b626b88f6f0c7a122a13567b6492f560ad5284176ef149f0b51fef -assets/NOTICES,1776853472497,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79 -main.dart.js,1776853469906,1fc20606c99d6d4dde1e7d2a8a0b2e8f2e6c6f81317b8e7e4dd17d54f71a23b2 +version.json,1777974738862,518123ebb7461c8343d5ad7d08a9bc31ca5555df3d9e09d36442cad4e5a4dcaa +index.html,1777974744949,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 +flutter_service_worker.js,1777974838520,527efc67156a0e2688a3aca09ef3f967cbb514258c91dc1d8ad1d6a4935e2c65 +assets/FontManifest.json,1777974834864,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 +flutter_bootstrap.js,1777974744934,09a1770005261de742912a7cf492d739d3e263d2383f53cda5ba5bac6896c39c +assets/AssetManifest.json,1777974834864,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6 +assets/AssetManifest.bin,1777974834864,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907 +assets/AssetManifest.bin.json,1777974834864,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53 +assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1777974837564,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb +assets/shaders/ink_sparkle.frag,1777974835049,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 +assets/fonts/MaterialIcons-Regular.otf,1777974837570,213705f583b626b88f6f0c7a122a13567b6492f560ad5284176ef149f0b51fef +assets/NOTICES,1777974834870,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e +main.dart.js,1777974833676,fbb6da7a84cb69d9dfb2a92eac87571303dadec0af700067d2d66ed69db416e8 diff --git a/em2rp/CHANGELOG.md b/em2rp/CHANGELOG.md index db38293..16e3a7e 100644 --- a/em2rp/CHANGELOG.md +++ b/em2rp/CHANGELOG.md @@ -2,6 +2,9 @@ Toutes les modifications notables de ce projet seront documentées dans ce fichier. +## 04/05/2026 +Optimisation du lancement de l'application et amélioration de la gestion du cache. + ## 22/04/2026 Ajout de la recherche d'événements et gestion avancée de la suppression d'équipement diff --git a/em2rp/lib/config/app_version.dart b/em2rp/lib/config/app_version.dart index fb2b95a..ce05d0d 100644 --- a/em2rp/lib/config/app_version.dart +++ b/em2rp/lib/config/app_version.dart @@ -1,6 +1,6 @@ /// Configuration de la version de l'application class AppVersion { - static const String version = '1.1.21'; + static const String version = '1.1.23'; /// Retourne la version complète de l'application static String get fullVersion => 'v$version'; diff --git a/em2rp/lib/main.dart b/em2rp/lib/main.dart index 5d53180..5d27428 100644 --- a/em2rp/lib/main.dart +++ b/em2rp/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:em2rp/providers/users_provider.dart'; import 'package:em2rp/providers/event_provider.dart'; import 'package:em2rp/providers/equipment_provider.dart'; @@ -19,10 +21,8 @@ import 'package:em2rp/views/event_statistics_page.dart'; import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/event_model.dart'; import 'package:firebase_auth/firebase_auth.dart'; -import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; -import 'package:firebase_core/firebase_core.dart'; -import 'firebase_options.dart'; +import 'package:em2rp/services/app_initializer.dart'; import 'utils/colors.dart'; import 'views/my_account_page.dart'; import 'views/user_management_page.dart'; @@ -30,35 +30,21 @@ import 'package:provider/provider.dart'; import 'providers/local_user_provider.dart'; import 'views/reset_password_page.dart'; import 'config/env.dart'; -import 'services/update_service.dart'; -import 'views/widgets/common/update_dialog.dart'; -import 'config/api_config.dart'; +import 'utils/app_start_gate.dart'; +import 'views/widgets/common/startup_splash_screen.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -void main() async { +void main() { + // Ne pas effectuer d'initialisations asynchrones lourdes ici. WidgetsFlutterBinding.ensureInitialized(); - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, - ); - - // Configuration des émulateurs en mode développement - if (ApiConfig.isDevelopment) { - print('🔧 Mode développement activé - Utilisation des émulateurs'); - - // Configurer l'émulateur Auth - await FirebaseAuth.instance.useAuthEmulator('localhost', 9199); - print('✓ Auth émulateur configuré: localhost:9199'); - - // Configurer l'émulateur Firestore - FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088); - print('✓ Firestore émulateur configuré: localhost:8088'); - } - - await FirebaseAuth.instance.setPersistence(Persistence.LOCAL); runApp( MultiProvider( providers: [ + // Fournisseur d'initialisation de l'application (initialise Firebase et cache en tâche de fond) + ChangeNotifierProvider( + create: (_) => AppInitializer(), + ), // LocalUserProvider pour la gestion de l'authentification ChangeNotifierProvider( create: (context) => LocalUserProvider()), @@ -96,241 +82,163 @@ void main() async { ); } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { const MyApp({super.key}); + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + late final Future _startupFuture; + + @override + void initState() { + super.initState(); + _startupFuture = _bootstrapApp(); + } + + Future _bootstrapApp() async { + final initializer = context.read(); + final localAuthProvider = context.read(); + + await initializer.initialize(); + + // Attendre la première valeur d'authentification avant toute décision + // de navigation, afin d'éviter un flash de la page login. + await FirebaseAuth.instance.authStateChanges().first; + + if (FirebaseAuth.instance.currentUser != null) { + unawaited( + localAuthProvider.loadUserData().catchError((e) { + print('User data bootstrap failed: $e'); + }), + ); + return; + } + + // En développement, on garde la connexion automatique existante. + if (Env.isDevelopment) { + await localAuthProvider.signInWithEmailAndPassword( + Env.devAdminEmail, + Env.devAdminPassword, + ); + unawaited( + localAuthProvider.loadUserData().catchError((e) { + print('Dev user bootstrap failed: $e'); + }), + ); + } + } + @override Widget build(BuildContext context) { - return MaterialApp( - title: 'EM2 Hub', - theme: ThemeData( - primarySwatch: Colors.red, - primaryColor: AppColors.noir, - colorScheme: - ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge), - textTheme: const TextTheme( - bodyMedium: TextStyle(color: AppColors.noir), - ), - inputDecorationTheme: const InputDecorationTheme( - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: AppColors.noir), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: AppColors.gris), - ), - labelStyle: TextStyle(color: AppColors.noir), - hintStyle: TextStyle(color: AppColors.gris), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - foregroundColor: AppColors.blanc, - backgroundColor: AppColors.noir, + return FutureBuilder( + future: _startupFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: StartupSplashScreen(), + ); + } + + return MaterialApp( + title: 'EM2 Hub', + theme: ThemeData( + primarySwatch: Colors.red, + primaryColor: AppColors.noir, + colorScheme: + ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge), + textTheme: const TextTheme( + bodyMedium: TextStyle(color: AppColors.noir), + ), + inputDecorationTheme: const InputDecorationTheme( + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.noir), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: AppColors.gris), + ), + labelStyle: TextStyle(color: AppColors.noir), + hintStyle: TextStyle(color: AppColors.gris), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + foregroundColor: AppColors.blanc, + backgroundColor: AppColors.noir, + ), + ), ), - ), - ), - locale: const Locale('fr', 'FR'), - supportedLocales: const [ - Locale('fr', 'FR'), - ], - localizationsDelegates: const [ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - initialRoute: '/', - routes: { - '/': (context) => const AutoLoginWrapper(), - '/login': (context) => const LoginPage(), - '/alerts': (context) => const AuthGuard(child: AlertsPage()), - '/calendar': (context) => const AuthGuard(child: CalendarPage()), - '/my_account': (context) => const AuthGuard(child: MyAccountPage()), - '/user_management': (context) => const AuthGuard( - requiredPermission: "view_all_users", child: UserManagementPage()), - '/reset_password': (context) { - final args = ModalRoute.of(context)!.settings.arguments - as Map; - return ResetPasswordPage( - email: args['email'] as String, - actionCode: args['actionCode'] as String, - ); - }, - '/equipment_management': (context) => const AuthGuard( - requiredPermission: "view_equipment", - child: EquipmentManagementPage()), - '/container_management': (context) => const AuthGuard( - requiredPermission: "view_equipment", - child: ContainerManagementPage()), - '/maintenance_management': (context) => const AuthGuard( - requiredPermission: "manage_maintenances", - child: MaintenanceManagementPage()), - '/container_form': (context) { - final args = ModalRoute.of(context)?.settings.arguments; - return AuthGuard( - requiredPermission: "manage_equipment", - child: ContainerFormPage( - container: args as ContainerModel?, - ), - ); - }, - '/container_detail': (context) { - final container = ModalRoute.of(context)!.settings.arguments as ContainerModel; - return AuthGuard( - requiredPermission: "view_equipment", - child: ContainerDetailPage(container: container), - ); - }, - '/event_preparation': (context) { - final args = ModalRoute.of(context)!.settings.arguments as Map; - final event = args['event'] as EventModel; - return AuthGuard( - child: EventPreparationPage( - initialEvent: event, - ), - ); - }, - '/event_statistics': (context) => const AuthGuard( - requiredPermission: 'generate_reports', child: EventStatisticsPage()), + locale: const Locale('fr', 'FR'), + supportedLocales: const [ + Locale('fr', 'FR'), + ], + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + routes: { + '/login': (context) => const LoginPage(), + '/alerts': (context) => const AuthGuard(child: AlertsPage()), + '/calendar': (context) => const AuthGuard( + allowWhileLoading: true, child: CalendarPage()), + '/my_account': (context) => const AuthGuard(child: MyAccountPage()), + '/user_management': (context) => const AuthGuard( + requiredPermission: "view_all_users", + child: UserManagementPage()), + '/reset_password': (context) { + final args = ModalRoute.of(context)!.settings.arguments + as Map; + return ResetPasswordPage( + email: args['email'] as String, + actionCode: args['actionCode'] as String, + ); + }, + '/equipment_management': (context) => const AuthGuard( + requiredPermission: "view_equipment", + child: EquipmentManagementPage()), + '/container_management': (context) => const AuthGuard( + requiredPermission: "view_equipment", + child: ContainerManagementPage()), + '/maintenance_management': (context) => const AuthGuard( + requiredPermission: "manage_maintenances", + child: MaintenanceManagementPage()), + '/container_form': (context) { + final args = ModalRoute.of(context)?.settings.arguments; + return AuthGuard( + requiredPermission: "manage_equipment", + child: ContainerFormPage( + container: args as ContainerModel?, + ), + ); + }, + '/container_detail': (context) { + final container = ModalRoute.of(context)!.settings.arguments + as ContainerModel; + return AuthGuard( + requiredPermission: "view_equipment", + child: ContainerDetailPage(container: container), + ); + }, + '/event_preparation': (context) { + final args = ModalRoute.of(context)!.settings.arguments + as Map; + final event = args['event'] as EventModel; + return AuthGuard( + child: EventPreparationPage( + initialEvent: event, + ), + ); + }, + '/event_statistics': (context) => const AuthGuard( + requiredPermission: 'generate_reports', + child: EventStatisticsPage()), + }, + home: const AppStartGate(), + ); }, ); } } - -class AutoLoginWrapper extends StatefulWidget { - const AutoLoginWrapper({super.key}); - - @override - State createState() => _AutoLoginWrapperState(); -} - -class _AutoLoginWrapperState extends State { - @override - void initState() { - super.initState(); - // Attendre la fin du premier build avant de naviguer - WidgetsBinding.instance.addPostFrameCallback((_) { - _autoLogin(); - // Vérifier les mises à jour après un délai pour ne pas interférer avec l'autologin - _checkForUpdateDelayed(); - }); - } - - /// Vérifie les mises à jour après un délai - Future _checkForUpdateDelayed() async { - try { - // Attendre que l'app soit complètement chargée (navigation effectuée, etc.) - await Future.delayed(const Duration(seconds: 3)); - - if (!mounted) return; - - final updateInfo = await UpdateService.checkForUpdate(); - - if (updateInfo != null && mounted) { - // Attendre encore un peu pour être sûr que le bon contexte est disponible - await Future.delayed(const Duration(milliseconds: 500)); - - if (mounted) { - showDialog( - context: context, - barrierDismissible: !updateInfo.forceUpdate, - builder: (context) => UpdateDialog(updateInfo: updateInfo), - ); - } - } - } catch (e) { - print('[AutoLoginWrapper] Error checking for update: $e'); - } - } - - Future _autoLogin() async { - PerformanceMonitor.start('App.autoLogin'); - try { - final localAuthProvider = - Provider.of(context, listen: false); - - // Vérifier si l'utilisateur est déjà connecté - if (FirebaseAuth.instance.currentUser == null && Env.isDevelopment) { - PerformanceMonitor.start('App.signIn'); - // Connexion automatique en mode développement - await localAuthProvider.signInWithEmailAndPassword( - Env.devAdminEmail, - Env.devAdminPassword, - ); - PerformanceMonitor.end('App.signIn'); - } - - if (mounted) { - // MODIFIÉ : Vérifier si une route spécifique est demandée dans l'URL - // En Flutter Web, on peut vérifier window.location.hash - final currentUri = Uri.base; - final fragment = currentUri.fragment; // Ex: "/alerts" si URL est /#/alerts - - print('[AutoLoginWrapper] Fragment URL: $fragment'); - - // Navigation immédiate sans attendre le chargement des données - if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') { - print('[AutoLoginWrapper] Redirection vers: $fragment'); - Navigator.of(context).pushReplacementNamed(fragment); - } else { - // Route par défaut : calendrier - print('[AutoLoginWrapper] Redirection vers: /calendar (défaut)'); - Navigator.of(context).pushReplacementNamed('/calendar'); - } - - PerformanceMonitor.end('App.autoLogin'); - PerformanceMonitor.printSummary(); - - // Charger les données utilisateur en arrière-plan - localAuthProvider.loadUserData().catchError((e) { - print('Error loading user data: $e'); - }); - } - } catch (e) { - print('Auto login failed: $e'); - PerformanceMonitor.end('App.autoLogin'); - if (mounted) { - Navigator.of(context).pushReplacementNamed('/login'); - } - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.white, - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Logo de l'application - Image.asset( - 'assets/logos/RectangleLogoBlack.png', - width: 200, - height: 200, - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) { - return const Icon( - Icons.event_available, - size: 80, - color: AppColors.rouge, - ); - }, - ), - const SizedBox(height: 40), - const CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(AppColors.rouge), - ), - const SizedBox(height: 20), - const Text( - 'Chargement...', - style: TextStyle( - fontSize: 16, - color: Colors.grey, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - ), - ); - } -} diff --git a/em2rp/lib/providers/local_user_provider.dart b/em2rp/lib/providers/local_user_provider.dart index 617f5f3..fa03f7f 100644 --- a/em2rp/lib/providers/local_user_provider.dart +++ b/em2rp/lib/providers/local_user_provider.dart @@ -12,7 +12,7 @@ import '../utils/performance_monitor.dart'; class LocalUserProvider with ChangeNotifier { UserModel? _currentUser; RoleModel? _currentRole; - final FirebaseAuth _auth = FirebaseAuth.instance; + FirebaseAuth? _auth; final FirebaseStorageManager _storageManager = FirebaseStorageManager(); final DataService _dataService = DataService(apiService); @@ -43,11 +43,41 @@ class LocalUserProvider with ChangeNotifier { /// Charge les données de l'utilisateur actuel via Cloud Function Future loadUserData({bool forceReload = false}) async { - if (_auth.currentUser == null) { + // Si FirebaseAuth n'est pas encore disponible + final FirebaseAuth auth; + try { + auth = _getAuthInstance(); + } catch (e) { + print('Auth instance not ready in loadUserData: $e'); + return; + } + + if (auth.currentUser == null) { print('No current user in Auth'); return; } + // Bootstrap léger : rendre l'UID disponible tout de suite pour les écrans + // qui en ont besoin, même si le profil complet n'est pas encore chargé. + if (_currentUser == null) { + final firebaseUser = auth.currentUser!; + _currentUser = UserModel( + uid: firebaseUser.uid, + email: firebaseUser.email ?? '', + firstName: '', + lastName: '', + role: 'USER', + phoneNumber: '', + profilePhotoUrl: firebaseUser.photoURL ?? '', + ); + _currentRole = RoleModel( + id: 'USER', + name: '', + permissions: const [], + ); + notifyListeners(); + } + // Éviter les rechargements inutiles if (!forceReload && !_shouldReloadUserData()) { print('Using cached user data'); @@ -62,7 +92,7 @@ class LocalUserProvider with ChangeNotifier { _isLoadingUserData = true; PerformanceMonitor.start('LocalUserProvider.loadUserData'); - print('Loading user data for: ${_auth.currentUser!.uid}'); + print('Loading user data for: ${_auth!.currentUser!.uid}'); try { // Utiliser la Cloud Function getCurrentUser PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API'); @@ -194,7 +224,8 @@ class LocalUserProvider with ChangeNotifier { Future signInWithEmailAndPassword( String email, String password) async { try { - UserCredential userCredential = await _auth.signInWithEmailAndPassword( + final auth = _getAuthInstance(); + UserCredential userCredential = await auth.signInWithEmailAndPassword( email: email, password: password); // Note: loadUserData() sera appelé en arrière-plan dans main.dart // pour ne pas bloquer la navigation @@ -206,10 +237,25 @@ class LocalUserProvider with ChangeNotifier { /// Déconnexion Future signOut() async { - await _auth.signOut(); + try { + final auth = _getAuthInstance(); + await auth.signOut(); + } catch (e) { + debugPrint('Error during signOut: $e'); + } clearUser(); } + FirebaseAuth _getAuthInstance() { + try { + _auth ??= FirebaseAuth.instance; + return _auth!; + } catch (e, st) { + debugPrint('[LocalUserProvider] FirebaseAuth.instance access error: $e\n$st'); + throw Exception('FirebaseAuth not available'); + } + } + /// Vérifie si l'utilisateur a une permission spécifique bool hasPermission(String permission) { return _currentRole?.permissions.contains(permission) ?? false; diff --git a/em2rp/lib/services/alert_service.dart b/em2rp/lib/services/alert_service.dart index 0932a35..2aca6b5 100644 --- a/em2rp/lib/services/alert_service.dart +++ b/em2rp/lib/services/alert_service.dart @@ -7,8 +7,8 @@ import 'api_service.dart' show FirebaseFunctionsApiService; /// Architecture simplifiée : le client appelle uniquement les Cloud Functions /// Toute la logique métier est gérée côté backend class AlertService { - final FirebaseFirestore _firestore = FirebaseFirestore.instance; - final FirebaseAuth _auth = FirebaseAuth.instance; + FirebaseFirestore get _firestore => FirebaseFirestore.instance; + FirebaseAuth get _auth => FirebaseAuth.instance; /// Stream des alertes pour l'utilisateur connecté Stream> getAlertsStream() { diff --git a/em2rp/lib/services/app_initializer.dart b/em2rp/lib/services/app_initializer.dart new file mode 100644 index 0000000..f64f685 --- /dev/null +++ b/em2rp/lib/services/app_initializer.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import '../firebase_options.dart'; +import '../config/api_config.dart'; +import 'cache_service.dart'; + +/// Service responsable des initialisations lourdes en tâche de fond. +/// +/// Objectif : réduire au maximum le travail synchrone dans main(), +/// afficher immédiatement une UI minimale, puis effectuer l'init asynchrone. +class AppInitializer with ChangeNotifier { + bool _isInitialized = false; + bool _isInitializing = false; + + bool get isInitialized => _isInitialized; + bool get isInitializing => _isInitializing; + + final CacheService cacheService = CacheService(); + + /// Démarre l'initialisation asynchrone. Idempotent. + Future initialize() async { + if (_isInitialized || _isInitializing) return; + _isInitializing = true; + notifyListeners(); + + try { + // Initialiser Firebase + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + + // Configurer les émulateurs en dev si demandé + if (ApiConfig.isDevelopment) { + try { + await FirebaseAuth.instance.useAuthEmulator('localhost', 9199); + FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8088); + } catch (e) { + // Ignorer si non supporté + if (kDebugMode) print('Emulator setup failed: $e'); + } + } + + // Initialiser le cache local sans bloquer l'écran de démarrage. + unawaited(cacheService.init()); + + // Précharger des assets critiques de façon asynchrone + unawaited(_preloadAssets()); + + // TODO: lancer ici d'autres initialisations non bloquantes + + _isInitialized = true; + _isInitializing = false; + notifyListeners(); + } catch (e, st) { + if (kDebugMode) print('AppInitializer failed: $e\n$st'); + _isInitializing = false; + // Ne rethrow pas pour éviter de planter l'app; laisser l'UI gérer les erreurs. + notifyListeners(); + } + } + + Future _preloadAssets() async { + try { + // Charger quelques assets en mémoire pour rendre l'affichage initial fluide + await rootBundle.load('assets/logos/RectangleLogoBlack.png'); + await rootBundle.load('assets/logos/SquareLogoWhite.png'); + } catch (e) { + if (kDebugMode) print('Preload assets failed: $e'); + } + } +} + diff --git a/em2rp/lib/services/cache_service.dart b/em2rp/lib/services/cache_service.dart new file mode 100644 index 0000000..9d44df6 --- /dev/null +++ b/em2rp/lib/services/cache_service.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Service simple de cache local basé sur SharedPreferences. +/// +/// Fonctionne sur mobile et sur Flutter Web pour conserver des données +/// locales légères quand cela apporte une vraie valeur. +class CacheService { + SharedPreferences? _prefs; + + Future init() async { + _prefs = await SharedPreferences.getInstance(); + } + + bool ready() => _prefs != null; + + Future setJson(String key, Map value) async { + if (_prefs == null) return; + await _prefs!.setString(key, jsonEncode(value)); + } + + Map? getJson(String key) { + if (_prefs == null) return null; + final s = _prefs!.getString(key); + if (s == null) return null; + try { + return jsonDecode(s) as Map; + } catch (e) { + if (kDebugMode) print('CacheService getJson error: $e'); + return null; + } + } + + Future setString(String key, String value) async { + if (_prefs == null) return; + await _prefs!.setString(key, value); + } + + String? getString(String key) => _prefs?.getString(key); +} + + diff --git a/em2rp/lib/services/email_service.dart b/em2rp/lib/services/email_service.dart index f7a125f..7d890f7 100644 --- a/em2rp/lib/services/email_service.dart +++ b/em2rp/lib/services/email_service.dart @@ -5,7 +5,7 @@ import 'package:firebase_auth/firebase_auth.dart'; /// Service d'envoi d'emails via Cloud Functions class EmailService { - final FirebaseFunctions _functions = FirebaseFunctions.instanceFor(region: 'europe-west9'); + FirebaseFunctions get _functions => FirebaseFunctions.instanceFor(region: 'europe-west9'); /// Envoie un email d'alerte à un utilisateur /// diff --git a/em2rp/lib/utils/app_start_gate.dart b/em2rp/lib/utils/app_start_gate.dart new file mode 100644 index 0000000..d473599 --- /dev/null +++ b/em2rp/lib/utils/app_start_gate.dart @@ -0,0 +1,134 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; + +import '../views/login_page.dart'; +import '../utils/colors.dart'; + +/// Gate de démarrage qui attend la restauration Firebase Auth avant +/// d'afficher soit le contenu connecté, soit la page de connexion. +class AppStartGate extends StatelessWidget { + const AppStartGate({super.key}); + + @override + Widget build(BuildContext context) { + // Sur le web, certaines erreurs natives (ex: cookies tiers bloqués) + // peuvent faire remonter une FirebaseException sur le stream d'auth. + // Pour éviter que StreamBuilder reçoive une erreur qui casse le build + // (TypeError JS interop), on "handleError" et on transforme l'erreur + // en une valeur nulle (pas d'utilisateur) afin de garder l'app stable. + // Accès protégé à `FirebaseAuth.instance` — sur le web certaines erreurs + // d'interop JS peuvent produire des TypeError non compatibles. Nous + // attrapons toute exception lors de l'accès et fournissons un stream + // neutre (pas d'utilisateur) afin de garder l'UI stable. + late final Stream safeAuthStream; + try { + safeAuthStream = FirebaseAuth.instance + .authStateChanges() + .handleError((error, stack) { + // Log pour debug ; ne rethrow pas + debugPrint('[AppStartGate] authStateChanges error: $error'); + }); + } catch (e, st) { + // Sur certaines configurations web l'accès à FirebaseAuth.instance + // peut échouer au niveau JS interop. On log puis on fournit un stream + // qui émet une seule valeur nulle pour indiquer "pas d'utilisateur". + debugPrint('[AppStartGate] FirebaseAuth.instance access error: $e\n$st'); + safeAuthStream = Stream.value(null); + } + + return StreamBuilder( + stream: safeAuthStream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const _StartupSplashScreen(); + } + + if (snapshot.hasError) { + // En théorie handleError évite d'arriver ici, mais on garde + // une protection supplémentaire. + debugPrint('[AppStartGate] snapshot error: ${snapshot.error}'); + return const _StartupSplashScreen(message: 'Erreur de connexion'); + } + + if (snapshot.data != null) { + return const _AuthenticatedBootstrap(); + } + + return const LoginPage(); + }, + ); + } +} + +class _AuthenticatedBootstrap extends StatefulWidget { + const _AuthenticatedBootstrap(); + + @override + State<_AuthenticatedBootstrap> createState() => + _AuthenticatedBootstrapState(); +} + +class _AuthenticatedBootstrapState extends State<_AuthenticatedBootstrap> { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _redirectAfterAuth(); + }); + } + + Future _redirectAfterAuth() async { + final fragment = Uri.base.fragment; + + if (!mounted) return; + + if (fragment.isNotEmpty && fragment != '/' && fragment != '/calendar') { + Navigator.of(context).pushReplacementNamed(fragment); + } else { + Navigator.of(context).pushReplacementNamed('/calendar'); + } + } + + @override + Widget build(BuildContext context) { + return const _StartupSplashScreen(); + } +} + +class _StartupSplashScreen extends StatelessWidget { + final String message; + + const _StartupSplashScreen({this.message = 'Démarrage...'}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/logos/RectangleLogoBlack.png', + width: 160, + height: 160, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.event_available, + size: 72, + color: AppColors.rouge, + ); + }, + ), + const SizedBox(height: 24), + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(message), + ], + ), + ), + ); + } +} + diff --git a/em2rp/lib/utils/auth_guard_widget.dart b/em2rp/lib/utils/auth_guard_widget.dart index 98a716f..44fe910 100644 --- a/em2rp/lib/utils/auth_guard_widget.dart +++ b/em2rp/lib/utils/auth_guard_widget.dart @@ -1,27 +1,48 @@ import 'package:em2rp/providers/local_user_provider.dart'; +import 'package:em2rp/views/login_page.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:em2rp/views/login_page.dart'; class AuthGuard extends StatelessWidget { final Widget child; final String? requiredPermission; + final bool allowWhileLoading; const AuthGuard({ super.key, required this.child, this.requiredPermission, + this.allowWhileLoading = false, }); @override Widget build(BuildContext context) { final localAuthProvider = Provider.of(context); + final firebaseUser = FirebaseAuth.instance.currentUser; // Log pour débug print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission'); + // Si Firebase n'a pas encore restauré la session ou si le profil charge, + // afficher un écran neutre plutôt que la page de connexion. + if (firebaseUser != null && + (localAuthProvider.currentUser == null || + localAuthProvider.isLoadingUserData)) { + if (allowWhileLoading) { + return child; + } + + return const Scaffold( + backgroundColor: Colors.white, + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + // Si l'utilisateur n'est pas connecté - if (localAuthProvider.currentUser == null) { + if (firebaseUser == null || localAuthProvider.currentUser == null) { print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage'); return const LoginPage(); } diff --git a/em2rp/lib/utils/firebase_storage_manager.dart b/em2rp/lib/utils/firebase_storage_manager.dart index 9be1a35..3590437 100644 --- a/em2rp/lib/utils/firebase_storage_manager.dart +++ b/em2rp/lib/utils/firebase_storage_manager.dart @@ -5,8 +5,8 @@ import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/api_service.dart'; class FirebaseStorageManager { - final FirebaseStorage _storage = FirebaseStorage.instance; - final DataService _dataService = DataService(FirebaseFunctionsApiService()); + FirebaseStorage get _storage => FirebaseStorage.instance; + final DataService _dataService = DataService(apiService); /// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage. /// Pour le Web, on fixe l'extension .jpg. diff --git a/em2rp/lib/view_model/login_view_model.dart b/em2rp/lib/view_model/login_view_model.dart index e4c8283..c975235 100644 --- a/em2rp/lib/view_model/login_view_model.dart +++ b/em2rp/lib/view_model/login_view_model.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:firebase_auth/firebase_auth.dart'; import '../providers/local_user_provider.dart'; @@ -33,22 +35,17 @@ class LoginViewModel extends ChangeNotifier { passwordController.text, ); - // --- Étape 2: Charger les données utilisateur depuis Firestore --- - await localAuthProvider.loadUserData(); + // --- Étape 2: Charger les données utilisateur en arrière-plan --- + unawaited( + localAuthProvider.loadUserData().catchError((e) { + debugPrint('Erreur chargement profil après connexion : $e'); + }), + ); // Vérifier si le contexte est toujours valide if (context.mounted) { - // Vérifier si l'utilisateur a bien été chargé dans le provider - if (localAuthProvider.currentUser != null) { - // Utiliser pushReplacementNamed pour une transition propre - Navigator.of(context, rootNavigator: true) - .pushReplacementNamed('/calendar'); - } else { - errorMessage = - 'Erreur inattendue après connexion: Données utilisateur non chargées.'; - isLoading = false; - notifyListeners(); - } + Navigator.of(context, rootNavigator: true) + .pushReplacementNamed('/calendar'); } } on FirebaseAuthException catch (e) { // Gestion spécifique des erreurs d'authentification (email/mot de passe incorrects, etc.) diff --git a/em2rp/lib/views/calendar_page.dart b/em2rp/lib/views/calendar_page.dart index 9387b4f..fd61ba8 100644 --- a/em2rp/lib/views/calendar_page.dart +++ b/em2rp/lib/views/calendar_page.dart @@ -52,6 +52,8 @@ class _CalendarPageState extends State { bool _isMobileSearchVisible = false; bool _isRefreshing = false; double _detailsPaneFraction = 0.35; + String? _lastLoadedUserId; + bool _initialLoadScheduled = false; @override void initState() { @@ -115,6 +117,24 @@ class _CalendarPageState extends State { } } + void _scheduleInitialEventsLoad(String? userId) { + if (userId == null || userId == _lastLoadedUserId || _initialLoadScheduled) { + return; + } + + _initialLoadScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + if (!mounted) return; + if (_lastLoadedUserId == userId) return; + await _loadCurrentMonthEvents(); + _lastLoadedUserId = userId; + } finally { + _initialLoadScheduled = false; + } + }); + } + /// Sélectionne automatiquement l'événement le plus proche de maintenant void _selectDefaultEvent() { final eventProvider = Provider.of(context, listen: false); @@ -813,6 +833,7 @@ class _CalendarPageState extends State { Widget build(BuildContext context) { final eventProvider = Provider.of(context); final localUserProvider = Provider.of(context); + _scheduleInitialEventsLoad(localUserProvider.uid); final canCreateEvents = localUserProvider.hasPermission('create_events'); final canViewAllUserEvents = localUserProvider.hasPermission('view_all_user_events'); diff --git a/em2rp/lib/views/widgets/common/startup_splash_screen.dart b/em2rp/lib/views/widgets/common/startup_splash_screen.dart new file mode 100644 index 0000000..2a061f7 --- /dev/null +++ b/em2rp/lib/views/widgets/common/startup_splash_screen.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/utils/colors.dart'; +class StartupSplashScreen extends StatelessWidget { + final String message; + const StartupSplashScreen({super.key, this.message = 'Démarrage...'}); + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/logos/RectangleLogoBlack.png', + width: 160, + height: 160, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.event_available, + size: 72, + color: AppColors.rouge, + ); + }, + ), + const SizedBox(height: 24), + const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppColors.rouge), + ), + const SizedBox(height: 16), + Text( + message, + style: const TextStyle( + color: AppColors.noir, + fontSize: 16, + ), + ), + ], + ), + ), + ); + } +} diff --git a/em2rp/pubspec.yaml b/em2rp/pubspec.yaml index ba728d4..c3f5e25 100644 --- a/em2rp/pubspec.yaml +++ b/em2rp/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: cloud_functions: ^6.0.4 google_sign_in: ^7.2.0 firebase_storage: ^13.0.3 + shared_preferences: ^2.0.15 # State Management provider: ^6.1.2 diff --git a/em2rp/web/version.json b/em2rp/web/version.json index 0d27b3a..b5977a1 100644 --- a/em2rp/web/version.json +++ b/em2rp/web/version.json @@ -1,7 +1,7 @@ { - "version": "1.1.21", + "version": "1.1.23", "updateUrl": "https://app.em2events.fr", "forceUpdate": true, - "releaseNotes": "Ajout de la recherche d'événements et gestion avancée de la suppression d'équipement", - "timestamp": "2026-04-22T10:22:26.036Z" + "releaseNotes": "Optimisation du lancement de l'application et amélioration de la gestion du cache.", + "timestamp": "2026-05-05T09:52:18.860Z" } \ No newline at end of file