fix(chat): Correction race condition + Implémentation TODOs

## 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
This commit is contained in:
dahoud
2026-01-10 10:43:17 +00:00
parent 06031b01f2
commit 92612abbd7
321 changed files with 43137 additions and 4285 deletions

View File

@@ -0,0 +1,544 @@
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'),
),
],
);
}
}