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:
@@ -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
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
163
app/lib/core/services/logger_service.dart
Normal file
163
app/lib/core/services/logger_service.dart
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
229
app/lib/presentation/widgets/loading_spin_overlay.dart
Normal file
229
app/lib/presentation/widgets/loading_spin_overlay.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user