membre add

This commit is contained in:
d6soft
2025-06-11 09:27:25 +02:00
parent f3f1a9c5e8
commit 4244b961fd
40 changed files with 144003 additions and 143144 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,31 @@
Extension Discovery Cache
=========================
This folder is used by `package:extension_discovery` to cache lists of
packages that contains extensions for other packages.
DO NOT USE THIS FOLDER
----------------------
* Do not read (or rely) the contents of this folder.
* Do write to this folder.
If you're interested in the lists of extensions stored in this folder use the
API offered by package `extension_discovery` to get this information.
If this package doesn't work for your use-case, then don't try to read the
contents of this folder. It may change, and will not remain stable.
Use package `extension_discovery`
---------------------------------
If you want to access information from this folder.
Feel free to delete this folder
-------------------------------
Files in this folder act as a cache, and the cache is discarded if the files
are older than the modification time of `.dart_tool/package_config.json`.
Hence, it should never be necessary to clear this cache manually, if you find a
need to do please file a bug.

View File

@@ -0,0 +1 @@
{"version":2,"entries":[{"package":"geosector_app","rootUri":"../","packageUri":"lib/"}]}

File diff suppressed because one or more lines are too long

View File

@@ -1789,6 +1789,7 @@ file:///Users/pierre/dev/geosector/app/lib/core/services/hive_web_fix.dart
file:///Users/pierre/dev/geosector/app/lib/core/services/location_service.dart
file:///Users/pierre/dev/geosector/app/lib/core/services/sync_service.dart
file:///Users/pierre/dev/geosector/app/lib/core/theme/app_theme.dart
file:///Users/pierre/dev/geosector/app/lib/core/utils/api_exception.dart
file:///Users/pierre/dev/geosector/app/lib/main.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_amicale_page.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_communication_page.dart
@@ -1833,10 +1834,10 @@ file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/membre_row_widge
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/membre_table_widget.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/passages/passage_form.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/passages/passages_list_widget.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/profile_dialog.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/responsive_navigation.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/sector_distribution_card.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/user_form.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/user_form_dialog.dart
org-dartlang-sdk:///dart-sdk/lib/_http/crypto.dart
org-dartlang-sdk:///dart-sdk/lib/_http/embedder_config.dart
org-dartlang-sdk:///dart-sdk/lib/_http/http.dart

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1788,6 +1788,7 @@ file:///Users/pierre/dev/geosector/app/lib/core/services/hive_web_fix.dart
file:///Users/pierre/dev/geosector/app/lib/core/services/location_service.dart
file:///Users/pierre/dev/geosector/app/lib/core/services/sync_service.dart
file:///Users/pierre/dev/geosector/app/lib/core/theme/app_theme.dart
file:///Users/pierre/dev/geosector/app/lib/core/utils/api_exception.dart
file:///Users/pierre/dev/geosector/app/lib/main.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_amicale_page.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_communication_page.dart
@@ -1832,10 +1833,10 @@ file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/membre_row_widge
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/membre_table_widget.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/passages/passage_form.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/passages/passages_list_widget.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/profile_dialog.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/responsive_navigation.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/sector_distribution_card.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/user_form.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/user_form_dialog.dart
org-dartlang-sdk:///dart-sdk/lib/_http/crypto.dart
org-dartlang-sdk:///dart-sdk/lib/_http/embedder_config.dart
org-dartlang-sdk:///dart-sdk/lib/_http/http.dart

File diff suppressed because one or more lines are too long

View File

