From c579fd92a2e2eb24191b4139cf52809f558cba35 Mon Sep 17 00:00:00 2001 From: "PC-PAUL\\paulf" Date: Mon, 10 Mar 2025 17:14:09 +0100 Subject: [PATCH] =?UTF-8?q?Photo=20de=20profile=20OK,=20modif=20nom,=20pr?= =?UTF-8?q?=C3=A9nom,=20tel=20OK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- em2rp/.vscode/launch.json | 31 +-- em2rp/cors.json | 8 + em2rp/lib/main.dart | 2 +- em2rp/lib/utils/auth_guard_widget.dart | 4 +- em2rp/lib/utils/firebase_storage_manager.dart | 90 +++++++++ em2rp/lib/views/login_page.dart | 4 + em2rp/lib/views/my_account_page.dart | 180 +++++++++++++++++- .../views/widgets/image/profile_picture.dart | 102 ++++++++++ em2rp/lib/views/widgets/nav/main_drawer.dart | 10 +- em2rp/pubspec.yaml | 3 + 10 files changed, 388 insertions(+), 46 deletions(-) create mode 100644 em2rp/cors.json create mode 100644 em2rp/lib/utils/firebase_storage_manager.dart create mode 100644 em2rp/lib/views/widgets/image/profile_picture.dart diff --git a/em2rp/.vscode/launch.json b/em2rp/.vscode/launch.json index 55b9f54..c1cee44 100644 --- a/em2rp/.vscode/launch.json +++ b/em2rp/.vscode/launch.json @@ -4,36 +4,7 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - { - "name": "Attach to Chrome", - "port": 9222, - "request": "attach", - "type": "chrome", - "webRoot": "${workspaceFolder}", - "preLaunchTask": "npm: vite", - - }, - { - "name": "Launch Chrome", - "request": "launch", - "type": "chrome", - "url": "http://localhost:8080", - "webRoot": "${workspaceFolder}" - }, - { - "name": "Launch Edge", - "request": "launch", - "type": "msedge", - "url": "http://localhost:8080", - "webRoot": "${workspaceFolder}" - }, - { - "name": "Attach to Edge", - "port": 9222, - "request": "attach", - "type": "msedge", - "webRoot": "${workspaceFolder}" - }, + { "name": "em2rp", "request": "launch", diff --git a/em2rp/cors.json b/em2rp/cors.json new file mode 100644 index 0000000..3ed8565 --- /dev/null +++ b/em2rp/cors.json @@ -0,0 +1,8 @@ +[ + { + "origin": ["http://localhost:63825"], + "method": ["GET"], + "maxAgeSeconds": 3600 + } + ] + \ No newline at end of file diff --git a/em2rp/lib/main.dart b/em2rp/lib/main.dart index 16a7b8d..ff81bd0 100644 --- a/em2rp/lib/main.dart +++ b/em2rp/lib/main.dart @@ -81,7 +81,7 @@ class MyApp extends StatelessWidget { //Pages réservées aux ADMIN '/user_management': (context) => - const AuthGuard(child: UserManagementPage(), requiredRole: "ADMIN"), + const AuthGuard(requiredRole: "ADMIN", child: UserManagementPage()), }, initialRoute: '/login', ); diff --git a/em2rp/lib/utils/auth_guard_widget.dart b/em2rp/lib/utils/auth_guard_widget.dart index 7b13176..b483330 100644 --- a/em2rp/lib/utils/auth_guard_widget.dart +++ b/em2rp/lib/utils/auth_guard_widget.dart @@ -9,10 +9,10 @@ class AuthGuard extends StatelessWidget { requiredRole; // Si non null, la page est réservée à ce rôle (ex: "ADMIN") const AuthGuard({ - Key? key, + super.key, required this.child, this.requiredRole, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/em2rp/lib/utils/firebase_storage_manager.dart b/em2rp/lib/utils/firebase_storage_manager.dart new file mode 100644 index 0000000..76b6322 --- /dev/null +++ b/em2rp/lib/utils/firebase_storage_manager.dart @@ -0,0 +1,90 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; // pour kIsWeb +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:image_picker/image_picker.dart'; + +class FirebaseStorageManager { + final FirebaseStorage _storage = FirebaseStorage.instance; + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + + /// Upload ou remplace la photo de profil d'un utilisateur dans Firebase Storage. + /// Pour le Web, on fixe l'extension .jpg. + /// 1. Construit le chemin : "ProfilePictures/UID.jpg" + /// 2. Supprime l'ancienne photo (si elle existe). + /// 3. Upload la nouvelle photo. + /// 4. Met à jour Firestore avec l'URL de la nouvelle image. + Future sendProfilePicture( + {required XFile imageFile, required String uid}) async { + try { + const String fileExtension = '.jpg'; // Extension fixe pour le web + final String fileName = '$uid$fileExtension'; + final String filePath = 'ProfilePictures/$fileName'; + final Reference storageRef = _storage.ref().child(filePath); + + // 2. Suppression de l'ancienne photo de profil (si elle existe) + try { + await _storage.ref().child(filePath).delete(); + print( + "FirebaseStorageManager: Ancienne photo supprimée pour l'utilisateur $uid."); + } on FirebaseException catch (e) { + if (e.code == 'object-not-found') { + print( + "FirebaseStorageManager: Pas d'ancienne photo à supprimer pour l'utilisateur $uid."); + } else { + print( + "FirebaseStorageManager: Erreur lors de la suppression pour l'utilisateur $uid: ${e.message}"); + return null; + } + } + + // 3. Upload de la nouvelle photo en fonction de la plateforme + UploadTask uploadTask; + if (kIsWeb) { + // Pour le web, lire les bytes et utiliser putData() + final bytes = await imageFile.readAsBytes(); + uploadTask = storageRef.putData(bytes); + } else { + // Pour mobile/desktop, utiliser un File (dart:io) + final file = File(imageFile.path); + uploadTask = storageRef.putFile(file); + } + + final TaskSnapshot snapshot = await uploadTask.whenComplete(() {}); + if (snapshot.state == TaskState.success) { + // 4. Récupérer l'URL de téléchargement + final String downloadUrl = await snapshot.ref.getDownloadURL(); + print( + "FirebaseStorageManager: Nouvelle photo uploadée pour l'utilisateur $uid. URL: $downloadUrl"); + + // 5. Mettre à jour Firestore avec l'URL de la photo de profil + try { + await _firestore + .collection('users') + .doc(uid) + .update({'profilePhotoUrl': downloadUrl}); + print( + "FirebaseStorageManager: Firestore mis à jour pour l'utilisateur $uid."); + } catch (firestoreError) { + print( + "FirebaseStorageManager: Erreur Firestore pour l'utilisateur $uid: $firestoreError"); + return downloadUrl; // On retourne l'URL même si la mise à jour échoue + } + return downloadUrl; + } else { + print( + "FirebaseStorageManager: Échec de l'upload pour l'utilisateur $uid. État: ${snapshot.state}"); + return null; + } + } on FirebaseException catch (storageError) { + print( + "FirebaseStorageManager: Erreur Firebase Storage pour l'utilisateur $uid: ${storageError.message}"); + return null; + } catch (e) { + print( + "FirebaseStorageManager: Erreur inattendue pour l'utilisateur $uid: $e"); + return null; + } + } +} diff --git a/em2rp/lib/views/login_page.dart b/em2rp/lib/views/login_page.dart index 86c95fc..843d61d 100644 --- a/em2rp/lib/views/login_page.dart +++ b/em2rp/lib/views/login_page.dart @@ -64,12 +64,14 @@ class _LoginPageState extends State { // Maintenant que toutes les données sont chargées, naviguer vers CalendarPage. Navigator.of(context).pushReplacementNamed('/calendar'); } else { + if (!mounted) return; setState(() { _errorMessage = "Aucune donnée utilisateur trouvée."; _isLoading = false; }); } } on FirebaseAuthException catch (e) { + if (!mounted) return; setState(() { _isLoading = false; if (e.code == 'user-not-found' || e.code == 'wrong-password') { @@ -81,6 +83,7 @@ class _LoginPageState extends State { } }); } catch (e) { + if (!mounted) return; setState(() { _errorMessage = "Erreur lors de la récupération des données utilisateur."; @@ -90,6 +93,7 @@ class _LoginPageState extends State { } void _togglePasswordVisibility() { + if (!mounted) return; setState(() { _obscurePassword = !_obscurePassword; }); diff --git a/em2rp/lib/views/my_account_page.dart b/em2rp/lib/views/my_account_page.dart index 5d904db..cdcd041 100644 --- a/em2rp/lib/views/my_account_page.dart +++ b/em2rp/lib/views/my_account_page.dart @@ -1,21 +1,187 @@ import 'package:em2rp/providers/user_provider.dart'; +import 'package:em2rp/utils/firebase_storage_manager.dart'; +import 'package:em2rp/views/widgets/image/profile_picture.dart'; import 'package:em2rp/views/widgets/nav/main_drawer.dart'; import 'package:flutter/material.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:provider/provider.dart'; +import 'package:image_picker/image_picker.dart'; -class MyAccountPage extends StatelessWidget { +class MyAccountPage extends StatefulWidget { const MyAccountPage({super.key}); + @override + _MyAccountPageState createState() => _MyAccountPageState(); +} + +class _MyAccountPageState extends State { + final User? user = FirebaseAuth.instance.currentUser; + final TextEditingController _firstNameController = TextEditingController(); + final TextEditingController _lastNameController = TextEditingController(); + final TextEditingController _phoneController = TextEditingController(); + String? profilePhotoUrl; + bool _isHoveringProfilePic = false; + final FirebaseStorageManager _storageManager = + FirebaseStorageManager(); // Instance of FirebaseStorageManager + + @override + void initState() { + super.initState(); + _loadUserData(); + } + + Future _loadUserData() async { + if (user != null) { + DocumentSnapshot userData = await FirebaseFirestore.instance + .collection('users') + .doc(user!.uid) + .get(); + + if (userData.exists) { + if (!mounted) return; + setState(() { + _firstNameController.text = userData['firstName'] ?? ''; + _lastNameController.text = userData['lastName'] ?? ''; + _phoneController.text = userData['phone'] ?? ''; + }); + } + } + } + + Future _updateUserData() async { + if (user != null) { + await FirebaseFirestore.instance.collection('users').doc(user!.uid).set({ + 'firstName': _firstNameController.text, + 'lastName': _lastNameController.text, + 'phone': _phoneController.text, + }, SetOptions(merge: true)); + } + } + + Future _changeProfilePicture() async { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage( + source: ImageSource.gallery, + ); // You can also use ImageSource.camera + + if (image != null) { + // Call FirebaseStorageManager to send the profile picture + String? newProfilePhotoUrl = await _storageManager.sendProfilePicture( + imageFile: image, + uid: user!.uid, + ); + if (newProfilePhotoUrl != null) { + if (!mounted) return; + setState(() { + profilePhotoUrl = + newProfilePhotoUrl; // Update the profilePhotoUrl to refresh the UI + }); + // Optionally, update the UserProvider if you are using it to manage user data globally + // Provider.of(context, listen: false).setUserProfilePhotoUrl(newProfilePhotoUrl); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Photo de profil mise à jour avec succès!'), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Erreur lors de la mise à jour de la photo de profil.', + ), + ), + ); + } + } else { + // User cancelled image picking + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Sélection de photo annulée.')), + ); + } + } @override Widget build(BuildContext context) { - final userProvider = Provider.of(context); + final userProvider = Provider.of( + context, + ); // Get UserProvider instance return Scaffold( - appBar: AppBar(title: const Text('Mon Compte')), - drawer: - MainDrawer(currentPage: '/my_account', userProvider: userProvider), - body: const Center( - child: Text('Page Mon Compte', style: TextStyle(fontSize: 24)), + appBar: AppBar(title: const Text('Gestion du compte')), + drawer: MainDrawer( + currentPage: '/my_account', + userProvider: userProvider, + ), // Pass UserProvider to MainDrawer + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + MouseRegion( + onEnter: (_) => setState(() => _isHoveringProfilePic = true), + onExit: (_) => setState(() => _isHoveringProfilePic = false), + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: + _changeProfilePicture, // Call _changeProfilePicture on tap + child: Stack( + alignment: Alignment.center, + children: [ + ProfilePictureWidget(userId: user!.uid, radius: 45), + if (_isHoveringProfilePic) + Container( + width: 140, + height: 140, + decoration: BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Center( + child: Icon( + Icons.edit, + color: Colors.white, + size: 30, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + _buildTextField('Prénom', _firstNameController), + _buildTextField('Nom', _lastNameController), + _buildTextField('Numéro de téléphone', _phoneController), + _buildTextField( + 'Email', + TextEditingController(text: user?.email ?? ''), + enabled: false, + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: _updateUserData, + child: const Text('Enregistrer'), + ), + ], + ), + ), + ); + } + + Widget _buildTextField( + String label, + TextEditingController controller, { + bool enabled = true, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: TextField( + controller: controller, + enabled: enabled, + decoration: InputDecoration( + labelText: label, + border: OutlineInputBorder(), + ), ), ); } diff --git a/em2rp/lib/views/widgets/image/profile_picture.dart b/em2rp/lib/views/widgets/image/profile_picture.dart new file mode 100644 index 0000000..4e5621b --- /dev/null +++ b/em2rp/lib/views/widgets/image/profile_picture.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; + +class ProfilePictureWidget extends StatelessWidget { + final String userId; + final double radius; + final String? defaultImageUrl; // URL de l'image par défaut (optionnel) + + const ProfilePictureWidget({ + super.key, + required this.userId, + this.radius = 25, + this.defaultImageUrl, + }); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: FirebaseFirestore.instance.collection('users').doc(userId).get(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return _buildLoadingAvatar( + radius); // Afficher un avatar de chargement + } else if (snapshot.hasError) { + print("Erreur FutureBuilder ProfilePictureWidget: ${snapshot.error}"); + return _buildDefaultAvatar(radius, + defaultImageUrl); // Afficher avatar par défaut en cas d'erreur Firestore + } else if (snapshot.data != null && snapshot.data!.exists) { + final userData = snapshot.data!; + final profilePhotoUrl = userData['profilePhotoUrl'] as String?; + + if (profilePhotoUrl != null && profilePhotoUrl.isNotEmpty) { + return CircleAvatar( + radius: radius, + // Utilisation de Image.network directement dans backgroundImage + backgroundImage: Image.network( + profilePhotoUrl, + errorBuilder: (context, error, stackTrace) { + print( + "Erreur de chargement Image.network pour l'URL: $profilePhotoUrl, Erreur: $error"); + // En cas d'erreur de chargement de l'image réseau, afficher l'avatar par défaut + return _buildDefaultAvatar(radius, defaultImageUrl); + }, + ).image, // .image pour obtenir un ImageProvider pour backgroundImage + ); + } else { + return _buildDefaultAvatar(radius, + defaultImageUrl); // Pas d'URL dans Firestore, afficher avatar par défaut + } + } else { + return _buildDefaultAvatar(radius, + defaultImageUrl); // Document utilisateur non trouvé ou n'existe pas + } + }, + ); + } + + // Widget utilitaire pour construire un CircleAvatar de chargement + Widget _buildLoadingAvatar(double radius) { + return CircleAvatar( + radius: radius, + backgroundColor: + Colors.grey[300], // Couleur de fond pendant le chargement + child: SizedBox( + width: radius * 0.8, // Ajuster la taille du loader + height: radius * 0.8, + child: CircularProgressIndicator( + strokeWidth: 2), // Indicateur de chargement + ), + ); + } + + // Widget utilitaire pour construire un CircleAvatar par défaut (avec icône ou image par défaut) + Widget _buildDefaultAvatar(double radius, String? defaultImageUrl) { + if (defaultImageUrl != null && defaultImageUrl.isNotEmpty) { + return CircleAvatar( + radius: radius, + // Utilisation de Image.network pour l'image par défaut, avec gestion d'erreur similaire + backgroundImage: Image.network( + defaultImageUrl, + errorBuilder: (context, error, stackTrace) { + print( + "Erreur de chargement Image.network pour l'URL par défaut: $defaultImageUrl, Erreur: $error"); + return _buildIconAvatar( + radius); // Si l'image par défaut ne charge pas, afficher l'icône + }, + ).image, // .image pour ImageProvider + ); + } else { + return _buildIconAvatar( + radius); // Si pas d'URL par défaut fournie, afficher l'icône + } + } + + // Widget utilitaire pour construire un CircleAvatar avec une icône par défaut + Widget _buildIconAvatar(double radius) { + return CircleAvatar( + radius: radius, + child: Icon(Icons.account_circle, size: radius * 1.5), // Icône par défaut + ); + } +} diff --git a/em2rp/lib/views/widgets/nav/main_drawer.dart b/em2rp/lib/views/widgets/nav/main_drawer.dart index 101e9fc..07a0daf 100644 --- a/em2rp/lib/views/widgets/nav/main_drawer.dart +++ b/em2rp/lib/views/widgets/nav/main_drawer.dart @@ -4,6 +4,7 @@ import 'package:em2rp/views/calendar_page.dart'; import 'package:em2rp/views/my_account_page.dart'; import 'package:em2rp/views/user_management_page.dart'; import 'package:flutter/material.dart'; +import 'package:em2rp/views/widgets/image/profile_picture.dart'; class MainDrawer extends StatelessWidget { final String currentPage; @@ -38,12 +39,9 @@ class MainDrawer extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.end, children: [ - CircleAvatar( - radius: 25, - backgroundImage: NetworkImage( - userProvider.profilePhotoUrl ?? - 'https://firebasestorage.googleapis.com/v0/b/em2rp-951dc/o/EM2_NsurB.jpg?alt=media&token=530479c3-5f8c-413b-86a2-53ec4a4ed734', //TODO: Remplacer par logo EM2 - ), + ProfilePictureWidget( + userId: userProvider.uid!, + radius: 30, ), const SizedBox(height: 8), Text( diff --git a/em2rp/pubspec.yaml b/em2rp/pubspec.yaml index 02702ad..8c3ed53 100644 --- a/em2rp/pubspec.yaml +++ b/em2rp/pubspec.yaml @@ -14,6 +14,9 @@ dependencies: cloud_firestore: ^5.6.5 google_sign_in: ^6.2.2 provider: ^6.1.2 + firebase_storage: ^12.4.4 + image_picker: ^1.1.2 + universal_io: ^2.2.2 dev_dependencies: flutter_test: