feat: optimisation du démarrage de l'application et de la gestion de l'authentification

- **Refonte du démarrage** : Mise en place d'un `AppInitializer` pour gérer l'initialisation asynchrone de Firebase et du cache en arrière-plan, réduisant le travail synchrone au lancement.
- **Sécurisation de l'authentification** :
    - Création d'un `AppStartGate` pour gérer proprement la restauration de la session Firebase Auth et les erreurs potentielles sur le Web.
    - Amélioration du `LocalUserProvider` avec un "bootstrap léger" permettant de rendre l'UID disponible immédiatement avant le chargement complet du profil.
    - Ajout de protections contre les erreurs d'accès à `FirebaseAuth.instance` (notamment pour les problèmes d'interop JS sur le Web).
- **Optimisation de l'UI** :
    - Remplacement du `AutoLoginWrapper` par une gestion plus robuste de la navigation post-authentification.
    - Amélioration de l'`AuthGuard` pour permettre l'affichage de certains écrans (comme le calendrier) pendant le chargement des données utilisateur (`allowWhileLoading`).
    - Ajout d'un écran de splash screen uniformisé (`StartupSplashScreen`).
- **Services & Cache** :
    - Introduction de `CacheService` utilisant `shared_preferences` pour le stockage local léger.
    - Refactoring des services (`AlertService`, `EmailService`, `FirebaseStorageManager`) pour accéder aux instances Firebase de manière plus flexible via des getters.
    - Mise à jour des dépendances dans `pubspec.yaml` pour inclure `shared_preferences`.
