Add camera and storage permissions, and implement profile picture upload functionality

This commit is contained in:
Van Leemput Dayron
2025-11-13 15:33:43 +01:00
parent 2ca30088ca
commit 3560b2d6f5
4 changed files with 327 additions and 57 deletions

View File

@@ -5,6 +5,15 @@
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Permissions pour la galerie et les fichiers -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- Permissions pour écrire dans le stockage -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application <application
android:label="travel_mate" android:label="travel_mate"
android:name="${applicationName}" android:name="${applicationName}"

View File

@@ -73,5 +73,16 @@
</array> </array>
</dict> </dict>
</array> </array>
<!-- Permission Caméra -->
<key>NSCameraUsageDescription</key>
<string>L'application a besoin d'accéder à votre caméra pour prendre des photos de profil.</string>
<!-- Permission Galerie -->
<key>NSPhotoLibraryUsageDescription</key>
<string>L'application a besoin d'accéder à votre galerie pour sélectionner une photo de profil.</string>
<!-- Permission pour ajouter à la galerie (iOS 14+) -->
<key>NSPhotoLibraryAddUsageDescription</key>
<string>L'application a besoin de sauvegarder vos photos.</string>
</dict> </dict>
</plist> </plist>

View File

@@ -78,10 +78,13 @@ class UserModel {
/// User's phone number (optional). /// User's phone number (optional).
final String? phoneNumber; final String? phoneNumber;
/// User's profile picture URL (optional).
final String? profilePictureUrl;
/// Creates a new [UserModel] instance. /// Creates a new [UserModel] instance.
/// ///
/// [id], [email], and [prenom] are required fields. /// [id], [email], and [prenom] are required fields.
/// [nom], [authMethod], and [phoneNumber] are optional and can be null. /// [nom], [authMethod], [phoneNumber], and [profilePictureUrl] are optional and can be null.
UserModel({ UserModel({
required this.id, required this.id,
required this.email, required this.email,
@@ -89,6 +92,7 @@ class UserModel {
this.nom, this.nom,
this.authMethod, this.authMethod,
this.phoneNumber, this.phoneNumber,
this.profilePictureUrl,
}); });
/// Creates a [UserModel] instance from a JSON map. /// Creates a [UserModel] instance from a JSON map.
@@ -103,6 +107,7 @@ class UserModel {
nom: json['nom'], nom: json['nom'],
authMethod: json['authMethod'] ?? json['platform'], authMethod: json['authMethod'] ?? json['platform'],
phoneNumber: json['phoneNumber'], phoneNumber: json['phoneNumber'],
profilePictureUrl: json['profilePictureUrl'],
); );
} }
@@ -117,6 +122,7 @@ class UserModel {
'nom': nom, 'nom': nom,
'authMethod': authMethod, 'authMethod': authMethod,
'phoneNumber': phoneNumber, 'phoneNumber': phoneNumber,
'profilePictureUrl': profilePictureUrl,
}; };
} }
} }

View File

