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_FINE_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
android:label="travel_mate"
android:name="${applicationName}"

View File

@@ -73,5 +73,16 @@
</array>
</dict>
</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>
</plist>

View File

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

View File

@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:travel_mate/components/widgets/user_state_widget.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_state.dart' as user_state;
import '../../../blocs/user/user_event.dart' as user_event;
@@ -40,20 +43,32 @@ class ProfileContent extends StatelessWidget {
)
],
),
child: CircleAvatar(
radius: 50,
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(
user.prenom.isNotEmpty
? user.prenom[0].toUpperCase()
: 'U',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
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,
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(
user.prenom.isNotEmpty
? user.prenom[0].toUpperCase()
: 'U',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
SizedBox(height: 16),
@@ -367,58 +382,287 @@ class ProfileContent extends StatelessWidget {
void _showEditProfileDialog(BuildContext context, user_state.UserModel user) {
final nomController = TextEditingController(text: user.nom);
final prenomController = TextEditingController(text: user.prenom);
final phoneController = TextEditingController(text: user.phoneNumber ?? '');
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: Text('Modifier le profil'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: prenomController,
decoration: InputDecoration(labelText: 'Prénom'),
),
SizedBox(height: 16),
TextField(
controller: nomController,
decoration: InputDecoration(labelText: 'Nom'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text('Annuler'),
),
TextButton(
onPressed: () {
if (prenomController.text.trim().isNotEmpty) {
context.read<UserBloc>().add(
user_event.UserUpdated({
'prenom': prenomController.text.trim(),
'nom': nomController.text.trim(),
}),
);
Navigator.of(dialogContext).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Profil mis à jour !'),
backgroundColor: Colors.green,
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: Text('Modifier le profil'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Photo de profil avec option de changement
Center(
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);
},
),
),
),
],
),
),
);
}
},
child: Text('Sauvegarder'),
),
],
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: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text('Annuler'),
),
TextButton(
onPressed: () {
if (prenomController.text.trim().isNotEmpty) {
context.read<UserBloc>().add(
user_event.UserUpdated({
'prenom': prenomController.text.trim(),
'nom': nomController.text.trim(),
'phoneNumber': phoneController.text.trim(),
}),
);
Navigator.of(dialogContext).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Profil mis à jour !'),
backgroundColor: Colors.green,
),
);
}
},
child: Text('Sauvegarder'),
),
],
);
},
);
},
);
}
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(
BuildContext context,
user_state.UserModel user,