feat: Livraison version 3.0.6

- Amélioration de la gestion des entités et des utilisateurs
- Mise à jour des modèles Amicale et Client avec champs supplémentaires
- Ajout du service de logging et amélioration du chargement UI
- Refactoring des formulaires utilisateur et amicale
- Intégration de file_picker et image_picker pour la gestion des fichiers
- Amélioration de la gestion des membres et de leur suppression
- Optimisation des performances de l'API
- Mise à jour de la documentation technique

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-08-08 20:33:54 +02:00
parent 599b9fcda0
commit 206c76c7db
69 changed files with 203569 additions and 174972 deletions

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/services/theme_service.dart';
import 'package:go_router/go_router.dart';
@@ -44,6 +45,17 @@ class GeosectorApp extends StatelessWidget {
themeMode: themeService.themeMode,
routerConfig: _createRouter(),
debugShowCheckedModeBanner: false,
// Configuration des localisations pour le français
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('fr', 'FR'), // Français comme langue principale
Locale('en', 'US'), // Anglais en fallback
],
locale: const Locale('fr', 'FR'), // Forcer le français par défaut
);
},
);

View File

@@ -70,6 +70,15 @@ class AmicaleModel extends HiveObject {
@HiveField(21)
final DateTime? updatedAt;
@HiveField(22)
final bool chkMdpManuel;
@HiveField(23)
final bool chkUsernameManuel;
@HiveField(24)
final String? logoBase64; // Logo en base64 (data:image/png;base64,...)
AmicaleModel({
required this.id,
required this.name,
@@ -93,6 +102,9 @@ class AmicaleModel extends HiveObject {
this.chkStripe = false,
this.createdAt,
this.updatedAt,
this.chkMdpManuel = false,
this.chkUsernameManuel = false,
this.logoBase64,
});
// Factory pour convertir depuis JSON (API)
@@ -123,6 +135,17 @@ class AmicaleModel extends HiveObject {
json['chk_active'] == 1 || json['chk_active'] == true;
final bool chkStripe =
json['chk_stripe'] == 1 || json['chk_stripe'] == true;
final bool chkMdpManuel =
json['chk_mdp_manuel'] == 1 || json['chk_mdp_manuel'] == true;
final bool chkUsernameManuel =
json['chk_username_manuel'] == 1 || json['chk_username_manuel'] == true;
// Traiter le logo si présent
String? logoBase64;
if (json['logo'] != null && json['logo'] is Map) {
final logoData = json['logo'] as Map<String, dynamic>;
logoBase64 = logoData['data_url'] as String?;
}
// Traiter les dates si présentes
DateTime? createdAt;
@@ -166,6 +189,9 @@ class AmicaleModel extends HiveObject {
chkStripe: chkStripe,
createdAt: createdAt,
updatedAt: updatedAt,
chkMdpManuel: chkMdpManuel,
chkUsernameManuel: chkUsernameManuel,
logoBase64: logoBase64,
);
}
@@ -194,6 +220,9 @@ class AmicaleModel extends HiveObject {
'chk_stripe': chkStripe ? 1 : 0,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
'chk_mdp_manuel': chkMdpManuel ? 1 : 0,
'chk_username_manuel': chkUsernameManuel ? 1 : 0,
// Note: logoBase64 n'est pas envoyé via toJson (lecture seule depuis l'API)
};
}
@@ -220,6 +249,9 @@ class AmicaleModel extends HiveObject {
bool? chkStripe,
DateTime? createdAt,
DateTime? updatedAt,
bool? chkMdpManuel,
bool? chkUsernameManuel,
String? logoBase64,
}) {
return AmicaleModel(
id: id,
@@ -244,6 +276,9 @@ class AmicaleModel extends HiveObject {
chkStripe: chkStripe ?? this.chkStripe,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
chkMdpManuel: chkMdpManuel ?? this.chkMdpManuel,
chkUsernameManuel: chkUsernameManuel ?? this.chkUsernameManuel,
logoBase64: logoBase64 ?? this.logoBase64,
);
}
}

View File

@@ -39,13 +39,16 @@ class AmicaleModelAdapter extends TypeAdapter<AmicaleModel> {
chkStripe: fields[19] as bool,
createdAt: fields[20] as DateTime?,
updatedAt: fields[21] as DateTime?,
chkMdpManuel: fields[22] as bool,
chkUsernameManuel: fields[23] as bool,
logoBase64: fields[24] as String?,
);
}
@override
void write(BinaryWriter writer, AmicaleModel obj) {
writer
..writeByte(22)
..writeByte(25)
..writeByte(0)
..write(obj.id)
..writeByte(1)
@@ -89,7 +92,13 @@ class AmicaleModelAdapter extends TypeAdapter<AmicaleModel> {
..writeByte(20)
..write(obj.createdAt)
..writeByte(21)
..write(obj.updatedAt);
..write(obj.updatedAt)
..writeByte(22)
..write(obj.chkMdpManuel)
..writeByte(23)
..write(obj.chkUsernameManuel)
..writeByte(24)
..write(obj.logoBase64);
}
@override

View File

@@ -70,6 +70,12 @@ class ClientModel extends HiveObject {
@HiveField(21)
final DateTime? updatedAt;
@HiveField(22)
final bool? chkMdpManuel;
@HiveField(23)
final bool? chkUsernameManuel;
ClientModel({
required this.id,
required this.name,
@@ -93,6 +99,8 @@ class ClientModel extends HiveObject {
this.chkStripe,
this.createdAt,
this.updatedAt,
this.chkMdpManuel,
this.chkUsernameManuel,
});
// Factory pour convertir depuis JSON (API)
@@ -138,6 +146,8 @@ class ClientModel extends HiveObject {
chkStripe: json['chk_stripe'] == 1 || json['chk_stripe'] == true,
createdAt: json['created_at'] != null ? DateTime.parse(json['created_at']) : null,
updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at']) : null,
chkMdpManuel: json['chk_mdp_manuel'] == 1 || json['chk_mdp_manuel'] == true,
chkUsernameManuel: json['chk_username_manuel'] == 1 || json['chk_username_manuel'] == true,
);
}
@@ -166,6 +176,8 @@ class ClientModel extends HiveObject {
'chk_stripe': chkStripe,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
'chk_mdp_manuel': chkMdpManuel,
'chk_username_manuel': chkUsernameManuel,
};
}
@@ -192,6 +204,8 @@ class ClientModel extends HiveObject {
bool? chkStripe,
DateTime? createdAt,
DateTime? updatedAt,
bool? chkMdpManuel,
bool? chkUsernameManuel,
}) {
return ClientModel(
id: id,
@@ -216,6 +230,8 @@ class ClientModel extends HiveObject {
chkStripe: chkStripe ?? this.chkStripe,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
chkMdpManuel: chkMdpManuel ?? this.chkMdpManuel,
chkUsernameManuel: chkUsernameManuel ?? this.chkUsernameManuel,
);
}
}

View File