@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:travel_mate/components/widgets/user_state_widget.dart'; import 'package:travel_mate/components/widgets/user_state_widget.dart';
import 'package:travel_mate/services/error_service.dart'; import 'package:travel_mate/services/error_service.dart';
import 'package:image_picker/image_picker.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'dart:io';
import '../../../blocs/user/user_bloc.dart'; import '../../../blocs/user/user_bloc.dart';
import '../../../blocs/user/user_state.dart' as user_state; import '../../../blocs/user/user_state.dart' as user_state;
import '../../../blocs/user/user_event.dart' as user_event; import '../../../blocs/user/user_event.dart' as user_event;
@@ -40,7 +43,19 @@ class ProfileContent extends StatelessWidget {
) )
], ],
), ),
child: CircleAvatar( child: user.profilePictureUrl != null && user.profilePictureUrl!.isNotEmpty
? CircleAvatar(
radius: 50,
backgroundImage: NetworkImage(
user.profilePictureUrl!,
// Force le rechargement avec un paramètre de cache
headers: {
'pragma': 'no-cache',
'cache-control': 'no-cache',
},
),
)
: CircleAvatar(
radius: 50, radius: 50,
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.primary,
child: Text( child: Text(
@@ -367,26 +382,97 @@ class ProfileContent extends StatelessWidget {
void _showEditProfileDialog(BuildContext context, user_state.UserModel user) { void _showEditProfileDialog(BuildContext context, user_state.UserModel user) {
final nomController = TextEditingController(text: user.nom); final nomController = TextEditingController(text: user.nom);
final prenomController = TextEditingController(text: user.prenom); final prenomController = TextEditingController(text: user.prenom);
final phoneController = TextEditingController(text: user.phoneNumber ?? '');
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext dialogContext) { builder: (BuildContext dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog( return AlertDialog(
title: Text('Modifier le profil'), title: Text('Modifier le profil'),
content: Column( content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField( // Photo de profil avec option de changement
controller: prenomController, Center(
decoration: InputDecoration(labelText: 'Prénom'), child: Stack(
children: [
CircleAvatar(
radius: 50,
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(
prenomController.text.isNotEmpty
? prenomController.text[0].toUpperCase()
: 'U',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
Positioned(
bottom: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).colorScheme.primary,
),
child: IconButton(
icon: Icon(Icons.camera_alt, color: Colors.white, size: 20),
onPressed: () {
_showPhotoPickerDialog(dialogContext);
},
),
), ),
SizedBox(height: 16),
TextField(
controller: nomController,
decoration: InputDecoration(labelText: 'Nom'),
), ),
], ],
), ),
),
SizedBox(height: 24),
// Champ Prénom
TextField(
controller: prenomController,
decoration: InputDecoration(
labelText: 'Prénom',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_outline),
),
onChanged: (_) {
setState(() {});
},
),
SizedBox(height: 16),
// Champ Nom
TextField(
controller: nomController,
decoration: InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_outline),
),
),
SizedBox(height: 16),
// Champ Téléphone
TextField(
controller: phoneController,
decoration: InputDecoration(
labelText: 'Numéro de téléphone',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone_outlined),
hintText: '+33 6 12 34 56 78',
),
keyboardType: TextInputType.phone,
),
],
),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(dialogContext).pop(), onPressed: () => Navigator.of(dialogContext).pop(),
@@ -399,6 +485,7 @@ class ProfileContent extends StatelessWidget {
user_event.UserUpdated({ user_event.UserUpdated({
'prenom': prenomController.text.trim(), 'prenom': prenomController.text.trim(),
'nom': nomController.text.trim(), 'nom': nomController.text.trim(),
'phoneNumber': phoneController.text.trim(),
}), }),
); );
@@ -417,6 +504,163 @@ class ProfileContent extends StatelessWidget {
); );
}, },
); );
},
);
}
void _showPhotoPickerDialog(BuildContext context) {
// Récupérer les références AVANT que le modal ne se ferme
final userBloc = context.read<UserBloc>();
showModalBottomSheet(
context: context,
builder: (BuildContext sheetContext) {
return Container(
child: Wrap(
children: [
ListTile(
leading: Icon(Icons.photo_library),
title: Text('Galerie'),
onTap: () {
Navigator.pop(sheetContext);
_pickImageFromGallery(context, userBloc);
},
),
ListTile(
leading: Icon(Icons.camera_alt),
title: Text('Caméra'),
onTap: () {
Navigator.pop(sheetContext);
_pickImageFromCamera(context, userBloc);
},
),
ListTile(
leading: Icon(Icons.close),
title: Text('Annuler'),
onTap: () => Navigator.pop(sheetContext),
),
],
),
);
},
);
}
Future<void> _pickImageFromGallery(BuildContext context, UserBloc userBloc) async {
try {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.gallery);
if (image != null) {
await _uploadProfilePicture(context, image.path, userBloc);
}
} catch (e) {
_errorService.showError(message: 'Erreur lors de la sélection de l\'image');
}
}
Future<void> _pickImageFromCamera(BuildContext context, UserBloc userBloc) async {
try {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.camera);
if (image != null) {
await _uploadProfilePicture(context, image.path, userBloc);
}
} catch (e) {
_errorService.showError(message: 'Erreur lors de la prise de photo');
}
}
Future<void> _uploadProfilePicture(BuildContext context, String imagePath, UserBloc userBloc) async {
try {
final File imageFile = File(imagePath);
// Vérifier que le fichier existe
if (!await imageFile.exists()) {
_errorService.showError(message: 'Le fichier image n\'existe pas');
return;
}
print('DEBUG: Taille du fichier: ${imageFile.lengthSync()} bytes');
final userState = userBloc.state;
if (userState is! user_state.UserLoaded) {
_errorService.showError(message: 'Utilisateur non connecté');
return;
}
final user = userState.user;
// Créer un nom unique pour la photo
final String fileName = 'profile_${user.id}_${DateTime.now().millisecondsSinceEpoch}.jpg';
final Reference storageRef = FirebaseStorage.instance
.ref()
.child('profile_pictures')
.child(fileName);
print('DEBUG: Chemin Storage: ${storageRef.fullPath}');
print('DEBUG: Upload en cours pour $fileName');
// Uploader l'image avec gestion d'erreur détaillée
try {
final uploadTask = storageRef.putFile(imageFile);
// Écouter la progression
uploadTask.snapshotEvents.listen((TaskSnapshot snapshot) {
print('DEBUG: Progression: ${snapshot.bytesTransferred}/${snapshot.totalBytes}');
});
final snapshot = await uploadTask;
print('DEBUG: Upload terminé. État: ${snapshot.state}');
} on FirebaseException catch (e) {
print('DEBUG: FirebaseException lors de l\'upload: ${e.code} - ${e.message}');
if (context.mounted) {
_errorService.showError(
message: 'Erreur Firebase: ${e.code}\n${e.message}',
);
}
return;
}
print('DEBUG: Upload terminé, récupération de l\'URL');
// Récupérer l'URL
final String downloadUrl = await storageRef.getDownloadURL();
print('DEBUG: URL obtenue: $downloadUrl');
// Mettre à jour le profil avec l'URL en utilisant la référence sauvegardée du BLoC
print('DEBUG: Envoi de UserUpdated event au BLoC');
userBloc.add(
user_event.UserUpdated({
'profilePictureUrl': downloadUrl,
}),
);
// Attendre un peu que Firestore se mette à jour
await Future.delayed(Duration(milliseconds: 500));
if (context.mounted) {
print('DEBUG: Affichage du succès');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Photo de profil mise à jour !'),
backgroundColor: Colors.green,
),
);
}
} catch (e, stackTrace) {
print('DEBUG: Erreur lors de l\'upload: $e');
print('DEBUG: Stack trace: $stackTrace');
_errorService.logError(
'ProfileContent - _uploadProfilePicture',
'Erreur lors de l\'upload de la photo: $e\n$stackTrace',
);
if (context.mounted) {
_errorService.showError(message: 'Erreur: ${e.toString()}');
}
}
} }
void _showChangePasswordDialog( void _showChangePasswordDialog(