- **Calendrier** : Ajout d'une logique de chargement initial différé des événements (`_scheduleInitialEventsLoad`) pour éviter les appels redondants au démarrage.
- **Maintenance** : Mise à jour de la version de l'application à `1.1.23` et nettoyage des fichiers de cache de déploiement.
This commit is contained in:
ElPoyo
2026-05-05 12:25:45 +02:00
parent eac103491f
commit af5ecaeee1
17 changed files with 594 additions and 296 deletions
+13 -13
View File
@@ -34,16 +34,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
version.json,1776853346038,b9cb334972abfae63e76477e574d02e1b3cdf4210fa3edf744a8d33c6250a12e version.json,1777974738862,518123ebb7461c8343d5ad7d08a9bc31ca5555df3d9e09d36442cad4e5a4dcaa
index.html,1776853378875,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 index.html,1777974744949,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
flutter_service_worker.js,1776853476352,5524fe7e227e1a0ecdd4c9b14a764638a86fe836ced8d3f80ab0817d043d436a flutter_service_worker.js,1777974838520,527efc67156a0e2688a3aca09ef3f967cbb514258c91dc1d8ad1d6a4935e2c65
flutter_bootstrap.js,1776853378859,a9fbceaa97579d418c548aaa1b4fc94284bc33ef5fc2a835d80f9a96d8d6bbd8 assets/FontManifest.json,1777974834864,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
assets/FontManifest.json,1776853472496,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 flutter_bootstrap.js,1777974744934,09a1770005261de742912a7cf492d739d3e263d2383f53cda5ba5bac6896c39c
assets/AssetManifest.json,1776853472495,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6 assets/AssetManifest.json,1777974834864,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
assets/AssetManifest.bin.json,1776853472495,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53 assets/AssetManifest.bin,1777974834864,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
assets/AssetManifest.bin,1776853472495,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907 assets/AssetManifest.bin.json,1777974834864,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1776853475437,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1777974837564,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
assets/shaders/ink_sparkle.frag,1776853472722,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 assets/shaders/ink_sparkle.frag,1777974835049,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
assets/fonts/MaterialIcons-Regular.otf,1776853475444,213705f583b626b88f6f0c7a122a13567b6492f560ad5284176ef149f0b51fef assets/fonts/MaterialIcons-Regular.otf,1777974837570,213705f583b626b88f6f0c7a122a13567b6492f560ad5284176ef149f0b51fef
assets/NOTICES,1776853472497,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79 assets/NOTICES,1777974834870,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e
main.dart.js,1776853469906,1fc20606c99d6d4dde1e7d2a8a0b2e8f2e6c6f81317b8e7e4dd17d54f71a23b2 main.dart.js,1777974833676,fbb6da7a84cb69d9dfb2a92eac87571303dadec0af700067d2d66ed69db416e8
+3
View File
@@ -2,6 +2,9 @@
Toutes les modifications notables de ce projet seront documentées dans ce fichier. 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 ## 22/04/2026
Ajout de la recherche d'événements et gestion avancée de la suppression d'équipement Ajout de la recherche d'événements et gestion avancée de la suppression d'équipement
+1 -1
View File
@@ -1,6 +1,6 @@
/// Configuration de la version de l'application /// Configuration de la version de l'application
class AppVersion { class AppVersion {
static const String version = '1.1.21'; static const String version = '1.1.23';
/// Retourne la version complète de l'application /// Retourne la version complète de l'application
static String get fullVersion => 'v$version'; static String get fullVersion => 'v$version';
+162 -254
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:em2rp/providers/users_provider.dart'; import 'package:em2rp/providers/users_provider.dart';
import 'package:em2rp/providers/event_provider.dart'; import 'package:em2rp/providers/event_provider.dart';
import 'package:em2rp/providers/equipment_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/container_model.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:em2rp/services/app_initializer.dart';
import 'firebase_options.dart';
import 'utils/colors.dart'; import 'utils/colors.dart';
import 'views/my_account_page.dart'; import 'views/my_account_page.dart';
import 'views/user_management_page.dart'; import 'views/user_management_page.dart';
@@ -30,35 +30,21 @@ import 'package:provider/provider.dart';
import 'providers/local_user_provider.dart'; import 'providers/local_user_provider.dart';
import 'views/reset_password_page.dart'; import 'views/reset_password_page.dart';
import 'config/env.dart'; import 'config/env.dart';
import 'services/update_service.dart'; import 'utils/app_start_gate.dart';
import 'views/widgets/common/update_dialog.dart'; import 'views/widgets/common/startup_splash_screen.dart';
import 'config/api_config.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
void main() async { void main() {
// Ne pas effectuer d'initialisations asynchrones lourdes ici.
WidgetsFlutterBinding.ensureInitialized(); 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( runApp(
MultiProvider( MultiProvider(
providers: [ providers: [
// Fournisseur d'initialisation de l'application (initialise Firebase et cache en tâche de fond)
ChangeNotifierProvider<AppInitializer>(
create: (_) => AppInitializer(),
),
// LocalUserProvider pour la gestion de l'authentification // LocalUserProvider pour la gestion de l'authentification
ChangeNotifierProvider<LocalUserProvider>( ChangeNotifierProvider<LocalUserProvider>(
create: (context) => LocalUserProvider()), create: (context) => LocalUserProvider()),
@@ -96,241 +82,163 @@ void main() async {
); );
} }
class MyApp extends StatelessWidget { class MyApp extends StatefulWidget {
const MyApp({super.key}); const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final Future<void> _startupFuture;
@override
void initState() {
super.initState();
_startupFuture = _bootstrapApp();
}
Future<void> _bootstrapApp() async {
final initializer = context.read<AppInitializer>();
final localAuthProvider = context.read<LocalUserProvider>();
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return FutureBuilder<void>(
title: 'EM2 Hub', future: _startupFuture,
theme: ThemeData( builder: (context, snapshot) {
primarySwatch: Colors.red, if (snapshot.connectionState != ConnectionState.done) {
primaryColor: AppColors.noir, return const MaterialApp(
colorScheme: debugShowCheckedModeBanner: false,
ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge), home: StartupSplashScreen(),
textTheme: const TextTheme( );
bodyMedium: TextStyle(color: AppColors.noir), }
),
inputDecorationTheme: const InputDecorationTheme( return MaterialApp(
focusedBorder: OutlineInputBorder( title: 'EM2 Hub',
borderSide: BorderSide(color: AppColors.noir), theme: ThemeData(
), primarySwatch: Colors.red,
enabledBorder: OutlineInputBorder( primaryColor: AppColors.noir,
borderSide: BorderSide(color: AppColors.gris), colorScheme:
), ColorScheme.fromSwatch().copyWith(secondary: AppColors.rouge),
labelStyle: TextStyle(color: AppColors.noir), textTheme: const TextTheme(
hintStyle: TextStyle(color: AppColors.gris), bodyMedium: TextStyle(color: AppColors.noir),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( inputDecorationTheme: const InputDecorationTheme(
style: ElevatedButton.styleFrom( focusedBorder: OutlineInputBorder(
foregroundColor: AppColors.blanc, borderSide: BorderSide(color: AppColors.noir),
backgroundColor: 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: const Locale('fr', 'FR'), Locale('fr', 'FR'),
supportedLocales: const [ ],
Locale('fr', 'FR'), localizationsDelegates: const [
], GlobalMaterialLocalizations.delegate,
localizationsDelegates: const [ GlobalWidgetsLocalizations.delegate,
GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate, ],
GlobalCupertinoLocalizations.delegate, routes: {
], '/login': (context) => const LoginPage(),
initialRoute: '/', '/alerts': (context) => const AuthGuard(child: AlertsPage()),
routes: { '/calendar': (context) => const AuthGuard(
'/': (context) => const AutoLoginWrapper(), allowWhileLoading: true, child: CalendarPage()),
'/login': (context) => const LoginPage(), '/my_account': (context) => const AuthGuard(child: MyAccountPage()),
'/alerts': (context) => const AuthGuard(child: AlertsPage()), '/user_management': (context) => const AuthGuard(
'/calendar': (context) => const AuthGuard(child: CalendarPage()), requiredPermission: "view_all_users",
'/my_account': (context) => const AuthGuard(child: MyAccountPage()), child: UserManagementPage()),
'/user_management': (context) => const AuthGuard( '/reset_password': (context) {
requiredPermission: "view_all_users", child: UserManagementPage()), final args = ModalRoute.of(context)!.settings.arguments
'/reset_password': (context) { as Map<String, dynamic>;
final args = ModalRoute.of(context)!.settings.arguments return ResetPasswordPage(
as Map<String, dynamic>; email: args['email'] as String,
return ResetPasswordPage( actionCode: args['actionCode'] as String,
email: args['email'] as String, );
actionCode: args['actionCode'] as String, },
); '/equipment_management': (context) => const AuthGuard(
}, requiredPermission: "view_equipment",
'/equipment_management': (context) => const AuthGuard( child: EquipmentManagementPage()),
requiredPermission: "view_equipment", '/container_management': (context) => const AuthGuard(
child: EquipmentManagementPage()), requiredPermission: "view_equipment",
'/container_management': (context) => const AuthGuard( child: ContainerManagementPage()),
requiredPermission: "view_equipment", '/maintenance_management': (context) => const AuthGuard(
child: ContainerManagementPage()), requiredPermission: "manage_maintenances",
'/maintenance_management': (context) => const AuthGuard( child: MaintenanceManagementPage()),
requiredPermission: "manage_maintenances", '/container_form': (context) {
child: MaintenanceManagementPage()), final args = ModalRoute.of(context)?.settings.arguments;
'/container_form': (context) { return AuthGuard(
final args = ModalRoute.of(context)?.settings.arguments; requiredPermission: "manage_equipment",
return AuthGuard( child: ContainerFormPage(
requiredPermission: "manage_equipment", container: args as ContainerModel?,
child: ContainerFormPage( ),
container: args as ContainerModel?, );
), },
); '/container_detail': (context) {
}, final container = ModalRoute.of(context)!.settings.arguments
'/container_detail': (context) { as ContainerModel;
final container = ModalRoute.of(context)!.settings.arguments as ContainerModel; return AuthGuard(
return AuthGuard( requiredPermission: "view_equipment",
requiredPermission: "view_equipment", child: ContainerDetailPage(container: container),
child: ContainerDetailPage(container: container), );
); },
}, '/event_preparation': (context) {
'/event_preparation': (context) { final args = ModalRoute.of(context)!.settings.arguments
final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>; as Map<String, dynamic>;
final event = args['event'] as EventModel; final event = args['event'] as EventModel;
return AuthGuard( return AuthGuard(
child: EventPreparationPage( child: EventPreparationPage(
initialEvent: event, initialEvent: event,
), ),
); );
}, },
'/event_statistics': (context) => const AuthGuard( '/event_statistics': (context) => const AuthGuard(
requiredPermission: 'generate_reports', child: EventStatisticsPage()), requiredPermission: 'generate_reports',
child: EventStatisticsPage()),
},
home: const AppStartGate(),
);
}, },
); );
} }
} }
class AutoLoginWrapper extends StatefulWidget {
const AutoLoginWrapper({super.key});
@override
State<AutoLoginWrapper> createState() => _AutoLoginWrapperState();
}
class _AutoLoginWrapperState extends State<AutoLoginWrapper> {
@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<void> _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<void> _autoLogin() async {
PerformanceMonitor.start('App.autoLogin');
try {
final localAuthProvider =
Provider.of<LocalUserProvider>(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<Color>(AppColors.rouge),
),
const SizedBox(height: 20),
const Text(
'Chargement...',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
fontWeight: FontWeight.w400,
),
),
],
),
),
);
}
}
+51 -5
View File
@@ -12,7 +12,7 @@ import '../utils/performance_monitor.dart';
class LocalUserProvider with ChangeNotifier { class LocalUserProvider with ChangeNotifier {
UserModel? _currentUser; UserModel? _currentUser;
RoleModel? _currentRole; RoleModel? _currentRole;
final FirebaseAuth _auth = FirebaseAuth.instance; FirebaseAuth? _auth;
final FirebaseStorageManager _storageManager = FirebaseStorageManager(); final FirebaseStorageManager _storageManager = FirebaseStorageManager();
final DataService _dataService = DataService(apiService); final DataService _dataService = DataService(apiService);
@@ -43,11 +43,41 @@ class LocalUserProvider with ChangeNotifier {
/// Charge les données de l'utilisateur actuel via Cloud Function /// Charge les données de l'utilisateur actuel via Cloud Function
Future<void> loadUserData({bool forceReload = false}) async { Future<void> 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'); print('No current user in Auth');
return; 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 // Éviter les rechargements inutiles
if (!forceReload && !_shouldReloadUserData()) { if (!forceReload && !_shouldReloadUserData()) {
print('Using cached user data'); print('Using cached user data');
@@ -62,7 +92,7 @@ class LocalUserProvider with ChangeNotifier {
_isLoadingUserData = true; _isLoadingUserData = true;
PerformanceMonitor.start('LocalUserProvider.loadUserData'); PerformanceMonitor.start('LocalUserProvider.loadUserData');
print('Loading user data for: ${_auth.currentUser!.uid}'); print('Loading user data for: ${_auth!.currentUser!.uid}');
try { try {
// Utiliser la Cloud Function getCurrentUser // Utiliser la Cloud Function getCurrentUser
PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API'); PerformanceMonitor.start('LocalUserProvider.getCurrentUser_API');
@@ -194,7 +224,8 @@ class LocalUserProvider with ChangeNotifier {
Future<UserCredential> signInWithEmailAndPassword( Future<UserCredential> signInWithEmailAndPassword(
String email, String password) async { String email, String password) async {
try { try {
UserCredential userCredential = await _auth.signInWithEmailAndPassword( final auth = _getAuthInstance();
UserCredential userCredential = await auth.signInWithEmailAndPassword(
email: email, password: password); email: email, password: password);
// Note: loadUserData() sera appelé en arrière-plan dans main.dart // Note: loadUserData() sera appelé en arrière-plan dans main.dart
// pour ne pas bloquer la navigation // pour ne pas bloquer la navigation
@@ -206,10 +237,25 @@ class LocalUserProvider with ChangeNotifier {
/// Déconnexion /// Déconnexion
Future<void> signOut() async { Future<void> signOut() async {
await _auth.signOut(); try {
final auth = _getAuthInstance();
await auth.signOut();
} catch (e) {
debugPrint('Error during signOut: $e');
}
clearUser(); 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 /// Vérifie si l'utilisateur a une permission spécifique
bool hasPermission(String permission) { bool hasPermission(String permission) {
return _currentRole?.permissions.contains(permission) ?? false; return _currentRole?.permissions.contains(permission) ?? false;
+2 -2
View File
@@ -7,8 +7,8 @@ import 'api_service.dart' show FirebaseFunctionsApiService;
/// Architecture simplifiée : le client appelle uniquement les Cloud Functions /// Architecture simplifiée : le client appelle uniquement les Cloud Functions
/// Toute la logique métier est gérée côté backend /// Toute la logique métier est gérée côté backend
class AlertService { class AlertService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance; FirebaseFirestore get _firestore => FirebaseFirestore.instance;
final FirebaseAuth _auth = FirebaseAuth.instance; FirebaseAuth get _auth => FirebaseAuth.instance;
/// Stream des alertes pour l'utilisateur connecté /// Stream des alertes pour l'utilisateur connecté
Stream<List<AlertModel>> getAlertsStream() { Stream<List<AlertModel>> getAlertsStream() {
+79
View File
@@ -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<void> 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<void> _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');
}
}
}
+44
View File
@@ -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<void> init() async {
_prefs = await SharedPreferences.getInstance();
}
bool ready() => _prefs != null;
Future<void> setJson(String key, Map<String, dynamic> value) async {
if (_prefs == null) return;
await _prefs!.setString(key, jsonEncode(value));
}
Map<String, dynamic>? getJson(String key) {
if (_prefs == null) return null;
final s = _prefs!.getString(key);
if (s == null) return null;
try {
return jsonDecode(s) as Map<String, dynamic>;
} catch (e) {
if (kDebugMode) print('CacheService getJson error: $e');
return null;
}
}
Future<void> setString(String key, String value) async {
if (_prefs == null) return;
await _prefs!.setString(key, value);
}
String? getString(String key) => _prefs?.getString(key);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import 'package:firebase_auth/firebase_auth.dart';
/// Service d'envoi d'emails via Cloud Functions /// Service d'envoi d'emails via Cloud Functions
class EmailService { 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 /// Envoie un email d'alerte à un utilisateur
/// ///
+134
View File
@@ -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<User?> 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<User?>.value(null);
}
return StreamBuilder<User?>(
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<void> _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),
],
),
),
);
}
}
+23 -2
View File
@@ -1,27 +1,48 @@
import 'package:em2rp/providers/local_user_provider.dart'; 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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:em2rp/views/login_page.dart';
class AuthGuard extends StatelessWidget { class AuthGuard extends StatelessWidget {
final Widget child; final Widget child;
final String? requiredPermission; final String? requiredPermission;
final bool allowWhileLoading;
const AuthGuard({ const AuthGuard({
super.key, super.key,
required this.child, required this.child,
this.requiredPermission, this.requiredPermission,
this.allowWhileLoading = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final localAuthProvider = Provider.of<LocalUserProvider>(context); final localAuthProvider = Provider.of<LocalUserProvider>(context);
final firebaseUser = FirebaseAuth.instance.currentUser;
// Log pour débug // Log pour débug
print('[AuthGuard] Vérification accès - User: ${localAuthProvider.currentUser?.uid}, Permission requise: $requiredPermission'); 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é // 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'); print('[AuthGuard] Utilisateur non connecté, redirection vers LoginPage');
return const LoginPage(); return const LoginPage();
} }
@@ -5,8 +5,8 @@ import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/services/api_service.dart';
class FirebaseStorageManager { class FirebaseStorageManager {
final FirebaseStorage _storage = FirebaseStorage.instance; FirebaseStorage get _storage => FirebaseStorage.instance;
final DataService _dataService = DataService(FirebaseFunctionsApiService()); final DataService _dataService = DataService(apiService);
/// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage. /// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage.
/// Pour le Web, on fixe l'extension .jpg. /// Pour le Web, on fixe l'extension .jpg.
+10 -13
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_auth/firebase_auth.dart';
import '../providers/local_user_provider.dart'; import '../providers/local_user_provider.dart';
@@ -33,22 +35,17 @@ class LoginViewModel extends ChangeNotifier {
passwordController.text, passwordController.text,
); );
// --- Étape 2: Charger les données utilisateur depuis Firestore --- // --- Étape 2: Charger les données utilisateur en arrière-plan ---
await localAuthProvider.loadUserData(); unawaited(
localAuthProvider.loadUserData().catchError((e) {
debugPrint('Erreur chargement profil après connexion : $e');
}),
);
// Vérifier si le contexte est toujours valide // Vérifier si le contexte est toujours valide
if (context.mounted) { if (context.mounted) {
// Vérifier si l'utilisateur a bien été chargé dans le provider Navigator.of(context, rootNavigator: true)
if (localAuthProvider.currentUser != null) { .pushReplacementNamed('/calendar');
// 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();
}
} }
} on FirebaseAuthException catch (e) { } on FirebaseAuthException catch (e) {
// Gestion spécifique des erreurs d'authentification (email/mot de passe incorrects, etc.) // Gestion spécifique des erreurs d'authentification (email/mot de passe incorrects, etc.)
+21
View File
@@ -52,6 +52,8 @@ class _CalendarPageState extends State<CalendarPage> {
bool _isMobileSearchVisible = false; bool _isMobileSearchVisible = false;
bool _isRefreshing = false; bool _isRefreshing = false;
double _detailsPaneFraction = 0.35; double _detailsPaneFraction = 0.35;
String? _lastLoadedUserId;
bool _initialLoadScheduled = false;
@override @override
void initState() { void initState() {
@@ -115,6 +117,24 @@ class _CalendarPageState extends State<CalendarPage> {
} }
} }
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 /// Sélectionne automatiquement l'événement le plus proche de maintenant
void _selectDefaultEvent() { void _selectDefaultEvent() {
final eventProvider = Provider.of<EventProvider>(context, listen: false); final eventProvider = Provider.of<EventProvider>(context, listen: false);
@@ -813,6 +833,7 @@ class _CalendarPageState extends State<CalendarPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final eventProvider = Provider.of<EventProvider>(context); final eventProvider = Provider.of<EventProvider>(context);
final localUserProvider = Provider.of<LocalUserProvider>(context); final localUserProvider = Provider.of<LocalUserProvider>(context);
_scheduleInitialEventsLoad(localUserProvider.uid);
final canCreateEvents = localUserProvider.hasPermission('create_events'); final canCreateEvents = localUserProvider.hasPermission('create_events');
final canViewAllUserEvents = final canViewAllUserEvents =
localUserProvider.hasPermission('view_all_user_events'); localUserProvider.hasPermission('view_all_user_events');
@@ -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<Color>(AppColors.rouge),
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
color: AppColors.noir,
fontSize: 16,
),
),
],
),
),
);
}
}
+1
View File
@@ -17,6 +17,7 @@ dependencies:
cloud_functions: ^6.0.4 cloud_functions: ^6.0.4
google_sign_in: ^7.2.0 google_sign_in: ^7.2.0
firebase_storage: ^13.0.3 firebase_storage: ^13.0.3
shared_preferences: ^2.0.15
# State Management # State Management
provider: ^6.1.2 provider: ^6.1.2
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"version": "1.1.21", "version": "1.1.23",
"updateUrl": "https://app.em2events.fr", "updateUrl": "https://app.em2events.fr",
"forceUpdate": true, "forceUpdate": true,
"releaseNotes": "Ajout de la recherche d'événements et gestion avancée de la suppression d'équipement", "releaseNotes": "Optimisation du lancement de l'application et amélioration de la gestion du cache.",
"timestamp": "2026-04-22T10:22:26.036Z" "timestamp": "2026-05-05T09:52:18.860Z"
} }