@@ -39,13 +39,15 @@ class ClientModelAdapter extends TypeAdapter<ClientModel> {
chkStripe: fields[19] as bool?,
createdAt: fields[20] as DateTime?,
updatedAt: fields[21] as DateTime?,
chkMdpManuel: fields[22] as bool?,
chkUsernameManuel: fields[23] as bool?,
);
}
@override
void write(BinaryWriter writer, ClientModel obj) {
writer
..writeByte(22)
..writeByte(24)
..writeByte(0)
..write(obj.id)
..writeByte(1)
@@ -89,7 +91,11 @@ class ClientModelAdapter extends TypeAdapter<ClientModel> {
..writeByte(20)
..write(obj.createdAt)
..writeByte(21)
..write(obj.updatedAt);
..write(obj.updatedAt)
..writeByte(22)
..write(obj.chkMdpManuel)
..writeByte(23)
..write(obj.chkUsernameManuel);
}
@override

View File

@@ -3,6 +3,8 @@ import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/services/logger_service.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
class MembreRepository extends ChangeNotifier {
@@ -19,7 +21,7 @@ class MembreRepository extends ChangeNotifier {
throw Exception('La boîte ${AppKeys.membresBoxName} n\'est pas ouverte. Initialisez d\'abord l\'application.');
}
_cachedMembreBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
debugPrint('MembreRepository: Box ${AppKeys.membresBoxName} mise en cache');
LoggerService.database('MembreRepository: Box ${AppKeys.membresBoxName} mise en cache');
}
return _cachedMembreBox!;
}
@@ -120,7 +122,7 @@ class MembreRepository extends ChangeNotifier {
// === MÉTHODES API ===
// Créer un membre via l'API
Future<MembreModel?> createMembre(MembreModel membre) async {
Future<MembreModel?> createMembre(MembreModel membre, {String? password}) async {
_isLoading = true;
notifyListeners();
@@ -130,48 +132,97 @@ class MembreRepository extends ChangeNotifier {
final data = userModel.toJson();
data.remove('id'); // L'API génère l'ID
data.remove('created_at'); // L'API génère created_at
// Supprimer les champs de session qui ne doivent pas être envoyés
data.remove('session_id');
data.remove('session_expiry');
data.remove('last_path');
// Convertir is_active en chk_active pour l'API
if (data.containsKey('is_active')) {
data['chk_active'] = data['is_active'] ? 1 : 0;
data.remove('is_active');
}
// Convertir role en fk_role pour l'API
if (data.containsKey('role')) {
data['fk_role'] = data['role'];
data.remove('role');
}
// Ajouter le mot de passe si fourni (sera ignoré par l'API si chk_mdp_manuel=0)
if (password != null && password.isNotEmpty) {
data['password'] = password;
debugPrint('🔑 Mot de passe inclus dans la requête');
} else {
debugPrint('⚠️ Pas de mot de passe fourni');
}
// Vérifier la présence de l'username (sera ignoré par l'API si chk_username_manuel=0)
if (data.containsKey('username') && data['username'] != null && data['username'].toString().isNotEmpty) {
debugPrint('👤 Username inclus dans la requête: ${data['username']}');
} else {
debugPrint('⚠️ Username manquant ou vide dans la requête');
// Si pas d'username, s'assurer qu'il n'est pas envoyé du tout
data.remove('username');
}
LoggerService.api('Données envoyées à l\'API pour création membre: $data');
// Appeler l'API users
final response = await ApiService.instance.post('/users', data: data);
if (response.statusCode == 201) {
// Extraire l'ID de la réponse API
final responseData = response.data;
debugPrint('🎉 Réponse API création utilisateur: $responseData');
// Vérifier d'abord si on a une réponse avec un statut d'erreur
if (response.data != null && response.data is Map<String, dynamic>) {
final responseData = response.data as Map<String, dynamic>;
// Si l'API retourne un status error, propager le message
if (responseData['status'] == 'error' && responseData['message'] != null) {
throw Exception(responseData['message']);
}
// Si succès avec code 201
if (response.statusCode == 201 && responseData['status'] == 'success') {
debugPrint('🎉 Réponse API création utilisateur: $responseData');
// L'API retourne {"status":"success","message":"Utilisateur créé avec succès","id":"10027748"}
final userId = responseData['id'] is String ? int.parse(responseData['id']) : responseData['id'] as int;
// L'API retourne {"status":"success","message":"Utilisateur créé avec succès","id":"10027748"}
final userId = responseData['id'] is String ? int.parse(responseData['id']) : responseData['id'] as int;
// Créer le nouveau membre avec l'ID retourné par l'API
final createdMember = MembreModel(
id: userId,
fkEntite: membre.fkEntite,
role: membre.role,
fkTitre: membre.fkTitre,
name: membre.name,
firstName: membre.firstName,
username: membre.username,
sectName: membre.sectName,
email: membre.email,
phone: membre.phone,
mobile: membre.mobile,
dateNaissance: membre.dateNaissance,
dateEmbauche: membre.dateEmbauche,
createdAt: DateTime.now(),
isActive: membre.isActive,
);
// Créer le nouveau membre avec l'ID retourné par l'API
final createdMember = MembreModel(
id: userId,
fkEntite: membre.fkEntite,
role: membre.role,
fkTitre: membre.fkTitre,
name: membre.name,
firstName: membre.firstName,
username: membre.username,
sectName: membre.sectName,
email: membre.email,
phone: membre.phone,
mobile: membre.mobile,
dateNaissance: membre.dateNaissance,
dateEmbauche: membre.dateEmbauche,
createdAt: DateTime.now(),
isActive: membre.isActive,
);
// Sauvegarder localement dans Hive (saveMembreBox gère déjà _resetCache)
await saveMembreBox(createdMember);
// Sauvegarder localement dans Hive (saveMembreBox gère déjà _resetCache)
await saveMembreBox(createdMember);
debugPrint('✅ Membre créé avec l\'ID: $userId et sauvegardé localement');
return createdMember;
debugPrint('✅ Membre créé avec l\'ID: $userId et sauvegardé localement');
return createdMember;
}
}
debugPrint('Échec création membre - Code: ${response.statusCode}');
return null;
LoggerService.error('Échec création membre - Code: ${response.statusCode}');
throw Exception('Erreur lors de la création du membre');
} catch (e) {
debugPrint('❌ Erreur lors de la création du membre: $e');
// Ne pas logger les détails techniques de DioException
if (e is ApiException) {
LoggerService.error('Erreur lors de la création du membre: ${e.message}');
} else {
LoggerService.error('Erreur lors de la création du membre');
}
rethrow; // Propager l'exception pour la gestion d'erreurs
} finally {
_isLoading = false;
@@ -180,27 +231,67 @@ class MembreRepository extends ChangeNotifier {
}
// Mettre à jour un membre via l'API
Future<bool> updateMembre(MembreModel membre) async {
Future<bool> updateMembre(MembreModel membre, {String? password}) async {
_isLoading = true;
notifyListeners();
try {
// Convertir en UserModel pour l'API
final userModel = membre.toUserModel();
final data = userModel.toJson();
// Supprimer les champs de session qui ne doivent pas être envoyés
data.remove('session_id');
data.remove('session_expiry');
data.remove('last_path');
// Convertir is_active en chk_active pour l'API
if (data.containsKey('is_active')) {
data['chk_active'] = data['is_active'] ? 1 : 0;
data.remove('is_active');
}
// Convertir role en fk_role pour l'API
if (data.containsKey('role')) {
data['fk_role'] = data['role'];
data.remove('role');
}
// Ajouter le mot de passe si fourni (sera ignoré par l'API si chk_mdp_manuel=0)
if (password != null && password.isNotEmpty) {
data['password'] = password;
debugPrint('🔑 Mot de passe inclus dans la requête de mise à jour');
} else {
debugPrint('⚠️ Pas de mot de passe fourni pour la mise à jour');
}
// L'username ne devrait pas être modifiable en update, mais on le garde pour l'API
if (data.containsKey('username') && data['username'] != null && data['username'].toString().isNotEmpty) {
debugPrint('👤 Username présent dans la requête de mise à jour: ${data['username']}');
} else {
debugPrint('⚠️ Username manquant dans la requête de mise à jour');
}
LoggerService.api('Données envoyées à l\'API pour mise à jour membre: $data');
// Appeler l'API users au lieu de membres
final response = await ApiService.instance.put('/users/${membre.id}', data: userModel.toJson());
final response = await ApiService.instance.put('/users/${membre.id}', data: data);
if (response.statusCode == 200) {
// Sauvegarder le membre mis à jour localement
await saveMembreBox(membre);
return true;
}
return false;
// Si on arrive ici, c'est que la requête a réussi (200)
// Sauvegarder le membre mis à jour localement
await saveMembreBox(membre);
return true;
} catch (e) {
debugPrint('Erreur lors de la mise à jour du membre: $e');
return false;
// Ne pas logger les détails techniques de DioException
if (e is ApiException) {
LoggerService.error('Erreur lors de la mise à jour du membre: ${e.message}');
} else {
LoggerService.error('Erreur lors de la mise à jour du membre');
}
// Si c'est une DioException, elle sera automatiquement convertie en ApiException
// par ApiException.fromDioException() qui extrait le message de l'API
rethrow; // Propager l'exception pour que le message d'erreur soit affiché
} finally {
_isLoading = false;
notifyListeners();
@@ -215,13 +306,26 @@ class MembreRepository extends ChangeNotifier {
try {
final response = await ApiService.instance.post('/users/$membreId/reset-password');
// Vérifier si on a une réponse avec un statut d'erreur
if (response.data != null && response.data is Map<String, dynamic>) {
final responseData = response.data as Map<String, dynamic>;
if (responseData['status'] == 'error' && responseData['message'] != null) {
throw Exception(responseData['message']);
}
}
if (response.statusCode == 200) {
return true;
}
return false;
throw Exception('Erreur lors de la réinitialisation du mot de passe');
} catch (e) {
debugPrint('Erreur lors de la réinitialisation du mot de passe: $e');
// Ne pas logger les détails techniques de DioException
if (e is ApiException) {
LoggerService.error('Erreur lors de la réinitialisation du mot de passe: ${e.message}');
} else {
LoggerService.error('Erreur lors de la réinitialisation du mot de passe');
}
rethrow;
} finally {
_isLoading = false;
@@ -254,19 +358,32 @@ class MembreRepository extends ChangeNotifier {
endpoint += '?${queryParams.join('&')}';
}
debugPrint('🔗 DELETE endpoint: $endpoint');
LoggerService.api('DELETE endpoint: $endpoint');
final response = await ApiService.instance.delete(endpoint);
// Vérifier si on a une réponse avec un statut d'erreur
if (response.data != null && response.data is Map<String, dynamic>) {
final responseData = response.data as Map<String, dynamic>;
if (responseData['status'] == 'error' && responseData['message'] != null) {
throw Exception(responseData['message']);
}
}
if (response.statusCode == 200 || response.statusCode == 204) {
// Supprimer le membre localement
await deleteMembreBox(membreId);
return true;
}
return false;
throw Exception('Erreur lors de la suppression du membre');
} catch (e) {
debugPrint('Erreur lors de la suppression du membre: $e');
// Ne pas logger les détails techniques de DioException
if (e is ApiException) {
LoggerService.error('Erreur lors de la suppression du membre: ${e.message}');
} else {
LoggerService.error('Erreur lors de la suppression du membre');
}
rethrow;
} finally {
_isLoading = false;

View File

@@ -15,12 +15,10 @@ import 'package:geosector_app/core/data/models/operation_model.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/presentation/widgets/loading_progress_overlay.dart';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
import 'package:geosector_app/core/models/loading_state.dart';
class UserRepository extends ChangeNotifier {
// Overlay pour afficher la progression du chargement
OverlayEntry? _progressOverlay;
bool _isLoading = false;
// Constructeur simplifié - plus d'injection d'ApiService
@@ -336,64 +334,39 @@ class UserRepository extends ChangeNotifier {
}
}
/// Connexion avec interface utilisateur et progression
Future<bool> loginWithUI(
/// Connexion avec spinner moderne (pour remplacer la barre de progression)
Future<bool> loginWithSpinner(
BuildContext context, String username, String password,
{required String type}) async {
OverlayEntry? spinOverlay;
try {
// Créer et afficher l'overlay de progression
_progressOverlay = LoadingProgressOverlayUtils.show(
// Déterminer le message selon le type de connexion
final message = type == 'admin'
? 'Connexion administrateur...'
: 'Connexion utilisateur...';
// Afficher le spinner moderne
spinOverlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Connexion en cours...',
progress: 0.0,
stepDescription: 'Préparation',
blurAmount: 5.0,
message: message,
blurAmount: 10.0,
showCard: true,
);
// Écouter les changements d'état du DataLoadingService
void listener() {
if (_progressOverlay != null) {
final loadingState = DataLoadingService.instance.loadingState;
LoadingProgressOverlayUtils.update(
overlayEntry: _progressOverlay!,
message: loadingState.message,
progress: loadingState.progress,
stepDescription: loadingState.stepDescription,
);
}
}
// Configurer le callback de progression
DataLoadingService.instance.setProgressCallback((_) => listener());
// Exécuter la connexion
final result = await login(username, password, type: type);
// Attendre un court instant pour que l'utilisateur voie le résultat
if (result) {
await Future.delayed(const Duration(milliseconds: 500));
} else {
await Future.delayed(const Duration(seconds: 2));
}
// Petit délai pour une meilleure UX
await Future.delayed(const Duration(milliseconds: 300));
// Supprimer l'overlay
if (_progressOverlay != null) {
_progressOverlay!.remove();
_progressOverlay = null;
}
// Nettoyer le callback
DataLoadingService.instance.setProgressCallback(null);
// Fermer le spinner
LoadingSpinOverlayUtils.hideSpecific(spinOverlay);
return result;
} catch (e) {
// En cas d'erreur, supprimer l'overlay
if (_progressOverlay != null) {
_progressOverlay!.remove();
_progressOverlay = null;
}
DataLoadingService.instance.setProgressCallback(null);
LoadingSpinOverlayUtils.hideSpecific(spinOverlay);
return false;
}
}

View File

@@ -18,6 +18,10 @@ class ApiService {
late final String _baseUrl;
late final String _appIdentifier;
String? _sessionId;
// Getters pour les propriétés (lecture seule)
String? get sessionId => _sessionId;
String get baseUrl => _baseUrl;
// Singleton thread-safe
static ApiService get instance {
@@ -142,8 +146,11 @@ class ApiService {
Future<Response> post(String path, {dynamic data}) async {
try {
return await _dio.post(path, data: data);
} on DioException catch (e) {
throw ApiException.fromDioException(e);
} catch (e) {
rethrow;
if (e is ApiException) rethrow;
throw ApiException('Erreur inattendue lors de la requête POST', originalError: e);
}
}
@@ -151,8 +158,11 @@ class ApiService {
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
try {
return await _dio.get(path, queryParameters: queryParameters);
} on DioException catch (e) {
throw ApiException.fromDioException(e);
} catch (e) {
rethrow;
if (e is ApiException) rethrow;
throw ApiException('Erreur inattendue lors de la requête GET', originalError: e);
}
}
@@ -160,8 +170,11 @@ class ApiService {
Future<Response> put(String path, {dynamic data}) async {
try {
return await _dio.put(path, data: data);
} on DioException catch (e) {
throw ApiException.fromDioException(e);
} catch (e) {
rethrow;
if (e is ApiException) rethrow;
throw ApiException('Erreur inattendue lors de la requête PUT', originalError: e);
}
}
@@ -169,8 +182,81 @@ class ApiService {
Future<Response> delete(String path) async {
try {
return await _dio.delete(path);
} on DioException catch (e) {
throw ApiException.fromDioException(e);
} catch (e) {
rethrow;
if (e is ApiException) rethrow;
throw ApiException('Erreur inattendue lors de la requête DELETE', originalError: e);
}
}
// Méthode pour uploader un logo d'amicale
Future<Map<String, dynamic>> uploadLogo(int entiteId, dynamic imageFile) async {
try {
FormData formData;
// Gestion différente selon la plateforme (Web ou Mobile)
if (kIsWeb) {
// Pour le web, imageFile est un XFile
final bytes = await imageFile.readAsBytes();
// Vérification de la taille (5 Mo max)
const int maxSize = 5 * 1024 * 1024;
if (bytes.length > maxSize) {
throw ApiException(
'Le fichier est trop volumineux. Taille maximale: 5 Mo',
statusCode: 413
);
}
formData = FormData.fromMap({
'logo': MultipartFile.fromBytes(
bytes,
filename: imageFile.name,
),
});
} else {
// Pour mobile, imageFile est un File
final fileLength = await imageFile.length();
// Vérification de la taille (5 Mo max)
const int maxSize = 5 * 1024 * 1024;
if (fileLength > maxSize) {
throw ApiException(
'Le fichier est trop volumineux. Taille maximale: 5 Mo',
statusCode: 413
);
}
formData = FormData.fromMap({
'logo': await MultipartFile.fromFile(
imageFile.path,
filename: imageFile.path.split('/').last,
),
});
}
final response = await _dio.post(
'/entites/$entiteId/logo',
data: formData,
options: Options(
headers: {
'Content-Type': 'multipart/form-data',
},
),
);
if (response.statusCode == 200 || response.statusCode == 201) {
return response.data;
} else {
throw ApiException('Erreur lors de l\'upload du logo',
statusCode: response.statusCode);
}
} on DioException catch (e) {
throw ApiException.fromDioException(e);
} catch (e) {
if (e is ApiException) rethrow;
throw ApiException('Erreur inattendue lors de l\'upload du logo', originalError: e);
}
}

View File

@@ -0,0 +1,163 @@
import 'package:flutter/foundation.dart';
import 'package:geosector_app/core/services/api_service.dart';
/// Service de logging centralisé qui désactive automatiquement les logs en production
class LoggerService {
static LoggerService? _instance;
static bool? _isProduction;
// Singleton
static LoggerService get instance {
_instance ??= LoggerService._internal();
return _instance!;
}
LoggerService._internal();
/// Détermine si on est en production
static bool get isProduction {
if (_isProduction != null) return _isProduction!;
try {
// Vérifier si ApiService est initialisé
final env = ApiService.instance.getCurrentEnvironment();
_isProduction = env == 'PROD';
} catch (e) {
// Si ApiService n'est pas encore initialisé, utiliser kReleaseMode
_isProduction = kReleaseMode;
}
return _isProduction!;
}
/// Réinitialiser le cache de l'environnement (utile pour les tests)
static void resetEnvironmentCache() {
_isProduction = null;
}
/// Log simple (remplace debugPrint)
static void log(String message, {String? prefix}) {
if (!isProduction) {
if (prefix != null) {
debugPrint('$prefix $message');
} else {
debugPrint(message);
}
}
}
/// Log d'information
static void info(String message) {
if (!isProduction) {
debugPrint(' $message');
}
}
/// Log de succès
static void success(String message) {
if (!isProduction) {
debugPrint('$message');
}
}
/// Log d'avertissement
static void warning(String message) {
if (!isProduction) {
debugPrint('⚠️ $message');
}
}
/// Log d'erreur (toujours affiché même en production pour le debugging)
static void error(String message, [dynamic error, StackTrace? stackTrace]) {
// Les erreurs sont toujours loggées, même en production
debugPrint('$message');
if (error != null) {
debugPrint(' Error: $error');
}
if (stackTrace != null && !isProduction) {
// La stack trace n'est affichée qu'en DEV/REC
debugPrint(' Stack trace:\n$stackTrace');
}
}
/// Log de debug (avec emoji personnalisé)
static void debug(String message, {String emoji = '🔧'}) {
if (!isProduction) {
debugPrint('$emoji $message');
}
}
/// Log de requête API
static void api(String message) {
if (!isProduction) {
debugPrint('🔗 $message');
}
}
/// Log de base de données
static void database(String message) {
if (!isProduction) {
debugPrint('💾 $message');
}
}
/// Log de navigation
static void navigation(String message) {
if (!isProduction) {
debugPrint('🧭 $message');
}
}
/// Log de performance/timing
static void performance(String message) {
if (!isProduction) {
debugPrint('⏱️ $message');
}
}
/// Log conditionnel basé sur un flag custom
static void conditional(String message, {required bool condition}) {
if (!isProduction && condition) {
debugPrint(message);
}
}
/// Groupe de logs (pour regrouper visuellement des logs liés)
static void group(String title, List<String> messages) {
if (!isProduction) {
debugPrint('┌─ $title');
for (int i = 0; i < messages.length; i++) {
if (i == messages.length - 1) {
debugPrint('└─ ${messages[i]}');
} else {
debugPrint('├─ ${messages[i]}');
}
}
}
}
/// Log d'objet JSON (formaté)
static void json(String label, Map<String, dynamic> data) {
if (!isProduction) {
debugPrint('📋 $label:');
data.forEach((key, value) {
debugPrint(' $key: $value');
});
}
}
}
/// Extension pour faciliter l'utilisation
extension LoggerExtension on String {
void log({String? prefix}) => LoggerService.log(this, prefix: prefix);
void logInfo() => LoggerService.info(this);
void logSuccess() => LoggerService.success(this);
void logWarning() => LoggerService.warning(this);
void logError([dynamic error, StackTrace? stackTrace]) =>
LoggerService.error(this, error, stackTrace);
void logDebug({String emoji = '🔧'}) => LoggerService.debug(this, emoji: emoji);
void logApi() => LoggerService.api(this);
void logDatabase() => LoggerService.database(this);
void logNavigation() => LoggerService.navigation(this);
void logPerformance() => LoggerService.performance(this);
}

View File

@@ -97,6 +97,9 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
}
void _handleEditMembre(MembreModel membre) {
// Récupérer l'amicale actuelle
final amicale = widget.amicaleRepository.getUserAmicale(_currentUser!.fkEntite!);
showDialog(
context: context,
builder: (context) => UserFormDialog(
@@ -104,8 +107,9 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
user: membre.toUserModel(),
showRoleSelector: true,
showActiveCheckbox: true, // Activer la checkbox
allowUsernameEdit: true, // Permettre l'édition du username
// allowSectNameEdit sera automatiquement true via UserForm
allowUsernameEdit: amicale?.chkUsernameManuel == true, // Conditionnel selon amicale
amicale: amicale, // Passer l'amicale
isAdmin: true, // Car on est dans la page admin
availableRoles: const [
RoleOption(
value: 1,
@@ -118,23 +122,22 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
description: 'Peut gérer l\'amicale et ses membres',
),
],
onSubmit: (updatedUser) async {
onSubmit: (updatedUser, {String? password}) async {
try {
// Convertir le UserModel mis à jour vers MembreModel
final updatedMembre =
MembreModel.fromUserModel(updatedUser, membre);
// Utiliser directement updateMembre qui passe par l'API /users
final success =
await widget.membreRepository.updateMembre(updatedMembre);
final success = await widget.membreRepository.updateMembre(
updatedMembre,
password: password,
);
if (success && mounted) {
Navigator.of(context).pop();
ApiException.showSuccess(context,
'Membre ${updatedMembre.firstName} ${updatedMembre.name} mis à jour');
} else if (!success && mounted) {
ApiException.showError(
context, Exception('Erreur lors de la mise à jour'));
}
} catch (e) {
debugPrint('❌ Erreur mise à jour membre: $e');
@@ -557,9 +560,6 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
if (success && mounted) {
ApiException.showSuccess(context,
'Membre ${membre.firstName} ${membre.name} désactivé avec succès');
} else if (mounted) {
ApiException.showError(
context, Exception('Erreur lors de la désactivation'));
}
} catch (e) {
if (mounted) {
@@ -571,6 +571,9 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
void _handleAddMembre() {
if (_currentUser?.fkEntite == null) return;
// Récupérer l'amicale actuelle
final amicale = widget.amicaleRepository.getUserAmicale(_currentUser!.fkEntite!);
// Créer un UserModel vide avec les valeurs par défaut
final newUser = UserModel(
id: 0, // ID temporaire pour nouveau membre
@@ -596,7 +599,9 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
user: newUser,
showRoleSelector: true,
showActiveCheckbox: true,
allowUsernameEdit: true,
allowUsernameEdit: amicale?.chkUsernameManuel == true, // Conditionnel selon amicale
amicale: amicale, // Passer l'amicale
isAdmin: true, // Car on est dans la page admin
availableRoles: const [
RoleOption(
value: 1,
@@ -609,7 +614,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
description: 'Peut gérer l\'amicale et ses membres',
),
],
onSubmit: (newUserData) async {
onSubmit: (newUserData, {String? password}) async {
try {
// Créer un nouveau MembreModel directement
final newMembre = MembreModel(
@@ -631,8 +636,10 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
);
// Créer le membre via l'API (retourne maintenant le membre créé)
final createdMembre =
await widget.membreRepository.createMembre(newMembre);
final createdMembre = await widget.membreRepository.createMembre(
newMembre,
password: password,
);
if (createdMembre != null && mounted) {
// Fermer le dialog

View File

@@ -434,7 +434,8 @@ class _LoginPageState extends State<LoginPage> {
'Login: Tentative avec type: $_loginType');
final success =
await userRepository.login(
await userRepository.loginWithSpinner(
context,
_usernameController.text.trim(),
_passwordController.text,
type: _loginType,
@@ -575,9 +576,9 @@ class _LoginPageState extends State<LoginPage> {
print(
'Login: Tentative avec type: $_loginType');
// Utiliser directement userRepository avec l'overlay de chargement
// Utiliser le nouveau spinner moderne pour la connexion
final success = await userRepository
.loginWithUI(
.loginWithSpinner(
context,
_usernameController.text.trim(),
_passwordController.text,

View File

@@ -1,11 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:typed_data';
import 'dart:convert';
import 'package:flutter_map/flutter_map.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/presentation/widgets/mapbox_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:io';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
import 'custom_text_field.dart';
class AmicaleForm extends StatefulWidget {
@@ -52,6 +58,13 @@ class _AmicaleFormState extends State<AmicaleForm> {
bool _chkAcceptSms = false;
bool _chkActive = true;
bool _chkStripe = false;
bool _chkMdpManuel = false;
bool _chkUsernameManuel = false;
// Pour l'upload du logo
final ImagePicker _picker = ImagePicker();
XFile? _selectedImage;
String? _logoUrl;
@override
void initState() {
@@ -78,6 +91,10 @@ class _AmicaleFormState extends State<AmicaleForm> {
_chkAcceptSms = amicale?.chkAcceptSms ?? false;
_chkActive = amicale?.chkActive ?? true;
_chkStripe = amicale?.chkStripe ?? false;
_chkMdpManuel = amicale?.chkMdpManuel ?? false;
_chkUsernameManuel = amicale?.chkUsernameManuel ?? false;
// Note : Le logo sera chargé dynamiquement depuis l'API
}
@override
@@ -133,6 +150,8 @@ class _AmicaleFormState extends State<AmicaleForm> {
'chk_copie_mail_recu': amicale.chkCopieMailRecu ? 1 : 0,
'chk_accept_sms': amicale.chkAcceptSms ? 1 : 0,
'chk_stripe': amicale.chkStripe ? 1 : 0,
'chk_mdp_manuel': amicale.chkMdpManuel ? 1 : 0,
'chk_username_manuel': amicale.chkUsernameManuel ? 1 : 0,
};
// Ajouter les champs réservés aux administrateurs si l'utilisateur est admin
@@ -232,6 +251,115 @@ class _AmicaleFormState extends State<AmicaleForm> {
}
}
// Méthode pour sélectionner une image
Future<void> _selectImage() async {
try {
final XFile? image = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1024,
maxHeight: 1024,
imageQuality: 85,
);
if (image != null) {
// Vérifier la taille du fichier (limite 5 Mo)
final int fileSize = await image.length();
const int maxSize = 5 * 1024 * 1024; // 5 Mo en octets
if (fileSize > maxSize) {
// Fichier trop volumineux
final double sizeMB = fileSize / (1024 * 1024);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Le fichier est trop volumineux (${sizeMB.toStringAsFixed(2)} Mo). '
'La taille maximale autorisée est de 5 Mo.',
),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 5),
),
);
}
return;
}
setState(() {
_selectedImage = image;
});
// Upload immédiatement après sélection
if (widget.amicale?.id != null) {
await _uploadLogo();
}
}
} catch (e) {
debugPrint('Erreur lors de la sélection de l\'image: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la sélection de l\'image: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
// Méthode pour uploader le logo
Future<void> _uploadLogo() async {
if (_selectedImage == null || widget.amicale?.id == null) return;
OverlayEntry? spinOverlay;
try {
// Afficher le spinner
spinOverlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Upload du logo en cours...',
blurAmount: 10.0,
showCard: true,
);
// Appeler l'API pour uploader le logo
final response = await widget.apiService?.uploadLogo(
widget.amicale!.id,
_selectedImage!,
);
if (response != null && response['status'] == 'success') {
// Succès
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logo uploadé avec succès'),
backgroundColor: Colors.green,
),
);
}
// Mettre à jour l'amicale avec le nouveau logo en base64
// Note : Le serveur devrait aussi mettre à jour le logo dans la session
// Pour l'instant on garde l'image sélectionnée en preview
setState(() {
// L'image reste en preview jusqu'au prochain login
});
}
} catch (e) {
debugPrint('Erreur lors de l\'upload du logo: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de l\'upload: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
} finally {
// Fermer le spinner
LoadingSpinOverlayUtils.hideSpecific(spinOverlay);
}
}
void _submitForm() {
debugPrint('🔧 _submitForm appelée');
@@ -271,6 +399,8 @@ class _AmicaleFormState extends State<AmicaleForm> {
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
chkStripe: _chkStripe,
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
) ??
AmicaleModel(
id: 0, // Sera remplacé par l'API
@@ -291,6 +421,9 @@ class _AmicaleFormState extends State<AmicaleForm> {
chkCopieMailRecu: _chkCopieMailRecu,
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
chkStripe: _chkStripe,
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
);
debugPrint('🔧 AmicaleModel créé: ${amicale.name}');
@@ -305,6 +438,10 @@ class _AmicaleFormState extends State<AmicaleForm> {
// Construire la section logo
Widget _buildLogoSection() {
// Vérifier si on est admin d'amicale (role 2)
final userRole = widget.userRepository.getUserRole();
final canUploadLogo = userRole == 2 && !widget.readOnly;
return Container(
width: 150,
height: 150,
@@ -323,40 +460,40 @@ class _AmicaleFormState extends State<AmicaleForm> {
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
// Image par défaut
// Afficher l'image sélectionnée, ou le logo depuis l'API, ou l'image par défaut
Center(
child: Image.asset(
'assets/images/logo_recu.png',
width: 150,
height: 150,
fit: BoxFit.contain,
),
child: _buildLogoImage(),
),
// Overlay pour indiquer que l'image est modifiable (si non en lecture seule)
if (!widget.readOnly)
// Overlay pour indiquer que l'image est modifiable (si admin d'amicale)
if (canUploadLogo)
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
// TODO: Implémenter la sélection d'image
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fonctionnalité de modification du logo à venir'),
),
);
},
onTap: _selectImage,
child: Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.1),
color: Colors.black.withOpacity(0.3),
),
child: const Center(
child: Icon(
Icons.camera_alt,
color: Colors.white,
size: 40,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(
Icons.camera_alt,
color: Colors.white,
size: 32,
),
SizedBox(height: 4),
Text(
'Modifier',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
@@ -367,6 +504,102 @@ class _AmicaleFormState extends State<AmicaleForm> {
),
);
}
// Méthode pour construire l'image du logo
Widget _buildLogoImage() {
// 1. Si une image a été sélectionnée localement (preview)
if (_selectedImage != null) {
if (kIsWeb) {
return FutureBuilder<Uint8List>(
future: _selectedImage!.readAsBytes(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Image.memory(
snapshot.data!,
width: 150,
height: 150,
fit: BoxFit.contain,
);
}
return const CircularProgressIndicator();
},
);
} else {
return Image.file(
File(_selectedImage!.path),
width: 150,
height: 150,
fit: BoxFit.contain,
);
}
}
// 2. Si l'amicale a un logo en base64 stocké dans Hive
if (widget.amicale?.logoBase64 != null && widget.amicale!.logoBase64!.isNotEmpty) {
try {
// Le logoBase64 contient déjà le data URL complet (data:image/png;base64,...)
final dataUrl = widget.amicale!.logoBase64!;
// Extraire le base64 du data URL
final base64Data = dataUrl.split(',').last;
final bytes = base64Decode(base64Data);
return Image.memory(
bytes,
width: 150,
height: 150,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
debugPrint('Erreur affichage logo base64: $error');
// En cas d'erreur, essayer l'API
return _buildLogoFromApi();
},
);
} catch (e) {
debugPrint('Erreur décodage base64: $e');
// En cas d'erreur, essayer l'API
return _buildLogoFromApi();
}
}
// 3. Sinon, essayer de charger depuis l'API
return _buildLogoFromApi();
}
// Méthode pour charger le logo depuis l'API
Widget _buildLogoFromApi() {
if (widget.amicale?.id != null && widget.apiService != null) {
// Construire l'URL complète du logo
final logoUrl = '${widget.apiService!.baseUrl}/entites/${widget.amicale!.id}/logo';
return Image.network(
logoUrl,
width: 150,
height: 150,
fit: BoxFit.contain,
headers: {
'Authorization': 'Bearer ${widget.apiService!.sessionId ?? ""}',
},
errorBuilder: (context, error, stackTrace) {
// En cas d'erreur, afficher l'image par défaut
return Image.asset(
'assets/images/logo_recu.png',
width: 150,
height: 150,
fit: BoxFit.contain,
);
},
);
}
// Par défaut, afficher l'image locale
return Image.asset(
'assets/images/logo_recu.png',
width: 150,
height: 150,
fit: BoxFit.contain,
);
}
// Construire la minimap
Widget _buildMiniMap() {
@@ -825,59 +1058,106 @@ class _AmicaleFormState extends State<AmicaleForm> {
),
const SizedBox(height: 8),
// Checkbox Demo
_buildCheckboxOption(
label: "Mode démo",
value: _chkDemo,
onChanged: restrictedFieldsReadOnly
? null
: (value) {
setState(() {
_chkDemo = value!;
});
},
),
const SizedBox(height: 8),
// Options organisées sur 2 colonnes
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Colonne de gauche
Expanded(
child: Column(
children: [
// Checkbox Demo
_buildCheckboxOption(
label: "Mode démo",
value: _chkDemo,
onChanged: restrictedFieldsReadOnly
? null
: (value) {
setState(() {
_chkDemo = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Copie Mail Reçu
_buildCheckboxOption(
label: "Copie des mails reçus",
value: _chkCopieMailRecu,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkCopieMailRecu = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Copie Mail Reçu
_buildCheckboxOption(
label: "Copie des mails reçus",
value: _chkCopieMailRecu,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkCopieMailRecu = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Accept SMS
_buildCheckboxOption(
label: "Accepte les SMS",
value: _chkAcceptSms,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkAcceptSms = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Accept SMS
_buildCheckboxOption(
label: "Accepte les SMS",
value: _chkAcceptSms,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkAcceptSms = value!;
});
},
),
],
),
),
const SizedBox(width: 32),
// Colonne de droite
Expanded(
child: Column(
children: [
// Checkbox Active
_buildCheckboxOption(
label: "Actif",
value: _chkActive,
onChanged: restrictedFieldsReadOnly
? null
: (value) {
setState(() {
_chkActive = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Active
_buildCheckboxOption(
label: "Actif",
value: _chkActive,
onChanged: restrictedFieldsReadOnly
? null
: (value) {
setState(() {
_chkActive = value!;
});
},
// Checkbox Mot de passe manuel
_buildCheckboxOption(
label: "Saisie manuelle des mots de passe",
value: _chkMdpManuel,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkMdpManuel = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Username manuel
_buildCheckboxOption(
label: "Saisie manuelle des identifiants",
value: _chkUsernameManuel,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkUsernameManuel = value!;
});
},
),
],
),
),
],
),
const SizedBox(height: 25),

View File

@@ -171,9 +171,10 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
user: user,
readOnly: false,
showRoleSelector: false,
onSubmit: (updatedUser) async {
onSubmit: (updatedUser, {String? password}) async {
try {
// Sauvegarder les modifications de l'utilisateur
// Note: password est ignoré ici car l'utilisateur normal ne peut pas changer son mot de passe
await userRepository.updateUser(updatedUser);
if (context.mounted) {

View File

@@ -1,199 +0,0 @@
import 'package:flutter/material.dart';
import 'dart:ui';
/// Widget d'overlay de chargement amélioré qui affiche une barre de progression
/// avec un effet de flou sur l'arrière-plan et un message détaillé sur l'étape en cours
class LoadingProgressOverlay extends StatefulWidget {
final String? message;
final double progress;
final String? stepDescription;
final Color backgroundColor;
final Color progressColor;
final Color textColor;
final double blurAmount;
final bool showPercentage;
const LoadingProgressOverlay({
super.key,
this.message,
required this.progress,
this.stepDescription,
this.backgroundColor = Colors.black54,
this.progressColor = Colors.white,
this.textColor = Colors.white,
this.blurAmount = 5.0,
this.showPercentage = true,
});
@override
State<LoadingProgressOverlay> createState() => _LoadingProgressOverlayState();
}
class _LoadingProgressOverlayState extends State<LoadingProgressOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _progressAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_progressAnimation = Tween<double>(begin: 0, end: widget.progress).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_animationController.forward();
}
@override
void didUpdateWidget(LoadingProgressOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.progress != widget.progress) {
_progressAnimation = Tween<double>(
begin: oldWidget.progress,
end: widget.progress,
).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_animationController.reset();
_animationController.forward();
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BackdropFilter(
filter: ImageFilter.blur(
sigmaX: widget.blurAmount, sigmaY: widget.blurAmount),
child: Container(
color: widget.backgroundColor,
child: Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.85,
padding: const EdgeInsets.all(30),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.9),
borderRadius: BorderRadius.circular(20),
boxShadow: const [
BoxShadow(
color: Colors.black45,
blurRadius: 15,
spreadRadius: 5,
offset: Offset(0, 5),
),
],
border: Border.all(
color: Colors.white.withOpacity(0.1),
width: 1.5,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.message != null) ...[
Text(
widget.message!,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: widget.textColor,
letterSpacing: 0.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
],
AnimatedBuilder(
animation: _progressAnimation,
builder: (context, child) {
return Column(
children: [
LinearProgressIndicator(
value: _progressAnimation.value,
backgroundColor:
widget.progressColor.withOpacity(0.3),
valueColor: AlwaysStoppedAnimation<Color>(
widget.progressColor),
minHeight: 15,
borderRadius: BorderRadius.circular(8),
),
if (widget.showPercentage) ...[
const SizedBox(height: 8),
Text(
'${(_progressAnimation.value * 100).toInt()}%',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: widget.textColor,
letterSpacing: 1.2,
),
),
],
],
);
},
),
if (widget.stepDescription != null) ...[
const SizedBox(height: 16),
Text(
widget.stepDescription!,
style: TextStyle(
fontSize: 16,
color: widget.textColor.withOpacity(0.9),
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
],
],
),
),
),
),
);
}
}
/// Classe utilitaire pour gérer l'overlay de chargement avec progression
class LoadingProgressOverlayUtils {
/// Méthode pour afficher l'overlay de chargement avec progression
static OverlayEntry show({
required BuildContext context,
String? message,
double progress = 0.0,
String? stepDescription,
double blurAmount = 5.0,
bool showPercentage = true,
}) {
final overlayEntry = OverlayEntry(
builder: (context) => LoadingProgressOverlay(
message: message,
progress: progress,
stepDescription: stepDescription,
blurAmount: blurAmount,
showPercentage: showPercentage,
),
);
Overlay.of(context).insert(overlayEntry);
return overlayEntry;
}
/// Méthode pour mettre à jour l'overlay existant
static void update({
required OverlayEntry overlayEntry,
String? message,
required double progress,
String? stepDescription,
}) {
overlayEntry.markNeedsBuild();
}
}

View File

@@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'dart:ui';
/// Widget d'overlay de chargement moderne avec spinner circulaire
/// Affiche un spinner animé avec fond flou et message optionnel
class LoadingSpinOverlay extends StatefulWidget {
final String? message;
final Color backgroundColor;
final Color spinnerColor;
final Color textColor;
final double blurAmount;
final double spinnerSize;
final bool showCard;
const LoadingSpinOverlay({
super.key,
this.message,
this.backgroundColor = Colors.black54,
this.spinnerColor = Colors.blue,
this.textColor = Colors.white,
this.blurAmount = 8.0,
this.spinnerSize = 50.0,
this.showCard = true,
});
@override
State<LoadingSpinOverlay> createState() => _LoadingSpinOverlayState();
}
class _LoadingSpinOverlayState extends State<LoadingSpinOverlay>
with TickerProviderStateMixin {
late AnimationController _fadeController;
late AnimationController _rotationController;
late Animation<double> _fadeAnimation;
late Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
_fadeController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_rotationController = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _fadeController,
curve: Curves.easeInOut,
));
_rotationAnimation = Tween<double>(
begin: 0.0,
end: 2 * 3.14159,
).animate(CurvedAnimation(
parent: _rotationController,
curve: Curves.linear,
));
_fadeController.forward();
_rotationController.repeat();
}
@override
void dispose() {
_fadeController.dispose();
_rotationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fadeAnimation,
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: widget.blurAmount,
sigmaY: widget.blurAmount,
),
child: Container(
color: widget.backgroundColor,
child: Center(
child: widget.showCard
? _buildCardContent()
: _buildSimpleContent(),
),
),
),
);
}
Widget _buildCardContent() {
return Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(32),
constraints: const BoxConstraints(
maxWidth: 280,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.92), // Semi-transparent
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 20,
spreadRadius: 2,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Spinner simple de Flutter
SizedBox(
width: widget.spinnerSize,
height: widget.spinnerSize,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(widget.spinnerColor),
),
),
if (widget.message != null) ...[
const SizedBox(height: 24),
Text(
widget.message!,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey[800],
letterSpacing: 0.3,
),
textAlign: TextAlign.center,
),
],
],
),
),
);
}
Widget _buildSimpleContent() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: widget.spinnerSize,
height: widget.spinnerSize,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(widget.spinnerColor),
),
),
if (widget.message != null) ...[
const SizedBox(height: 20),
Text(
widget.message!,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: widget.textColor,
letterSpacing: 0.5,
),
textAlign: TextAlign.center,
),
],
],
);
}
}
/// Classe utilitaire pour gérer l'overlay de chargement avec spinner
class LoadingSpinOverlayUtils {
static OverlayEntry? _currentOverlay;
/// Affiche l'overlay de chargement avec spinner
static OverlayEntry show({
required BuildContext context,
String? message,
double blurAmount = 8.0,
bool showCard = true,
Color? spinnerColor,
}) {
// Fermer l'overlay existant s'il y en a un
hide();
final theme = Theme.of(context);
final overlayEntry = OverlayEntry(
builder: (context) => LoadingSpinOverlay(
message: message,
blurAmount: blurAmount,
showCard: showCard,
spinnerColor: spinnerColor ?? theme.colorScheme.primary,
),
);
_currentOverlay = overlayEntry;
Overlay.of(context).insert(overlayEntry);
return overlayEntry;
}
/// Met à jour le message de l'overlay existant
static void updateMessage({
required OverlayEntry overlayEntry,
String? message,
}) {
overlayEntry.markNeedsBuild();
}
/// Cache l'overlay de chargement
static void hide() {
_currentOverlay?.remove();
_currentOverlay = null;
}
/// Cache un overlay spécifique
static void hideSpecific(OverlayEntry? overlayEntry) {
overlayEntry?.remove();
if (_currentOverlay == overlayEntry) {
_currentOverlay = null;
}
}
}

View File

@@ -8,6 +8,7 @@ class MembreRowWidget extends StatelessWidget {
final Function(MembreModel)? onResetPassword;
final bool isAlternate;
final VoidCallback? onTap;
final bool isMobile;
const MembreRowWidget({
super.key,
@@ -17,6 +18,7 @@ class MembreRowWidget extends StatelessWidget {
this.onResetPassword,
this.isAlternate = false,
this.onTap,
this.isMobile = false,
});
@override
@@ -37,20 +39,29 @@ class MembreRowWidget extends StatelessWidget {
),
child: Row(
children: [
// ... existing row content ...
// ID
Expanded(
flex: 1,
child: Text(
membre.id.toString() ?? '',
style: theme.textTheme.bodyMedium,
// ID - masqué en mobile
if (!isMobile)
Expanded(
flex: 1,
child: Text(
membre.id.toString() ?? '',
style: theme.textTheme.bodyMedium,
),
),
// Identifiant (username) - masqué en mobile
if (!isMobile)
Expanded(
flex: 2,
child: Text(
membre.username ?? '',
style: theme.textTheme.bodyMedium,
),
),
),
// Prénom
Expanded(
flex: 2,
flex: isMobile ? 2 : 2,
child: Text(
membre.firstName ?? '',
style: theme.textTheme.bodyMedium,
@@ -59,47 +70,44 @@ class MembreRowWidget extends StatelessWidget {
// Nom
Expanded(
flex: 2,
flex: isMobile ? 2 : 2,
child: Text(
membre.name ?? '',
style: theme.textTheme.bodyMedium,
),
),
// Email
Expanded(
flex: 3,
child: Text(
membre.email ?? '',
style: theme.textTheme.bodyMedium,
// Email - masqué en mobile
if (!isMobile)
Expanded(
flex: 3,
child: Text(
membre.email ?? '',
style: theme.textTheme.bodyMedium,
),
),
),
// Rôle
Expanded(
flex: 1,
child: Text(
_getRoleName(membre.role),
style: theme.textTheme.bodyMedium,
// Rôle - masqué en mobile
if (!isMobile)
Expanded(
flex: 1,
child: Text(
_getRoleName(membre.role),
style: theme.textTheme.bodyMedium,
),
),
),
// Statut
Expanded(
flex: 1,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
decoration: BoxDecoration(
color: _getStatusColor(membre.isActive),
borderRadius: BorderRadius.circular(12.0),
),
child: Text(
membre.isActive == true ? 'Actif' : 'Inactif',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w500,
child: Center(
child: Tooltip(
message: membre.isActive == true ? 'Actif' : 'Inactif',
child: Icon(
membre.isActive == true ? Icons.check_circle : Icons.cancel,
color: membre.isActive == true ? Colors.green : Colors.red,
size: 24,
),
textAlign: TextAlign.center,
),
),
),
@@ -107,21 +115,21 @@ class MembreRowWidget extends StatelessWidget {
// Actions
if (onEdit != null || onDelete != null || onResetPassword != null)
Expanded(
flex: 2,
flex: isMobile ? 2 : 2,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Bouton reset password (uniquement pour les membres actifs)
if (onResetPassword != null && membre.isActive == true)
IconButton(
icon: const Icon(Icons.lock_reset, size: 22),
icon: Icon(Icons.lock_reset, size: isMobile ? 20 : 22),
onPressed: () => onResetPassword!(membre),
tooltip: 'Réinitialiser le mot de passe',
color: theme.colorScheme.primary,
),
if (onDelete != null)
IconButton(
icon: const Icon(Icons.delete, size: 22),
icon: Icon(Icons.delete, size: isMobile ? 20 : 22),
onPressed: () => onDelete!(membre),
tooltip: 'Supprimer',
color: theme.colorScheme.error,

View File

@@ -32,6 +32,8 @@ class MembreTableWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final screenWidth = MediaQuery.of(context).size.width;
final isMobile = screenWidth < 768;
return Container(
height: height,
@@ -61,21 +63,35 @@ class MembreTableWidget extends StatelessWidget {
),
child: Row(
children: [
// ID
Expanded(
flex: 1,
child: Text(
'ID',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
// ID - masqué en mobile
if (!isMobile)
Expanded(
flex: 1,
child: Text(
'ID',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
// Identifiant (username) - masqué en mobile
if (!isMobile)
Expanded(
flex: 2,
child: Text(
'Identifiant',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
),
// Prénom (firstName)
Expanded(
flex: 2,
flex: isMobile ? 2 : 2,
child: Text(
'Prénom',
style: theme.textTheme.titleSmall?.copyWith(
@@ -87,7 +103,7 @@ class MembreTableWidget extends StatelessWidget {
// Nom (name)
Expanded(
flex: 2,
flex: isMobile ? 2 : 2,
child: Text(
'Nom',
style: theme.textTheme.titleSmall?.copyWith(
@@ -97,29 +113,31 @@ class MembreTableWidget extends StatelessWidget {
),
),
// Email
Expanded(
flex: 3,
child: Text(
'Email',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
// Email - masqué en mobile
if (!isMobile)
Expanded(
flex: 3,
child: Text(
'Email',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
),
// Rôle (fkRole)
Expanded(
flex: 1,
child: Text(
'Rôle',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
// Rôle (fkRole) - masqué en mobile
if (!isMobile)
Expanded(
flex: 1,
child: Text(
'Rôle',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
),
// Statut
Expanded(
@@ -136,7 +154,7 @@ class MembreTableWidget extends StatelessWidget {
// Actions (si onEdit, onDelete ou onResetPassword sont fournis)
if (onEdit != null || onDelete != null || onResetPassword != null)
Expanded(
flex: 2,
flex: isMobile ? 2 : 2,
child: Text(
'Actions',
style: theme.textTheme.titleSmall?.copyWith(
@@ -152,14 +170,14 @@ class MembreTableWidget extends StatelessWidget {
// Corps du tableau
Expanded(
child: _buildTableContent(context),
child: _buildTableContent(context, isMobile),
),
],
),
);
}
Widget _buildTableContent(BuildContext context) {
Widget _buildTableContent(BuildContext context, bool isMobile) {
// Afficher un indicateur de chargement si isLoading est true
if (isLoading) {
return const Center(child: CircularProgressIndicator());
@@ -193,6 +211,7 @@ class MembreTableWidget extends StatelessWidget {
onResetPassword: onResetPassword,
isAlternate: index % 2 == 1,
onTap: onEdit != null ? () => onEdit!(membre) : null,
isMobile: isMobile,
);
},
);

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'dart:math';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'custom_text_field.dart';
class UserForm extends StatefulWidget {
@@ -10,6 +13,8 @@ class UserForm extends StatefulWidget {
final bool readOnly;
final bool allowUsernameEdit;
final bool allowSectNameEdit;
final AmicaleModel? amicale; // Nouveau paramètre pour l'amicale
final bool isAdmin; // Nouveau paramètre pour savoir si c'est un admin
const UserForm({
super.key,
@@ -18,6 +23,8 @@ class UserForm extends StatefulWidget {
this.readOnly = false,
this.allowUsernameEdit = false,
this.allowSectNameEdit = false,
this.amicale,
this.isAdmin = false,
});
@override
@@ -37,11 +44,19 @@ class _UserFormState extends State<UserForm> {
late final TextEditingController _emailController;
late final TextEditingController _dateNaissanceController;
late final TextEditingController _dateEmbaucheController;
late final TextEditingController _passwordController; // Nouveau controller pour le mot de passe
// Form values
int _fkTitre = 1; // 1 = M., 2 = Mme
DateTime? _dateNaissance;
DateTime? _dateEmbauche;
// Pour la génération automatique d'username
bool _isGeneratingUsername = false;
final Random _random = Random();
// Pour afficher/masquer le mot de passe
bool _obscurePassword = true;
@override
void initState() {
@@ -64,11 +79,34 @@ class _UserFormState extends State<UserForm> {
_dateEmbaucheController = TextEditingController(text: _dateEmbauche != null ? DateFormat('dd/MM/yyyy').format(_dateEmbauche!) : '');
_passwordController = TextEditingController(); // Initialiser le controller du mot de passe
_fkTitre = user?.fkTitre ?? 1;
// Ajouter des listeners pour auto-générer l'username en création
if (widget.user?.id == 0 && widget.isAdmin && widget.amicale?.chkUsernameManuel == true) {
_nameController.addListener(_onNameOrSectNameChanged);
_sectNameController.addListener(_onNameOrSectNameChanged);
}
}
void _onNameOrSectNameChanged() {
// Auto-générer username seulement en création et si le champ username est vide
if (widget.user?.id == 0 &&
_usernameController.text.isEmpty &&
(_nameController.text.isNotEmpty || _sectNameController.text.isNotEmpty)) {
_generateAndCheckUsername();
}
}
@override
void dispose() {
// Retirer les listeners si ajoutés
if (widget.user?.id == 0 && widget.isAdmin && widget.amicale?.chkUsernameManuel == true) {
_nameController.removeListener(_onNameOrSectNameChanged);
_sectNameController.removeListener(_onNameOrSectNameChanged);
}
_usernameController.dispose();
_firstNameController.dispose();
_nameController.dispose();
@@ -78,6 +116,7 @@ class _UserFormState extends State<UserForm> {
_emailController.dispose();
_dateNaissanceController.dispose();
_dateEmbaucheController.dispose();
_passwordController.dispose();
super.dispose();
}
@@ -98,13 +137,49 @@ class _UserFormState extends State<UserForm> {
void _selectDate(BuildContext context, bool isDateNaissance) {
// Utiliser un bloc try-catch pour capturer toutes les erreurs possibles
try {
// Afficher le sélecteur de date sans spécifier de locale
// Déterminer la date initiale
DateTime initialDate;
if (isDateNaissance) {
initialDate = _dateNaissance ?? DateTime.now().subtract(const Duration(days: 365 * 30)); // 30 ans par défaut
} else {
initialDate = _dateEmbauche ?? DateTime.now();
}
// S'assurer que la date initiale est dans la plage autorisée
if (initialDate.isAfter(DateTime.now())) {
initialDate = DateTime.now();
}
if (initialDate.isBefore(DateTime(1900))) {
initialDate = DateTime(1950);
}
// Afficher le sélecteur de date avec locale française
showDatePicker(
context: context,
initialDate: DateTime.now(), // Toujours utiliser la date actuelle
initialDate: initialDate,
firstDate: DateTime(1900),
lastDate: DateTime.now(),
// Ne pas spécifier de locale pour éviter les problèmes
locale: const Locale('fr', 'FR'), // Forcer la locale française
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: Theme.of(context).colorScheme.primary,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
helpText: isDateNaissance ? 'SÉLECTIONNER LA DATE DE NAISSANCE' : 'SÉLECTIONNER LA DATE D\'EMBAUCHE',
cancelText: 'ANNULER',
confirmText: 'VALIDER',
fieldLabelText: 'Entrer une date',
fieldHintText: 'jj/mm/aaaa',
errorFormatText: 'Format de date invalide',
errorInvalidText: 'Date invalide',
).then((DateTime? picked) {
// Vérifier si une date a été sélectionnée
if (picked != null) {
@@ -141,30 +216,247 @@ class _UserFormState extends State<UserForm> {
}
}
// Nettoyer une chaîne pour l'username
String _cleanString(String input) {
return input.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]'), ''); // Garder seulement lettres et chiffres
}
// Extraire une partie aléatoire d'une chaîne
String _extractRandomPart(String input, int minLength, int maxLength) {
if (input.isEmpty) return '';
final cleaned = _cleanString(input);
if (cleaned.isEmpty) return '';
final length = minLength + _random.nextInt(maxLength - minLength + 1);
if (cleaned.length <= length) return cleaned;
// Prendre les premiers caractères jusqu'à la longueur désirée
return cleaned.substring(0, length);
}
// Générer un username selon l'algorithme spécifié
String _generateUsername() {
// Récupérer les données nécessaires
final nom = _nameController.text.isNotEmpty ? _nameController.text : _sectNameController.text;
final codePostal = widget.amicale?.codePostal ?? '';
final ville = widget.amicale?.ville ?? '';
// Nettoyer et extraire les parties
final nomPart = _extractRandomPart(nom, 2, 5);
final cpPart = _extractRandomPart(codePostal, 2, 3);
final villePart = _extractRandomPart(ville, 2, 4);
final nombreAleatoire = 10 + _random.nextInt(990); // 10 à 999
// Choisir des séparateurs aléatoires (uniquement ceux autorisés: ., _, -)
final separateurs = ['', '.', '_', '-'];
final sep1 = separateurs[_random.nextInt(separateurs.length)];
final sep2 = separateurs[_random.nextInt(separateurs.length)];
// Assembler l'username
String username = '$nomPart$sep1$cpPart$sep2$villePart$nombreAleatoire';
// Si trop court, ajouter des chiffres pour atteindre minimum 10 caractères
while (username.length < 10) {
username += _random.nextInt(10).toString();
}
// S'assurer que l'username ne contient que des caractères autorisés (a-z, 0-9, ., -, _)
// Normalement déjà le cas avec notre algorithme, mais au cas où
username = username.toLowerCase().replaceAll(RegExp(r'[^a-z0-9._-]'), '');
return username;
}
// Vérifier la disponibilité d'un username via l'API
Future<Map<String, dynamic>> _checkUsernameAvailability(String username) async {
try {
final response = await ApiService.instance.post(
'/users/check-username',
data: {'username': username},
);
if (response.statusCode == 200) {
return response.data;
}
return {'available': false};
} catch (e) {
debugPrint('Erreur lors de la vérification de l\'username: $e');
return {'available': false};
}
}
// Générer et vérifier un username jusqu'à en trouver un disponible
Future<void> _generateAndCheckUsername() async {
if (_isGeneratingUsername) return; // Éviter les appels multiples
setState(() {
_isGeneratingUsername = true;
});
try {
int attempts = 0;
const maxAttempts = 10;
while (attempts < maxAttempts) {
final username = _generateUsername();
debugPrint('Tentative ${attempts + 1}: Vérification de $username');
final result = await _checkUsernameAvailability(username);
if (result['available'] == true) {
// Username disponible, l'utiliser
setState(() {
_usernameController.text = username;
});
debugPrint('✅ Username disponible trouvé: $username');
break;
} else {
// Si l'API propose des suggestions, essayer la première
if (result['suggestions'] != null && result['suggestions'].isNotEmpty) {
final suggestion = result['suggestions'][0];
debugPrint('Vérification de la suggestion: $suggestion');
final suggestionResult = await _checkUsernameAvailability(suggestion);
if (suggestionResult['available'] == true) {
setState(() {
_usernameController.text = suggestion;
});
debugPrint('✅ Suggestion disponible utilisée: $suggestion');
break;
}
}
}
attempts++;
}
if (attempts >= maxAttempts) {
debugPrint('⚠️ Impossible de trouver un username disponible après $maxAttempts tentatives');
}
} finally {
setState(() {
_isGeneratingUsername = false;
});
}
}
// Valider le mot de passe selon les règles
String? _validatePassword(String? value) {
if (value == null || value.isEmpty) {
// Pour un nouveau membre, le mot de passe est obligatoire si le champ est affiché
if (widget.user?.id == 0) {
return "Veuillez entrer un mot de passe";
}
return null; // En modification, vide = garder l'ancien
}
// Faire un trim pour retirer les espaces en début/fin
final trimmedValue = value.trim();
if (trimmedValue.isEmpty) {
return "Le mot de passe ne peut pas être vide";
}
// Vérifier qu'il n'y a pas d'espaces dans le mot de passe
if (trimmedValue.contains(' ')) {
return "Le mot de passe ne doit pas contenir d'espaces";
}
// Vérifier la longueur
if (trimmedValue.length < 12) {
return "Le mot de passe doit contenir au moins 12 caractères";
}
if (trimmedValue.length > 16) {
return "Le mot de passe ne doit pas dépasser 16 caractères";
}
// Vérifier qu'il n'est pas égal au username (après trim des deux)
if (trimmedValue == _usernameController.text.trim()) {
return "Le mot de passe ne doit pas être identique au nom d'utilisateur";
}
// Vérifier la présence d'au moins une minuscule
if (!trimmedValue.contains(RegExp(r'[a-z]'))) {
return "Le mot de passe doit contenir au moins une lettre minuscule";
}
// Vérifier la présence d'au moins une majuscule
if (!trimmedValue.contains(RegExp(r'[A-Z]'))) {
return "Le mot de passe doit contenir au moins une lettre majuscule";
}
// Vérifier la présence d'au moins un chiffre
if (!trimmedValue.contains(RegExp(r'[0-9]'))) {
return "Le mot de passe doit contenir au moins un chiffre";
}
// Vérifier la présence d'au moins un caractère spécial
if (!trimmedValue.contains(RegExp(r'[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]'))) {
return "Le mot de passe doit contenir au moins un caractère spécial (!@#\$%^&*()_+-=[]{}|;:,.<>?)";
}
return null;
}
// Générer un mot de passe aléatoire respectant les règles
String _generatePassword() {
const String lowercase = 'abcdefghijklmnopqrstuvwxyz';
const String uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const String digits = '0123456789';
const String special = '!@#\$%^&*()_+-=[]{}|;:,.<>?';
// Longueur aléatoire entre 12 et 16
final length = 12 + _random.nextInt(5);
// S'assurer d'avoir au moins un caractère de chaque type
List<String> password = [];
password.add(lowercase[_random.nextInt(lowercase.length)]);
password.add(uppercase[_random.nextInt(uppercase.length)]);
password.add(digits[_random.nextInt(digits.length)]);
password.add(special[_random.nextInt(special.length)]);
// Compléter avec des caractères aléatoires
const String allChars = lowercase + uppercase + digits + special;
for (int i = password.length; i < length; i++) {
password.add(allChars[_random.nextInt(allChars.length)]);
}
// Mélanger les caractères
password.shuffle(_random);
return password.join('');
}
// Méthode publique pour récupérer le mot de passe si défini
String? getPassword() {
final password = _passwordController.text.trim();
return password.isNotEmpty ? password : null;
}
// Méthode publique pour valider et récupérer l'utilisateur
UserModel? validateAndGetUser() {
if (_formKey.currentState!.validate()) {
return widget.user?.copyWith(
username: _usernameController.text,
firstName: _firstNameController.text,
name: _nameController.text,
sectName: _sectNameController.text,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
username: _usernameController.text.trim(), // Appliquer trim
firstName: _firstNameController.text.trim(),
name: _nameController.text.trim(),
sectName: _sectNameController.text.trim(),
phone: _phoneController.text.trim(),
mobile: _mobileController.text.trim(),
email: _emailController.text.trim(),
fkTitre: _fkTitre,
dateNaissance: _dateNaissance,
dateEmbauche: _dateEmbauche,
) ??
UserModel(
id: 0,
username: _usernameController.text,
firstName: _firstNameController.text,
name: _nameController.text,
sectName: _sectNameController.text,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
username: _usernameController.text.trim(), // Appliquer trim
firstName: _firstNameController.text.trim(),
name: _nameController.text.trim(),
sectName: _sectNameController.text.trim(),
phone: _phoneController.text.trim(),
mobile: _mobileController.text.trim(),
email: _emailController.text.trim(),
fkTitre: _fkTitre,
dateNaissance: _dateNaissance,
dateEmbauche: _dateEmbauche,
@@ -180,90 +472,36 @@ class _UserFormState extends State<UserForm> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isWideScreen = MediaQuery.of(context).size.width > 900;
// Déterminer si on doit afficher le champ username selon les règles
final bool shouldShowUsernameField = widget.isAdmin && widget.amicale?.chkUsernameManuel == true;
// Déterminer si le username est éditable (seulement en création, jamais en modification)
final bool canEditUsername = shouldShowUsernameField && widget.allowUsernameEdit && widget.user?.id == 0;
// Déterminer si on doit afficher le champ mot de passe
final bool shouldShowPasswordField = widget.isAdmin && widget.amicale?.chkMdpManuel == true;
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Ligne 1: Username et Email (si écran large)
if (isWideScreen)
Row(
children: [
Expanded(
child: CustomTextField(
controller: _usernameController,
label: "Nom d'utilisateur",
readOnly: !widget.allowUsernameEdit, // Utiliser le paramètre
prefixIcon: Icons.account_circle,
isRequired: widget.allowUsernameEdit,
validator: widget.allowUsernameEdit
? (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom d'utilisateur";
}
return null;
}
: null,
),
),
const SizedBox(width: 16),
Expanded(
child: CustomTextField(
controller: _emailController,
label: "Email",
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
isRequired: true, // Email toujours obligatoire
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer l'adresse email";
}
if (!value.contains('@') || !value.contains('.')) {
return "Veuillez entrer une adresse email valide";
}
return null;
},
),
),
],
)
else ...[
// Version mobile: Username seul
CustomTextField(
controller: _usernameController,
label: "Nom d'utilisateur",
readOnly: !widget.allowUsernameEdit, // Utiliser le paramètre
prefixIcon: Icons.account_circle,
isRequired: widget.allowUsernameEdit, // Obligatoire si éditable
validator: widget.allowUsernameEdit
? (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom d'utilisateur";
}
return null;
}
: null,
),
const SizedBox(height: 16),
// Email seul en mobile
CustomTextField(
controller: _emailController,
label: "Email",
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
isRequired: true, // Email toujours obligatoire
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer l'adresse email";
}
if (!value.contains('@') || !value.contains('.')) {
return "Veuillez entrer une adresse email valide";
}
return null;
},
),
],
// Email seul sur la première ligne
CustomTextField(
controller: _emailController,
label: "Email",
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
isRequired: true,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer l'adresse email";
}
if (!value.contains('@') || !value.contains('.')) {
return "Veuillez entrer une adresse email valide";
}
return null;
},
),
const SizedBox(height: 16),
// Titre (M. ou Mme)
@@ -378,7 +616,7 @@ class _UserFormState extends State<UserForm> {
const SizedBox(height: 16),
],
// Ligne 3: Téléphones (fixe et mobile)
// Ligne 2: Téléphones (fixe et mobile)
if (isWideScreen)
Row(
children: [
@@ -458,6 +696,224 @@ class _UserFormState extends State<UserForm> {
),
],
const SizedBox(height: 16),
// Ligne 3: Username et Password (si applicable)
if (shouldShowUsernameField || shouldShowPasswordField) ...[
if (isWideScreen)
Row(
children: [
if (shouldShowUsernameField)
Expanded(
child: CustomTextField(
controller: _usernameController,
label: "Nom d'utilisateur",
readOnly: !canEditUsername,
prefixIcon: Icons.account_circle,
isRequired: canEditUsername,
suffixIcon: (widget.user?.id == 0 && canEditUsername)
? _isGeneratingUsername
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
)
: IconButton(
icon: Icon(Icons.refresh),
onPressed: _generateAndCheckUsername,
tooltip: "Générer un nom d'utilisateur",
)
: null,
helperText: canEditUsername
? "Min. 10 caractères (a-z, 0-9, . - _)"
: null,
validator: canEditUsername
? (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom d'utilisateur";
}
// Faire un trim pour retirer les espaces en début/fin
final trimmedValue = value.trim();
if (trimmedValue.isEmpty) {
return "Le nom d'utilisateur ne peut pas être vide";
}
// Vérifier qu'il n'y a pas d'espaces dans l'username
if (trimmedValue.contains(' ')) {
return "Le nom d'utilisateur ne doit pas contenir d'espaces";
}
// Vérifier la longueur minimale
if (trimmedValue.length < 10) {
return "Le nom d'utilisateur doit contenir au moins 10 caractères";
}
// Vérifier les caractères autorisés (a-z, 0-9, ., -, _)
if (!RegExp(r'^[a-z0-9._-]+$').hasMatch(trimmedValue)) {
return "Caractères autorisés : lettres minuscules, chiffres, . - _";
}
return null;
}
: null,
),
),
if (shouldShowUsernameField && shouldShowPasswordField)
const SizedBox(width: 16),
if (shouldShowPasswordField)
Expanded(
child: CustomTextField(
controller: _passwordController,
label: "Mot de passe",
obscureText: _obscurePassword,
readOnly: widget.readOnly,
prefixIcon: Icons.lock,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton pour afficher/masquer le mot de passe
IconButton(
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
),
// Bouton pour générer un mot de passe (seulement si éditable)
if (!widget.readOnly)
IconButton(
icon: Icon(Icons.auto_awesome),
onPressed: () {
final newPassword = _generatePassword();
setState(() {
_passwordController.text = newPassword;
_obscurePassword = false; // Afficher le mot de passe généré
});
// Revalider le formulaire
_formKey.currentState?.validate();
},
tooltip: "Générer un mot de passe sécurisé",
),
],
),
helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel"
: "12-16 car. avec min/maj, chiffres et spéciaux (!@#\$%^&*()_+-=[]{}|;:,.<>?)",
validator: _validatePassword,
),
),
// Si seulement un des deux est affiché, ajouter un Expanded vide pour garder l'alignement
if ((shouldShowUsernameField && !shouldShowPasswordField) || (!shouldShowUsernameField && shouldShowPasswordField))
const Expanded(child: SizedBox()),
],
)
else ...[
// Version mobile: Username et Password séparés
if (shouldShowUsernameField) ...[
CustomTextField(
controller: _usernameController,
label: "Nom d'utilisateur",
readOnly: !canEditUsername,
prefixIcon: Icons.account_circle,
isRequired: canEditUsername,
suffixIcon: (widget.user?.id == 0 && canEditUsername)
? _isGeneratingUsername
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
)
: IconButton(
icon: Icon(Icons.refresh),
onPressed: _generateAndCheckUsername,
tooltip: "Générer un nom d'utilisateur",
)
: null,
helperText: canEditUsername
? "Min. 10 caractères (a-z, 0-9, . - _)"
: null,
validator: canEditUsername
? (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom d'utilisateur";
}
// Faire un trim pour retirer les espaces en début/fin
final trimmedValue = value.trim();
if (trimmedValue.isEmpty) {
return "Le nom d'utilisateur ne peut pas être vide";
}
// Vérifier qu'il n'y a pas d'espaces dans l'username
if (trimmedValue.contains(' ')) {
return "Le nom d'utilisateur ne doit pas contenir d'espaces";
}
// Vérifier la longueur minimale
if (trimmedValue.length < 10) {
return "Le nom d'utilisateur doit contenir au moins 10 caractères";
}
// Vérifier les caractères autorisés (a-z, 0-9, ., -, _)
if (!RegExp(r'^[a-z0-9._-]+$').hasMatch(trimmedValue)) {
return "Caractères autorisés : lettres minuscules, chiffres, . - _";
}
return null;
}
: null,
),
const SizedBox(height: 16),
],
if (shouldShowPasswordField) ...[
CustomTextField(
controller: _passwordController,
label: "Mot de passe",
obscureText: _obscurePassword,
readOnly: widget.readOnly,
prefixIcon: Icons.lock,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton pour afficher/masquer le mot de passe
IconButton(
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
),
// Bouton pour générer un mot de passe (seulement si éditable)
if (!widget.readOnly)
IconButton(
icon: Icon(Icons.auto_awesome),
onPressed: () {
final newPassword = _generatePassword();
setState(() {
_passwordController.text = newPassword;
_obscurePassword = false; // Afficher le mot de passe généré
});
// Revalider le formulaire
_formKey.currentState?.validate();
},
tooltip: "Générer un mot de passe sécurisé",
),
],
),
helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel"
: "12-16 car. avec min/maj, chiffres et spéciaux (!@#\$%^&*()_+-=[]{}|;:,.<>?)",
validator: _validatePassword,
),
const SizedBox(height: 16),
],
],
const SizedBox(height: 16),
],
// Ligne 4: Dates (naissance et embauche)
if (isWideScreen)

View File

@@ -1,16 +1,19 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/presentation/widgets/user_form.dart';
class UserFormDialog extends StatefulWidget {
final UserModel? user;
final String title;
final bool readOnly;
final Function(UserModel)? onSubmit;
final Function(UserModel, {String? password})? onSubmit; // Modifié pour inclure le mot de passe
final bool showRoleSelector;
final List<RoleOption>? availableRoles;
final bool showActiveCheckbox;
final bool allowUsernameEdit;
final AmicaleModel? amicale; // Nouveau paramètre
final bool isAdmin; // Nouveau paramètre
const UserFormDialog({
super.key,
@@ -22,6 +25,8 @@ class UserFormDialog extends StatefulWidget {
this.availableRoles,
this.showActiveCheckbox = false,
this.allowUsernameEdit = false,
this.amicale,
this.isAdmin = false,
});
@override
@@ -55,6 +60,7 @@ class _UserFormDialogState extends State<UserFormDialog> {
void _handleSubmit() async {
// Utiliser la méthode validateAndGetUser du UserForm
final userData = _userFormKey.currentState?.validateAndGetUser();
final password = _userFormKey.currentState?.getPassword(); // Récupérer le mot de passe
if (userData != null) {
var finalUser = userData;
@@ -70,7 +76,7 @@ class _UserFormDialogState extends State<UserFormDialog> {
}
if (widget.onSubmit != null) {
widget.onSubmit!(finalUser);
widget.onSubmit!(finalUser, password: password); // Passer le mot de passe
}
}
}
@@ -200,6 +206,8 @@ class _UserFormDialogState extends State<UserFormDialog> {
readOnly: widget.readOnly,
allowUsernameEdit: widget.allowUsernameEdit,
allowSectNameEdit: widget.allowUsernameEdit,
amicale: widget.amicale, // Passer l'amicale
isAdmin: widget.isAdmin, // Passer isAdmin
onSubmit: null, // Pas besoin de callback
),
],