## Corrections Critiques ### Race Condition - Statuts de Messages - Fix : Les icônes de statut (✓, ✓✓, ✓✓ bleu) ne s'affichaient pas - Cause : WebSocket delivery confirmations arrivaient avant messages locaux - Solution : Pattern Optimistic UI dans chat_bloc.dart - Création message temporaire immédiate - Ajout à la liste AVANT requête HTTP - Remplacement par message serveur à la réponse - Fichier : lib/presentation/state_management/chat_bloc.dart ## Implémentation TODOs (13/21) ### Social (social_header_widget.dart) - ✅ Copier lien du post dans presse-papiers - ✅ Partage natif via Share.share() - ✅ Dialogue de signalement avec 5 raisons ### Partage (share_post_dialog.dart) - ✅ Interface sélection d'amis avec checkboxes - ✅ Partage externe via Share API ### Média (media_upload_service.dart) - ✅ Parsing JSON réponse backend - ✅ Méthode deleteMedia() pour suppression - ✅ Génération miniature vidéo ### Posts (create_post_dialog.dart, edit_post_dialog.dart) - ✅ Extraction URL depuis uploads - ✅ Documentation chargement médias ### Chat (conversations_screen.dart) - ✅ Navigation vers notifications - ✅ ConversationSearchDelegate pour recherche ## Nouveaux Fichiers ### Configuration - build-prod.ps1 : Script build production avec dart-define - lib/core/constants/env_config.dart : Gestion environnements ### Documentation - TODOS_IMPLEMENTED.md : Documentation complète TODOs ## Améliorations ### Architecture - Refactoring injection de dépendances - Amélioration routing et navigation - Optimisation providers (UserProvider, FriendsProvider) ### UI/UX - Amélioration thème et couleurs - Optimisation animations - Meilleure gestion erreurs ### Services - Configuration API avec env_config - Amélioration datasources (events, users) - Optimisation modèles de données
545 lines
19 KiB
Dart
545 lines
19 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../../core/constants/design_system.dart';
|
|
import '../../../data/datasources/user_remote_data_source.dart';
|
|
import '../../../data/models/user_model.dart';
|
|
import '../../../data/providers/user_provider.dart';
|
|
import '../../../data/services/preferences_helper.dart';
|
|
import '../../../data/services/secure_storage.dart';
|
|
import '../../../domain/entities/user.dart';
|
|
import '../../widgets/animated_widgets.dart';
|
|
import '../../widgets/custom_snackbar.dart';
|
|
|
|
/// Écran d'édition de profil utilisateur avec design moderne.
|
|
///
|
|
/// Permet à l'utilisateur de modifier:
|
|
/// - Prénom et nom
|
|
/// - Email
|
|
/// - Photo de profil
|
|
/// - Mot de passe
|
|
class EditProfileScreen extends StatefulWidget {
|
|
const EditProfileScreen({required this.user, super.key});
|
|
|
|
final User user;
|
|
|
|
@override
|
|
State<EditProfileScreen> createState() => _EditProfileScreenState();
|
|
}
|
|
|
|
class _EditProfileScreenState extends State<EditProfileScreen> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _firstNameController = TextEditingController();
|
|
final _lastNameController = TextEditingController();
|
|
final _emailController = TextEditingController();
|
|
|
|
final UserRemoteDataSource _dataSource = UserRemoteDataSource(http.Client());
|
|
final SecureStorage _secureStorage = SecureStorage();
|
|
final PreferencesHelper _preferencesHelper = PreferencesHelper();
|
|
final ImagePicker _imagePicker = ImagePicker();
|
|
|
|
bool _isLoading = false;
|
|
bool _hasChanges = false;
|
|
File? _newProfileImage;
|
|
String? _newProfileImageUrl;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_firstNameController.text = widget.user.userFirstName;
|
|
_lastNameController.text = widget.user.userLastName;
|
|
_emailController.text = widget.user.email;
|
|
|
|
// Écoute les changements pour activer/désactiver le bouton Sauvegarder
|
|
_firstNameController.addListener(_onFieldChanged);
|
|
_lastNameController.addListener(_onFieldChanged);
|
|
_emailController.addListener(_onFieldChanged);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_firstNameController.dispose();
|
|
_lastNameController.dispose();
|
|
_emailController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onFieldChanged() {
|
|
final hasChanges = _firstNameController.text != widget.user.userFirstName ||
|
|
_lastNameController.text != widget.user.userLastName ||
|
|
_emailController.text != widget.user.email ||
|
|
_newProfileImage != null;
|
|
|
|
if (hasChanges != _hasChanges) {
|
|
setState(() {
|
|
_hasChanges = hasChanges;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _pickImage() async {
|
|
try {
|
|
final XFile? pickedFile = await _imagePicker.pickImage(
|
|
source: ImageSource.gallery,
|
|
maxWidth: 1024,
|
|
maxHeight: 1024,
|
|
imageQuality: 85,
|
|
);
|
|
|
|
if (pickedFile != null) {
|
|
setState(() {
|
|
_newProfileImage = File(pickedFile.path);
|
|
_hasChanges = true;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
context.showError('Erreur lors de la sélection de l\'image');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _saveChanges() async {
|
|
if (!_formKey.currentState!.validate()) {
|
|
return;
|
|
}
|
|
|
|
if (!_hasChanges) {
|
|
context.showInfo('Aucune modification à enregistrer');
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
try {
|
|
// TODO: Si une nouvelle image est sélectionnée, l'uploader d'abord
|
|
// et récupérer l'URL depuis le backend
|
|
String profileImageUrl = widget.user.profileImageUrl;
|
|
if (_newProfileImage != null) {
|
|
// Pour l'instant, on utilise l'URL existante
|
|
// Dans une vraie implémentation, il faudrait uploader l'image
|
|
// vers le backend et récupérer la nouvelle URL
|
|
context.showWarning('L\'upload d\'image sera implémenté avec le backend');
|
|
}
|
|
|
|
// Créer le modèle utilisateur mis à jour
|
|
final updatedUser = UserModel(
|
|
userId: widget.user.userId,
|
|
userFirstName: _firstNameController.text.trim(),
|
|
userLastName: _lastNameController.text.trim(),
|
|
email: _emailController.text.trim(),
|
|
motDePasse: widget.user.motDePasse, // Mot de passe inchangé
|
|
profileImageUrl: profileImageUrl,
|
|
);
|
|
|
|
// Envoyer la mise à jour au backend
|
|
final result = await _dataSource.updateUser(updatedUser);
|
|
|
|
if (mounted) {
|
|
// Mettre à jour le provider
|
|
final userProvider = Provider.of<UserProvider>(context, listen: false);
|
|
userProvider.setUser(result.toEntity());
|
|
|
|
// Mettre à jour le stockage local
|
|
await _preferencesHelper.saveUserName(result.userFirstName);
|
|
await _preferencesHelper.saveUserLastName(result.userLastName);
|
|
|
|
setState(() {
|
|
_isLoading = false;
|
|
_hasChanges = false;
|
|
});
|
|
|
|
context.showSuccess('Profil mis à jour avec succès');
|
|
Navigator.pop(context, true); // Retourne true pour indiquer la modification
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
context.showError('Erreur lors de la mise à jour: ${e.toString()}');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _changePassword() async {
|
|
final result = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => const _ChangePasswordDialog(),
|
|
);
|
|
|
|
if (result == true && mounted) {
|
|
context.showSuccess('Mot de passe modifié avec succès');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Modifier le profil'),
|
|
actions: [
|
|
if (_hasChanges && !_isLoading)
|
|
TextButton(
|
|
onPressed: _saveChanges,
|
|
child: const Text(
|
|
'SAUVEGARDER',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: _isLoading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(DesignSystem.spacingLg),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Photo de profil
|
|
Center(
|
|
child: Stack(
|
|
children: [
|
|
Hero(
|
|
tag: 'user_profile_avatar_${widget.user.userId}',
|
|
child: CircleAvatar(
|
|
radius: 60,
|
|
backgroundImage: _newProfileImage != null
|
|
? FileImage(_newProfileImage!)
|
|
: NetworkImage(widget.user.profileImageUrl) as ImageProvider,
|
|
child: _newProfileImage == null &&
|
|
widget.user.profileImageUrl.isEmpty
|
|
? const Icon(Icons.person, size: 60)
|
|
: null,
|
|
),
|
|
),
|
|
Positioned(
|
|
right: 0,
|
|
bottom: 0,
|
|
child: AnimatedScaleButton(
|
|
onTap: _pickImage,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.primary,
|
|
shape: BoxShape.circle,
|
|
boxShadow: DesignSystem.shadowMd,
|
|
),
|
|
child: const Icon(
|
|
Icons.camera_alt,
|
|
color: Colors.white,
|
|
size: 20,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: DesignSystem.spacing2xl),
|
|
|
|
// Prénom
|
|
TextFormField(
|
|
controller: _firstNameController,
|
|
decoration: InputDecoration(
|
|
labelText: 'Prénom',
|
|
prefixIcon: const Icon(Icons.person),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
|
|
),
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Le prénom est requis';
|
|
}
|
|
if (value.trim().length < 2) {
|
|
return 'Le prénom doit contenir au moins 2 caractères';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: DesignSystem.spacingLg),
|
|
|
|
// Nom
|
|
TextFormField(
|
|
controller: _lastNameController,
|
|
decoration: InputDecoration(
|
|
labelText: 'Nom',
|
|
prefixIcon: const Icon(Icons.person_outline),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
|
|
),
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Le nom est requis';
|
|
}
|
|
if (value.trim().length < 2) {
|
|
return 'Le nom doit contenir au moins 2 caractères';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: DesignSystem.spacingLg),
|
|
|
|
// Email
|
|
TextFormField(
|
|
controller: _emailController,
|
|
keyboardType: TextInputType.emailAddress,
|
|
decoration: InputDecoration(
|
|
labelText: 'Email',
|
|
prefixIcon: const Icon(Icons.email),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
|
|
),
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'L\'email est requis';
|
|
}
|
|
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
|
if (!emailRegex.hasMatch(value.trim())) {
|
|
return 'Email invalide';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: DesignSystem.spacing2xl),
|
|
|
|
// Bouton Changer le mot de passe
|
|
OutlinedButton.icon(
|
|
onPressed: _changePassword,
|
|
icon: const Icon(Icons.lock),
|
|
label: const Text('Changer le mot de passe'),
|
|
style: OutlinedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: DesignSystem.spacingLg),
|
|
|
|
// Bouton Sauvegarder (grand bouton en bas)
|
|
if (_hasChanges)
|
|
FadeInWidget(
|
|
child: ElevatedButton(
|
|
onPressed: _isLoading ? null : _saveChanges,
|
|
style: ElevatedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
|
|
),
|
|
),
|
|
child: _isLoading
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.white,
|
|
),
|
|
)
|
|
: const Text(
|
|
'Enregistrer les modifications',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Dialogue pour changer le mot de passe
|
|
class _ChangePasswordDialog extends StatefulWidget {
|
|
const _ChangePasswordDialog();
|
|
|
|
@override
|
|
State<_ChangePasswordDialog> createState() => _ChangePasswordDialogState();
|
|
}
|
|
|
|
class _ChangePasswordDialogState extends State<_ChangePasswordDialog> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _currentPasswordController = TextEditingController();
|
|
final _newPasswordController = TextEditingController();
|
|
final _confirmPasswordController = TextEditingController();
|
|
|
|
bool _isLoading = false;
|
|
bool _obscureCurrentPassword = true;
|
|
bool _obscureNewPassword = true;
|
|
bool _obscureConfirmPassword = true;
|
|
|
|
@override
|
|
void dispose() {
|
|
_currentPasswordController.dispose();
|
|
_newPasswordController.dispose();
|
|
_confirmPasswordController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _changePassword() async {
|
|
if (!_formKey.currentState!.validate()) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
try {
|
|
// TODO: Appel API pour changer le mot de passe
|
|
// await _dataSource.changePassword(
|
|
// currentPassword: _currentPasswordController.text,
|
|
// newPassword: _newPasswordController.text,
|
|
// );
|
|
|
|
// Simulation pour l'instant
|
|
await Future.delayed(const Duration(seconds: 1));
|
|
|
|
if (mounted) {
|
|
Navigator.pop(context, true);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
context.showError('Erreur: ${e.toString()}');
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Changer le mot de passe'),
|
|
content: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Mot de passe actuel
|
|
TextFormField(
|
|
controller: _currentPasswordController,
|
|
obscureText: _obscureCurrentPassword,
|
|
decoration: InputDecoration(
|
|
labelText: 'Mot de passe actuel',
|
|
prefixIcon: const Icon(Icons.lock_outline),
|
|
suffixIcon: IconButton(
|
|
icon: Icon(
|
|
_obscureCurrentPassword ? Icons.visibility : Icons.visibility_off,
|
|
),
|
|
onPressed: () {
|
|
setState(() {
|
|
_obscureCurrentPassword = !_obscureCurrentPassword;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Mot de passe requis';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: DesignSystem.spacingLg),
|
|
|
|
// Nouveau mot de passe
|
|
TextFormField(
|
|
controller: _newPasswordController,
|
|
obscureText: _obscureNewPassword,
|
|
decoration: InputDecoration(
|
|
labelText: 'Nouveau mot de passe',
|
|
prefixIcon: const Icon(Icons.lock),
|
|
suffixIcon: IconButton(
|
|
icon: Icon(
|
|
_obscureNewPassword ? Icons.visibility : Icons.visibility_off,
|
|
),
|
|
onPressed: () {
|
|
setState(() {
|
|
_obscureNewPassword = !_obscureNewPassword;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Nouveau mot de passe requis';
|
|
}
|
|
if (value.length < 8) {
|
|
return 'Minimum 8 caractères';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: DesignSystem.spacingLg),
|
|
|
|
// Confirmer nouveau mot de passe
|
|
TextFormField(
|
|
controller: _confirmPasswordController,
|
|
obscureText: _obscureConfirmPassword,
|
|
decoration: InputDecoration(
|
|
labelText: 'Confirmer le mot de passe',
|
|
prefixIcon: const Icon(Icons.lock),
|
|
suffixIcon: IconButton(
|
|
icon: Icon(
|
|
_obscureConfirmPassword ? Icons.visibility : Icons.visibility_off,
|
|
),
|
|
onPressed: () {
|
|
setState(() {
|
|
_obscureConfirmPassword = !_obscureConfirmPassword;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Confirmation requise';
|
|
}
|
|
if (value != _newPasswordController.text) {
|
|
return 'Les mots de passe ne correspondent pas';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _isLoading ? null : () => Navigator.pop(context),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: _isLoading ? null : _changePassword,
|
|
child: _isLoading
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('Changer'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|