@@ -535,12 +535,6 @@
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "nested",
"rootUri": "file:///Users/pierre/.pub-cache/hosted/pub.dev/nested-1.0.0",
"packageUri": "lib/",
"languageVersion": "2.12"
},
{
"name": "nm",
"rootUri": "file:///Users/pierre/.pub-cache/hosted/pub.dev/nm-0.5.0",
@@ -655,12 +649,6 @@
"packageUri": "lib/",
"languageVersion": "2.12"
},
{
"name": "provider",
"rootUri": "file:///Users/pierre/.pub-cache/hosted/pub.dev/provider-6.1.5",
"packageUri": "lib/",
"languageVersion": "2.12"
},
{
"name": "pub_semver",
"rootUri": "file:///Users/pierre/.pub-cache/hosted/pub.dev/pub_semver-2.2.0",

View File

@@ -338,10 +338,6 @@ mqtt5_client
3.0
file:///Users/pierre/.pub-cache/hosted/pub.dev/mqtt5_client-4.11.0/
file:///Users/pierre/.pub-cache/hosted/pub.dev/mqtt5_client-4.11.0/lib/
nested
2.12
file:///Users/pierre/.pub-cache/hosted/pub.dev/nested-1.0.0/
file:///Users/pierre/.pub-cache/hosted/pub.dev/nested-1.0.0/lib/
nm
2.12
file:///Users/pierre/.pub-cache/hosted/pub.dev/nm-0.5.0/
@@ -418,10 +414,6 @@ proj4dart
2.12
file:///Users/pierre/.pub-cache/hosted/pub.dev/proj4dart-2.1.0/
file:///Users/pierre/.pub-cache/hosted/pub.dev/proj4dart-2.1.0/lib/
provider
2.12
file:///Users/pierre/.pub-cache/hosted/pub.dev/provider-6.1.5/
file:///Users/pierre/.pub-cache/hosted/pub.dev/provider-6.1.5/lib/
pub_semver
3.4
file:///Users/pierre/.pub-cache/hosted/pub.dev/pub_semver-2.2.0/

View File

@@ -5,7 +5,7 @@
"packages": [
{
"name": "geosector_app",
"version": "0.3.3",
"version": "0.3.4",
"dependencies": [
"connectivity_plus",
"cupertino_icons",
@@ -24,8 +24,6 @@
"latlong2",
"mqtt5_client",
"package_info_plus",
"path_provider",
"provider",
"retry",
"syncfusion_flutter_charts",
"universal_html",
@@ -336,27 +334,6 @@
"path"
]
},
{
"name": "provider",
"version": "6.1.5",
"dependencies": [
"collection",
"flutter",
"nested"
]
},
{
"name": "path_provider",
"version": "2.1.5",
"dependencies": [
"flutter",
"path_provider_android",
"path_provider_foundation",
"path_provider_linux",
"path_provider_platform_interface",
"path_provider_windows"
]
},
{
"name": "hive_flutter",
"version": "1.1.0",
@@ -1228,6 +1205,18 @@
"vector_graphics_codec"
]
},
{
"name": "path_provider",
"version": "2.1.5",
"dependencies": [
"flutter",
"path_provider_android",
"path_provider_foundation",
"path_provider_linux",
"path_provider_platform_interface",
"path_provider_windows"
]
},
{
"name": "nm",
"version": "0.5.0",
@@ -1264,59 +1253,6 @@
"typed_data"
]
},
{
"name": "nested",
"version": "1.0.0",
"dependencies": [
"flutter"
]
},
{
"name": "path_provider_windows",
"version": "2.3.0",
"dependencies": [
"ffi",
"flutter",
"path",
"path_provider_platform_interface"
]
},
{
"name": "path_provider_platform_interface",
"version": "2.1.2",
"dependencies": [
"flutter",
"platform",
"plugin_platform_interface"
]
},
{
"name": "path_provider_linux",
"version": "2.2.1",
"dependencies": [
"ffi",
"flutter",
"path",
"path_provider_platform_interface",
"xdg_directories"
]
},
{
"name": "path_provider_foundation",
"version": "2.4.1",
"dependencies": [
"flutter",
"path_provider_platform_interface"
]
},
{
"name": "path_provider_android",
"version": "2.2.16",
"dependencies": [
"flutter",
"path_provider_platform_interface"
]
},
{
"name": "sky_engine",
"version": "0.0.0",
@@ -1437,9 +1373,50 @@
]
},
{
"name": "platform",
"version": "3.1.6",
"dependencies": []
"name": "path_provider_windows",
"version": "2.3.0",
"dependencies": [
"ffi",
"flutter",
"path",
"path_provider_platform_interface"
]
},
{
"name": "path_provider_platform_interface",
"version": "2.1.2",
"dependencies": [
"flutter",
"platform",
"plugin_platform_interface"
]
},
{
"name": "path_provider_linux",
"version": "2.2.1",
"dependencies": [
"ffi",
"flutter",
"path",
"path_provider_platform_interface",
"xdg_directories"
]
},
{
"name": "path_provider_foundation",
"version": "2.4.1",
"dependencies": [
"flutter",
"path_provider_platform_interface"
]
},
{
"name": "path_provider_android",
"version": "2.2.16",
"dependencies": [
"flutter",
"path_provider_platform_interface"
]
},
{
"name": "petitparser",
@@ -1470,6 +1447,11 @@
"lists"
]
},
{
"name": "platform",
"version": "3.1.6",
"dependencies": []
},
{
"name": "lists",
"version": "1.0.1",

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -30972,31 +30972,6 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
nested
provider
MIT License
Copyright (c) 2019 Remi Rousselet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
nm

View File

@@ -39,6 +39,6 @@ _flutter.buildConfig = {"engineRevision":"1425e5e9ec5eeb4f225c401d8db69b860e0fde
_flutter.loader.load({
serviceWorkerSettings: {
serviceWorkerVersion: "3209567515"
serviceWorkerVersion: "4143491003"
}
});

View File

@@ -3,13 +3,13 @@ const MANIFEST = 'flutter-app-manifest';
const TEMP = 'flutter-temp-cache';
const CACHE_NAME = 'flutter-app-cache';
const RESOURCES = {"flutter_bootstrap.js": "dbeddb5a67a21614f83df7c02ea4f54e",
"version.json": "727f6f584c125faac83c6d2a4c96fb3d",
const RESOURCES = {"flutter_bootstrap.js": "d64cc0cfde96e267f6c2306a601e64e1",
"version.json": "b01f276d289a7da276afae267e1f955a",
"index.html": "2aab03d10fea3b608e3eddc0fc0077e5",
"/": "2aab03d10fea3b608e3eddc0fc0077e5",
"favicon-64.png": "259540a3217e969237530444ca0eaed3",
"favicon-16.png": "106142fb24eba190e475dbe6513cc9ff",
"main.dart.js": "50fc919ba8d82c79ce420d777c0230eb",
"main.dart.js": "201747c331c2e399470d7f971f0387a8",
"flutter.js": "83d881c1dbb6d6bcd6b42e274605b69c",
"favicon.png": "21510778ead066ac826ad69302400773",
"icons/Icon-192.png": "f36879dd176101fac324b68793e4683c",
@@ -22,14 +22,14 @@ const RESOURCES = {"flutter_bootstrap.js": "dbeddb5a67a21614f83df7c02ea4f54e",
"manifest.json": "4c436b37549165212484247d584e67cc",
"favicon-32.png": "21510778ead066ac826ad69302400773",
"assets/AssetManifest.json": "cee1bd4de4a781ec66f996f4b1313d84",
"assets/NOTICES": "32934942e5eaab159ae218933f3e08aa",
"assets/NOTICES": "6d34b19be383933cef4b4a76fb2beae8",
"assets/FontManifest.json": "2eb88ea349cfc4d8628e771303d003ca",
"assets/AssetManifest.bin.json": "6c9ed0d5d7f4ade08e6e07b6c6a6513f",
"assets/packages/cupertino_icons/assets/CupertinoIcons.ttf": "33b7d9392238c04c131b6ce224e13711",
"assets/packages/flutter_map/lib/assets/flutter_map_logo.png": "208d63cc917af9713fc9572bd5c09362",
"assets/shaders/ink_sparkle.frag": "ecc85a2e95f5e9f53123dcaf8cb9b6ce",
"assets/AssetManifest.bin": "bb9240a2148a79f4e1593ed3a51f47d0",
"assets/fonts/MaterialIcons-Regular.otf": "073156adb10885610c57bf589a14a1e1",
"assets/fonts/MaterialIcons-Regular.otf": "6adfc0c15b0e12095dad895cfa1299cb",
"assets/assets/images/geosector-logo.png": "b78408af5aa357b1107e1cb7be9e7c1e",
"assets/assets/images/logo-geosector-1024.png": "adb1be034f0b983acf6246369a794de5",
"assets/assets/images/icon-geosector.svg": "c9dd0fb514a53ee434b57895cf6cd5fd",

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"app_name":"geosector_app","version":"0.3.3","package_name":"geosector_app"}
{"app_name":"geosector_app","version":"0.3.4","package_name":"geosector_app"}

View File

@@ -9,7 +9,6 @@ import 'package:flutter/material.dart';
class AppKeys {
// Noms des boîtes Hive
static const String userBoxName = 'user';
static const String usersBoxNameOld = 'users';
static const String amicaleBoxName = 'amicale';
static const String clientsBoxName = 'clients';
static const String operationsBoxName = 'operations';

View File

@@ -1,4 +1,5 @@
import 'package:hive/hive.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
part 'membre_model.g.dart';
@@ -169,4 +170,46 @@ class MembreModel extends HiveObject {
isActive: isActive ?? this.isActive,
);
}
// Convertir un MembreModel vers UserModel pour l'édition
UserModel toUserModel() {
return UserModel(
id: id,
email: email,
name: name,
username: username,
firstName: firstName,
role: role,
createdAt: createdAt,
lastSyncedAt: DateTime.now(),
isActive: isActive,
isSynced: false,
fkEntite: fkEntite,
fkTitre: fkTitre,
phone: phone,
mobile: mobile,
dateNaissance: dateNaissance,
dateEmbauche: dateEmbauche,
sectName: sectName,
);
}
// Créer un MembreModel depuis un UserModel mis à jour
static MembreModel fromUserModel(UserModel user, MembreModel originalMembre) {
return originalMembre.copyWith(
name: user.name,
firstName: user.firstName,
username: user.username,
email: user.email,
fkEntite: user.fkEntite,
role: user.role,
sectName: user.sectName,
fkTitre: user.fkTitre,
phone: user.phone,
mobile: user.mobile,
dateNaissance: user.dateNaissance,
dateEmbauche: user.dateEmbauche,
isActive: user.isActive,
);
}
}

View File

@@ -99,13 +99,13 @@ class MembreRepository extends ChangeNotifier {
}
// Sauvegarder un membre
Future<void> saveMembre(MembreModel membre) async {
Future<void> saveMembreBox(MembreModel membre) async {
await _membreBox.put(membre.id, membre);
notifyListeners();
}
// Supprimer un membre
Future<void> deleteMembre(int id) async {
Future<void> deleteMembreBox(int id) async {
await _membreBox.delete(id);
notifyListeners();
}
@@ -113,48 +113,33 @@ class MembreRepository extends ChangeNotifier {
// === MÉTHODES API ===
// Créer un membre via l'API
Future<bool> createMembre(MembreModel membre) async {
Future<MembreModel?> createMembre(MembreModel membre) async {
_isLoading = true;
notifyListeners();
try {
// Préparer les données pour l'API - exclure l'id pour la création
final data = membre.toJson();
// Convertir en UserModel pour l'API
final userModel = membre.toUserModel();
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
// Appeler l'API pour créer le membre
final response = await ApiService.instance.post('/membres', data: data);
// Appeler l'API users
final response = await ApiService.instance.post('/users', data: data);
if (response.statusCode == 201 || response.statusCode == 200) {
// Récupérer l'ID du nouveau membre
final membreId = response.data['id'] is String ? int.parse(response.data['id']) : response.data['id'] as int;
// Créer le membre avec les données retournées par l'API
final createdMember = MembreModel.fromJson(response.data);
// Créer le membre localement avec l'ID retourné par l'API
final newMembre = MembreModel(
id: membreId,
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
await saveMembreBox(createdMember);
await saveMembre(newMembre);
return true;
return createdMember; // Retourner le membre créé
}
return false;
return null;
} catch (e) {
debugPrint('Erreur lors de la création du membre: $e');
return false;
rethrow; // Propager l'exception pour la gestion d'erreurs
} finally {
_isLoading = false;
notifyListeners();
@@ -167,15 +152,15 @@ class MembreRepository extends ChangeNotifier {
notifyListeners();
try {
// Préparer les données pour l'API
final data = membre.toJson();
// Convertir en UserModel pour l'API
final userModel = membre.toUserModel();
// Appeler l'API pour mettre à jour le membre
final response = await ApiService.instance.put('/membres/${membre.id}', data: data);
// Appeler l'API users au lieu de membres
final response = await ApiService.instance.put('/users/${membre.id}', data: userModel.toJson());
if (response.statusCode == 200) {
// Sauvegarder le membre mis à jour localement
await saveMembre(membre);
await saveMembreBox(membre);
return true;
}
@@ -190,17 +175,17 @@ class MembreRepository extends ChangeNotifier {
}
// Supprimer un membre via l'API
Future<bool> deleteMembreViaApi(int id) async {
Future<bool> deleteMembre(int id) async {
_isLoading = true;
notifyListeners();
try {
// Appeler l'API pour supprimer le membre
final response = await ApiService.instance.delete('/membres/$id');
// Appeler l'API users au lieu de membres (correction ici)
final response = await ApiService.instance.delete('/users/$id');
if (response.statusCode == 200 || response.statusCode == 204) {
// Supprimer le membre localement
await deleteMembre(id);
await deleteMembreBox(id);
return true;
}
@@ -259,12 +244,12 @@ class MembreRepository extends ChangeNotifier {
}
// Récupérer les membres depuis l'API
Future<List<MembreModel>> fetchMembresFromApi() async {
Future<List<MembreModel>> fetchMembres() async {
_isLoading = true;
notifyListeners();
try {
final response = await ApiService.instance.get('/membres');
final response = await ApiService.instance.get('/users');
if (response.statusCode == 200) {
final membresData = response.data;

View File

@@ -423,6 +423,49 @@ class UserRepository extends ChangeNotifier {
await _userBox.delete(id);
}
/// Mettre à jour un utilisateur (pour le profil personnel et la gestion des membres)
Future<UserModel> updateUser(UserModel updatedUser) async {
try {
debugPrint('🔄 Mise à jour utilisateur: ${updatedUser.email}');
// D'ABORD essayer de synchroniser avec l'API
try {
final hasConnection = await ApiService.instance.hasInternetConnection();
if (hasConnection) {
// Tentative de mise à jour sur l'API
await ApiService.instance.updateUser(updatedUser);
debugPrint('✅ Utilisateur mis à jour sur l\'API');
// Si succès API, sauvegarder localement avec sync = true
final syncedUser = updatedUser.copyWith(
isSynced: true,
lastSyncedAt: DateTime.now(),
);
await _userBox.put(syncedUser.id, syncedUser);
// Si c'est l'utilisateur connecté, mettre à jour le service
if (currentUser?.id == syncedUser.id) {
await CurrentUserService.instance.setUser(syncedUser);
}
notifyListeners();
return syncedUser;
} else {
debugPrint('⚠️ Pas de connexion internet');
throw Exception('Pas de connexion internet');
}
} catch (apiError) {
debugPrint('❌ Erreur API lors de la mise à jour: $apiError');
// Relancer l'erreur pour qu'elle soit gérée par l'appelant
rethrow;
}
} catch (e) {
debugPrint('❌ Erreur mise à jour utilisateur: $e');
rethrow;
}
}
// === MÉTHODES UTILITAIRES POUR LES DONNÉES ===
/// Récupérer la dernière opération active

View File

@@ -7,6 +7,7 @@ import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:retry/retry.dart';
import 'package:universal_html/html.dart' as html;
import 'package:geosector_app/core/utils/api_exception.dart';
class ApiService {
static ApiService? _instance;
@@ -179,31 +180,32 @@ class ApiService {
final response = await _dio.post(AppKeys.loginEndpoint, data: {
'username': username,
'password': password,
'type': type, // Ajouter le type de connexion (user ou admin)
'type': type,
});
// Vérifier la structure de la réponse
final data = response.data as Map<String, dynamic>;
final status = data['status'] as String?;
// Afficher le message en cas d'erreur
// Si le statut n'est pas 'success', créer une exception avec le message de l'API
if (status != 'success') {
final message = data['message'] as String?;
debugPrint('Erreur d\'authentification: $message');
final message = data['message'] as String? ?? 'Erreur de connexion';
throw ApiException(message);
}
// Si le statut est 'success', récupérer le session_id
if (status == 'success' && data.containsKey('session_id')) {
// Si succès, configurer la session
if (data.containsKey('session_id')) {
final sessionId = data['session_id'];
// Définir la session pour les futures requêtes
if (sessionId != null) {
setSessionId(sessionId);
}
}
return data;
} on DioException catch (e) {
throw ApiException.fromDioException(e);
} catch (e) {
rethrow;
if (e is ApiException) rethrow;
throw ApiException('Erreur inattendue lors de la connexion', originalError: e);
}
}
@@ -245,21 +247,39 @@ class ApiService {
}
}
Future<UserModel> updateUser(UserModel user) async {
try {
final response = await _dio.put('/users/${user.id}', data: user.toJson());
// Vérifier la structure de la réponse
final data = response.data as Map<String, dynamic>;
// Si l'API retourne {status: "success", message: "..."}
if (data.containsKey('status') && data['status'] == 'success') {
// L'API confirme le succès mais ne retourne pas l'objet user
// On retourne l'utilisateur original qui a été envoyé
debugPrint('✅ API updateUser success: ${data['message']}');
return user;
}
// Si l'API retourne directement un UserModel (fallback)
return UserModel.fromJson(data);
} on DioException catch (e) {
throw ApiException.fromDioException(e);
} catch (e) {
throw ApiException('Erreur inattendue lors de la mise à jour', originalError: e);
}
}
// Appliquer la même logique aux autres méthodes
Future<UserModel> createUser(UserModel user) async {
try {
final response = await _dio.post('/users', data: user.toJson());
return UserModel.fromJson(response.data);
} on DioException catch (e) {
throw ApiException.fromDioException(e);
} catch (e) {
rethrow;
}
}
Future<UserModel> updateUser(UserModel user) async {
try {
final response = await _dio.put('/users/${user.id}', data: user.toJson());
return UserModel.fromJson(response.data);
} catch (e) {
rethrow;
throw ApiException('Erreur inattendue lors de la création', originalError: e);
}
}

View File

@@ -0,0 +1,273 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'dart:async';
/// Exception personnalisée pour les erreurs API avec méthodes d'affichage intégrées
class ApiException implements Exception {
final String message;
final int? statusCode;
final String? errorCode;
final Map<String, dynamic>? details;
final Object? originalError;
const ApiException(
this.message, {
this.statusCode,
this.errorCode,
this.details,
this.originalError,
});
/// Créer une ApiException depuis une DioException
factory ApiException.fromDioException(DioException dioException) {
final response = dioException.response;
final statusCode = response?.statusCode;
// Essayer d'extraire le message de la réponse API
String message = 'Erreur de communication avec le serveur';
String? errorCode;
Map<String, dynamic>? details;
if (response?.data != null) {
try {
final data = response!.data as Map<String, dynamic>;
// Message spécifique de l'API
if (data.containsKey('message')) {
message = data['message'] as String;
}
// Code d'erreur spécifique
if (data.containsKey('error_code')) {
errorCode = data['error_code'] as String;
}
// Détails supplémentaires
if (data.containsKey('errors')) {
details = data['errors'] as Map<String, dynamic>?;
}
} catch (e) {
// Si on ne peut pas parser la réponse, utiliser le message par défaut
}
}
// Messages par défaut selon le code de statut
if (response?.data == null || message == 'Erreur de communication avec le serveur') {
switch (statusCode) {
case 400:
message = 'Données invalides';
break;
case 401:
message = 'Non autorisé : veuillez vous reconnecter';
break;
case 403:
message = 'Accès interdit';
break;
case 404:
message = 'Ressource non trouvée';
break;
case 409:
message = 'Conflit : données déjà existantes';
break;
case 422:
message = 'Données de validation incorrectes';
break;
case 500:
message = 'Erreur serveur interne';
break;
case 502:
case 503:
case 504:
message = 'Service temporairement indisponible';
break;
default:
switch (dioException.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
message = 'Délai d\'attente dépassé';
break;
case DioExceptionType.connectionError:
message = 'Problème de connexion réseau';
break;
case DioExceptionType.cancel:
message = 'Requête annulée';
break;
default:
message = 'Erreur de communication avec le serveur';
}
}
}
return ApiException(
message,
statusCode: statusCode,
errorCode: errorCode,
details: details,
originalError: dioException,
);
}
/// Créer une ApiException depuis n'importe quelle erreur
factory ApiException.fromError(Object error) {
if (error is ApiException) {
return error;
}
final errorString = error.toString();
if (errorString.contains('SocketException') || errorString.contains('NetworkException')) {
return const ApiException('Problème de connexion réseau');
}
if (errorString.contains('TimeoutException')) {
return const ApiException('Délai d\'attente dépassé');
}
return ApiException('Erreur inattendue', originalError: error);
}
@override
String toString() => message;
/// Obtenir un message d'erreur formaté pour l'affichage
String get displayMessage => message;
/// Vérifier si c'est une erreur de validation
bool get isValidationError => statusCode == 422 || statusCode == 400;
/// Vérifier si c'est une erreur d'authentification
bool get isAuthError => statusCode == 401 || statusCode == 403;
/// Vérifier si c'est une erreur de conflit (données déjà existantes)
bool get isConflictError => statusCode == 409;
/// Vérifier si c'est une erreur réseau
bool get isNetworkError =>
statusCode == null ||
statusCode == 502 ||
statusCode == 503 ||
statusCode == 504 ||
originalError is DioException && (originalError as DioException).type == DioExceptionType.connectionError;
// === MÉTHODES D'AFFICHAGE INTÉGRÉES ===
/// Afficher cette erreur (méthode d'instance)
void show(BuildContext context, {Duration? duration}) {
if (context.mounted) {
final isInDialog = _isInDialog(context);
if (isInDialog) {
_showOverlaySnackBar(context, displayMessage, Colors.red, duration);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(displayMessage),
backgroundColor: Colors.red,
duration: duration ?? const Duration(seconds: 5),
action: SnackBarAction(
label: 'Fermer',
textColor: Colors.white,
onPressed: () => ScaffoldMessenger.of(context).hideCurrentSnackBar(),
),
),
);
}
}
}
/// Méthode statique pour afficher une erreur depuis n'importe quel objet
static void showError(BuildContext context, Object error, {Duration? duration}) {
final apiException = ApiException.fromError(error);
apiException.show(context, duration: duration);
}
/// Méthode statique pour afficher un message de succès (compatible avec les Dialogs)
static void showSuccess(BuildContext context, String message, {Duration? duration}) {
if (context.mounted) {
final isInDialog = _isInDialog(context);
if (isInDialog) {
_showOverlaySnackBar(context, message, Colors.green, duration);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green,
duration: duration ?? const Duration(seconds: 3),
),
);
}
}
}
/// Vérifier si le contexte est dans un Dialog
static bool _isInDialog(BuildContext context) {
return context.findAncestorWidgetOfExactType<Dialog>() != null;
}
/// Afficher un SnackBar en overlay au-dessus de tout
static void _showOverlaySnackBar(BuildContext context, String message, Color backgroundColor, Duration? duration) {
final overlay = Overlay.of(context);
late OverlayEntry overlayEntry;
overlayEntry = OverlayEntry(
builder: (context) => Positioned(
top: MediaQuery.of(context).padding.top + 20, // En haut de l'écran
left: 16,
right: 16,
child: Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Icon(
backgroundColor == Colors.red ? Icons.error_outline : Icons.check_circle_outline,
color: Colors.white,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white, size: 18),
onPressed: () => overlayEntry.remove(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 30, minHeight: 30),
),
],
),
),
),
),
);
overlay.insert(overlayEntry);
// Auto-suppression après la durée spécifiée
Timer(duration ?? const Duration(seconds: 5), () {
if (overlayEntry.mounted) {
overlayEntry.remove();
}
});
}
}

View File

@@ -5,11 +5,13 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/presentation/widgets/user_form_dialog.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/core/repositories/membre_repository.dart';
import 'package:geosector_app/presentation/widgets/amicale_table_widget.dart';
import 'package:geosector_app/presentation/widgets/membre_table_widget.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
/// Class pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@@ -93,30 +95,86 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
}
void _handleEditMembre(MembreModel membre) {
showDialog(
context: context,
builder: (context) => UserFormDialog(
title: 'Modifier le membre',
user: membre.toUserModel(),
showRoleSelector: true,
showActiveCheckbox: true, // Activer la checkbox
allowUsernameEdit: true, // Permettre l'édition du username
// allowSectNameEdit sera automatiquement true via UserForm
availableRoles: const [
RoleOption(
value: 1,
label: 'Membre',
description: 'Peut consulter et distribuer dans ses secteurs',
),
RoleOption(
value: 2,
label: 'Administrateur',
description: 'Peut gérer l\'amicale et ses membres',
),
],
onSubmit: (updatedUser) 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);
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');
if (mounted) {
ApiException.showError(context, e);
}
}
},
),
);
}
void _handleDeleteMembre(MembreModel membre) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Modifier le membre'),
content: Text('Voulez-vous modifier le membre ${membre.firstName} ${membre.name} ?'),
title: const Text('Confirmer la suppression'),
content: Text('Voulez-vous vraiment supprimer le membre ${membre.firstName} ${membre.name} ?\n\nCette action est irréversible.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
onPressed: () async {
Navigator.of(context).pop();
// TODO: Naviguer vers la page de modification
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => EditMembrePage(
// membre: membre,
// membreRepository: widget.membreRepository,
// ),
// ),
// );
try {
// Utiliser la méthode qui passe par l'API
final success = await widget.membreRepository.deleteMembre(membre.id);
if (success && mounted) {
ApiException.showSuccess(context, 'Membre ${membre.firstName} ${membre.name} supprimé avec succès');
} else if (!success && mounted) {
ApiException.showError(context, Exception('Erreur lors de la suppression'));
}
} catch (e) {
if (mounted) {
ApiException.showError(context, e);
}
}
},
child: const Text('Modifier'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Supprimer'),
),
],
),
@@ -126,15 +184,82 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
void _handleAddMembre() {
if (_currentUser?.fkEntite == null) return;
// TODO: Naviguer vers la page d'ajout de membre
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => AddMembrePage(
// amicaleId: _currentUser!.fkEntite!,
// membreRepository: widget.membreRepository,
// ),
// ),
// );
// Créer un UserModel vide avec les valeurs par défaut
final newUser = UserModel(
id: 0, // ID temporaire pour nouveau membre
username: '',
firstName: '',
name: '',
sectName: '',
phone: '',
mobile: '',
email: '',
fkTitre: 1, // Par défaut M.
fkEntite: _currentUser!.fkEntite!, // Association à l'amicale courante
role: 1, // Par défaut membre
isActive: true, // Par défaut actif
createdAt: DateTime.now(),
lastSyncedAt: DateTime.now(),
);
showDialog(
context: context,
builder: (context) => UserFormDialog(
title: 'Ajouter un nouveau membre',
user: newUser,
showRoleSelector: true,
showActiveCheckbox: true,
allowUsernameEdit: true,
availableRoles: const [
RoleOption(
value: 1,
label: 'Membre',
description: 'Peut consulter et distribuer dans ses secteurs',
),
RoleOption(
value: 2,
label: 'Administrateur',
description: 'Peut gérer l\'amicale et ses membres',
),
],
onSubmit: (newUserData) async {
try {
// Créer un nouveau MembreModel directement
final newMembre = MembreModel(
id: 0, // L'API assignera un vrai ID
username: newUserData.username,
firstName: newUserData.firstName,
name: newUserData.name,
sectName: newUserData.sectName,
phone: newUserData.phone,
mobile: newUserData.mobile,
email: newUserData.email,
fkTitre: newUserData.fkTitre,
fkEntite: newUserData.fkEntite!,
role: newUserData.role,
isActive: newUserData.isActive,
dateNaissance: newUserData.dateNaissance,
dateEmbauche: newUserData.dateEmbauche,
createdAt: DateTime.now(),
);
final createdMembre = await widget.membreRepository.createMembre(newMembre);
if (createdMembre != null && mounted) {
Navigator.of(context).pop();
ApiException.showSuccess(context, 'Membre ${createdMembre.firstName} ${createdMembre.name} ajouté avec succès');
} else if (mounted) {
ApiException.showError(context, Exception('Erreur lors de la création du membre'));
}
} catch (e) {
debugPrint('❌ Erreur création membre: $e');
if (mounted) {
ApiException.showError(context, e);
}
}
},
),
);
}
@override
@@ -328,7 +453,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
child: MembreTableWidget(
membres: membres,
onEdit: _handleEditMembre,
onDelete: null, // Géré par l'admin principal
onDelete: _handleDeleteMembre,
membreRepository: widget.membreRepository,
),
),

View File

@@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/services/app_info_service.dart';
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
import 'package:geosector_app/presentation/widgets/profile_dialog.dart';
import 'package:geosector_app/presentation/widgets/user_form_dialog.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:go_router/go_router.dart';
/// AppBar personnalisée pour les tableaux de bord
@@ -117,10 +118,32 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
icon: const Icon(Icons.person),
tooltip: 'Mon compte',
onPressed: () {
// Afficher la boîte de dialogue de profil avec l'utilisateur actuel
// Afficher la boîte de dialogue UserForm avec l'utilisateur actuel
final user = userRepository.currentUser;
if (user != null) {
ProfileDialog.show(context, user);
showDialog(
context: context,
builder: (context) => UserFormDialog(
title: 'Mon compte',
user: user,
readOnly: false,
showRoleSelector: false,
onSubmit: (updatedUser) async {
try {
// Sauvegarder les modifications de l'utilisateur
await userRepository.updateUser(updatedUser);
if (context.mounted) {
Navigator.of(context).pop();
ApiException.showSuccess(context, 'Profil mis à jour');
}
} catch (e) {
debugPrint('❌ Erreur mise à jour de votre profil: $e');
ApiException.showError(context, e);
}
},
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:geosector_app/core/services/api_service.dart';
/// Widget qui affiche les informations sur l'environnement actuel
@@ -8,13 +7,13 @@ class EnvironmentInfoWidget extends StatelessWidget {
final bool showInDialog;
const EnvironmentInfoWidget({
Key? key,
super.key,
this.showInDialog = false,
}) : super(key: key);
});
@override
Widget build(BuildContext context) {
final apiService = Provider.of<ApiService>(context, listen: false);
final apiService = ApiService.instance;
final environment = apiService.getCurrentEnvironment();
final apiUrl = apiService.getCurrentApiUrl();
final appIdentifier = apiService.getCurrentAppIdentifier();
@@ -27,9 +26,7 @@ class EnvironmentInfoWidget extends StatelessWidget {
children: [
Text(
'🌍 Environnement GeoSector',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: _getEnvironmentColor(environment)),
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold, color: _getEnvironmentColor(environment)),
),
const SizedBox(height: 16),
_buildInfoRow(context, 'Environnement', environment),
@@ -70,10 +67,7 @@ class EnvironmentInfoWidget extends StatelessWidget {
width: 120,
child: Text(
label,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
Expanded(

View File

@@ -6,6 +6,7 @@ class MembreRowWidget extends StatelessWidget {
final Function(MembreModel)? onEdit;
final Function(MembreModel)? onDelete;
final bool isAlternate;
final VoidCallback? onTap;
const MembreRowWidget({
super.key,
@@ -13,6 +14,7 @@ class MembreRowWidget extends StatelessWidget {
this.onEdit,
this.onDelete,
this.isAlternate = false,
this.onTap,
});
@override
@@ -20,43 +22,45 @@ class MembreRowWidget extends StatelessWidget {
final theme = Theme.of(context);
// Couleur de fond alternée
final backgroundColor = isAlternate ? theme.colorScheme.surface : theme.colorScheme.surface;
final backgroundColor = isAlternate ? theme.colorScheme.primary.withValues(alpha: 0.05) : Colors.transparent;
return InkWell(
onTap: () => _showMembreDetails(context),
// Envelopper le contenu dans un InkWell
onTap: onTap, // Utiliser le callback onTap
hoverColor: theme.colorScheme.primary.withValues(alpha: 0.15),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
decoration: BoxDecoration(
color: backgroundColor,
),
child: Row(
children: [
// ... existing row content ...
// ID
Expanded(
flex: 1,
child: Text(
membre.id.toString(),
membre.id.toString() ?? '',
style: theme.textTheme.bodyMedium,
),
),
// Prénom (firstName)
// Prénom
Expanded(
flex: 2,
child: Text(
membre.firstName ?? '',
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
// Nom (name)
// Nom
Expanded(
flex: 2,
child: Text(
membre.name ?? '',
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
@@ -64,13 +68,12 @@ class MembreRowWidget extends StatelessWidget {
Expanded(
flex: 3,
child: Text(
membre.email,
membre.email ?? '',
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
// Rôle (role au lieu de fkRole)
// Rôle
Expanded(
flex: 1,
child: Text(
@@ -79,22 +82,19 @@ class MembreRowWidget extends StatelessWidget {
),
),
// Statut (isActive au lieu de chkActive)
// Statut
Expanded(
flex: 1,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
decoration: BoxDecoration(
color: membre.isActive ? Colors.green.withOpacity(0.1) : Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: membre.isActive ? Colors.green.withOpacity(0.3) : Colors.red.withOpacity(0.3),
),
color: _getStatusColor(membre.isActive),
borderRadius: BorderRadius.circular(12.0),
),
child: Text(
membre.isActive ? 'Actif' : 'Inactif',
membre.isActive == true ? 'Actif' : 'Inactif',
style: theme.textTheme.bodySmall?.copyWith(
color: membre.isActive ? Colors.green[700] : Colors.red[700],
color: Colors.white,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
@@ -109,43 +109,12 @@ class MembreRowWidget extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Bouton Edit
if (onEdit != null)
IconButton(
icon: Icon(
Icons.edit,
size: 20,
color: theme.colorScheme.primary,
),
onPressed: () => onEdit!(membre),
tooltip: 'Modifier',
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
// Espacement entre les boutons
if (onEdit != null && onDelete != null) const SizedBox(width: 8),
// Bouton Delete
if (onDelete != null)
IconButton(
icon: Icon(
Icons.delete,
size: 20,
color: theme.colorScheme.error,
),
icon: const Icon(Icons.delete, size: 22),
onPressed: () => onDelete!(membre),
tooltip: 'Supprimer',
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
color: theme.colorScheme.error,
),
],
),
@@ -212,14 +181,18 @@ class MembreRowWidget extends StatelessWidget {
);
}
Color _getStatusColor(bool? isActive) {
return isActive == true ? Colors.green : Colors.red;
}
// Méthode pour convertir l'ID de rôle en nom lisible
String _getRoleName(int roleId) {
switch (roleId) {
case 1:
return 'User';
return 'Membre';
case 2:
return 'Admin';
case 3:
case 9:
return 'Super';
default:
return roleId.toString();

View File

@@ -48,10 +48,15 @@ class MembreTableWidget extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête du tableau
// En-tête du tableau avec fond grisé
if (showHeader)
Padding(
padding: const EdgeInsets.only(bottom: 16.0, left: 16.0, right: 16.0),
Container(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
margin: const EdgeInsets.only(bottom: 16.0),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(4.0),
),
child: Row(
children: [
// ID
@@ -184,6 +189,7 @@ class MembreTableWidget extends StatelessWidget {
onEdit: onEdit,
onDelete: onDelete,
isAlternate: index % 2 == 1,
onTap: onEdit != null ? () => onEdit!(membre) : null,
);
},
);

View File

@@ -1,268 +0,0 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/presentation/widgets/user_form.dart';
class ProfileDialog extends StatefulWidget {
final UserModel user;
const ProfileDialog({
Key? key,
required this.user,
}) : super(key: key);
/// Méthode statique pour afficher la boîte de dialogue
static Future<bool?> show(BuildContext context, UserModel user) {
return showDialog<bool>(
context: context,
builder: (context) => ProfileDialog(user: user),
);
}
@override
State<ProfileDialog> createState() => _ProfileDialogState();
}
class _ProfileDialogState extends State<ProfileDialog> {
final _formKey = GlobalKey<FormState>();
late UserModel _user;
bool _isLoading = false;
@override
void initState() {
super.initState();
_user = widget.user;
}
// Fonction pour capitaliser la première lettre de chaque mot
String _capitalizeFirstLetter(String text) {
if (text.isEmpty) return text;
return text.split(' ').map((word) {
if (word.isEmpty) return word;
return word[0].toUpperCase() + word.substring(1).toLowerCase();
}).join(' ');
}
// Fonction pour mettre en majuscule
String _toUpperCase(String text) {
return text.toUpperCase();
}
// Fonction pour valider et soumettre le formulaire
Future<void> _saveProfile(UserModel updatedUser) async {
// Validation supplémentaire
if (!_validateUser(updatedUser)) {
return;
}
setState(() {
_isLoading = true;
});
try {
// Formatage des données
final formattedUser = updatedUser.copyWith(
name: _toUpperCase(updatedUser.name ?? ''),
firstName: _capitalizeFirstLetter(updatedUser.firstName ?? ''),
);
// Sauvegarde de l'utilisateur
await userRepository.saveUser(formattedUser);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Profil mis à jour avec succès'),
backgroundColor: Colors.green,
),
);
Navigator.of(context).pop(true); // Fermer la modale avec succès
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la mise à jour du profil: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
// Validation supplémentaire
bool _validateUser(UserModel user) {
// Vérifier que l'email est valide
if (user.email.isEmpty ||
!user.email.contains('@') ||
!user.email.contains('.')) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez entrer une adresse email valide'),
backgroundColor: Colors.red,
),
);
return false;
}
// Vérifier que le nom ou le sectName est renseigné
if ((user.name == null || user.name!.isEmpty) &&
(user.sectName == null || user.sectName!.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Le nom ou le nom du secteur doit être renseigné'),
backgroundColor: Colors.red,
),
);
return false;
}
// Vérifier que le téléphone fixe est valide s'il est renseigné
if (user.phone != null &&
user.phone!.isNotEmpty &&
user.phone!.length != 10) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Le numéro de téléphone fixe doit contenir 10 chiffres'),
backgroundColor: Colors.red,
),
);
return false;
}
// Vérifier que le téléphone mobile est valide s'il est renseigné
if (user.mobile != null &&
user.mobile!.isNotEmpty &&
user.mobile!.length != 10) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Le numéro de téléphone mobile doit contenir 10 chiffres'),
backgroundColor: Colors.red,
),
);
return false;
}
return true;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
constraints: const BoxConstraints(
maxWidth: 600,
maxHeight: 700,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Mon compte',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
tooltip: 'Fermer',
),
],
),
const Divider(),
const SizedBox(height: 10),
// Formulaire
Expanded(
child: SingleChildScrollView(
child: UserForm(
user: _user,
onSubmit: _saveProfile,
),
),
),
// Boutons
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed:
_isLoading ? null : () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 12),
),
child: const Text('Fermer'),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: _isLoading
? null
: () {
// Appeler directement la méthode onSubmit du UserForm
// qui va déclencher la validation et la soumission
_saveProfile(_user);
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 12),
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text('Enregistrer'),
),
],
),
],
),
),
);
}
}

View File

@@ -8,13 +8,17 @@ class UserForm extends StatefulWidget {
final UserModel? user;
final Function(UserModel)? onSubmit;
final bool readOnly;
final bool allowUsernameEdit;
final bool allowSectNameEdit;
const UserForm({
Key? key,
super.key,
this.user,
this.onSubmit,
this.readOnly = false,
}) : super(key: key);
this.allowUsernameEdit = false,
this.allowSectNameEdit = false,
});
@override
State<UserForm> createState() => _UserFormState();
@@ -27,6 +31,7 @@ class _UserFormState extends State<UserForm> {
late final TextEditingController _usernameController;
late final TextEditingController _firstNameController;
late final TextEditingController _nameController;
late final TextEditingController _sectNameController;
late final TextEditingController _phoneController;
late final TextEditingController _mobileController;
late final TextEditingController _emailController;
@@ -47,6 +52,7 @@ class _UserFormState extends State<UserForm> {
_usernameController = TextEditingController(text: user?.username ?? '');
_firstNameController = TextEditingController(text: user?.firstName ?? '');
_nameController = TextEditingController(text: user?.name ?? '');
_sectNameController = TextEditingController(text: user?.sectName ?? '');
_phoneController = TextEditingController(text: user?.phone ?? '');
_mobileController = TextEditingController(text: user?.mobile ?? '');
_emailController = TextEditingController(text: user?.email ?? '');
@@ -54,15 +60,9 @@ class _UserFormState extends State<UserForm> {
_dateNaissance = user?.dateNaissance;
_dateEmbauche = user?.dateEmbauche;
_dateNaissanceController = TextEditingController(
text: _dateNaissance != null
? DateFormat('dd/MM/yyyy').format(_dateNaissance!)
: '');
_dateNaissanceController = TextEditingController(text: _dateNaissance != null ? DateFormat('dd/MM/yyyy').format(_dateNaissance!) : '');
_dateEmbaucheController = TextEditingController(
text: _dateEmbauche != null
? DateFormat('dd/MM/yyyy').format(_dateEmbauche!)
: '');
_dateEmbaucheController = TextEditingController(text: _dateEmbauche != null ? DateFormat('dd/MM/yyyy').format(_dateEmbauche!) : '');
_fkTitre = user?.fkTitre ?? 1;
}
@@ -72,6 +72,7 @@ class _UserFormState extends State<UserForm> {
_usernameController.dispose();
_firstNameController.dispose();
_nameController.dispose();
_sectNameController.dispose();
_phoneController.dispose();
_mobileController.dispose();
_emailController.dispose();
@@ -80,6 +81,19 @@ class _UserFormState extends State<UserForm> {
super.dispose();
}
// Validation conditionnelle pour name/sectName
String? _validateNameOrSectName(String? value, bool isNameField) {
final nameValue = _nameController.text.trim();
final sectNameValue = _sectNameController.text.trim();
// Si les deux sont vides
if (nameValue.isEmpty && sectNameValue.isEmpty) {
return isNameField ? "Veuillez renseigner soit le nom soit le nom de tournée" : "Veuillez renseigner soit le nom de tournée soit le nom";
}
return null; // Validation OK si au moins un des deux est rempli
}
// Méthode simplifiée pour sélectionner une date
void _selectDate(BuildContext context, bool isDateNaissance) {
// Utiliser un bloc try-catch pour capturer toutes les erreurs possibles
@@ -98,12 +112,10 @@ class _UserFormState extends State<UserForm> {
// Mettre à jour la date et le texte du contrôleur
if (isDateNaissance) {
_dateNaissance = picked;
_dateNaissanceController.text =
DateFormat('dd/MM/yyyy').format(picked);
_dateNaissanceController.text = DateFormat('dd/MM/yyyy').format(picked);
} else {
_dateEmbauche = picked;
_dateEmbaucheController.text =
DateFormat('dd/MM/yyyy').format(picked);
_dateEmbaucheController.text = DateFormat('dd/MM/yyyy').format(picked);
}
});
}
@@ -111,7 +123,7 @@ class _UserFormState extends State<UserForm> {
// Gérer les erreurs spécifiques au sélecteur de date
debugPrint('Erreur lors de la sélection de la date: $error');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
const SnackBar(
content: Text('Erreur lors de la sélection de la date'),
backgroundColor: Colors.red,
),
@@ -121,7 +133,7 @@ class _UserFormState extends State<UserForm> {
// Gérer toutes les autres erreurs
debugPrint('Exception lors de l\'affichage du sélecteur de date: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
const SnackBar(
content: Text('Impossible d\'afficher le sélecteur de date'),
backgroundColor: Colors.red,
),
@@ -129,11 +141,14 @@ class _UserFormState extends State<UserForm> {
}
}
void _submitForm() {
// Méthode publique pour valider et récupérer l'utilisateur
UserModel? validateAndGetUser() {
if (_formKey.currentState!.validate()) {
final user = widget.user?.copyWith(
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,
@@ -142,42 +157,113 @@ class _UserFormState extends State<UserForm> {
dateEmbauche: _dateEmbauche,
) ??
UserModel(
id: 0, // Sera remplacé par l'API
id: 0,
username: _usernameController.text,
firstName: _firstNameController.text,
name: _nameController.text,
sectName: _sectNameController.text,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
fkTitre: _fkTitre,
dateNaissance: _dateNaissance,
dateEmbauche: _dateEmbauche,
role: 1, // Valeur par défaut
role: 1,
createdAt: DateTime.now(),
lastSyncedAt: DateTime.now(),
);
if (widget.onSubmit != null) {
widget.onSubmit!(user);
}
}
return null;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isWideScreen = MediaQuery.of(context).size.width > 900;
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom d'utilisateur (en lecture seule)
CustomTextField(
controller: _usernameController,
label: "Nom d'utilisateur",
readOnly: true, // Toujours en lecture seule
prefixIcon: Icons.account_circle,
),
// 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;
},
),
],
const SizedBox(height: 16),
// Titre (M. ou Mme)
@@ -188,7 +274,7 @@ class _UserFormState extends State<UserForm> {
"Titre",
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
@@ -225,115 +311,210 @@ class _UserFormState extends State<UserForm> {
),
const SizedBox(height: 16),
// Prénom
CustomTextField(
controller: _firstNameController,
label: "Prénom",
readOnly: widget.readOnly,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le prénom";
}
return null;
},
),
const SizedBox(height: 16),
// Nom
CustomTextField(
controller: _nameController,
label: "Nom",
readOnly: widget.readOnly,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom";
}
return null;
},
),
const SizedBox(height: 16),
// Téléphone fixe
CustomTextField(
controller: _phoneController,
label: "Téléphone fixe",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro de téléphone doit contenir 10 chiffres";
}
return null;
},
),
const SizedBox(height: 16),
// Téléphone mobile
CustomTextField(
controller: _mobileController,
label: "Téléphone mobile",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro de mobile doit contenir 10 chiffres";
}
return null;
},
),
const SizedBox(height: 16),
// Email
CustomTextField(
controller: _emailController,
label: "Email",
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
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),
// Date de naissance
CustomTextField(
controller: _dateNaissanceController,
label: "Date de naissance",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, true),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
// Ligne 2: Prénom et Nom
if (isWideScreen)
Row(
children: [
Expanded(
child: CustomTextField(
controller: _firstNameController,
label: "Prénom",
readOnly: widget.readOnly,
),
),
const SizedBox(width: 16),
Expanded(
child: CustomTextField(
controller: _nameController,
label: "Nom",
readOnly: widget.readOnly,
validator: (value) => _validateNameOrSectName(value, true),
onChanged: (value) {
// Revalider sectName quand name change
if (widget.allowSectNameEdit) {
_formKey.currentState?.validate();
}
},
),
),
],
)
else ...[
// Version mobile: Prénom et nom séparés
CustomTextField(
controller: _firstNameController,
label: "Prénom",
readOnly: widget.readOnly,
),
),
const SizedBox(height: 16),
CustomTextField(
controller: _nameController,
label: "Nom",
readOnly: widget.readOnly,
validator: (value) => _validateNameOrSectName(value, true),
onChanged: (value) {
// Revalider sectName quand name change
if (widget.allowSectNameEdit) {
_formKey.currentState?.validate();
}
},
),
],
const SizedBox(height: 16),
// Date d'embauche
CustomTextField(
controller: _dateEmbaucheController,
label: "Date d'embauche",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, false),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
// Ligne 2.5: Nom de tournée (sectName) - uniquement si éditable
if (widget.allowSectNameEdit) ...[
CustomTextField(
controller: _sectNameController,
label: "Nom de tournée",
readOnly: widget.readOnly,
validator: (value) => _validateNameOrSectName(value, false),
onChanged: (value) {
// Revalider name quand sectName change
_formKey.currentState?.validate();
},
hintText: "Nom utilisé pour identifier la tournée",
),
),
// Espace en bas du formulaire
const SizedBox(height: 16),
],
// Ligne 3: Téléphones (fixe et mobile)
if (isWideScreen)
Row(
children: [
Expanded(
child: CustomTextField(
controller: _phoneController,
label: "Téléphone fixe",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro doit contenir 10 chiffres";
}
return null;
},
),
),
const SizedBox(width: 16),
Expanded(
child: CustomTextField(
controller: _mobileController,
label: "Téléphone mobile",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro doit contenir 10 chiffres";
}
return null;
},
),
),
],
)
else ...[
// Version mobile: Téléphones séparés
CustomTextField(
controller: _phoneController,
label: "Téléphone fixe",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro doit contenir 10 chiffres";
}
return null;
},
),
const SizedBox(height: 16),
CustomTextField(
controller: _mobileController,
label: "Téléphone mobile",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro doit contenir 10 chiffres";
}
return null;
},
),
],
const SizedBox(height: 16),
// Ligne 4: Dates (naissance et embauche)
if (isWideScreen)
Row(
children: [
Expanded(
child: CustomTextField(
controller: _dateNaissanceController,
label: "Date de naissance",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, true),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
),
const SizedBox(width: 16),
Expanded(
child: CustomTextField(
controller: _dateEmbaucheController,
label: "Date d'embauche",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, false),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
),
],
)
else ...[
// Version mobile: Dates séparées
CustomTextField(
controller: _dateNaissanceController,
label: "Date de naissance",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, true),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 16),
CustomTextField(
controller: _dateEmbaucheController,
label: "Date d'embauche",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, false),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
],
const SizedBox(height: 16),
],
),
@@ -360,7 +541,7 @@ class _UserFormState extends State<UserForm> {
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onBackground,
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
@@ -368,3 +549,6 @@ class _UserFormState extends State<UserForm> {
);
}
}
// Exporter la classe State pour pouvoir l'utiliser avec GlobalKey
typedef UserFormState = _UserFormState;

