membre add

This commit is contained in:
d6soft
2025-06-11 09:27:25 +02:00
parent 511be5a535
commit ace38d4025
34 changed files with 72757 additions and 73229 deletions

View File

@@ -26,10 +26,6 @@ class UserController {
$this->appConfig = AppConfig::getInstance(); $this->appConfig = AppConfig::getInstance();
} }
public function getUsers(): void { public function getUsers(): void {
Session::requireAuth(); Session::requireAuth();
@@ -53,7 +49,7 @@ class UserController {
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
SELECT SELECT
u.id, u.id,
u.encrypt_email, u.encrypted_email,
u.encrypted_name, u.encrypted_name,
u.first_name, u.first_name,
u.fk_role as role, u.fk_role as role,
@@ -71,7 +67,7 @@ class UserController {
// Déchiffrement des données sensibles pour chaque utilisateur // Déchiffrement des données sensibles pour chaque utilisateur
foreach ($users as &$user) { foreach ($users as &$user) {
$user['email'] = ApiService::decryptSearchableData($user['encrypt_email']); $user['email'] = ApiService::decryptSearchableData($user['encrypted_email']);
$user['name'] = ApiService::decryptData($user['encrypted_name']); $user['name'] = ApiService::decryptData($user['encrypted_name']);
if (!empty($user['entite_name'])) { if (!empty($user['entite_name'])) {
@@ -79,7 +75,7 @@ class UserController {
} }
// Suppression des champs chiffrés // Suppression des champs chiffrés
unset($user['encrypt_email']); unset($user['encrypted_email']);
unset($user['encrypted_name']); unset($user['encrypted_name']);
} }
@@ -123,12 +119,12 @@ class UserController {
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
SELECT SELECT
u.id, u.id,
u.encrypt_email, u.encrypted_email,
u.encrypted_name, u.encrypted_name,
u.first_name, u.first_name,
u.sect_name, u.sect_name,
u.encrypt_phone, u.encrypted_phone,
u.encrypt_mobile, u.encrypted_mobile,
u.fk_role as role, u.fk_role as role,
u.fk_entite, u.fk_entite,
u.infos, u.infos,
@@ -162,20 +158,20 @@ class UserController {
} }
// Déchiffrement des données sensibles // Déchiffrement des données sensibles
$user['email'] = ApiService::decryptSearchableData($user['encrypt_email']); $user['email'] = ApiService::decryptSearchableData($user['encrypted_email']);
$user['name'] = ApiService::decryptData($user['encrypted_name']); $user['name'] = ApiService::decryptData($user['encrypted_name']);
$user['phone'] = ApiService::decryptData($user['encrypt_phone'] ?? ''); $user['phone'] = ApiService::decryptData($user['encrypted_phone'] ?? '');
$user['mobile'] = ApiService::decryptData($user['encrypt_mobile'] ?? ''); $user['mobile'] = ApiService::decryptData($user['encrypted_mobile'] ?? '');
if (!empty($user['entite_name'])) { if (!empty($user['entite_name'])) {
$user['entite_name'] = ApiService::decryptData($user['entite_name']); $user['entite_name'] = ApiService::decryptData($user['entite_name']);
} }
// Suppression des champs chiffrés // Suppression des champs chiffrés
unset($user['encrypt_email']); unset($user['encrypted_email']);
unset($user['encrypted_name']); unset($user['encrypted_name']);
unset($user['encrypt_phone']); unset($user['encrypted_phone']);
unset($user['encrypt_mobile']); unset($user['encrypted_mobile']);
Response::json([ Response::json([
'status' => 'success', 'status' => 'success',
@@ -256,7 +252,7 @@ class UserController {
$encryptedName = ApiService::encryptData($name); $encryptedName = ApiService::encryptData($name);
// Vérification de l'existence de l'email // Vérification de l'existence de l'email
$checkStmt = $this->db->prepare('SELECT id FROM users WHERE encrypt_email = ?'); $checkStmt = $this->db->prepare('SELECT id FROM users WHERE encrypted_email = ?');
$checkStmt->execute([$encryptedEmail]); $checkStmt->execute([$encryptedEmail]);
if ($checkStmt->fetch()) { if ($checkStmt->fetch()) {
Response::json([ Response::json([
@@ -284,8 +280,8 @@ class UserController {
// Insertion en base de données // Insertion en base de données
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
INSERT INTO users ( INSERT INTO users (
encrypt_email, user_pswd, encrypted_name, first_name, encrypted_email, user_pswd, encrypted_name, first_name,
sect_name, encrypt_phone, encrypt_mobile, fk_role, sect_name, encrypted_phone, encrypted_mobile, fk_role,
fk_entite, infos, chk_alert_email, chk_suivi, fk_entite, infos, chk_alert_email, chk_suivi,
date_naissance, date_embauche, matricule, date_naissance, date_embauche, matricule,
created_at, fk_user_creat, chk_active created_at, fk_user_creat, chk_active
@@ -387,7 +383,7 @@ class UserController {
$email = trim(strtolower($data['email'])); $email = trim(strtolower($data['email']));
$encryptedEmail = ApiService::encryptSearchableData($email); $encryptedEmail = ApiService::encryptSearchableData($email);
$checkStmt = $this->db->prepare('SELECT id FROM users WHERE encrypt_email = ? AND id != ?'); $checkStmt = $this->db->prepare('SELECT id FROM users WHERE encrypted_email = ? AND id != ?');
$checkStmt->execute([$encryptedEmail, $id]); $checkStmt->execute([$encryptedEmail, $id]);
if ($checkStmt->fetch()) { if ($checkStmt->fetch()) {
Response::json([ Response::json([
@@ -397,8 +393,8 @@ class UserController {
return; return;
} }
$updateFields[] = "encrypt_email = :encrypt_email"; $updateFields[] = "encrypted_email = :encrypted_email";
$params['encrypt_email'] = $encryptedEmail; $params['encrypted_email'] = $encryptedEmail;
} }
if (isset($data['name'])) { if (isset($data['name'])) {
@@ -407,13 +403,13 @@ class UserController {
} }
if (isset($data['phone'])) { if (isset($data['phone'])) {
$updateFields[] = "encrypt_phone = :encrypt_phone"; $updateFields[] = "encrypted_phone = :encrypted_phone";
$params['encrypt_phone'] = ApiService::encryptData(trim($data['phone'])); $params['encrypted_phone'] = ApiService::encryptData(trim($data['phone']));
} }
if (isset($data['mobile'])) { if (isset($data['mobile'])) {
$updateFields[] = "encrypt_mobile = :encrypt_mobile"; $updateFields[] = "encrypted_mobile = :encrypted_mobile";
$params['encrypt_mobile'] = ApiService::encryptData(trim($data['mobile'])); $params['encrypted_mobile'] = ApiService::encryptData(trim($data['mobile']));
} }
// Traitement des champs non chiffrés // Traitement des champs non chiffrés
@@ -473,7 +469,7 @@ class UserController {
'level' => 'info', 'level' => 'info',
'modifiedBy' => $currentUserId, 'modifiedBy' => $currentUserId,
'userId' => $id, 'userId' => $id,
'fields' => array_keys($data) 'fields' => array_keys($data),
]); ]);
Response::json([ Response::json([

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/location_service.dart
file:///Users/pierre/dev/geosector/app/lib/core/services/sync_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/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/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_amicale_page.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_communication_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/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/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/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/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/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.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/crypto.dart
org-dartlang-sdk:///dart-sdk/lib/_http/embedder_config.dart org-dartlang-sdk:///dart-sdk/lib/_http/embedder_config.dart
org-dartlang-sdk:///dart-sdk/lib/_http/http.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/location_service.dart
file:///Users/pierre/dev/geosector/app/lib/core/services/sync_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/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/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_amicale_page.dart
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_communication_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/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/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/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/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/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.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/crypto.dart
org-dartlang-sdk:///dart-sdk/lib/_http/embedder_config.dart org-dartlang-sdk:///dart-sdk/lib/_http/embedder_config.dart
org-dartlang-sdk:///dart-sdk/lib/_http/http.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/", "packageUri": "lib/",
"languageVersion": "3.0" "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", "name": "nm",
"rootUri": "file:///Users/pierre/.pub-cache/hosted/pub.dev/nm-0.5.0", "rootUri": "file:///Users/pierre/.pub-cache/hosted/pub.dev/nm-0.5.0",
@@ -655,12 +649,6 @@
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "2.12" "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", "name": "pub_semver",
"rootUri": "file:///Users/pierre/.pub-cache/hosted/pub.dev/pub_semver-2.2.0", "rootUri": "file:///Users/pierre/.pub-cache/hosted/pub.dev/pub_semver-2.2.0",

View File

@@ -338,10 +338,6 @@ mqtt5_client
3.0 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/
file:///Users/pierre/.pub-cache/hosted/pub.dev/mqtt5_client-4.11.0/lib/ 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 nm
2.12 2.12
file:///Users/pierre/.pub-cache/hosted/pub.dev/nm-0.5.0/ file:///Users/pierre/.pub-cache/hosted/pub.dev/nm-0.5.0/
@@ -418,10 +414,6 @@ proj4dart
2.12 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/
file:///Users/pierre/.pub-cache/hosted/pub.dev/proj4dart-2.1.0/lib/ 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 pub_semver
3.4 3.4
file:///Users/pierre/.pub-cache/hosted/pub.dev/pub_semver-2.2.0/ file:///Users/pierre/.pub-cache/hosted/pub.dev/pub_semver-2.2.0/

View File

@@ -5,7 +5,7 @@
"packages": [ "packages": [
{ {
"name": "geosector_app", "name": "geosector_app",
"version": "0.3.3", "version": "0.3.4",
"dependencies": [ "dependencies": [
"connectivity_plus", "connectivity_plus",
"cupertino_icons", "cupertino_icons",
@@ -24,8 +24,6 @@
"latlong2", "latlong2",
"mqtt5_client", "mqtt5_client",
"package_info_plus", "package_info_plus",
"path_provider",
"provider",
"retry", "retry",
"syncfusion_flutter_charts", "syncfusion_flutter_charts",
"universal_html", "universal_html",
@@ -336,27 +334,6 @@
"path" "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", "name": "hive_flutter",
"version": "1.1.0", "version": "1.1.0",
@@ -1228,6 +1205,18 @@
"vector_graphics_codec" "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", "name": "nm",
"version": "0.5.0", "version": "0.5.0",
@@ -1264,59 +1253,6 @@
"typed_data" "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", "name": "sky_engine",
"version": "0.0.0", "version": "0.0.0",
@@ -1437,9 +1373,50 @@
] ]
}, },
{ {
"name": "platform", "name": "path_provider_windows",
"version": "3.1.6", "version": "2.3.0",
"dependencies": [] "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", "name": "petitparser",
@@ -1470,6 +1447,11 @@
"lists" "lists"
] ]
}, },
{
"name": "platform",
"version": "3.1.6",
"dependencies": []
},
{ {
"name": "lists", "name": "lists",
"version": "1.0.1", "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

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

View File

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

View File

@@ -423,6 +423,49 @@ class UserRepository extends ChangeNotifier {
await _userBox.delete(id); 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 === // === MÉTHODES UTILITAIRES POUR LES DONNÉES ===
/// Récupérer la dernière opération active /// 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:geosector_app/core/constants/app_keys.dart';
import 'package:retry/retry.dart'; import 'package:retry/retry.dart';
import 'package:universal_html/html.dart' as html; import 'package:universal_html/html.dart' as html;
import 'package:geosector_app/core/utils/api_exception.dart';
class ApiService { class ApiService {
static ApiService? _instance; static ApiService? _instance;
@@ -179,31 +180,32 @@ class ApiService {
final response = await _dio.post(AppKeys.loginEndpoint, data: { final response = await _dio.post(AppKeys.loginEndpoint, data: {
'username': username, 'username': username,
'password': password, '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 data = response.data as Map<String, dynamic>;
final status = data['status'] as String?; 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') { if (status != 'success') {
final message = data['message'] as String?; final message = data['message'] as String? ?? 'Erreur de connexion';
debugPrint('Erreur d\'authentification: $message'); throw ApiException(message);
} }
// Si le statut est 'success', récupérer le session_id // Si succès, configurer la session
if (status == 'success' && data.containsKey('session_id')) { if (data.containsKey('session_id')) {
final sessionId = data['session_id']; final sessionId = data['session_id'];
// Définir la session pour les futures requêtes
if (sessionId != null) { if (sessionId != null) {
setSessionId(sessionId); setSessionId(sessionId);
} }
} }
return data; return data;
} on DioException catch (e) {
throw ApiException.fromDioException(e);
} catch (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 { Future<UserModel> createUser(UserModel user) async {
try { try {
final response = await _dio.post('/users', data: user.toJson()); final response = await _dio.post('/users', data: user.toJson());
return UserModel.fromJson(response.data); return UserModel.fromJson(response.data);
} on DioException catch (e) {
throw ApiException.fromDioException(e);
} catch (e) { } catch (e) {
rethrow; throw ApiException('Erreur inattendue lors de la création', originalError: e);
}
}
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;
} }
} }

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/amicale_model.dart';
import 'package:geosector_app/core/data/models/membre_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/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/user_repository.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart'; import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/core/repositories/membre_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/amicale_table_widget.dart';
import 'package:geosector_app/presentation/widgets/membre_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 pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter { class DotsPainter extends CustomPainter {
@@ -93,30 +95,86 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
} }
void _handleEditMembre(MembreModel membre) { 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( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Modifier le membre'), title: const Text('Confirmer la suppression'),
content: Text('Voulez-vous modifier le membre ${membre.firstName} ${membre.name} ?'), content: Text('Voulez-vous vraiment supprimer le membre ${membre.firstName} ${membre.name} ?\n\nCette action est irréversible.'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'), child: const Text('Annuler'),
), ),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () async {
Navigator.of(context).pop(); Navigator.of(context).pop();
// TODO: Naviguer vers la page de modification try {
// Navigator.of(context).push( // Utiliser la méthode qui passe par l'API
// MaterialPageRoute( final success = await widget.membreRepository.deleteMembre(membre.id);
// builder: (context) => EditMembrePage(
// membre: membre, if (success && mounted) {
// membreRepository: widget.membreRepository, 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() { void _handleAddMembre() {
if (_currentUser?.fkEntite == null) return; if (_currentUser?.fkEntite == null) return;
// TODO: Naviguer vers la page d'ajout de membre // Créer un UserModel vide avec les valeurs par défaut
// Navigator.of(context).push( final newUser = UserModel(
// MaterialPageRoute( id: 0, // ID temporaire pour nouveau membre
// builder: (context) => AddMembrePage( username: '',
// amicaleId: _currentUser!.fkEntite!, firstName: '',
// membreRepository: widget.membreRepository, 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 @override
@@ -328,7 +453,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
child: MembreTableWidget( child: MembreTableWidget(
membres: membres, membres: membres,
onEdit: _handleEditMembre, onEdit: _handleEditMembre,
onDelete: null, // Géré par l'admin principal onDelete: _handleDeleteMembre,
membreRepository: widget.membreRepository, membreRepository: widget.membreRepository,
), ),
), ),

View File

@@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart'; import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/services/app_info_service.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/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'; import 'package:go_router/go_router.dart';
/// AppBar personnalisée pour les tableaux de bord /// AppBar personnalisée pour les tableaux de bord
@@ -117,10 +118,32 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
icon: const Icon(Icons.person), icon: const Icon(Icons.person),
tooltip: 'Mon compte', tooltip: 'Mon compte',
onPressed: () { 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; final user = userRepository.currentUser;
if (user != null) { 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 { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(

View File

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

View File

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

View File

@@ -48,10 +48,15 @@ class MembreTableWidget extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// En-tête du tableau // En-tête du tableau avec fond grisé
if (showHeader) if (showHeader)
Padding( Container(
padding: const EdgeInsets.only(bottom: 16.0, left: 16.0, right: 16.0), 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( child: Row(
children: [ children: [
// ID // ID
@@ -184,6 +189,7 @@ class MembreTableWidget extends StatelessWidget {
onEdit: onEdit, onEdit: onEdit,
onDelete: onDelete, onDelete: onDelete,
isAlternate: index % 2 == 1, 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 UserModel? user;
final Function(UserModel)? onSubmit; final Function(UserModel)? onSubmit;
final bool readOnly; final bool readOnly;
final bool allowUsernameEdit;
final bool allowSectNameEdit;
const UserForm({ const UserForm({
Key? key, super.key,
this.user, this.user,
this.onSubmit, this.onSubmit,
this.readOnly = false, this.readOnly = false,
}) : super(key: key); this.allowUsernameEdit = false,
this.allowSectNameEdit = false,
});
@override @override
State<UserForm> createState() => _UserFormState(); State<UserForm> createState() => _UserFormState();
@@ -27,6 +31,7 @@ class _UserFormState extends State<UserForm> {
late final TextEditingController _usernameController; late final TextEditingController _usernameController;
late final TextEditingController _firstNameController; late final TextEditingController _firstNameController;
late final TextEditingController _nameController; late final TextEditingController _nameController;
late final TextEditingController _sectNameController;
late final TextEditingController _phoneController; late final TextEditingController _phoneController;
late final TextEditingController _mobileController; late final TextEditingController _mobileController;
late final TextEditingController _emailController; late final TextEditingController _emailController;
@@ -47,6 +52,7 @@ class _UserFormState extends State<UserForm> {
_usernameController = TextEditingController(text: user?.username ?? ''); _usernameController = TextEditingController(text: user?.username ?? '');
_firstNameController = TextEditingController(text: user?.firstName ?? ''); _firstNameController = TextEditingController(text: user?.firstName ?? '');
_nameController = TextEditingController(text: user?.name ?? ''); _nameController = TextEditingController(text: user?.name ?? '');
_sectNameController = TextEditingController(text: user?.sectName ?? '');
_phoneController = TextEditingController(text: user?.phone ?? ''); _phoneController = TextEditingController(text: user?.phone ?? '');
_mobileController = TextEditingController(text: user?.mobile ?? ''); _mobileController = TextEditingController(text: user?.mobile ?? '');
_emailController = TextEditingController(text: user?.email ?? ''); _emailController = TextEditingController(text: user?.email ?? '');
@@ -54,15 +60,9 @@ class _UserFormState extends State<UserForm> {
_dateNaissance = user?.dateNaissance; _dateNaissance = user?.dateNaissance;
_dateEmbauche = user?.dateEmbauche; _dateEmbauche = user?.dateEmbauche;
_dateNaissanceController = TextEditingController( _dateNaissanceController = TextEditingController(text: _dateNaissance != null ? DateFormat('dd/MM/yyyy').format(_dateNaissance!) : '');
text: _dateNaissance != null
? DateFormat('dd/MM/yyyy').format(_dateNaissance!)
: '');
_dateEmbaucheController = TextEditingController( _dateEmbaucheController = TextEditingController(text: _dateEmbauche != null ? DateFormat('dd/MM/yyyy').format(_dateEmbauche!) : '');
text: _dateEmbauche != null
? DateFormat('dd/MM/yyyy').format(_dateEmbauche!)
: '');
_fkTitre = user?.fkTitre ?? 1; _fkTitre = user?.fkTitre ?? 1;
} }
@@ -72,6 +72,7 @@ class _UserFormState extends State<UserForm> {
_usernameController.dispose(); _usernameController.dispose();
_firstNameController.dispose(); _firstNameController.dispose();
_nameController.dispose(); _nameController.dispose();
_sectNameController.dispose();
_phoneController.dispose(); _phoneController.dispose();
_mobileController.dispose(); _mobileController.dispose();
_emailController.dispose(); _emailController.dispose();
@@ -80,6 +81,19 @@ class _UserFormState extends State<UserForm> {
super.dispose(); 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 // Méthode simplifiée pour sélectionner une date
void _selectDate(BuildContext context, bool isDateNaissance) { void _selectDate(BuildContext context, bool isDateNaissance) {
// Utiliser un bloc try-catch pour capturer toutes les erreurs possibles // 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 // Mettre à jour la date et le texte du contrôleur
if (isDateNaissance) { if (isDateNaissance) {
_dateNaissance = picked; _dateNaissance = picked;
_dateNaissanceController.text = _dateNaissanceController.text = DateFormat('dd/MM/yyyy').format(picked);
DateFormat('dd/MM/yyyy').format(picked);
} else { } else {
_dateEmbauche = picked; _dateEmbauche = picked;
_dateEmbaucheController.text = _dateEmbaucheController.text = DateFormat('dd/MM/yyyy').format(picked);
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 // Gérer les erreurs spécifiques au sélecteur de date
debugPrint('Erreur lors de la sélection de la date: $error'); debugPrint('Erreur lors de la sélection de la date: $error');
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text('Erreur lors de la sélection de la date'), content: Text('Erreur lors de la sélection de la date'),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
@@ -121,7 +133,7 @@ class _UserFormState extends State<UserForm> {
// Gérer toutes les autres erreurs // Gérer toutes les autres erreurs
debugPrint('Exception lors de l\'affichage du sélecteur de date: $e'); debugPrint('Exception lors de l\'affichage du sélecteur de date: $e');
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text('Impossible d\'afficher le sélecteur de date'), content: Text('Impossible d\'afficher le sélecteur de date'),
backgroundColor: Colors.red, 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()) { if (_formKey.currentState!.validate()) {
final user = widget.user?.copyWith( return widget.user?.copyWith(
username: _usernameController.text,
firstName: _firstNameController.text, firstName: _firstNameController.text,
name: _nameController.text, name: _nameController.text,
sectName: _sectNameController.text,
phone: _phoneController.text, phone: _phoneController.text,
mobile: _mobileController.text, mobile: _mobileController.text,
email: _emailController.text, email: _emailController.text,
@@ -142,43 +157,114 @@ class _UserFormState extends State<UserForm> {
dateEmbauche: _dateEmbauche, dateEmbauche: _dateEmbauche,
) ?? ) ??
UserModel( UserModel(
id: 0, // Sera remplacé par l'API id: 0,
username: _usernameController.text,
firstName: _firstNameController.text, firstName: _firstNameController.text,
name: _nameController.text, name: _nameController.text,
sectName: _sectNameController.text,
phone: _phoneController.text, phone: _phoneController.text,
mobile: _mobileController.text, mobile: _mobileController.text,
email: _emailController.text, email: _emailController.text,
fkTitre: _fkTitre, fkTitre: _fkTitre,
dateNaissance: _dateNaissance, dateNaissance: _dateNaissance,
dateEmbauche: _dateEmbauche, dateEmbauche: _dateEmbauche,
role: 1, // Valeur par défaut role: 1,
createdAt: DateTime.now(), createdAt: DateTime.now(),
lastSyncedAt: DateTime.now(), lastSyncedAt: DateTime.now(),
); );
if (widget.onSubmit != null) {
widget.onSubmit!(user);
}
} }
return null;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final isWideScreen = MediaQuery.of(context).size.width > 900;
return Form( return Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Nom d'utilisateur (en lecture seule) // 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( CustomTextField(
controller: _usernameController, controller: _usernameController,
label: "Nom d'utilisateur", label: "Nom d'utilisateur",
readOnly: true, // Toujours en lecture seule readOnly: !widget.allowUsernameEdit, // Utiliser le paramètre
prefixIcon: Icons.account_circle, 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), 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) // Titre (M. ou Mme)
Column( Column(
@@ -188,7 +274,7 @@ class _UserFormState extends State<UserForm> {
"Titre", "Titre",
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground, color: theme.colorScheme.onSurface,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -225,35 +311,118 @@ class _UserFormState extends State<UserForm> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Prénom // 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( CustomTextField(
controller: _firstNameController, controller: _firstNameController,
label: "Prénom", label: "Prénom",
readOnly: widget.readOnly, readOnly: widget.readOnly,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le prénom";
}
return null;
},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Nom
CustomTextField( CustomTextField(
controller: _nameController, controller: _nameController,
label: "Nom", label: "Nom",
readOnly: widget.readOnly, 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),
// 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",
),
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) { validator: (value) {
if (value == null || value.isEmpty) { if (value != null && value.isNotEmpty && value.length < 10) {
return "Veuillez entrer le nom"; return "Le numéro doit contenir 10 chiffres";
} }
return null; return null;
}, },
), ),
const SizedBox(height: 16), ),
const SizedBox(width: 16),
// Téléphone fixe 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( CustomTextField(
controller: _phoneController, controller: _phoneController,
label: "Téléphone fixe", label: "Téléphone fixe",
@@ -265,14 +434,12 @@ class _UserFormState extends State<UserForm> {
], ],
validator: (value) { validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) { if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro de téléphone doit contenir 10 chiffres"; return "Le numéro doit contenir 10 chiffres";
} }
return null; return null;
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Téléphone mobile
CustomTextField( CustomTextField(
controller: _mobileController, controller: _mobileController,
label: "Téléphone mobile", label: "Téléphone mobile",
@@ -284,32 +451,47 @@ class _UserFormState extends State<UserForm> {
], ],
validator: (value) { validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) { if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro de mobile doit contenir 10 chiffres"; return "Le numéro doit contenir 10 chiffres";
} }
return null; return null;
}, },
), ),
],
const SizedBox(height: 16), const SizedBox(height: 16),
// Email // Ligne 4: Dates (naissance et embauche)
CustomTextField( if (isWideScreen)
controller: _emailController, Row(
label: "Email", children: [
keyboardType: TextInputType.emailAddress, Expanded(
readOnly: widget.readOnly, child: CustomTextField(
validator: (value) { controller: _dateNaissanceController,
if (value == null || value.isEmpty) { label: "Date de naissance",
return "Veuillez entrer l'adresse email"; readOnly: true,
} onTap: widget.readOnly ? null : () => _selectDate(context, true),
if (!value.contains('@') || !value.contains('.')) { suffixIcon: Icon(
return "Veuillez entrer une adresse email valide"; Icons.calendar_today,
} color: theme.colorScheme.primary,
return null;
},
), ),
const SizedBox(height: 16), ),
),
// Date de naissance 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( CustomTextField(
controller: _dateNaissanceController, controller: _dateNaissanceController,
label: "Date de naissance", label: "Date de naissance",
@@ -321,8 +503,6 @@ class _UserFormState extends State<UserForm> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Date d'embauche
CustomTextField( CustomTextField(
controller: _dateEmbaucheController, controller: _dateEmbaucheController,
label: "Date d'embauche", label: "Date d'embauche",
@@ -333,7 +513,8 @@ class _UserFormState extends State<UserForm> {
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
), ),
// Espace en bas du formulaire ],
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
), ),
@@ -360,7 +541,7 @@ class _UserFormState extends State<UserForm> {
Text( Text(
label, label,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onBackground, color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w500, 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" url: "https://pub.dev"
source: hosted source: hosted
version: "4.11.0" version: "4.11.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
nm: nm:
dependency: transitive dependency: transitive
description: description:
@@ -758,7 +750,7 @@ packages:
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
path_provider: path_provider:
dependency: "direct main" dependency: transitive
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@@ -861,14 +853,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" 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: pub_semver:
dependency: transitive dependency: transitive
description: description:

View File

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