View File

@@ -0,0 +1,237 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/user_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 bool showRoleSelector;
final List<RoleOption>? availableRoles;
final bool showActiveCheckbox;
final bool allowUsernameEdit;
const UserFormDialog({
super.key,
this.user,
required this.title,
this.readOnly = false,
this.onSubmit,
this.showRoleSelector = false,
this.availableRoles,
this.showActiveCheckbox = false,
this.allowUsernameEdit = false,
});
@override
State<UserFormDialog> createState() => _UserFormDialogState();
}
class RoleOption {
final int value;
final String label;
final String description;
const RoleOption({
required this.value,
required this.label,
required this.description,
});
}
class _UserFormDialogState extends State<UserFormDialog> {
final GlobalKey<UserFormState> _userFormKey = GlobalKey<UserFormState>();
int? _selectedRole;
bool? _isActive;
@override
void initState() {
super.initState();
_selectedRole = widget.user?.role;
_isActive = widget.user?.isActive ?? true; // Initialiser le statut actif
}
void _handleSubmit() async {
// Utiliser la méthode validateAndGetUser du UserForm
final userData = _userFormKey.currentState?.validateAndGetUser();
if (userData != null) {
var finalUser = userData;
// Ajouter le rôle sélectionné si applicable
if (widget.showRoleSelector && _selectedRole != null) {
finalUser = finalUser.copyWith(role: _selectedRole);
}
// Ajouter le statut actif si applicable
if (widget.showActiveCheckbox && _isActive != null) {
finalUser = finalUser.copyWith(isActive: _isActive);
}
if (widget.onSubmit != null) {
widget.onSubmit!(finalUser);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
width: MediaQuery.of(context).size.width * 0.5,
constraints: const BoxConstraints(
maxWidth: 600,
maxHeight: 700,
),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const Divider(),
// Contenu du formulaire
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Sélecteur de rôle (si activé)
if (widget.showRoleSelector && widget.availableRoles != null) ...[
Text(
'Rôle dans l\'amicale',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: widget.availableRoles!.map((role) {
return RadioListTile<int>(
title: Text(role.label),
subtitle: Text(
role.description,
style: theme.textTheme.bodySmall,
),
value: role.value,
groupValue: _selectedRole,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_selectedRole = value;
});
},
activeColor: theme.colorScheme.primary,
);
}).toList(),
),
),
const SizedBox(height: 16),
],
// Checkbox Statut Actif (si activé)
if (widget.showActiveCheckbox) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: CheckboxListTile(
title: Text(
'Compte actif',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
_isActive == true ? 'Le membre peut se connecter et utiliser l\'application' : 'Le membre ne peut pas se connecter',
style: theme.textTheme.bodySmall,
),
value: _isActive,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_isActive = value ?? true;
});
},
activeColor: theme.colorScheme.primary,
controlAffinity: ListTileControlAffinity.leading,
),
),
const SizedBox(height: 16),
],
// Formulaire utilisateur avec la clé
UserForm(
key: _userFormKey,
user: widget.user,
readOnly: widget.readOnly,
allowUsernameEdit: widget.allowUsernameEdit,
allowSectNameEdit: widget.allowUsernameEdit,
onSubmit: null, // Pas besoin de callback
),
],
),
),
),
const SizedBox(height: 24),
// Footer avec boutons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const SizedBox(width: 16),
if (!widget.readOnly)
ElevatedButton(
onPressed: _handleSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
),
child: const Text('Enregistrer'),
),
],
),
],
),
),
);
}
}

View File

@@ -701,14 +701,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.11.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
nm:
dependency: transitive
description:
@@ -758,7 +750,7 @@ packages:
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@@ -861,14 +853,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84"
url: "https://pub.dev"
source: hosted
version: "6.1.5"
pub_semver:
dependency: transitive
description:

View File

@@ -1,7 +1,7 @@
name: geosector_app
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
publish_to: 'none'
version: 0.3.3
version: 0.3.4
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -17,8 +17,6 @@ dependencies:
# État et gestion des données
hive: ^2.2.3
hive_flutter: ^1.1.0
path_provider: ^2.1.1
provider: ^6.1.2
# API & Réseau
dio: ^5.3.3