Livraison d ela gestion des opérations v0.4.0
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -18,6 +18,7 @@ class OperationModelAdapter extends TypeAdapter<OperationModel> {
|
||||
dateDebut: fields[2] as DateTime,
|
||||
dateFin: fields[3] as DateTime,
|
||||
lastSyncedAt: fields[4] as DateTime,
|
||||
fkEntite: fields[7] as int,
|
||||
isActive: fields[5] as bool,
|
||||
isSynced: fields[6] as bool,
|
||||
);
|
||||
@@ -26,7 +27,7 @@ class OperationModelAdapter extends TypeAdapter<OperationModel> {
|
||||
@override
|
||||
void write(BinaryWriter writer, OperationModel obj) {
|
||||
writer
|
||||
..writeByte(7)
|
||||
..writeByte(8)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
@@ -40,7 +41,9 @@ class OperationModelAdapter extends TypeAdapter<OperationModel> {
|
||||
..writeByte(5)
|
||||
..write(obj.isActive)
|
||||
..writeByte(6)
|
||||
..write(obj.isSynced);
|
||||
..write(obj.isSynced)
|
||||
..writeByte(7)
|
||||
..write(obj.fkEntite);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
31
app/.dart_tool/extension_discovery/README.md
Normal file
31
app/.dart_tool/extension_discovery/README.md
Normal 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.
|
||||
1
app/.dart_tool/extension_discovery/vs_code.json
Normal file
1
app/.dart_tool/extension_discovery/vs_code.json
Normal file
@@ -0,0 +1 @@
|
||||
{"version":2,"entries":[{"package":"geosector_app","rootUri":"../","packageUri":"lib/"}]}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -1731,8 +1731,8 @@ file:///Users/pierre/dev/flutter/packages/flutter_web_plugins/lib/src/navigation
|
||||
file:///Users/pierre/dev/flutter/packages/flutter_web_plugins/lib/src/plugin_event_channel.dart
|
||||
file:///Users/pierre/dev/flutter/packages/flutter_web_plugins/lib/src/plugin_registry.dart
|
||||
file:///Users/pierre/dev/flutter/packages/flutter_web_plugins/lib/url_strategy.dart
|
||||
file:///Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/main.dart
|
||||
file:///Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/web_plugin_registrant.dart
|
||||
file:///Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/main.dart
|
||||
file:///Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/web_plugin_registrant.dart
|
||||
file:///Users/pierre/dev/geosector/app/.dart_tool/package_config.json
|
||||
file:///Users/pierre/dev/geosector/app/lib/app.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/chat/models/anonymous_user_model.dart
|
||||
@@ -1785,6 +1785,7 @@ file:///Users/pierre/dev/geosector/app/lib/core/services/current_user_service.da
|
||||
file:///Users/pierre/dev/geosector/app/lib/core/services/data_loading_service.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/core/services/hive_adapters.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/core/services/hive_reset_state_service.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/core/services/hive_service.dart
|
||||
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
|
||||
@@ -1797,6 +1798,7 @@ file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_dashboard_ho
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_dashboard_page.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_history_page.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_map_page.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_operations_page.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_statistics_page.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/auth/login_page.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/auth/splash_page.dart
|
||||
@@ -1832,6 +1834,7 @@ file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/loading_progress
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/mapbox_map.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/membre_row_widget.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/membre_table_widget.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/operation_form_dialog.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/responsive_navigation.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
@@ -1730,9 +1730,9 @@ file:///Users/pierre/dev/flutter/packages/flutter_web_plugins/lib/src/navigation
|
||||
file:///Users/pierre/dev/flutter/packages/flutter_web_plugins/lib/src/plugin_event_channel.dart
|
||||
file:///Users/pierre/dev/flutter/packages/flutter_web_plugins/lib/src/plugin_registry.dart
|
||||
file:///Users/pierre/dev/flutter/packages/flutter_web_plugins/lib/url_strategy.dart
|
||||
file:///Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/app.dill
|
||||
file:///Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/main.dart
|
||||
file:///Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/web_plugin_registrant.dart
|
||||
file:///Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/app.dill
|
||||
file:///Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/main.dart
|
||||
file:///Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/web_plugin_registrant.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/app.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/chat/models/anonymous_user_model.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/chat/models/anonymous_user_model.g.dart
|
||||
@@ -1784,6 +1784,7 @@ file:///Users/pierre/dev/geosector/app/lib/core/services/current_user_service.da
|
||||
file:///Users/pierre/dev/geosector/app/lib/core/services/data_loading_service.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/core/services/hive_adapters.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/core/services/hive_reset_state_service.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/core/services/hive_service.dart
|
||||
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
|
||||
@@ -1796,6 +1797,7 @@ file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_dashboard_ho
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_dashboard_page.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_history_page.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_map_page.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_operations_page.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/admin/admin_statistics_page.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/auth/login_page.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/auth/splash_page.dart
|
||||
@@ -1831,6 +1833,7 @@ file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/loading_progress
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/mapbox_map.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/membre_row_widget.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/membre_table_widget.dart
|
||||
file:///Users/pierre/dev/geosector/app/lib/presentation/widgets/operation_form_dialog.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/responsive_navigation.dart
|
||||
@@ -1 +1 @@
|
||||
{"inputs":["/Users/pierre/dev/flutter/packages/flutter_tools/lib/src/build_system/targets/web.dart"],"outputs":["/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/main.dart"]}
|
||||
{"inputs":["/Users/pierre/dev/flutter/packages/flutter_tools/lib/src/build_system/targets/web.dart"],"outputs":["/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/main.dart"]}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"inputs":["/Users/pierre/dev/flutter/bin/cache/engine.stamp"],"outputs":["/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/flutter.js","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/canvaskit/skwasm.js","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/canvaskit/skwasm.js.symbols","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/canvaskit/canvaskit.js.symbols","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/canvaskit/skwasm.wasm","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/canvaskit/chromium/canvaskit.js.symbols","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/canvaskit/chromium/canvaskit.js","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/canvaskit/chromium/canvaskit.wasm","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/canvaskit/canvaskit.js","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/01af3ba6904766cfc820f0897fc71456/canvaskit/canvaskit.wasm"]}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"inputs":["/Users/pierre/dev/flutter/bin/cache/engine.stamp"],"outputs":["/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/flutter.js","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/canvaskit/skwasm.js","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/canvaskit/skwasm.js.symbols","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/canvaskit/canvaskit.js.symbols","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/canvaskit/skwasm.wasm","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/canvaskit/chromium/canvaskit.js.symbols","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/canvaskit/chromium/canvaskit.js","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/canvaskit/chromium/canvaskit.wasm","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/canvaskit/canvaskit.js","/Users/pierre/dev/geosector/app/.dart_tool/flutter_build/41acb28aedc1da36af63ba5cb8859018/canvaskit/canvaskit.wasm"]}
|
||||
@@ -941,6 +941,6 @@
|
||||
"generator": "pub",
|
||||
"generatorVersion": "3.8.1",
|
||||
"flutterRoot": "file:///Users/pierre/dev/flutter",
|
||||
"flutterVersion": "3.32.1",
|
||||
"flutterVersion": "3.32.4",
|
||||
"pubCache": "file:///Users/pierre/.pub-cache"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"packages": [
|
||||
{
|
||||
"name": "geosector_app",
|
||||
"version": "0.3.5",
|
||||
"version": "0.4.0",
|
||||
"dependencies": [
|
||||
"connectivity_plus",
|
||||
"cupertino_icons",
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.32.1
|
||||
3.32.4
|
||||
File diff suppressed because one or more lines are too long
22
app/.vscode/settings.json
vendored
Normal file
22
app/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"activityBar.activeBackground": "#2f7c47",
|
||||
"activityBar.background": "#2f7c47",
|
||||
"activityBar.foreground": "#e7e7e7",
|
||||
"activityBar.inactiveForeground": "#e7e7e799",
|
||||
"activityBarBadge.background": "#422c74",
|
||||
"activityBarBadge.foreground": "#e7e7e7",
|
||||
"commandCenter.border": "#e7e7e799",
|
||||
"sash.hoverBorder": "#2f7c47",
|
||||
"statusBar.background": "#215732",
|
||||
"statusBar.foreground": "#e7e7e7",
|
||||
"statusBarItem.hoverBackground": "#2f7c47",
|
||||
"statusBarItem.remoteBackground": "#215732",
|
||||
"statusBarItem.remoteForeground": "#e7e7e7",
|
||||
"titleBar.activeBackground": "#215732",
|
||||
"titleBar.activeForeground": "#e7e7e7",
|
||||
"titleBar.inactiveBackground": "#21573299",
|
||||
"titleBar.inactiveForeground": "#e7e7e799"
|
||||
},
|
||||
"peacock.color": "#215732"
|
||||
}
|
||||
1100
app/README-APP.md
1100
app/README-APP.md
File diff suppressed because it is too large
Load Diff
@@ -1,645 +0,0 @@
|
||||
# GEOSECTOR v2.0
|
||||
|
||||
🚒 **Application de gestion des distributions de calendriers par secteurs géographiques pour les amicales de pompiers**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Vue d'ensemble
|
||||
|
||||
GEOSECTOR est une solution complète développée en Flutter qui révolutionne la gestion des campagnes de distribution de calendriers pour les amicales de pompiers. L'application combine géolocalisation, gestion multi-rôles et synchronisation en temps réel pour optimiser les tournées et maximiser l'efficacité des équipes.
|
||||
|
||||
### 🏆 Points forts de la v2.0
|
||||
|
||||
- **Architecture moderne** sans Provider, basée sur l'injection de dépendances
|
||||
- **Réactivité native** avec ValueListenableBuilder et Hive
|
||||
- **Interface adaptative** selon les rôles utilisateur
|
||||
- **Performance optimisée** avec un ApiService singleton
|
||||
- **Gestion avancée des permissions** multi-niveaux
|
||||
- **Gestion d'erreurs centralisée** avec ApiException
|
||||
- **Interface utilisateur unifiée** avec UserFormDialog réutilisable
|
||||
|
||||
---
|
||||
|
||||
## 📋 Table des matières
|
||||
|
||||
1. [Fonctionnalités](#-fonctionnalités)
|
||||
2. [Architecture technique](#️-architecture-technique)
|
||||
3. [Installation](#-installation-et-configuration)
|
||||
4. [Modèles de données](#️-modèles-de-données)
|
||||
5. [Architecture des composants](#-architecture-des-composants)
|
||||
6. [Gestion des rôles](#-gestion-des-rôles)
|
||||
7. [Interface utilisateur](#-interface-utilisateur)
|
||||
8. [API et synchronisation](#-api-et-synchronisation)
|
||||
9. [Gestion des erreurs](#-gestion-des-erreurs)
|
||||
10. [Cartes et géolocalisation](#️-cartes-et-géolocalisation)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Fonctionnalités
|
||||
|
||||
### 🎯 Fonctionnalités métier
|
||||
|
||||
#### Pour les **Membres** (Rôle 1)
|
||||
|
||||
- ✅ Visualisation des secteurs assignés sur carte interactive
|
||||
- ✅ Suivi GPS en temps réel des tournées
|
||||
- ✅ Enregistrement des passages avec géolocalisation
|
||||
- ✅ Gestion des stocks de calendriers
|
||||
- ✅ Historique des distributions
|
||||
- ✅ Chat intégré avec l'équipe
|
||||
|
||||
#### Pour les **Admins Amicale** (Rôle 2)
|
||||
|
||||
- ✅ Gestion de leur amicale (informations, coordonnées)
|
||||
- ✅ Gestion des membres de l'amicale (création, modification, suppression)
|
||||
- ✅ Attribution des rôles aux membres (Membre/Administrateur)
|
||||
- ✅ Gestion du statut actif des comptes membres
|
||||
- ✅ Consultation des statistiques de l'amicale
|
||||
- ✅ Attribution des secteurs aux membres
|
||||
- ✅ Suivi des performances équipe
|
||||
|
||||
#### Pour les **Super Admins** (Rôle 3+)
|
||||
|
||||
- ✅ Gestion globale multi-amicales
|
||||
- ✅ Administration des utilisateurs et permissions
|
||||
- ✅ Configuration des paramètres système
|
||||
- ✅ Analytics avancées et reporting
|
||||
- ✅ Gestion des secteurs géographiques
|
||||
|
||||
### 🔧 Fonctionnalités techniques
|
||||
|
||||
- **🗺️ Cartographie avancée** : Flutter Map avec tuiles Mapbox
|
||||
- **📍 Géolocalisation précise** : Suivi GPS des équipes
|
||||
- **💾 Stockage hybride** : Cache local Hive + synchronisation cloud
|
||||
- **💬 Communication** : Chat MQTT en temps réel
|
||||
- **🔐 Sécurité** : Authentification JWT + gestion fine des permissions
|
||||
- **📱 Multi-plateforme** : iOS, Android, Web
|
||||
- **🌐 Mode hors-ligne** : Fonctionnement dégradé sans connexion
|
||||
- **⚡ Gestion d'erreurs robuste** : Extraction automatique des messages API
|
||||
- **🎨 Interface responsive** : Adaptation automatique selon la taille d'écran
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture technique
|
||||
|
||||
### Stack technologique
|
||||
|
||||
| Composant | Technologie | Version | Usage |
|
||||
| ------------------- | ---------------------- | ------- | ------------------------------ |
|
||||
| **Framework** | Flutter | 3.32+ | Interface multi-plateforme |
|
||||
| **Langage** | Dart | 3.0+ | Logique applicative |
|
||||
| **Navigation** | GoRouter | 12.1.3 | Routing déclaratif |
|
||||
| **Stockage local** | Hive | 2.2.3 | Base de données NoSQL locale |
|
||||
| **Réactivité** | ValueListenableBuilder | Native | Écoute des changements Hive |
|
||||
| **HTTP** | Dio | 5.4.0 | Client HTTP avec intercepteurs |
|
||||
| **Cartes** | Flutter Map | 6.1.0 | Rendu cartographique |
|
||||
| **Géolocalisation** | Geolocator | 10.1.0 | Services de localisation |
|
||||
| **Chat** | MQTT5 Client | 4.2.0 | Messagerie temps réel |
|
||||
| **UI** | Material Design 3 | Native | Composants d'interface |
|
||||
|
||||
### 🏛️ Architecture en couches
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[UI Layer - Widgets] --> B[Repository Layer - Business Logic]
|
||||
B --> C[Data Layer - Hive + API]
|
||||
|
||||
A1[ValueListenableBuilder] --> A
|
||||
A2[Custom Widgets] --> A
|
||||
A3[UserFormDialog] --> A
|
||||
|
||||
B1[UserRepository] --> B
|
||||
B2[AmicaleRepository] --> B
|
||||
B3[MembreRepository] --> B
|
||||
|
||||
C1[Hive Boxes] --> C
|
||||
C2[API Service Singleton] --> C
|
||||
C3[ApiException Handler] --> C
|
||||
```
|
||||
|
||||
### 📁 Structure du projet
|
||||
|
||||
```
|
||||
app/
|
||||
├── lib/
|
||||
│ ├── core/ # Couche centrale
|
||||
│ │ ├── constants/ # Constantes globales
|
||||
│ │ │ ├── app_keys.dart # Clés des Box Hive
|
||||
│ │ │ └── api_endpoints.dart # Endpoints API
|
||||
│ │ ├── data/
|
||||
│ │ │ └── models/ # Modèles Hive
|
||||
│ │ │ ├── user_model.dart # @HiveType(typeId: 3)
|
||||
│ │ │ ├── amicale_model.dart # @HiveType(typeId: 4)
|
||||
│ │ │ └── membre_model.dart # @HiveType(typeId: 5)
|
||||
│ │ ├── repositories/ # Logique métier
|
||||
│ │ │ ├── user_repository.dart
|
||||
│ │ │ ├── amicale_repository.dart
|
||||
│ │ │ └── membre_repository.dart
|
||||
│ │ ├── services/ # Services externes
|
||||
│ │ │ ├── api_service.dart # HTTP Singleton
|
||||
│ │ │ ├── chat_service.dart # MQTT
|
||||
│ │ │ └── location_service.dart # GPS
|
||||
│ │ └── utils/ # Utilitaires
|
||||
│ │ ├── validators.dart
|
||||
│ │ └── formatters.dart
|
||||
│ ├── presentation/ # Interface utilisateur
|
||||
│ │ ├── admin/ # Pages administrateur
|
||||
│ │ │ ├── admin_dashboard_page.dart
|
||||
│ │ │ ├── admin_amicale_page.dart
|
||||
│ │ │ └── admin_statistics_page.dart
|
||||
│ │ ├── user/ # Pages utilisateur
|
||||
│ │ │ ├── user_dashboard_page.dart
|
||||
│ │ │ ├── map_page.dart
|
||||
│ │ │ └── distribution_page.dart
|
||||
│ │ ├── widgets/ # Composants réutilisables
|
||||
│ │ │ ├── tables/
|
||||
│ │ │ │ ├── amicale_table_widget.dart
|
||||
│ │ │ │ ├── amicale_row_widget.dart
|
||||
│ │ │ │ ├── membre_table_widget.dart
|
||||
│ │ │ │ └── membre_row_widget.dart
|
||||
│ │ │ ├── forms/
|
||||
│ │ │ │ ├── amicale_form.dart
|
||||
│ │ │ │ └── custom_text_field.dart
|
||||
│ │ │ └── common/
|
||||
│ │ │ ├── dashboard_layout.dart
|
||||
│ │ │ └── loading_widget.dart
|
||||
│ │ └── theme/
|
||||
│ │ └── app_theme.dart
|
||||
│ ├── app.dart # Configuration app
|
||||
│ └── main.dart # Point d'entrée
|
||||
├── assets/ # Ressources statiques
|
||||
│ ├── images/
|
||||
│ ├── icons/
|
||||
│ └── fonts/
|
||||
├── test/ # Tests unitaires
|
||||
├── integration_test/ # Tests d'intégration
|
||||
└── docs/ # Documentation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Installation et configuration
|
||||
|
||||
### Prérequis système
|
||||
|
||||
- **Flutter SDK** : 3.32 ou supérieur
|
||||
- **Dart SDK** : 3.0 ou supérieur
|
||||
- **IDE** : Android Studio, VS Code, ou IntelliJ
|
||||
- **Environnement** :
|
||||
- Android : SDK 21+ (Android 5.0+)
|
||||
- iOS : iOS 12.0+
|
||||
- Web : Navigateurs modernes
|
||||
|
||||
## 🔐 Configuration des clés API
|
||||
|
||||
### Mapbox (Cartographie)
|
||||
|
||||
1. Créer un compte sur [Mapbox](https://www.mapbox.com/)
|
||||
2. Générer un token d'accès
|
||||
3. Ajouter le token dans `.env`
|
||||
|
||||
### Configuration MQTT (Chat)
|
||||
|
||||
1. Configurer votre broker MQTT
|
||||
2. Créer les credentials
|
||||
3. Tester la connexion
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Modèles de données
|
||||
|
||||
### Registres Hive des adaptateurs
|
||||
|
||||
```dart
|
||||
// Modèles principaux
|
||||
UserModelAdapter() // typeId: 0
|
||||
OperationModelAdapter() // typeId: 1
|
||||
SectorModelAdapter() // typeId: 3
|
||||
PassageModelAdapter() // typeId: 4
|
||||
MembreModelAdapter() // typeId: 5
|
||||
UserSectorModelAdapter() // typeId: 6
|
||||
RegionModelAdapter() // typeId: 7
|
||||
ClientModelAdapter() // typeId: 10
|
||||
AmicaleModelAdapter() // typeId: 11
|
||||
|
||||
// Modèles de chat
|
||||
ConversationModelAdapter() // typeId: 20
|
||||
MessageModelAdapter() // typeId: 21
|
||||
ParticipantModelAdapter() // typeId: 22
|
||||
AnonymousUserModelAdapter() // typeId: 23
|
||||
AudienceTargetModelAdapter() // typeId: 24
|
||||
NotificationSettingsAdapter() // typeId: 25
|
||||
```
|
||||
|
||||
Compatibilité entre modèles
|
||||
UserModel ↔ MembreModel : Conversion bidirectionnelle via toUserModel() et fromUserModel()
|
||||
Synchronisation : Maintien de la cohérence entre les deux représentations
|
||||
Champs spécialisés : Préservation des données spécifiques à chaque modèle
|
||||
🎨 Interface utilisateur
|
||||
Architecture des composants
|
||||
UserFormDialog - Modale unifiée
|
||||
Réutilisabilité : Même widget pour "Mon Compte" et "Gestion des Membres"
|
||||
Personnalisation contextuelle :
|
||||
Sélecteur de rôle (Membre/Administrateur)
|
||||
Checkbox statut actif/inactif
|
||||
Édition du nom d'utilisateur selon le contexte
|
||||
Gestion du nom de tournée (sectName)
|
||||
Interface responsive : Adaptation automatique selon la largeur d'écran
|
||||
UserForm - Formulaire intelligent
|
||||
Layout adaptatif :
|
||||
|
||||
> 900px : Champs groupés en lignes (username+email, prénom+nom, téléphones, dates)
|
||||
> ≤ 900px : Champs empilés verticalement
|
||||
> Validation conditionnelle : Au moins nom OU nom de tournée requis
|
||||
> Champs dynamiques : Affichage selon les permissions et le contexte
|
||||
> Indicateurs visuels : Points rouges sur les champs obligatoires
|
||||
> Tableaux interactifs
|
||||
> AmicaleTableWidget : Gestion des amicales avec édition inline
|
||||
> MembreTableWidget : Gestion des membres avec actions contextuelles
|
||||
> Alternance de couleurs : Amélioration de la lisibilité
|
||||
> Clic sur ligne : Ouverture directe du formulaire d'édition
|
||||
|
||||
## 🔗 API et synchronisation
|
||||
|
||||
Principe "API First"
|
||||
|
||||
### Flow de mise à jour
|
||||
|
||||
Validation API : Tentative de mise à jour sur le serveur
|
||||
Succès → Sauvegarde locale avec isSynced: true
|
||||
Erreur → Aucune modification locale + affichage de l'erreur
|
||||
|
||||
### Avantages
|
||||
|
||||
Cohérence des données : Local toujours synchronisé avec le serveur
|
||||
Gestion d'erreurs propre : Pas de conflits entre données locales et distantes
|
||||
UX claire : Feedback immédiat sur les erreurs de validation
|
||||
|
||||
### ApiService Singleton
|
||||
|
||||
- Thread-safe : Initialisation sécurisée avec verrous
|
||||
- Auto-configuration : Détection automatique de l'environnement (DEV/REC/PROD)
|
||||
- Gestion de session : Headers d'authentification automatiques
|
||||
- Retry logic : Nouvelles tentatives pour les erreurs réseau
|
||||
|
||||
## ⚠️ Gestion des erreurs
|
||||
|
||||
Architecture centralisée
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as dashboard_app_bar.dart
|
||||
participant UR as user_repository.dart
|
||||
participant AS as api_service.dart
|
||||
participant API as API Server
|
||||
participant AE as ApiException
|
||||
participant EU as ErrorUtils
|
||||
|
||||
Note over UI: Utilisateur clique "Enregistrer"
|
||||
UI->>UR: updateUser(updatedUser)
|
||||
|
||||
Note over UR: Tente la mise à jour
|
||||
UR->>AS: updateUser(user)
|
||||
|
||||
Note over AS: Appel HTTP PUT
|
||||
AS->>API: PUT /users/123 {email: "test@test.com"}
|
||||
|
||||
alt Succès API
|
||||
API-->>AS: 200 OK {user data}
|
||||
AS-->>UR: UserModel (mis à jour)
|
||||
UR-->>UI: UserModel (succès)
|
||||
UI->>EU: showSuccessSnackBar()
|
||||
Note over UI: ✅ "Profil mis à jour"
|
||||
|
||||
else Erreur API (ex: email déjà utilisé)
|
||||
API-->>AS: 409 Conflict {"message": "Cet email est déjà utilisé"}
|
||||
|
||||
Note over AS: Conversion en ApiException
|
||||
AS->>AE: ApiException.fromDioException(dioError)
|
||||
AE-->>AS: ApiException("Cet email est déjà utilisé")
|
||||
AS-->>UR: throw ApiException
|
||||
UR-->>UI: throw ApiException
|
||||
|
||||
Note over UI: Gestion de l'erreur
|
||||
UI->>EU: showErrorSnackBar(context, exception)
|
||||
EU->>AE: extractErrorMessage(exception)
|
||||
AE-->>EU: "Cet email est déjà utilisé"
|
||||
Note over UI: ❌ "Erreur: Cet email est déjà utilisé"
|
||||
Note over UI: Dialog reste ouvert
|
||||
|
||||
else Erreur réseau
|
||||
API-->>AS: Network Error / Timeout
|
||||
AS->>AE: ApiException.fromDioException(networkError)
|
||||
AE-->>AS: ApiException("Problème de connexion réseau")
|
||||
AS-->>UR: throw ApiException
|
||||
UR-->>UI: throw ApiException
|
||||
UI->>EU: showErrorSnackBar(context, exception)
|
||||
Note over UI: ❌ "Problème de connexion réseau"
|
||||
end
|
||||
```
|
||||
|
||||
## Composants de gestion d'erreurs
|
||||
|
||||
### ApiException
|
||||
|
||||
Extraction intelligente : Messages spécifiques depuis la réponse API
|
||||
Codes HTTP standardisés : Mapping automatique des erreurs communes
|
||||
Types d'erreurs : Classification (validation, authentification, réseau, conflit)
|
||||
Méthodes d'affichage : showError() et showSuccess() intégrées
|
||||
|
||||
### Responsabilités par couche
|
||||
|
||||
ApiService : Conversion des erreurs Dio en ApiException
|
||||
Repository : Propagation transparente des erreurs
|
||||
Interface : Affichage utilisateur via ApiException.showError()
|
||||
|
||||
### Messages d'erreurs contextuels
|
||||
|
||||
409 Conflict : "Cet email est déjà utilisé par un autre utilisateur"
|
||||
400 Bad Request : "Données invalides"
|
||||
401 Unauthorized : "Non autorisé : veuillez vous reconnecter"
|
||||
500 Server Error : "Erreur serveur interne"
|
||||
Network Errors : "Problème de connexion réseau"
|
||||
Timeout : "Délai d'attente dépassé"
|
||||
|
||||
## 🎯 Gestion des rôles
|
||||
|
||||
### Hiérarchie des permissions
|
||||
|
||||
Membre (Rôle 1) : Consultation et distribution dans ses secteurs
|
||||
Admin Amicale (Rôle 2) : Gestion complète de son amicale et ses membres
|
||||
Super Admin (Rôle 3+) : Administration globale multi-amicales
|
||||
|
||||
### Fonctionnalités par rôle
|
||||
|
||||
Admin Amicale - Gestion des membres
|
||||
Création : Nouveaux membres avec attribution de rôle
|
||||
Modification : Informations personnelles, rôle, statut actif
|
||||
Suppression : Avec confirmation obligatoire
|
||||
Validation : Contrôle d'unicité email/username par l'API
|
||||
|
||||
### Interface adaptative
|
||||
|
||||
Sélecteur de rôle : Visible uniquement pour les admins
|
||||
Checkbox statut actif : Contrôle d'accès aux comptes
|
||||
Édition contextuelle : Champs modifiables selon les permissions
|
||||
Actions conditionnelles : Boutons disponibles selon le niveau d'autorisation
|
||||
|
||||
## 👥 Gestion des membres (Admin Amicale)
|
||||
|
||||
### 🎯 Vue d'ensemble
|
||||
|
||||
La gestion des membres est une fonctionnalité clé pour les **Admins Amicale** (Rôle 2) qui permet une administration complète des équipes au sein de leur amicale. Cette interface centralise toutes les opérations liées aux membres avec une approche sécurisée et intuitive.
|
||||
|
||||
### 📱 Interface AdminAmicalePage
|
||||
|
||||
L'interface principale `admin_amicale_page.dart` offre une vue d'ensemble complète :
|
||||
|
||||
- **Informations de l'amicale** : Affichage des détails de l'amicale courante
|
||||
- **Liste des membres** : Tableau interactif avec actions contextuelles
|
||||
- **Ajout de membres** : Bouton d'action pour créer de nouveaux comptes
|
||||
- **Opération courante** : Indication de l'opération active en cours
|
||||
|
||||
### ✨ Fonctionnalités principales
|
||||
|
||||
#### 🆕 Création de nouveaux membres
|
||||
|
||||
```dart
|
||||
// Workflow de création
|
||||
1. Clic sur "Ajouter un membre"
|
||||
2. Ouverture du UserFormDialog
|
||||
3. Formulaire vierge avec valeurs par défaut
|
||||
4. Sélection du rôle (Membre/Administrateur)
|
||||
5. Configuration du statut actif
|
||||
6. Validation et création via API
|
||||
7. Attribution automatique à l'amicale courante
|
||||
```
|
||||
|
||||
**Champs obligatoires :**
|
||||
|
||||
- Email (unique dans le système)
|
||||
- Au moins un nom (nom de famille OU nom de tournée)
|
||||
- Rôle dans l'amicale
|
||||
|
||||
**Champs optionnels :**
|
||||
|
||||
- Nom d'utilisateur (éditable pour les admins)
|
||||
- Prénom, téléphones, dates
|
||||
- Nom de tournée (pour identification terrain)
|
||||
|
||||
#### ✏️ Modification des membres existants
|
||||
|
||||
```dart
|
||||
// Actions disponibles
|
||||
- Clic sur une ligne → Ouverture du formulaire d'édition
|
||||
- Modification de tous les champs personnels
|
||||
- Changement de rôle (Membre ↔ Administrateur)
|
||||
- Activation/Désactivation du compte
|
||||
- Gestion du nom de tournée
|
||||
```
|
||||
|
||||
**Workflow de modification :**
|
||||
|
||||
1. Sélection du membre dans le tableau
|
||||
2. Ouverture automatique du `UserFormDialog`
|
||||
3. Formulaire pré-rempli avec données existantes
|
||||
4. Modification des champs souhaités
|
||||
5. Validation et mise à jour via API
|
||||
6. Synchronisation automatique avec Hive
|
||||
|
||||
#### 🗑️ Suppression intelligente des membres
|
||||
|
||||
La suppression des membres intègre une **logique métier avancée** pour préserver l'intégrité des données :
|
||||
|
||||
##### 🔍 Vérification des passages
|
||||
|
||||
```dart
|
||||
// Algorithme de vérification
|
||||
1. Récupération de l'opération courante
|
||||
2. Analyse des passages du membre pour cette opération
|
||||
3. Classification : passages réalisés vs passages à finaliser
|
||||
4. Comptage total des passages actifs
|
||||
```
|
||||
|
||||
##### 📊 Scénarios de suppression
|
||||
|
||||
**Cas 1 : Aucun passage (suppression simple)**
|
||||
|
||||
```http
|
||||
DELETE /users/{id}
|
||||
```
|
||||
|
||||
- Confirmation simple
|
||||
- Suppression directe
|
||||
- Aucun transfert nécessaire
|
||||
|
||||
**Cas 2 : Passages existants (suppression avec transfert)**
|
||||
|
||||
```http
|
||||
DELETE /users/{id}?transfer_to={destinataire}&operation_id={operation}
|
||||
```
|
||||
|
||||
- Dialog d'avertissement avec détails
|
||||
- Sélection obligatoire d'un membre destinataire
|
||||
- Transfert automatique de tous les passages
|
||||
- Préservation de l'historique
|
||||
|
||||
**Cas 3 : Alternative recommandée (désactivation)**
|
||||
|
||||
```dart
|
||||
// Mise à jour du statut
|
||||
membre.copyWith(isActive: false)
|
||||
```
|
||||
|
||||
- Préservation complète des données
|
||||
- Blocage de la connexion
|
||||
- Maintien de l'historique
|
||||
- Réactivation possible ultérieurement
|
||||
|
||||
### 🔐 Sécurité et permissions
|
||||
|
||||
#### Contrôles d'accès
|
||||
|
||||
- **Isolation par amicale** : Admins limités à leur amicale
|
||||
- **Vérification des rôles** : Validation côté client et serveur
|
||||
- **Opération courante** : Filtrage par contexte d'opération
|
||||
- **Validation API** : Contrôles d'unicité et cohérence
|
||||
|
||||
#### Gestion des erreurs
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Action utilisateur] --> B[Validation locale]
|
||||
B --> C[Appel API]
|
||||
C --> D{Succès ?}
|
||||
D -->|Oui| E[Mise à jour Hive]
|
||||
D -->|Non| F[Affichage erreur]
|
||||
E --> G[Notification succès]
|
||||
F --> H[Dialog reste ouvert]
|
||||
```
|
||||
|
||||
### 🎨 Interface utilisateur
|
||||
|
||||
#### Tableaux interactifs
|
||||
|
||||
**MembreTableWidget** - Composant principal :
|
||||
|
||||
- Colonnes : ID, Prénom, Nom, Email, Rôle, Statut
|
||||
- Actions : Modification, Suppression
|
||||
- Alternance de couleurs pour lisibilité
|
||||
- Tri et navigation intuitifs
|
||||
|
||||
**MembreRowWidget** - Ligne individuelle :
|
||||
|
||||
- Clic pour édition rapide
|
||||
- Boutons d'action contextuels
|
||||
- Indicateurs visuels de statut
|
||||
- Tooltip informatifs
|
||||
|
||||
#### Formulaires adaptatifs
|
||||
|
||||
**UserFormDialog** - Modale réutilisable :
|
||||
|
||||
- Layout responsive (>900px vs mobile)
|
||||
- Validation en temps réel
|
||||
- Gestion des erreurs inline
|
||||
- Sauvegarde avec feedback
|
||||
|
||||
### 📡 Synchronisation et réactivité
|
||||
|
||||
#### Architecture ValueListenableBuilder
|
||||
|
||||
```dart
|
||||
// Écoute automatique des changements
|
||||
ValueListenableBuilder<Box<MembreModel>>(
|
||||
valueListenable: membreRepository.getMembresBox().listenable(),
|
||||
builder: (context, membresBox, child) {
|
||||
// Interface mise à jour automatiquement
|
||||
final membres = membresBox.values
|
||||
.where((m) => m.fkEntite == currentUser.fkEntite)
|
||||
.toList();
|
||||
|
||||
return MembreTableWidget(membres: membres);
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
#### Principe "API First"
|
||||
|
||||
1. **Validation API** : Tentative sur serveur en priorité
|
||||
2. **Succès** → Sauvegarde locale + mise à jour interface
|
||||
3. **Erreur** → Affichage message + maintien état local
|
||||
4. **Cohérence** : Données locales toujours synchronisées
|
||||
|
||||
### 🔄 Workflow complet
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Admin
|
||||
participant UI as Interface
|
||||
participant R as Repository
|
||||
participant API as Serveur
|
||||
participant H as Hive
|
||||
|
||||
A->>UI: Action membre
|
||||
UI->>R: Appel repository
|
||||
R->>API: Requête HTTP
|
||||
API-->>R: Réponse
|
||||
|
||||
alt Succès
|
||||
R->>H: Sauvegarde locale
|
||||
H-->>UI: Notification changement
|
||||
UI-->>A: Interface mise à jour
|
||||
else Erreur
|
||||
R-->>UI: Exception
|
||||
UI-->>A: Message d'erreur
|
||||
end
|
||||
```
|
||||
|
||||
### 🎯 Bonnes pratiques
|
||||
|
||||
#### Pour les administrateurs
|
||||
|
||||
1. **Vérification avant suppression** : Toujours examiner les passages
|
||||
2. **Préférer la désactivation** : Éviter la perte de données
|
||||
3. **Noms de tournée** : Utiliser des identifiants terrain clairs
|
||||
4. **Rôles appropriés** : Limiter les administrateurs aux besoins
|
||||
|
||||
#### Gestion des erreurs courantes
|
||||
|
||||
| Erreur | Cause | Solution |
|
||||
| ----------------------- | ------------- | ------------------------ |
|
||||
| Email déjà utilisé | Duplication | Choisir un autre email |
|
||||
| Membre avec passages | Données liées | Transférer ou désactiver |
|
||||
| Aucune opération active | Configuration | Vérifier les opérations |
|
||||
| Accès refusé | Permissions | Vérifier le rôle admin |
|
||||
|
||||
Cette architecture garantit une gestion des membres robuste, sécurisée et intuitive, optimisant l'efficacité administrative tout en préservant l'intégrité des données opérationnelles. 👥✨
|
||||
|
||||
## 🗺️ Cartes et géolocalisation
|
||||
|
||||
Flutter Map : Rendu cartographique haute performance
|
||||
Tuiles Mapbox : Cartographie détaillée et personnalisable
|
||||
Géolocalisation temps réel : Suivi GPS des équipes
|
||||
Secteurs géographiques : Visualisation et attribution dynamique
|
||||
Passages géolocalisés : Enregistrement précis des distributions
|
||||
|
||||
## 🔄 Synchronisation et réactivité
|
||||
|
||||
### Hive + ValueListenableBuilder
|
||||
|
||||
Réactivité native : Mise à jour automatique de l'interface
|
||||
Performance optimisée : Pas de Provider, injection directe
|
||||
Écoute sélective : Réactivité fine par Box Hive
|
||||
Cohérence des données : Synchronisation bidirectionnelle User/Membre
|
||||
|
||||
### Services Singleton
|
||||
|
||||
CurrentUserService : Gestion de l'utilisateur connecté
|
||||
CurrentAmicaleService : Amicale de l'utilisateur actuel
|
||||
ApiService : Communication centralisée avec l'API
|
||||
DataLoadingService : Orchestration du chargement des données
|
||||
|
||||
Cette architecture garantit une application performante, maintenable et évolutive avec une excellente expérience utilisateur. 🚀
|
||||
@@ -1 +1 @@
|
||||
41acb28aedc1da36af63ba5cb8859018
|
||||
01af3ba6904766cfc820f0897fc71456
|
||||
Binary file not shown.
@@ -34,11 +34,11 @@ addEventListener("message", eventListener);
|
||||
if (!window._flutter) {
|
||||
window._flutter = {};
|
||||
}
|
||||
_flutter.buildConfig = {"engineRevision":"1425e5e9ec5eeb4f225c401d8db69b860e0fde9a","builds":[{"compileTarget":"dart2js","renderer":"canvaskit","mainJsPath":"main.dart.js"}]};
|
||||
_flutter.buildConfig = {"engineRevision":"8cd19e509d6bece8ccd74aef027c4ca947363095","builds":[{"compileTarget":"dart2js","renderer":"canvaskit","mainJsPath":"main.dart.js"}]};
|
||||
|
||||
|
||||
_flutter.loader.load({
|
||||
serviceWorkerSettings: {
|
||||
serviceWorkerVersion: "1907117848"
|
||||
serviceWorkerVersion: "1341609338"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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": "8a2b42aa8136fe0225b2b62c68d0dbcd",
|
||||
"version.json": "e8d1bf0cf13b62f11b193e5c745aa8d1",
|
||||
const RESOURCES = {"flutter_bootstrap.js": "5437c5ddd6a5fccc16e63821cf243b0a",
|
||||
"version.json": "e7dfc195497a7522565cd5f518a8fb08",
|
||||
"index.html": "2aab03d10fea3b608e3eddc0fc0077e5",
|
||||
"/": "2aab03d10fea3b608e3eddc0fc0077e5",
|
||||
"favicon-64.png": "259540a3217e969237530444ca0eaed3",
|
||||
"favicon-16.png": "106142fb24eba190e475dbe6513cc9ff",
|
||||
"main.dart.js": "b45a67a2883e7ac3efa7375758392344",
|
||||
"main.dart.js": "4e39d8f5925fe36f9c6cb4759f8ca868",
|
||||
"flutter.js": "83d881c1dbb6d6bcd6b42e274605b69c",
|
||||
"favicon.png": "21510778ead066ac826ad69302400773",
|
||||
"icons/Icon-192.png": "f36879dd176101fac324b68793e4683c",
|
||||
@@ -29,7 +29,7 @@ const RESOURCES = {"flutter_bootstrap.js": "8a2b42aa8136fe0225b2b62c68d0dbcd",
|
||||
"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": "ac09b81b3261e74c47ed73d08f520ce8",
|
||||
"assets/fonts/MaterialIcons-Regular.otf": "8d825fa7ed340557e7a81e9c327cc4e2",
|
||||
"assets/assets/images/geosector-logo.png": "b78408af5aa357b1107e1cb7be9e7c1e",
|
||||
"assets/assets/images/logo-geosector-1024.png": "adb1be034f0b983acf6246369a794de5",
|
||||
"assets/assets/images/icon-geosector.svg": "c9dd0fb514a53ee434b57895cf6cd5fd",
|
||||
|
||||
144291
app/build/web/main.dart.js
144291
app/build/web/main.dart.js
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"app_name":"geosector_app","version":"0.3.5","package_name":"geosector_app"}
|
||||
{"app_name":"geosector_app","version":"0.4.0","package_name":"geosector_app"}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
|
||||
@@ -60,58 +61,64 @@ class MembreModel extends HiveObject {
|
||||
|
||||
// Factory pour convertir depuis JSON (API)
|
||||
factory MembreModel.fromJson(Map<String, dynamic> json) {
|
||||
// Convertir l'ID en int, qu'il soit déjà int ou string
|
||||
final dynamic rawId = json['id'];
|
||||
final int id = rawId is String ? int.parse(rawId) : rawId as int;
|
||||
try {
|
||||
// Convertir l'ID en int, qu'il soit déjà int ou string
|
||||
final dynamic rawId = json['id'];
|
||||
final int id = rawId is String ? int.parse(rawId) : rawId as int;
|
||||
|
||||
// Convertir le rôle en int (ATTENTION: le champ JSON est 'fk_role' pas 'role')
|
||||
final dynamic rawRole = json['fk_role']; // Correction ici !
|
||||
final int role = rawRole is String ? int.parse(rawRole) : rawRole as int;
|
||||
// Convertir le rôle en int (ATTENTION: le champ JSON est 'fk_role' pas 'role')
|
||||
final dynamic rawRole = json['fk_role']; // Correction ici !
|
||||
final int role = rawRole is String ? int.parse(rawRole) : rawRole as int;
|
||||
|
||||
// Convertir fkEntite en int si présent
|
||||
int? fkEntite;
|
||||
if (json['fk_entite'] != null) {
|
||||
final dynamic rawEntite = json['fk_entite'];
|
||||
fkEntite = rawEntite is String ? int.parse(rawEntite) : rawEntite as int;
|
||||
}
|
||||
|
||||
// Convertir fkTitre en int si présent
|
||||
int? fkTitre;
|
||||
if (json['fk_titre'] != null) {
|
||||
final dynamic rawTitre = json['fk_titre'];
|
||||
fkTitre = rawTitre is String ? int.parse(rawTitre) : rawTitre as int;
|
||||
}
|
||||
|
||||
// Gérer les dates nulles ou avec des valeurs invalides comme "0000-00-00"
|
||||
DateTime? parseDate(String? dateStr) {
|
||||
if (dateStr == null || dateStr.isEmpty || dateStr == "0000-00-00") {
|
||||
return null;
|
||||
// Convertir fkEntite en int si présent
|
||||
int? fkEntite;
|
||||
if (json['fk_entite'] != null) {
|
||||
final dynamic rawEntite = json['fk_entite'];
|
||||
fkEntite = rawEntite is String ? int.parse(rawEntite) : rawEntite as int;
|
||||
}
|
||||
try {
|
||||
return DateTime.parse(dateStr);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return MembreModel(
|
||||
id: id,
|
||||
fkEntite: fkEntite,
|
||||
role: role,
|
||||
fkTitre: fkTitre,
|
||||
name: json['name'],
|
||||
firstName: json['first_name'],
|
||||
username: json['username'],
|
||||
sectName: json['sect_name'],
|
||||
email: json['email'] ?? '',
|
||||
phone: json['phone'],
|
||||
mobile: json['mobile'],
|
||||
dateNaissance: parseDate(json['date_naissance']),
|
||||
dateEmbauche: parseDate(json['date_embauche']),
|
||||
createdAt: json['created_at'] != null ? DateTime.parse(json['created_at']) : DateTime.now(),
|
||||
// Le champ JSON est 'chk_active' pas 'is_active'
|
||||
isActive: json['chk_active'] == 1 || json['chk_active'] == true,
|
||||
);
|
||||
// Convertir fkTitre en int si présent
|
||||
int? fkTitre;
|
||||
if (json['fk_titre'] != null) {
|
||||
final dynamic rawTitre = json['fk_titre'];
|
||||
fkTitre = rawTitre is String ? int.parse(rawTitre) : rawTitre as int;
|
||||
}
|
||||
|
||||
// Gérer les dates nulles ou avec des valeurs invalides comme "0000-00-00"
|
||||
DateTime? parseDate(String? dateStr) {
|
||||
if (dateStr == null || dateStr.isEmpty || dateStr == "0000-00-00") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return DateTime.parse(dateStr);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return MembreModel(
|
||||
id: id,
|
||||
fkEntite: fkEntite,
|
||||
role: role,
|
||||
fkTitre: fkTitre,
|
||||
name: json['name'] ?? '', // ← Fallback pour champs manquants
|
||||
firstName: json['first_name'] ?? '', // ← Fallback pour champs manquants
|
||||
username: json['username'] ?? '', // ← Fallback pour champs manquants
|
||||
sectName: json['sect_name'] ?? '',
|
||||
email: json['email'] ?? '', // ← Déjà OK mais renforcé
|
||||
phone: json['phone'],
|
||||
mobile: json['mobile'],
|
||||
dateNaissance: parseDate(json['date_naissance']),
|
||||
dateEmbauche: parseDate(json['date_embauche']),
|
||||
createdAt: DateTime.now(), // ← Simplifié car created_at n'existe pas dans l'API
|
||||
// Le champ JSON est 'chk_active' pas 'is_active'
|
||||
isActive: json['chk_active'] == 1 || json['chk_active'] == true,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur parsing MembreModel: $e');
|
||||
debugPrint('❌ Données JSON: $json');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir en JSON pour l'API
|
||||
|
||||
@@ -25,12 +25,16 @@ class OperationModel extends HiveObject {
|
||||
@HiveField(6)
|
||||
bool isSynced;
|
||||
|
||||
@HiveField(7)
|
||||
final int fkEntite;
|
||||
|
||||
OperationModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.dateDebut,
|
||||
required this.dateFin,
|
||||
required this.lastSyncedAt,
|
||||
required this.fkEntite,
|
||||
this.isActive = true,
|
||||
this.isSynced = false,
|
||||
});
|
||||
@@ -40,15 +44,18 @@ class OperationModel extends HiveObject {
|
||||
// Convertir l'ID en int, qu'il soit déjà int ou string
|
||||
final dynamic rawId = json['id'];
|
||||
final int id = rawId is String ? int.parse(rawId) : rawId as int;
|
||||
|
||||
final dynamic rawEntite = json['fk_entite'];
|
||||
final int fkEntite = rawEntite is String ? int.parse(rawEntite) : rawEntite as int;
|
||||
|
||||
return OperationModel(
|
||||
id: id,
|
||||
name: json['name'],
|
||||
name: json['libelle'], // ← Correction: utiliser 'libelle' au lieu de 'name'
|
||||
dateDebut: DateTime.parse(json['date_deb']),
|
||||
dateFin: DateTime.parse(json['date_fin']),
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: true,
|
||||
isActive: json['chk_active'] == true || json['chk_active'] == 1 || json['chk_active'] == "1",
|
||||
isSynced: true,
|
||||
fkEntite: fkEntite,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,6 +67,7 @@ class OperationModel extends HiveObject {
|
||||
'date_deb': dateDebut.toIso8601String().split('T')[0], // Format YYYY-MM-DD
|
||||
'date_fin': dateFin.toIso8601String().split('T')[0], // Format YYYY-MM-DD
|
||||
'is_active': isActive,
|
||||
'fk_entite': fkEntite,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,15 +79,17 @@ class OperationModel extends HiveObject {
|
||||
bool? isActive,
|
||||
bool? isSynced,
|
||||
DateTime? lastSyncedAt,
|
||||
int? fkEntite,
|
||||
}) {
|
||||
return OperationModel(
|
||||
id: this.id,
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
dateDebut: dateDebut ?? this.dateDebut,
|
||||
dateFin: dateFin ?? this.dateFin,
|
||||
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
|
||||
isActive: isActive ?? this.isActive,
|
||||
isSynced: isSynced ?? this.isSynced,
|
||||
fkEntite: fkEntite ?? this.fkEntite,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ class OperationModelAdapter extends TypeAdapter<OperationModel> {
|
||||
dateDebut: fields[2] as DateTime,
|
||||
dateFin: fields[3] as DateTime,
|
||||
lastSyncedAt: fields[4] as DateTime,
|
||||
fkEntite: fields[7] as int,
|
||||
isActive: fields[5] as bool,
|
||||
isSynced: fields[6] as bool,
|
||||
);
|
||||
@@ -30,7 +31,7 @@ class OperationModelAdapter extends TypeAdapter<OperationModel> {
|
||||
@override
|
||||
void write(BinaryWriter writer, OperationModel obj) {
|
||||
writer
|
||||
..writeByte(7)
|
||||
..writeByte(8)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
@@ -44,7 +45,9 @@ class OperationModelAdapter extends TypeAdapter<OperationModel> {
|
||||
..writeByte(5)
|
||||
..write(obj.isActive)
|
||||
..writeByte(6)
|
||||
..write(obj.isSynced);
|
||||
..write(obj.isSynced)
|
||||
..writeByte(7)
|
||||
..write(obj.fkEntite);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'passage_model.g.dart';
|
||||
@@ -125,66 +126,72 @@ class PassageModel extends HiveObject {
|
||||
|
||||
// Factory pour convertir depuis JSON (API)
|
||||
factory PassageModel.fromJson(Map<String, dynamic> json) {
|
||||
// Convertir l'ID en int, qu'il soit déjà int ou string
|
||||
final dynamic rawId = json['id'];
|
||||
final int id = rawId is String ? int.parse(rawId) : rawId as int;
|
||||
|
||||
// Convertir les autres champs numériques
|
||||
final dynamic rawFkOperation = json['fk_operation'];
|
||||
final int fkOperation = rawFkOperation is String ? int.parse(rawFkOperation) : rawFkOperation as int;
|
||||
|
||||
final dynamic rawFkSector = json['fk_sector'];
|
||||
final int fkSector = rawFkSector is String ? int.parse(rawFkSector) : rawFkSector as int;
|
||||
|
||||
final dynamic rawFkUser = json['fk_user'];
|
||||
final int fkUser = rawFkUser is String ? int.parse(rawFkUser) : rawFkUser as int;
|
||||
|
||||
final dynamic rawFkType = json['fk_type'];
|
||||
final int fkType = rawFkType is String ? int.parse(rawFkType) : rawFkType as int;
|
||||
|
||||
final dynamic rawFkHabitat = json['fk_habitat'];
|
||||
final int fkHabitat = rawFkHabitat is String ? int.parse(rawFkHabitat) : rawFkHabitat as int;
|
||||
|
||||
final dynamic rawFkTypeReglement = json['fk_type_reglement'];
|
||||
final int fkTypeReglement = rawFkTypeReglement is String ? int.parse(rawFkTypeReglement) : rawFkTypeReglement as int;
|
||||
|
||||
final dynamic rawNbPassages = json['nb_passages'];
|
||||
final int nbPassages = rawNbPassages is String ? int.parse(rawNbPassages) : rawNbPassages as int;
|
||||
|
||||
// Convertir la date
|
||||
final DateTime passedAt = DateTime.parse(json['passed_at']);
|
||||
|
||||
return PassageModel(
|
||||
id: id,
|
||||
fkOperation: fkOperation,
|
||||
fkSector: fkSector,
|
||||
fkUser: fkUser,
|
||||
fkType: fkType,
|
||||
fkAdresse: json['fk_adresse'] as String,
|
||||
passedAt: passedAt,
|
||||
numero: json['numero'] as String,
|
||||
rue: json['rue'] as String,
|
||||
rueBis: json['rue_bis'] as String? ?? '',
|
||||
ville: json['ville'] as String,
|
||||
residence: json['residence'] as String? ?? '',
|
||||
fkHabitat: fkHabitat,
|
||||
appt: json['appt'] as String? ?? '',
|
||||
niveau: json['niveau'] as String? ?? '',
|
||||
gpsLat: json['gps_lat'] as String,
|
||||
gpsLng: json['gps_lng'] as String,
|
||||
nomRecu: json['nom_recu'] as String? ?? '',
|
||||
remarque: json['remarque'] as String? ?? '',
|
||||
montant: json['montant'] as String,
|
||||
fkTypeReglement: fkTypeReglement,
|
||||
emailErreur: json['email_erreur'] as String? ?? '',
|
||||
nbPassages: nbPassages,
|
||||
name: json['name'] as String,
|
||||
email: json['email'] as String? ?? '',
|
||||
phone: json['phone'] as String? ?? '',
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: true,
|
||||
isSynced: true,
|
||||
);
|
||||
try {
|
||||
// Convertir l'ID en int, qu'il soit déjà int ou string
|
||||
final dynamic rawId = json['id'];
|
||||
final int id = rawId is String ? int.parse(rawId) : rawId as int;
|
||||
|
||||
// Convertir les autres champs numériques
|
||||
final dynamic rawFkOperation = json['fk_operation'];
|
||||
final int fkOperation = rawFkOperation is String ? int.parse(rawFkOperation) : rawFkOperation as int;
|
||||
|
||||
final dynamic rawFkSector = json['fk_sector'];
|
||||
final int fkSector = rawFkSector is String ? int.parse(rawFkSector) : rawFkSector as int;
|
||||
|
||||
final dynamic rawFkUser = json['fk_user'];
|
||||
final int fkUser = rawFkUser is String ? int.parse(rawFkUser) : rawFkUser as int;
|
||||
|
||||
final dynamic rawFkType = json['fk_type'];
|
||||
final int fkType = rawFkType is String ? int.parse(rawFkType) : rawFkType as int;
|
||||
|
||||
final dynamic rawFkHabitat = json['fk_habitat'];
|
||||
final int fkHabitat = rawFkHabitat is String ? int.parse(rawFkHabitat) : rawFkHabitat as int;
|
||||
|
||||
final dynamic rawFkTypeReglement = json['fk_type_reglement'];
|
||||
final int fkTypeReglement = rawFkTypeReglement is String ? int.parse(rawFkTypeReglement) : rawFkTypeReglement as int;
|
||||
|
||||
final dynamic rawNbPassages = json['nb_passages'];
|
||||
final int nbPassages = rawNbPassages is String ? int.parse(rawNbPassages) : rawNbPassages as int;
|
||||
|
||||
// Convertir la date
|
||||
final DateTime passedAt = DateTime.parse(json['passed_at']);
|
||||
|
||||
return PassageModel(
|
||||
id: id,
|
||||
fkOperation: fkOperation,
|
||||
fkSector: fkSector,
|
||||
fkUser: fkUser,
|
||||
fkType: fkType,
|
||||
fkAdresse: json['fk_adresse']?.toString() ?? '', // ← Gestion null
|
||||
passedAt: passedAt,
|
||||
numero: json['numero']?.toString() ?? '', // ← Gestion null
|
||||
rue: json['rue']?.toString() ?? '', // ← Gestion null
|
||||
rueBis: json['rue_bis']?.toString() ?? '',
|
||||
ville: json['ville']?.toString() ?? '', // ← Gestion null
|
||||
residence: json['residence']?.toString() ?? '',
|
||||
fkHabitat: fkHabitat,
|
||||
appt: json['appt']?.toString() ?? '',
|
||||
niveau: json['niveau']?.toString() ?? '',
|
||||
gpsLat: json['gps_lat']?.toString() ?? '', // ← Gestion null
|
||||
gpsLng: json['gps_lng']?.toString() ?? '', // ← Gestion null
|
||||
nomRecu: json['nom_recu']?.toString() ?? '', // ← Gestion null explicite
|
||||
remarque: json['remarque']?.toString() ?? '',
|
||||
montant: json['montant']?.toString() ?? '0.00', // ← Gestion null avec fallback
|
||||
fkTypeReglement: fkTypeReglement,
|
||||
emailErreur: json['email_erreur']?.toString() ?? '',
|
||||
nbPassages: nbPassages,
|
||||
name: json['name']?.toString() ?? '', // ← Gestion null
|
||||
email: json['email']?.toString() ?? '',
|
||||
phone: json['phone']?.toString() ?? '',
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: true,
|
||||
isSynced: true,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur parsing PassageModel: $e');
|
||||
debugPrint('❌ Données JSON: $json');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir en JSON pour l'API
|
||||
|
||||
@@ -2,7 +2,11 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:geosector_app/core/data/models/operation_model.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/data/models/user_sector_model.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/services/data_loading_service.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
|
||||
class OperationRepository extends ChangeNotifier {
|
||||
@@ -13,6 +17,9 @@ class OperationRepository extends ChangeNotifier {
|
||||
return Hive.box<OperationModel>(AppKeys.operationsBoxName);
|
||||
}
|
||||
|
||||
// Getter exposant publiquement la Hive Box
|
||||
Box<OperationModel> get operationBox => _operationBox;
|
||||
|
||||
// Méthode pour vérifier si la boîte est ouverte et l'ouvrir si nécessaire
|
||||
Future<void> _ensureBoxIsOpen() async {
|
||||
const boxName = AppKeys.operationsBoxName;
|
||||
@@ -97,10 +104,14 @@ class OperationRepository extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
debugPrint('🔄 Traitement de ${operationsData.length} opérations depuis l\'API');
|
||||
|
||||
for (var operationData in operationsData) {
|
||||
final operationJson = operationData as Map<String, dynamic>;
|
||||
final operationId = operationJson['id'] is String ? int.parse(operationJson['id']) : operationJson['id'] as int;
|
||||
|
||||
debugPrint('📝 Traitement opération ID: $operationId, libelle: ${operationJson['libelle']}');
|
||||
|
||||
// Vérifier si l'opération existe déjà
|
||||
OperationModel? existingOperation = getOperationById(operationId);
|
||||
|
||||
@@ -108,20 +119,27 @@ class OperationRepository extends ChangeNotifier {
|
||||
// Créer une nouvelle opération
|
||||
final newOperation = OperationModel.fromJson(operationJson);
|
||||
await saveOperation(newOperation);
|
||||
debugPrint('✅ Nouvelle opération créée: ${newOperation.name}');
|
||||
} else {
|
||||
// Mettre à jour l'opération existante
|
||||
final updatedOperation = existingOperation.copyWith(
|
||||
name: operationJson['name'],
|
||||
name: operationJson['libelle'], // ← Correction: utiliser 'libelle' au lieu de 'name'
|
||||
fkEntite: operationJson['fk_entite'],
|
||||
dateDebut: DateTime.parse(operationJson['date_deb']),
|
||||
dateFin: DateTime.parse(operationJson['date_fin']),
|
||||
isActive: operationJson['chk_active'] == true || operationJson['chk_active'] == 1 || operationJson['chk_active'] == "1",
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isSynced: true,
|
||||
);
|
||||
await saveOperation(updatedOperation);
|
||||
debugPrint('✅ Opération mise à jour: ${updatedOperation.name}');
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('🎉 Traitement terminé - ${_operationBox.length} opérations dans la box');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du traitement des opérations: $e');
|
||||
debugPrint('❌ Erreur lors du traitement des opérations: $e');
|
||||
debugPrint('❌ Stack trace: ${StackTrace.current}');
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
@@ -141,40 +159,116 @@ class OperationRepository extends ChangeNotifier {
|
||||
'date_fin': dateFin.toIso8601String().split('T')[0], // Format YYYY-MM-DD
|
||||
};
|
||||
|
||||
debugPrint('🚀 Création d\'une nouvelle opération: $data');
|
||||
|
||||
// Appeler l'API pour créer l'opération
|
||||
final response = await ApiService.instance.post('/operations', data: data);
|
||||
|
||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
||||
// Récupérer l'ID de la nouvelle opération
|
||||
final operationId = response.data['id'] is String ? int.parse(response.data['id']) : response.data['id'] as int;
|
||||
debugPrint('✅ Opération créée avec succès');
|
||||
|
||||
// Créer l'opération localement
|
||||
final newOperation = OperationModel(
|
||||
id: operationId,
|
||||
name: name,
|
||||
dateDebut: dateDebut,
|
||||
dateFin: dateFin,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: true,
|
||||
isSynced: true,
|
||||
);
|
||||
|
||||
await saveOperation(newOperation);
|
||||
// Traiter la réponse complète de l'API
|
||||
await _processCreationResponse(response.data);
|
||||
return true;
|
||||
}
|
||||
|
||||
debugPrint('❌ Échec de la création - Code: ${response.statusCode}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la création de l\'opération: $e');
|
||||
return false;
|
||||
debugPrint('❌ Erreur lors de la création de l\'opération: $e');
|
||||
rethrow;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Traiter la réponse complète après création d'opération
|
||||
Future<void> _processCreationResponse(Map<String, dynamic> responseData) async {
|
||||
try {
|
||||
debugPrint('🔄 Traitement de la réponse de création d\'opération');
|
||||
|
||||
// Traiter les opérations (groupe operations)
|
||||
if (responseData['operations'] != null) {
|
||||
await processOperationsFromApi(responseData['operations']);
|
||||
debugPrint('✅ Opérations traitées');
|
||||
}
|
||||
|
||||
// Traiter les secteurs (groupe secteurs) via DataLoadingService
|
||||
if (responseData['secteurs'] != null) {
|
||||
await DataLoadingService.instance.processSectorsFromApi(responseData['secteurs']);
|
||||
debugPrint('✅ Secteurs traités');
|
||||
}
|
||||
|
||||
// Traiter les passages (groupe passages) via DataLoadingService
|
||||
if (responseData['passages'] != null) {
|
||||
await DataLoadingService.instance.processPassagesFromApi(responseData['passages']);
|
||||
debugPrint('✅ Passages traités');
|
||||
}
|
||||
|
||||
// Traiter les users_sectors (groupe users_sectors) via DataLoadingService
|
||||
if (responseData['users_sectors'] != null) {
|
||||
await DataLoadingService.instance.processUserSectorsFromApi(responseData['users_sectors']);
|
||||
debugPrint('✅ Users_sectors traités');
|
||||
}
|
||||
|
||||
debugPrint('🎉 Tous les groupes de données ont été traités avec succès');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors du traitement de la réponse: $e');
|
||||
// Ne pas faire échouer la création si le traitement des données supplémentaires échoue
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode universelle pour sauvegarder une opération (création ou mise à jour)
|
||||
Future<bool> saveOperationFromModel(OperationModel operation) async {
|
||||
debugPrint('=== saveOperationFromModel APPELÉ ===');
|
||||
debugPrint('operation.id: ${operation.id}');
|
||||
debugPrint('operation.name: ${operation.name}');
|
||||
|
||||
try {
|
||||
if (operation.id == 0) {
|
||||
// Nouvelle opération - créer
|
||||
debugPrint('=== CRÉATION (POST) ===');
|
||||
return await createOperation(
|
||||
operation.name,
|
||||
operation.dateDebut,
|
||||
operation.dateFin,
|
||||
);
|
||||
} else {
|
||||
// Opération existante - mettre à jour
|
||||
debugPrint('=== MISE À JOUR (PUT) ===');
|
||||
final result = await updateOperation(
|
||||
operation.id,
|
||||
name: operation.name,
|
||||
dateDebut: operation.dateDebut,
|
||||
dateFin: operation.dateFin,
|
||||
isActive: operation.isActive,
|
||||
fkEntite: operation.fkEntite, // ← Inclure fkEntite
|
||||
);
|
||||
debugPrint('=== RÉSULTAT UPDATE: $result ===');
|
||||
return result;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('=== ERREUR dans saveOperationFromModel: $e ===');
|
||||
// Propager l'exception pour que la page parente puisse la gérer
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode pour mettre à jour une opération avec un objet OperationModel
|
||||
Future<bool> updateOperationFromModel(OperationModel operation) async {
|
||||
return await updateOperation(
|
||||
operation.id,
|
||||
name: operation.name,
|
||||
dateDebut: operation.dateDebut,
|
||||
dateFin: operation.dateFin,
|
||||
isActive: operation.isActive,
|
||||
fkEntite: operation.fkEntite, // ← Inclure fkEntite
|
||||
);
|
||||
}
|
||||
|
||||
// Mettre à jour une opération
|
||||
Future<bool> updateOperation(int id, {String? name, DateTime? dateDebut, DateTime? dateFin, bool? isActive}) async {
|
||||
Future<bool> updateOperation(int id, {String? name, DateTime? dateDebut, DateTime? dateFin, bool? isActive, int? fkEntite}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
@@ -182,7 +276,8 @@ class OperationRepository extends ChangeNotifier {
|
||||
// Récupérer l'opération existante
|
||||
final existingOperation = getOperationById(id);
|
||||
if (existingOperation == null) {
|
||||
return false;
|
||||
debugPrint('❌ Opération avec l\'ID $id non trouvée');
|
||||
throw Exception('Opération non trouvée');
|
||||
}
|
||||
|
||||
// Préparer les données pour l'API
|
||||
@@ -191,59 +286,203 @@ class OperationRepository extends ChangeNotifier {
|
||||
'name': name ?? existingOperation.name,
|
||||
'date_deb': (dateDebut ?? existingOperation.dateDebut).toIso8601String().split('T')[0],
|
||||
'date_fin': (dateFin ?? existingOperation.dateFin).toIso8601String().split('T')[0],
|
||||
'is_active': isActive ?? existingOperation.isActive,
|
||||
'chk_active': isActive ?? existingOperation.isActive, // Utiliser chk_active comme dans l'API
|
||||
'fk_entite': fkEntite ?? existingOperation.fkEntite, // ← Inclure fkEntite
|
||||
};
|
||||
|
||||
debugPrint('🔄 Mise à jour de l\'opération $id avec les données: $data');
|
||||
// Appeler l'API pour mettre à jour l'opération
|
||||
final response = await ApiService.instance.put('/operations/$id', data: data);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
debugPrint('✅ Opération $id mise à jour avec succès');
|
||||
// Mettre à jour l'opération localement
|
||||
final updatedOperation = existingOperation.copyWith(
|
||||
name: name,
|
||||
dateDebut: dateDebut,
|
||||
dateFin: dateFin,
|
||||
isActive: isActive,
|
||||
fkEntite: fkEntite,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isSynced: true,
|
||||
);
|
||||
|
||||
await saveOperation(updatedOperation);
|
||||
return true;
|
||||
} else {
|
||||
debugPrint('❌ Échec de la mise à jour - Code: ${response.statusCode}');
|
||||
throw Exception('Échec de la mise à jour de l\'opération');
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la mise à jour de l\'opération: $e');
|
||||
return false;
|
||||
debugPrint('❌ Erreur lors de la mise à jour de l\'opération: $e');
|
||||
// Propager l'exception pour qu'elle soit gérée par l'interface
|
||||
rethrow;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer une opération via l'API
|
||||
// Supprimer une opération inactive via l'API
|
||||
Future<bool> deleteOperationViaApi(int id) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// Appeler l'API pour supprimer l'opération
|
||||
debugPrint('🗑️ Suppression opération inactive $id');
|
||||
|
||||
// Appeler l'API pour supprimer l'opération inactive
|
||||
final response = await ApiService.instance.delete('/operations/$id');
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 204) {
|
||||
// Supprimer l'opération localement
|
||||
await deleteOperation(id);
|
||||
debugPrint('✅ Suppression réussie - Traitement de la réponse');
|
||||
|
||||
// Traiter la réponse qui contient les 3 dernières opérations
|
||||
if (response.data != null && response.data['operations'] != null) {
|
||||
// Vider la box des opérations
|
||||
await _operationBox.clear();
|
||||
|
||||
// Recharger les opérations depuis la réponse API
|
||||
await processOperationsFromApi(response.data['operations']);
|
||||
debugPrint('✅ Opérations rechargées après suppression');
|
||||
} else {
|
||||
// Fallback : supprimer localement seulement
|
||||
await deleteOperation(id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
debugPrint('❌ Échec suppression - Code: ${response.statusCode}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la suppression de l\'opération: $e');
|
||||
return false;
|
||||
debugPrint('❌ Erreur lors de la suppression de l\'opération: $e');
|
||||
rethrow;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer une opération active via l'API (avec réactivation automatique)
|
||||
Future<bool> deleteActiveOperationViaApi(int id) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
debugPrint('🗑️ Suppression opération active $id');
|
||||
|
||||
// Appeler l'API pour supprimer l'opération active
|
||||
final response = await ApiService.instance.delete('/operations/$id');
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 204) {
|
||||
debugPrint('✅ Suppression opération active réussie - Traitement complet');
|
||||
|
||||
// Traiter la réponse complète qui contient tous les groupes de données
|
||||
if (response.data != null) {
|
||||
await _processActiveDeleteResponse(response.data);
|
||||
debugPrint('✅ Données rechargées après suppression opération active');
|
||||
} else {
|
||||
// Fallback : supprimer localement seulement
|
||||
await deleteOperation(id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
debugPrint('❌ Échec suppression opération active - Code: ${response.statusCode}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de la suppression de l\'opération active: $e');
|
||||
rethrow;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Traiter la réponse complète après suppression d'opération active
|
||||
Future<void> _processActiveDeleteResponse(Map<String, dynamic> responseData) async {
|
||||
try {
|
||||
debugPrint('🔄 Traitement de la réponse de suppression d\'opération active');
|
||||
|
||||
// Vider toutes les Box concernées
|
||||
await _clearAllRelatedBoxes();
|
||||
|
||||
// Traiter les opérations (groupe operations)
|
||||
if (responseData['operations'] != null) {
|
||||
await processOperationsFromApi(responseData['operations']);
|
||||
debugPrint('✅ Opérations traitées');
|
||||
}
|
||||
|
||||
// Traiter les secteurs (groupe secteurs) via DataLoadingService
|
||||
if (responseData['secteurs'] != null) {
|
||||
await DataLoadingService.instance.processSectorsFromApi(responseData['secteurs']);
|
||||
debugPrint('✅ Secteurs traités');
|
||||
}
|
||||
|
||||
// Traiter les passages (groupe passages) via DataLoadingService
|
||||
if (responseData['passages'] != null) {
|
||||
await DataLoadingService.instance.processPassagesFromApi(responseData['passages']);
|
||||
debugPrint('✅ Passages traités');
|
||||
}
|
||||
|
||||
// Traiter les users_sectors (groupe users_sectors) via DataLoadingService
|
||||
if (responseData['users_sectors'] != null) {
|
||||
await DataLoadingService.instance.processUserSectorsFromApi(responseData['users_sectors']);
|
||||
debugPrint('✅ Users_sectors traités');
|
||||
}
|
||||
|
||||
debugPrint('🎉 Tous les groupes de données ont été traités après suppression opération active');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors du traitement de la réponse de suppression: $e');
|
||||
// Ne pas faire échouer la suppression si le traitement des données supplémentaires échoue
|
||||
}
|
||||
}
|
||||
|
||||
// Vider toutes les Box liées lors de suppression d'opération active
|
||||
Future<void> _clearAllRelatedBoxes() async {
|
||||
try {
|
||||
// Vider les Box respectives avant rechargement complet
|
||||
await _operationBox.clear();
|
||||
|
||||
if (Hive.isBoxOpen(AppKeys.sectorsBoxName)) {
|
||||
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
|
||||
await sectorsBox.clear();
|
||||
}
|
||||
|
||||
if (Hive.isBoxOpen(AppKeys.passagesBoxName)) {
|
||||
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
await passagesBox.clear();
|
||||
}
|
||||
|
||||
if (Hive.isBoxOpen(AppKeys.userSectorBoxName)) {
|
||||
final userSectorsBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
|
||||
await userSectorsBox.clear();
|
||||
}
|
||||
|
||||
debugPrint('✅ Toutes les Box ont été vidées');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors du vidage des Box: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Export Excel d'une opération
|
||||
Future<void> exportOperationToExcel(int operationId, String operationName) async {
|
||||
try {
|
||||
debugPrint('📊 Export Excel opération $operationId: $operationName');
|
||||
|
||||
// Générer le nom de fichier avec la date actuelle
|
||||
final now = DateTime.now();
|
||||
final dateStr = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||
final fileName = 'operation_${operationName.replaceAll(' ', '_')}_$dateStr.xlsx';
|
||||
|
||||
// Appeler l'API pour télécharger le fichier Excel
|
||||
await ApiService.instance.downloadOperationExcel(operationId, fileName);
|
||||
|
||||
debugPrint('✅ Export Excel terminé pour opération $operationId');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de l\'export Excel: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
import 'package:geosector_app/core/services/data_loading_service.dart';
|
||||
import 'package:geosector_app/core/services/hive_service.dart';
|
||||
import 'package:geosector_app/core/services/hive_reset_state_service.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
@@ -280,7 +281,7 @@ class UserRepository extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Déconnexion simplifiée avec DataLoadingService
|
||||
/// Déconnexion simplifiée avec HiveService
|
||||
Future<bool> logout(BuildContext context) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
@@ -301,8 +302,8 @@ class UserRepository extends ChangeNotifier {
|
||||
await CurrentUserService.instance.clearUser();
|
||||
await CurrentAmicaleService.instance.clearAmicale();
|
||||
|
||||
// Nettoyage complet via DataLoadingService
|
||||
await DataLoadingService.instance.cleanDataAfterLogout();
|
||||
// Nettoyage des données via HiveService (préserve les utilisateurs)
|
||||
await HiveService.instance.cleanDataOnLogout();
|
||||
|
||||
// Réinitialiser l'état de HiveResetStateService
|
||||
hiveResetStateService.reset();
|
||||
|
||||
@@ -307,6 +307,69 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
// Export Excel d'une opération
|
||||
Future<void> downloadOperationExcel(int operationId, String fileName) async {
|
||||
try {
|
||||
debugPrint('📊 Téléchargement Excel pour opération $operationId');
|
||||
|
||||
final response = await _dio.get(
|
||||
'/operations/$operationId/export/excel',
|
||||
options: Options(
|
||||
responseType: ResponseType.bytes, // Important pour les fichiers binaires
|
||||
headers: {
|
||||
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
debugPrint('✅ Fichier Excel reçu (${response.data.length} bytes)');
|
||||
|
||||
if (kIsWeb) {
|
||||
// Pour le web : déclencher le téléchargement via le navigateur
|
||||
_downloadFileWeb(response.data, fileName);
|
||||
} else {
|
||||
// Pour mobile : sauvegarder dans le dossier de téléchargements
|
||||
await _downloadFileMobile(response.data, fileName);
|
||||
}
|
||||
|
||||
debugPrint('✅ Export Excel terminé: $fileName');
|
||||
} else {
|
||||
throw ApiException('Erreur lors du téléchargement: ${response.statusCode}');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
throw ApiException.fromDioException(e);
|
||||
} catch (e) {
|
||||
if (e is ApiException) rethrow;
|
||||
throw ApiException('Erreur inattendue lors de l\'export Excel', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
// Téléchargement pour le web
|
||||
void _downloadFileWeb(List<int> bytes, String fileName) {
|
||||
final blob = html.Blob([bytes]);
|
||||
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||
|
||||
final anchor = html.AnchorElement(href: url)
|
||||
..setAttribute('download', fileName)
|
||||
..click();
|
||||
|
||||
html.Url.revokeObjectUrl(url);
|
||||
debugPrint('🌐 Téléchargement web déclenché: $fileName');
|
||||
}
|
||||
|
||||
// Téléchargement pour mobile
|
||||
Future<void> _downloadFileMobile(List<int> bytes, String fileName) async {
|
||||
try {
|
||||
// Pour mobile, on pourrait utiliser path_provider pour obtenir le dossier de téléchargements
|
||||
// et file_picker ou similar pour sauvegarder le fichier
|
||||
// Pour l'instant, on lance juste une exception informative
|
||||
throw const ApiException('Téléchargement mobile non implémenté. Utilisez la version web.');
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode de nettoyage pour les tests
|
||||
static void reset() {
|
||||
_instance = null;
|
||||
|
||||
@@ -126,6 +126,28 @@ class DataLoadingService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// === MÉTHODES PUBLIQUES POUR TRAITEMENT EXTERNE ===
|
||||
|
||||
/// Méthode publique pour traiter les secteurs depuis l'extérieur
|
||||
Future<void> processSectorsFromApi(dynamic sectorsData) async {
|
||||
await _processSectors(sectorsData);
|
||||
}
|
||||
|
||||
/// Méthode publique pour traiter les passages depuis l'extérieur
|
||||
Future<void> processPassagesFromApi(dynamic passagesData) async {
|
||||
await _processPassages(passagesData);
|
||||
}
|
||||
|
||||
/// Méthode publique pour traiter les associations user-sectors depuis l'extérieur
|
||||
Future<void> processUserSectorsFromApi(dynamic userSectorsData) async {
|
||||
await _processUserSectors(userSectorsData);
|
||||
}
|
||||
|
||||
/// Méthode publique pour traiter les opérations depuis l'extérieur
|
||||
Future<void> processOperationsFromApi(dynamic operationsData) async {
|
||||
await _processOperations(operationsData);
|
||||
}
|
||||
|
||||
// === MÉTHODES DE TRAITEMENT DES DONNÉES ===
|
||||
|
||||
Future<void> _processClients(dynamic clientsData) async {
|
||||
@@ -251,17 +273,20 @@ class DataLoadingService extends ChangeNotifier {
|
||||
await _passageBox.clear();
|
||||
|
||||
int count = 0;
|
||||
int errorCount = 0;
|
||||
for (final passageData in passagesList) {
|
||||
try {
|
||||
final passage = PassageModel.fromJson(passageData);
|
||||
await _passageBox.put(passage.id, passage);
|
||||
count++;
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur traitement passage: $e');
|
||||
errorCount++;
|
||||
debugPrint('⚠️ Erreur traitement passage ${passageData['id']}: $e');
|
||||
// Continue avec le passage suivant au lieu de s'arrêter
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('✅ $count passages stockés');
|
||||
debugPrint('✅ $count passages stockés${errorCount > 0 ? ' ($errorCount erreurs ignorées)' : ''}');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur traitement passages: $e');
|
||||
}
|
||||
@@ -278,7 +303,6 @@ class DataLoadingService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
await _amicaleBox.clear();
|
||||
|
||||
try {
|
||||
// Les données d'amicale sont un objet unique
|
||||
final Map<String, dynamic> amicaleMap = Map<String, dynamic>.from(amicaleData as Map);
|
||||
@@ -316,17 +340,20 @@ class DataLoadingService extends ChangeNotifier {
|
||||
await _membreBox.clear();
|
||||
|
||||
int count = 0;
|
||||
int errorCount = 0;
|
||||
for (final membreData in membresList) {
|
||||
try {
|
||||
final membre = MembreModel.fromJson(membreData);
|
||||
await _membreBox.put(membre.id, membre);
|
||||
count++;
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur traitement membre: $e');
|
||||
errorCount++;
|
||||
debugPrint('⚠️ Erreur traitement membre ${membreData['id']}: $e');
|
||||
// Continue avec le membre suivant au lieu de s'arrêter
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('✅ $count membres stockés');
|
||||
debugPrint('✅ $count membres stockés${errorCount > 0 ? ' ($errorCount erreurs ignorées)' : ''}');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur traitement membres: $e');
|
||||
}
|
||||
|
||||
499
app/lib/core/services/hive_service.dart
Normal file
499
app/lib/core/services/hive_service.dart
Normal file
@@ -0,0 +1,499 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/services/hive_web_fix.dart';
|
||||
import 'package:geosector_app/core/services/hive_adapters.dart';
|
||||
|
||||
// Import de tous les modèles typés
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/data/models/client_model.dart';
|
||||
import 'package:geosector_app/core/data/models/operation_model.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/core/data/models/user_sector_model.dart';
|
||||
import 'package:geosector_app/chat/models/conversation_model.dart';
|
||||
import 'package:geosector_app/chat/models/message_model.dart';
|
||||
|
||||
/// Service singleton centralisé pour la gestion complète des Box Hive
|
||||
/// Utilisé par main.dart pour l'initialisation et par logout pour le nettoyage
|
||||
class HiveService {
|
||||
static HiveService? _instance;
|
||||
static HiveService get instance => _instance ??= HiveService._internal();
|
||||
HiveService._internal();
|
||||
|
||||
/// Configuration des Box typées de l'application
|
||||
static const List<HiveBoxConfig> _boxConfigs = [
|
||||
HiveBoxConfig<UserModel>(AppKeys.userBoxName, 'UserModel'),
|
||||
HiveBoxConfig<AmicaleModel>(AppKeys.amicaleBoxName, 'AmicaleModel'),
|
||||
HiveBoxConfig<ClientModel>(AppKeys.clientsBoxName, 'ClientModel'),
|
||||
HiveBoxConfig<OperationModel>(AppKeys.operationsBoxName, 'OperationModel'),
|
||||
HiveBoxConfig<SectorModel>(AppKeys.sectorsBoxName, 'SectorModel'),
|
||||
HiveBoxConfig<PassageModel>(AppKeys.passagesBoxName, 'PassageModel'),
|
||||
HiveBoxConfig<MembreModel>(AppKeys.membresBoxName, 'MembreModel'),
|
||||
HiveBoxConfig<UserSectorModel>(AppKeys.userSectorBoxName, 'UserSectorModel'),
|
||||
HiveBoxConfig<ConversationModel>(AppKeys.chatConversationsBoxName, 'ConversationModel'),
|
||||
HiveBoxConfig<MessageModel>(AppKeys.chatMessagesBoxName, 'MessageModel'),
|
||||
HiveBoxConfig<dynamic>(AppKeys.settingsBoxName, 'Settings'),
|
||||
HiveBoxConfig<dynamic>(AppKeys.regionsBoxName, 'Regions'),
|
||||
];
|
||||
|
||||
bool _isInitialized = false;
|
||||
bool get isInitialized => _isInitialized;
|
||||
// === INITIALISATION COMPLÈTE (appelée par main.dart) ===
|
||||
|
||||
/// Initialisation complète de Hive avec réinitialisation totale
|
||||
Future<void> initializeAndResetHive() async {
|
||||
if (_isInitialized) {
|
||||
debugPrint('ℹ️ HiveService déjà initialisé');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🔧 Initialisation complète de Hive avec reset...');
|
||||
|
||||
// 1. Initialisation de base de Hive
|
||||
await Hive.initFlutter();
|
||||
debugPrint('✅ Hive.initFlutter() terminé');
|
||||
|
||||
// 2. Enregistrement des adaptateurs
|
||||
_registerAdapters();
|
||||
|
||||
// 3. Destruction complète des anciennes données
|
||||
await _destroyAllData();
|
||||
|
||||
// 4. Création des Box vides et propres
|
||||
await _createAllBoxes();
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('✅ HiveService initialisé avec succès');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de l\'initialisation complète: $e');
|
||||
_isInitialized = false;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// === INITIALISATION SIMPLE (appelée par splash_page si besoin) ===
|
||||
|
||||
/// Initialisation simple sans reset (utilisée par splash_page si déjà initialisé)
|
||||
Future<void> ensureBoxesAreOpen() async {
|
||||
try {
|
||||
debugPrint('🔍 Vérification et ouverture des Box...');
|
||||
|
||||
// Vérifier si toutes les Box sont ouvertes
|
||||
bool allOpen = true;
|
||||
for (final config in _boxConfigs) {
|
||||
if (!Hive.isBoxOpen(config.name)) {
|
||||
allOpen = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allOpen) {
|
||||
debugPrint('✅ Toutes les Box sont déjà ouvertes');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ouvrir les Box manquantes
|
||||
await _createAllBoxes();
|
||||
debugPrint('✅ Box manquantes ouvertes');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de la vérification des Box: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// === NETTOYAGE LOGOUT (appelé lors du logout) ===
|
||||
|
||||
/// Nettoyage sélectif lors du logout (préserve les utilisateurs)
|
||||
Future<void> cleanDataOnLogout() async {
|
||||
try {
|
||||
debugPrint('🧹 Nettoyage des données au logout...');
|
||||
|
||||
// Nettoyer toutes les Box sauf les utilisateurs
|
||||
for (final config in _boxConfigs) {
|
||||
if (config.name != AppKeys.userBoxName) {
|
||||
await _clearSingleBox(config.name);
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('✅ Nettoyage logout terminé (utilisateurs préservés)');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors du nettoyage logout: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// === MÉTHODES PRIVÉES D'INITIALISATION ===
|
||||
|
||||
/// Enregistrement de tous les adaptateurs Hive
|
||||
void _registerAdapters() {
|
||||
try {
|
||||
if (!Hive.isAdapterRegistered(0)) {
|
||||
// Utiliser HiveAdapters existant pour enregistrer tous les adaptateurs
|
||||
HiveAdapters.registerAll();
|
||||
debugPrint('🔌 Adaptateurs Hive enregistrés via HiveAdapters');
|
||||
} else {
|
||||
debugPrint('ℹ️ Adaptateurs déjà enregistrés');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur enregistrement adaptateurs: $e');
|
||||
// Ne pas faire échouer l'initialisation
|
||||
}
|
||||
}
|
||||
|
||||
/// Destruction complète de toutes les données selon la plateforme
|
||||
Future<void> _destroyAllData() async {
|
||||
try {
|
||||
debugPrint('💥 Destruction complète des données Hive...');
|
||||
|
||||
// 1. Fermer toutes les Box ouvertes
|
||||
await _closeAllOpenBoxes();
|
||||
|
||||
// 2. Suppression selon la plateforme
|
||||
if (kIsWeb) {
|
||||
await _destroyDataWeb();
|
||||
} else if (Platform.isIOS) {
|
||||
await _destroyDataIOS();
|
||||
} else if (Platform.isAndroid) {
|
||||
await _destroyDataAndroid();
|
||||
} else {
|
||||
await _destroyDataDesktop();
|
||||
}
|
||||
|
||||
// 3. Attendre pour s'assurer que tout est détruit
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
debugPrint('✅ Destruction complète terminée');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur destruction: $e');
|
||||
// Continuer malgré l'erreur
|
||||
}
|
||||
}
|
||||
|
||||
/// Fermeture de toutes les Box ouvertes
|
||||
Future<void> _closeAllOpenBoxes() async {
|
||||
try {
|
||||
debugPrint('🔒 Fermeture de toutes les Box ouvertes...');
|
||||
|
||||
// Fermer les Box configurées
|
||||
for (final config in _boxConfigs) {
|
||||
try {
|
||||
if (Hive.isBoxOpen(config.name)) {
|
||||
await Hive.box(config.name).close();
|
||||
debugPrint('🔒 Box ${config.name} fermée');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur fermeture ${config.name}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Fermer aussi les Box potentiellement orphelines
|
||||
final orphanBoxes = ['auth', 'temp', 'cache', 'locations', 'messages'];
|
||||
for (final boxName in orphanBoxes) {
|
||||
try {
|
||||
if (Hive.isBoxOpen(boxName)) {
|
||||
await Hive.box(boxName).close();
|
||||
debugPrint('🔒 Box orpheline $boxName fermée');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur fermeture orpheline $boxName: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur fermeture générale: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Destruction des données sur Web
|
||||
Future<void> _destroyDataWeb() async {
|
||||
try {
|
||||
debugPrint('🌐 Destruction Web...');
|
||||
|
||||
// Sur Web, utiliser le HiveWebFix si disponible
|
||||
try {
|
||||
await HiveWebFix.resetHiveCompletely();
|
||||
debugPrint('✅ Destruction Web via HiveWebFix');
|
||||
return;
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ HiveWebFix échoué, fallback...');
|
||||
}
|
||||
|
||||
// Fallback : supprimer Box par Box
|
||||
for (final config in _boxConfigs) {
|
||||
try {
|
||||
await Hive.deleteBoxFromDisk(config.name);
|
||||
debugPrint('🗑️ Box Web ${config.name} supprimée');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur suppression Web ${config.name}: $e');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur destruction Web: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Destruction des données sur iOS
|
||||
Future<void> _destroyDataIOS() async {
|
||||
try {
|
||||
debugPrint('🍎 Destruction iOS...');
|
||||
|
||||
// Méthode 1: Destruction totale
|
||||
try {
|
||||
await Hive.deleteFromDisk();
|
||||
debugPrint('✅ Destruction iOS complète');
|
||||
return;
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Destruction iOS totale échouée, méthode alternative...');
|
||||
}
|
||||
|
||||
// Méthode 2: Suppression des fichiers manuellement
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final hiveDir = Directory('${appDir.path}/hive');
|
||||
|
||||
if (await hiveDir.exists()) {
|
||||
await hiveDir.delete(recursive: true);
|
||||
debugPrint('✅ Dossier Hive iOS supprimé');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Suppression dossier iOS échouée: $e');
|
||||
}
|
||||
|
||||
// Méthode 3: Fallback Box par Box
|
||||
await _fallbackDeleteBoxes();
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur destruction iOS: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Destruction des données sur Android
|
||||
Future<void> _destroyDataAndroid() async {
|
||||
try {
|
||||
debugPrint('🤖 Destruction Android...');
|
||||
|
||||
// Méthode 1: Destruction totale
|
||||
try {
|
||||
await Hive.deleteFromDisk();
|
||||
debugPrint('✅ Destruction Android complète');
|
||||
return;
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Destruction Android totale échouée, méthode alternative...');
|
||||
}
|
||||
|
||||
// Méthode 2: Suppression des fichiers .hive et .lock
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final files = await appDir.list().toList();
|
||||
|
||||
int deletedCount = 0;
|
||||
for (final file in files) {
|
||||
final fileName = file.path.split('/').last;
|
||||
if (fileName.endsWith('.hive') || fileName.endsWith('.lock')) {
|
||||
try {
|
||||
await file.delete();
|
||||
deletedCount++;
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur suppression fichier $fileName: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('✅ $deletedCount fichiers Android supprimés');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Suppression fichiers Android échouée: $e');
|
||||
}
|
||||
|
||||
// Méthode 3: Fallback Box par Box
|
||||
await _fallbackDeleteBoxes();
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur destruction Android: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Destruction des données sur Desktop
|
||||
Future<void> _destroyDataDesktop() async {
|
||||
try {
|
||||
debugPrint('🖥️ Destruction Desktop...');
|
||||
|
||||
// Destruction totale
|
||||
await Hive.deleteFromDisk();
|
||||
debugPrint('✅ Destruction Desktop complète');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Destruction Desktop échouée, fallback...');
|
||||
await _fallbackDeleteBoxes();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback : suppression Box par Box
|
||||
Future<void> _fallbackDeleteBoxes() async {
|
||||
debugPrint('🔄 Fallback: suppression Box par Box...');
|
||||
|
||||
for (final config in _boxConfigs) {
|
||||
try {
|
||||
await Hive.deleteBoxFromDisk(config.name);
|
||||
debugPrint('🗑️ Box fallback ${config.name} supprimée');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur suppression fallback ${config.name}: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Création de toutes les Box vides et propres
|
||||
Future<void> _createAllBoxes() async {
|
||||
try {
|
||||
debugPrint('🆕 Création de toutes les Box...');
|
||||
|
||||
for (int i = 0; i < _boxConfigs.length; i++) {
|
||||
final config = _boxConfigs[i];
|
||||
|
||||
try {
|
||||
await _createSingleBox(config);
|
||||
debugPrint('✅ Box ${config.name} créée (${i + 1}/${_boxConfigs.length})');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur création ${config.name}: $e');
|
||||
// Continuer même en cas d'erreur
|
||||
}
|
||||
|
||||
// Petite pause entre les créations
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
debugPrint('✅ Toutes les Box ont été créées');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur création des Box: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Création d'une Box individuelle avec le bon type
|
||||
Future<void> _createSingleBox(HiveBoxConfig config) async {
|
||||
try {
|
||||
if (Hive.isBoxOpen(config.name)) {
|
||||
debugPrint('ℹ️ Box ${config.name} déjà ouverte');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (config.type) {
|
||||
case 'UserModel':
|
||||
await Hive.openBox<UserModel>(config.name);
|
||||
break;
|
||||
case 'AmicaleModel':
|
||||
await Hive.openBox<AmicaleModel>(config.name);
|
||||
break;
|
||||
case 'ClientModel':
|
||||
await Hive.openBox<ClientModel>(config.name);
|
||||
break;
|
||||
case 'OperationModel':
|
||||
await Hive.openBox<OperationModel>(config.name);
|
||||
break;
|
||||
case 'SectorModel':
|
||||
await Hive.openBox<SectorModel>(config.name);
|
||||
break;
|
||||
case 'PassageModel':
|
||||
await Hive.openBox<PassageModel>(config.name);
|
||||
break;
|
||||
case 'MembreModel':
|
||||
await Hive.openBox<MembreModel>(config.name);
|
||||
break;
|
||||
case 'UserSectorModel':
|
||||
await Hive.openBox<UserSectorModel>(config.name);
|
||||
break;
|
||||
case 'ConversationModel':
|
||||
await Hive.openBox<ConversationModel>(config.name);
|
||||
break;
|
||||
case 'MessageModel':
|
||||
await Hive.openBox<MessageModel>(config.name);
|
||||
break;
|
||||
default:
|
||||
// Pour Settings, Regions, etc.
|
||||
await Hive.openBox(config.name);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur création spécifique ${config.name}: $e');
|
||||
// Fallback : essayer sans type
|
||||
try {
|
||||
await Hive.openBox(config.name);
|
||||
debugPrint('⚠️ Box ${config.name} créée sans type');
|
||||
} catch (e2) {
|
||||
debugPrint('❌ Échec total ${config.name}: $e2');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === MÉTHODES UTILITAIRES ===
|
||||
|
||||
/// Vider une Box individuelle
|
||||
Future<void> _clearSingleBox(String boxName) async {
|
||||
try {
|
||||
if (Hive.isBoxOpen(boxName)) {
|
||||
await Hive.box(boxName).clear();
|
||||
debugPrint('🧹 Box $boxName vidée');
|
||||
} else {
|
||||
debugPrint('ℹ️ Box $boxName n\'est pas ouverte, impossible de la vider');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur vidage $boxName: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérification que toutes les Box sont ouvertes
|
||||
bool areAllBoxesOpen() {
|
||||
for (final config in _boxConfigs) {
|
||||
if (!Hive.isBoxOpen(config.name)) {
|
||||
debugPrint('❌ Box ${config.name} n\'est pas ouverte');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
debugPrint('✅ Toutes les Box sont ouvertes');
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Récupération sécurisée d'une Box typée
|
||||
Box<T> getTypedBox<T>(String boxName) {
|
||||
if (!Hive.isBoxOpen(boxName)) {
|
||||
throw Exception('La Box $boxName n\'est pas ouverte');
|
||||
}
|
||||
return Hive.box<T>(boxName);
|
||||
}
|
||||
|
||||
/// Récupération sécurisée d'une Box non-typée
|
||||
Box getBox(String boxName) {
|
||||
if (!Hive.isBoxOpen(boxName)) {
|
||||
throw Exception('La Box $boxName n\'est pas ouverte');
|
||||
}
|
||||
return Hive.box(boxName);
|
||||
}
|
||||
|
||||
/// Liste des noms de toutes les Box configurées
|
||||
List<String> getAllBoxNames() {
|
||||
return _boxConfigs.map((config) => config.name).toList();
|
||||
}
|
||||
|
||||
/// Diagnostic complet de l'état des Box
|
||||
Map<String, bool> getDiagnostic() {
|
||||
final diagnostic = <String, bool>{};
|
||||
for (final config in _boxConfigs) {
|
||||
diagnostic[config.name] = Hive.isBoxOpen(config.name);
|
||||
}
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
/// Reset complet du service (pour tests)
|
||||
static void reset() {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration d'une Box Hive avec type
|
||||
class HiveBoxConfig<T> {
|
||||
final String name;
|
||||
final String type;
|
||||
|
||||
const HiveBoxConfig(this.name, this.type);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import 'package:geosector_app/core/services/app_info_service.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/services/hive_adapters.dart';
|
||||
|
||||
void main() async {
|
||||
// IMPORTANT: Configurer l'URL strategy pour éviter les # dans les URLs
|
||||
@@ -17,7 +16,7 @@ void main() async {
|
||||
// Initialiser les services essentiels
|
||||
await _initializeServices();
|
||||
|
||||
// Initialiser Hive avec gestion des erreurs
|
||||
// Initialiser Hive de façon minimale (le traitement lourd se fait dans splash)
|
||||
await _initializeHive();
|
||||
|
||||
// Configurer l'orientation de l'application (mobile uniquement)
|
||||
@@ -52,14 +51,15 @@ Future<void> _initializeServices() async {
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialise Hive de façon minimale (le traitement lourd se fait dans splash_page)
|
||||
Future<void> _initializeHive() async {
|
||||
try {
|
||||
debugPrint('🔧 Initialisation minimale de Hive...');
|
||||
|
||||
// SEULEMENT l'initialisation de base - pas d'adaptateurs, pas de Box
|
||||
await Hive.initFlutter();
|
||||
|
||||
// Enregistrer tous les adapters
|
||||
HiveAdapters.registerAll();
|
||||
|
||||
debugPrint('✅ Hive et TypeAdapters initialisés');
|
||||
debugPrint('✅ Hive initialisé (traitement lourd dans splash_page)');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur Hive: $e');
|
||||
rethrow;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'dart:math' as math;
|
||||
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';
|
||||
@@ -16,29 +15,6 @@ import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/operation_repository.dart';
|
||||
|
||||
/// Class pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withOpacity(0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
final numberOfDots = (size.width * size.height) ~/ 1500;
|
||||
|
||||
for (int i = 0; i < numberOfDots; i++) {
|
||||
final x = random.nextDouble() * size.width;
|
||||
final y = random.nextDouble() * size.height;
|
||||
final radius = 1.0 + random.nextDouble() * 2.0;
|
||||
canvas.drawCircle(Offset(x, y), radius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// Page d'administration de l'amicale et des membres
|
||||
/// Cette page est intégrée dans le tableau de bord administrateur
|
||||
class AdminAmicalePage extends StatefulWidget {
|
||||
@@ -571,128 +547,165 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
return SafeArea(
|
||||
child:
|
||||
// Contenu principal
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre de la page
|
||||
Text(
|
||||
'Mon amicale et ses membres',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: const SizedBox(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
// Contenu principal
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre de la page
|
||||
Text(
|
||||
'Mon amicale et ses membres',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Message d'erreur si présent
|
||||
if (_errorMessage != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Message d'erreur si présent
|
||||
if (_errorMessage != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
// Contenu principal avec ValueListenableBuilder
|
||||
if (_currentUser != null && _currentUser!.fkEntite != null)
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<Box<AmicaleModel>>(
|
||||
valueListenable: widget.amicaleRepository.getAmicalesBox().listenable(),
|
||||
builder: (context, amicalesBox, child) {
|
||||
debugPrint('🔍 AmicalesBox - Nombre d\'amicales: ${amicalesBox.length}');
|
||||
debugPrint('🔍 AmicalesBox - Clés disponibles: ${amicalesBox.keys.toList()}');
|
||||
debugPrint('🔍 Recherche amicale avec fkEntite: ${_currentUser!.fkEntite}');
|
||||
|
||||
final amicale = amicalesBox.get(_currentUser!.fkEntite!);
|
||||
debugPrint('🔍 Amicale récupérée: ${amicale?.name ?? 'AUCUNE'}');
|
||||
|
||||
if (amicale == null) {
|
||||
// Ajouter plus d'informations de debug
|
||||
debugPrint('❌ PROBLÈME: Amicale non trouvée');
|
||||
debugPrint('❌ fkEntite recherché: ${_currentUser!.fkEntite}');
|
||||
debugPrint('❌ Contenu de la box: ${amicalesBox.values.map((a) => '${a.id}: ${a.name}').join(', ')}');
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.business_outlined,
|
||||
size: 64,
|
||||
color: theme.colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Amicale non trouvée',
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'L\'amicale associée à votre compte n\'existe plus.\nfkEntite: ${_currentUser!.fkEntite}',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Contenu principal avec ValueListenableBuilder
|
||||
if (_currentUser != null && _currentUser!.fkEntite != null)
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<Box<AmicaleModel>>(
|
||||
valueListenable: widget.amicaleRepository.getAmicalesBox().listenable(),
|
||||
builder: (context, amicalesBox, child) {
|
||||
debugPrint('🔍 AmicalesBox - Nombre d\'amicales: ${amicalesBox.length}');
|
||||
debugPrint('🔍 AmicalesBox - Clés disponibles: ${amicalesBox.keys.toList()}');
|
||||
debugPrint('🔍 Recherche amicale avec fkEntite: ${_currentUser!.fkEntite}');
|
||||
return ValueListenableBuilder<Box<MembreModel>>(
|
||||
valueListenable: widget.membreRepository.getMembresBox().listenable(),
|
||||
builder: (context, membresBox, child) {
|
||||
// Filtrer les membres par amicale
|
||||
// Note: Il faudra ajouter le champ fkEntite au modèle MembreModel
|
||||
final membres = membresBox.values.where((membre) => membre.fkEntite == _currentUser!.fkEntite).toList();
|
||||
|
||||
final amicale = amicalesBox.get(_currentUser!.fkEntite!);
|
||||
debugPrint('🔍 Amicale récupérée: ${amicale?.name ?? 'AUCUNE'}');
|
||||
|
||||
if (amicale == null) {
|
||||
// Ajouter plus d'informations de debug
|
||||
debugPrint('❌ PROBLÈME: Amicale non trouvée');
|
||||
debugPrint('❌ fkEntite recherché: ${_currentUser!.fkEntite}');
|
||||
debugPrint('❌ Contenu de la box: ${amicalesBox.values.map((a) => '${a.id}: ${a.name}').join(', ')}');
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.business_outlined,
|
||||
size: 64,
|
||||
color: theme.colorScheme.primary.withOpacity(0.7),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section Amicale
|
||||
Text(
|
||||
'Informations de l\'amicale',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Amicale non trouvée',
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'L\'amicale associée à votre compte n\'existe plus.\nfkEntite: ${_currentUser!.fkEntite}',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
return ValueListenableBuilder<Box<MembreModel>>(
|
||||
valueListenable: widget.membreRepository.getMembresBox().listenable(),
|
||||
builder: (context, membresBox, child) {
|
||||
// Filtrer les membres par amicale
|
||||
// Note: Il faudra ajouter le champ fkEntite au modèle MembreModel
|
||||
final membres = membresBox.values.where((membre) => membre.fkEntite == _currentUser!.fkEntite).toList();
|
||||
// Tableau Amicale
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: AmicaleTableWidget(
|
||||
amicales: [amicale],
|
||||
onEdit: null,
|
||||
onDelete: null,
|
||||
amicaleRepository: widget.amicaleRepository,
|
||||
userRepository: widget.userRepository,
|
||||
apiService: ApiService.instance,
|
||||
showActionsColumn: false,
|
||||
),
|
||||
),
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section Amicale
|
||||
Text(
|
||||
'Informations de l\'amicale',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Section Membres
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Membres de l\'amicale (${membres.length})',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _handleAddMembre,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Ajouter un membre'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Tableau Amicale
|
||||
Container(
|
||||
// Tableau Membres
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -704,84 +717,32 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: AmicaleTableWidget(
|
||||
amicales: [amicale],
|
||||
onEdit: null,
|
||||
onDelete: null,
|
||||
amicaleRepository: widget.amicaleRepository,
|
||||
userRepository: widget.userRepository,
|
||||
apiService: ApiService.instance,
|
||||
showActionsColumn: false,
|
||||
child: MembreTableWidget(
|
||||
membres: membres,
|
||||
onEdit: _handleEditMembre,
|
||||
onDelete: _handleDeleteMembre,
|
||||
membreRepository: widget.membreRepository,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Section Membres
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Membres de l\'amicale (${membres.length})',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _handleAddMembre,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Ajouter un membre'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Tableau Membres
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: MembreTableWidget(
|
||||
membres: membres,
|
||||
onEdit: _handleEditMembre,
|
||||
onDelete: _handleDeleteMembre,
|
||||
membreRepository: widget.membreRepository,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Message si pas d'utilisateur connecté
|
||||
if (_currentUser == null)
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
// Message si pas d'utilisateur connecté
|
||||
if (_currentUser == null)
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,8 @@ import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'dart:math' as math;
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/presentation/widgets/sector_distribution_card.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/operation_model.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
|
||||
@@ -35,7 +31,7 @@ class DotsPainter extends CustomPainter {
|
||||
}
|
||||
|
||||
class AdminDashboardHomePage extends StatefulWidget {
|
||||
const AdminDashboardHomePage({Key? key}) : super(key: key);
|
||||
const AdminDashboardHomePage({super.key});
|
||||
|
||||
@override
|
||||
State<AdminDashboardHomePage> createState() => _AdminDashboardHomePageState();
|
||||
@@ -54,127 +50,10 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
List<PaymentData> paymentData = [];
|
||||
Map<int, int> passagesByType = {};
|
||||
|
||||
// Future pour initialiser les boîtes Hive
|
||||
late Future<void> _initFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialiser les boîtes Hive avant de charger les données
|
||||
_initFuture = _initHiveBoxes().then((_) {
|
||||
// Charger les données une fois les boîtes initialisées
|
||||
_loadDashboardData();
|
||||
|
||||
// Après l'affichage des logs "VERIFICATION FINALE DES DONNEES",
|
||||
// attendre un court délai puis rafraîchir automatiquement les données
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = true; // Afficher le spinner pendant le rafraîchissement
|
||||
});
|
||||
_loadDashboardData(); // Rafraîchir les données
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Méthode pour initialiser les boîtes Hive nécessaires
|
||||
Future<void> _initHiveBoxes() async {
|
||||
try {
|
||||
debugPrint('AdminDashboardHomePage: Initialisation des boîtes Hive...');
|
||||
|
||||
// Liste des boîtes à ouvrir
|
||||
final boxesToOpen = [
|
||||
{
|
||||
'name': AppKeys.operationsBoxName,
|
||||
'type': 'OperationModel',
|
||||
'opened': false
|
||||
},
|
||||
{
|
||||
'name': AppKeys.passagesBoxName,
|
||||
'type': 'PassageModel',
|
||||
'opened': false
|
||||
},
|
||||
{
|
||||
'name': AppKeys.sectorsBoxName,
|
||||
'type': 'SectorModel',
|
||||
'opened': false
|
||||
},
|
||||
];
|
||||
|
||||
// Ouvrir chaque boîte
|
||||
for (final boxInfo in boxesToOpen) {
|
||||
final boxName = boxInfo['name'] as String;
|
||||
|
||||
if (!Hive.isBoxOpen(boxName)) {
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Ouverture de la boîte $boxName...');
|
||||
try {
|
||||
switch (boxInfo['type']) {
|
||||
case 'OperationModel':
|
||||
await Hive.openBox<OperationModel>(boxName);
|
||||
break;
|
||||
case 'PassageModel':
|
||||
await Hive.openBox<PassageModel>(boxName);
|
||||
break;
|
||||
case 'SectorModel':
|
||||
await Hive.openBox<SectorModel>(boxName);
|
||||
break;
|
||||
}
|
||||
boxInfo['opened'] = true;
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Boîte $boxName ouverte avec succès');
|
||||
} catch (boxError) {
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Erreur lors de l\'ouverture de la boîte $boxName: $boxError');
|
||||
// Continuer malgré l'erreur
|
||||
}
|
||||
} else {
|
||||
boxInfo['opened'] = true;
|
||||
debugPrint('AdminDashboardHomePage: Boîte $boxName déjà ouverte');
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier si toutes les boîtes ont été ouvertes
|
||||
final allBoxesOpened = boxesToOpen.every((box) => box['opened'] == true);
|
||||
|
||||
if (allBoxesOpened) {
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Toutes les boîtes Hive ont été ouvertes avec succès');
|
||||
} else {
|
||||
// Identifier les boîtes qui n'ont pas pu être ouvertes
|
||||
final failedBoxes = boxesToOpen
|
||||
.where((box) => box['opened'] == false)
|
||||
.map((box) => box['name'])
|
||||
.join(', ');
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Certaines boîtes n\'ont pas pu être ouvertes: $failedBoxes');
|
||||
}
|
||||
|
||||
// Afficher le nombre d'éléments dans chaque boîte
|
||||
debugPrint('VERIFICATION FINALE DES DONNEES');
|
||||
if (Hive.isBoxOpen(AppKeys.operationsBoxName)) {
|
||||
final operationsBox =
|
||||
Hive.box<OperationModel>(AppKeys.operationsBoxName);
|
||||
debugPrint('Nombre d\'opérations: ${operationsBox.length}');
|
||||
}
|
||||
if (Hive.isBoxOpen(AppKeys.passagesBoxName)) {
|
||||
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
debugPrint('Nombre de passages: ${passagesBox.length}');
|
||||
}
|
||||
if (Hive.isBoxOpen(AppKeys.sectorsBoxName)) {
|
||||
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
|
||||
debugPrint('Nombre de secteurs: ${sectorsBox.length}');
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Initialisation des boîtes Hive terminée');
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Erreur lors de l\'initialisation des boîtes Hive: $e');
|
||||
// Ne pas propager l'erreur, mais retourner normalement
|
||||
// pour éviter que le FutureBuilder ne reste bloqué en état d'erreur
|
||||
}
|
||||
_loadDashboardData();
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique de paiement
|
||||
@@ -192,9 +71,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
|
||||
// Calculer les montants par type de règlement
|
||||
for (final passage in passages) {
|
||||
if (passage.fkTypeReglement != null &&
|
||||
passage.montant != null &&
|
||||
passage.montant.isNotEmpty) {
|
||||
if (passage.fkTypeReglement != null && passage.montant != null && passage.montant.isNotEmpty) {
|
||||
final typeId = passage.fkTypeReglement;
|
||||
final amount = double.tryParse(passage.montant) ?? 0.0;
|
||||
paymentAmounts[typeId] = (paymentAmounts[typeId] ?? 0.0) + amount;
|
||||
@@ -224,61 +101,25 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Chargement des données du tableau de bord...');
|
||||
debugPrint('AdminDashboardHomePage: Chargement des données du tableau de bord...');
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
// Pas besoin de Provider.of car les instances sont déjà disponibles
|
||||
|
||||
// S'assurer que la boîte des opérations est ouverte avant d'y accéder
|
||||
OperationModel? currentOperation;
|
||||
try {
|
||||
// Vérifier si la boîte Hive est ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.operationsBoxName)) {
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Ouverture de la boîte operations dans _loadDashboardData...');
|
||||
try {
|
||||
await Hive.openBox<OperationModel>(AppKeys.operationsBoxName);
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Boîte operations ouverte avec succès dans _loadDashboardData');
|
||||
} catch (boxError) {
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Erreur lors de l\'ouverture de la boîte operations dans _loadDashboardData: $boxError');
|
||||
// Continuer malgré l'erreur
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer l'opération en cours
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Récupération de l\'opération en cours...');
|
||||
currentOperation = userRepository.getCurrentOperation();
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Opération récupérée: ${currentOperation?.id ?? "null"}');
|
||||
} catch (boxError) {
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Erreur lors de la récupération de l\'opération: $boxError');
|
||||
// Afficher un message d'erreur ou gérer l'erreur de manière appropriée
|
||||
}
|
||||
// Récupérer l'opération en cours (les boxes sont déjà ouvertes par SplashPage)
|
||||
final currentOperation = userRepository.getCurrentOperation();
|
||||
debugPrint('AdminDashboardHomePage: Opération récupérée: ${currentOperation?.id ?? "null"}');
|
||||
|
||||
if (currentOperation != null) {
|
||||
// Charger les passages pour l'opération en cours
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Chargement des passages pour l\'opération ${currentOperation.id}...');
|
||||
final passages =
|
||||
passageRepository.getPassagesByOperation(currentOperation.id);
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: ${passages.length} passages récupérés');
|
||||
debugPrint('AdminDashboardHomePage: Chargement des passages pour l\'opération ${currentOperation.id}...');
|
||||
final passages = passageRepository.getPassagesByOperation(currentOperation.id);
|
||||
debugPrint('AdminDashboardHomePage: ${passages.length} passages récupérés');
|
||||
|
||||
// Calculer le nombre total de passages
|
||||
totalPassages = passages.length;
|
||||
|
||||
// Calculer le montant total collecté
|
||||
totalAmounts = passages.fold(
|
||||
0.0,
|
||||
(sum, passage) =>
|
||||
sum +
|
||||
(passage.montant.isNotEmpty
|
||||
? double.tryParse(passage.montant) ?? 0.0
|
||||
: 0.0));
|
||||
totalAmounts = passages.fold(0.0, (sum, passage) => sum + (passage.montant.isNotEmpty ? double.tryParse(passage.montant) ?? 0.0 : 0.0));
|
||||
|
||||
// Préparer les données pour le graphique de paiement
|
||||
_preparePaymentData(passages);
|
||||
@@ -295,8 +136,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
passagesByType.forEach((typeId, count) {
|
||||
final typeInfo = AppKeys.typesPassages[typeId];
|
||||
final typeName = typeInfo != null ? typeInfo['titre'] : 'Inconnu';
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Type $typeId ($typeName): $count passages');
|
||||
debugPrint('AdminDashboardHomePage: Type $typeId ($typeName): $count passages');
|
||||
});
|
||||
|
||||
// Charger les statistiques par membre
|
||||
@@ -305,8 +145,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
|
||||
// Compter les passages par membre
|
||||
for (final passage in passages) {
|
||||
memberCounts[passage.fkUser] =
|
||||
(memberCounts[passage.fkUser] ?? 0) + 1;
|
||||
memberCounts[passage.fkUser] = (memberCounts[passage.fkUser] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Récupérer les informations des membres
|
||||
@@ -321,11 +160,9 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
}
|
||||
|
||||
// Trier les membres par nombre de passages (décroissant)
|
||||
memberStats
|
||||
.sort((a, b) => (b['count'] as int).compareTo(a['count'] as int));
|
||||
memberStats.sort((a, b) => (b['count'] as int).compareTo(a['count'] as int));
|
||||
} else {
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Aucune opération en cours, impossible de charger les passages');
|
||||
debugPrint('AdminDashboardHomePage: Aucune opération en cours, impossible de charger les passages');
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
@@ -340,8 +177,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Données chargées: isDataLoaded=$isDataLoaded, totalPassages=$totalPassages, passagesByType=${passagesByType.length} types');
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Erreur lors du chargement des données: $e');
|
||||
debugPrint('AdminDashboardHomePage: Erreur lors du chargement des données: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
@@ -353,289 +189,230 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
debugPrint('Building AdminDashboardHomePage');
|
||||
return FutureBuilder<void>(
|
||||
future: _initFuture,
|
||||
builder: (context, snapshot) {
|
||||
// Afficher un indicateur de chargement pendant l'initialisation des boîtes Hive
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
debugPrint('FutureBuilder: ConnectionState.waiting');
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
// Même si nous avons une erreur, nous continuons à afficher le contenu
|
||||
// car nous avons modifié _initHiveBoxes pour ne pas propager les erreurs
|
||||
if (snapshot.hasError) {
|
||||
debugPrint('FutureBuilder: hasError - ${snapshot.error}');
|
||||
// Nous affichons un message d'erreur mais continuons à afficher le contenu
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Erreur lors de l\'initialisation: ${snapshot.error}'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 5),
|
||||
action: SnackBarAction(
|
||||
label: 'Réessayer',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_initFuture = _initHiveBoxes().then((_) {
|
||||
_loadDashboardData();
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
debugPrint('FutureBuilder: Initialisation réussie');
|
||||
}
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth > 800;
|
||||
|
||||
// L'initialisation a réussi, afficher le contenu
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth > 800;
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
// Récupérer l'opération en cours (les boîtes sont déjà ouvertes par SplashPage)
|
||||
final currentOperation = userRepository.getCurrentOperation();
|
||||
|
||||
// Récupérer l'opération en cours (les boîtes sont déjà ouvertes)
|
||||
final currentOperation = userRepository.getCurrentOperation();
|
||||
// Titre dynamique avec l'ID et le nom de l'opération
|
||||
final String title = currentOperation != null ? 'Synthèse de l\'opération #${currentOperation.id} ${currentOperation.name}' : 'Synthèse de l\'opération';
|
||||
|
||||
// Titre dynamique avec l'ID et le nom de l'opération
|
||||
final String title = currentOperation != null
|
||||
? 'Synthèse de l\'opération #${currentOperation.id} ${currentOperation.name}'
|
||||
: 'Synthèse de l\'opération';
|
||||
|
||||
return Stack(children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: Container(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
return Stack(children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
// Contenu de la page
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingL),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: const SizedBox(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
// Contenu de la page
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingL),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre avec bouton de rafraîchissement sur la même ligne
|
||||
Row(
|
||||
children: [
|
||||
// Titre avec bouton de rafraîchissement sur la même ligne
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bouton de rafraîchissement
|
||||
if (!isLoading)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Rafraîchir les données',
|
||||
onPressed: _loadDashboardData,
|
||||
)
|
||||
else
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
// Afficher un indicateur de chargement si les données ne sont pas encore chargées
|
||||
if (isLoading && !isDataLoaded)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
// Bouton de rafraîchissement
|
||||
if (!isLoading)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Rafraîchir les données',
|
||||
onPressed: _loadDashboardData,
|
||||
)
|
||||
else
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
// Afficher un indicateur de chargement si les données ne sont pas encore chargées
|
||||
if (isLoading && !isDataLoaded)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
|
||||
// Afficher le contenu seulement si les données sont chargées ou en cours de mise à jour
|
||||
if (isDataLoaded || isLoading) ...[
|
||||
// Cartes de synthèse
|
||||
isDesktop
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _buildSummaryCard(
|
||||
context,
|
||||
'Passages totaux',
|
||||
totalPassages.toString(),
|
||||
Icons.map_outlined,
|
||||
AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _buildSummaryCard(
|
||||
context,
|
||||
'Montant collecté',
|
||||
'${totalAmounts.toStringAsFixed(2)} €',
|
||||
Icons.euro_outlined,
|
||||
AppTheme.buttonSuccessColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: SectorDistributionCard(
|
||||
key: ValueKey(
|
||||
'sector_distribution_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
|
||||
height: 200,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildSummaryCard(
|
||||
context,
|
||||
'Passages totaux',
|
||||
totalPassages.toString(),
|
||||
Icons.map_outlined,
|
||||
AppTheme.primaryColor,
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildSummaryCard(
|
||||
context,
|
||||
'Montant collecté',
|
||||
'${totalAmounts.toStringAsFixed(2)} €',
|
||||
Icons.euro_outlined,
|
||||
AppTheme.buttonSuccessColor,
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
SectorDistributionCard(
|
||||
key: ValueKey(
|
||||
'sector_distribution_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
|
||||
height: 200,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Graphique d'activité
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: ActivityChart(
|
||||
key: ValueKey(
|
||||
'activity_chart_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
|
||||
height: 350,
|
||||
showAllPassages:
|
||||
true, // Tous les passages, pas seulement ceux de l'utilisateur courant
|
||||
title: 'Passages réalisés par jour (15 derniers jours)',
|
||||
daysToShow: 15,
|
||||
),
|
||||
// Si vous avez besoin de passer l'ID de l'opération en cours, décommentez les lignes suivantes
|
||||
// child: ActivityChart(
|
||||
// height: 350,
|
||||
// loadFromHive: true,
|
||||
// showAllPassages: true,
|
||||
// title: 'Passages réalisés par jour (15 derniers jours)',
|
||||
// daysToShow: 15,
|
||||
// operationId: userRepository.getCurrentOperation()?.id,
|
||||
// ),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Graphiques de répartition
|
||||
isDesktop
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildPassageTypeCard(context),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
child: _buildPaymentTypeCard(context),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildPassageTypeCard(context),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildPaymentTypeCard(context),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Actions rapides - uniquement visible sur le web
|
||||
if (kIsWeb) ...[
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Actions sur cette opération',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
// Afficher le contenu seulement si les données sont chargées ou en cours de mise à jour
|
||||
if (isDataLoaded || isLoading) ...[
|
||||
// Cartes de synthèse
|
||||
isDesktop
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _buildSummaryCard(
|
||||
context,
|
||||
'Passages totaux',
|
||||
totalPassages.toString(),
|
||||
Icons.map_outlined,
|
||||
AppTheme.primaryColor,
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Wrap(
|
||||
spacing: AppTheme.spacingM,
|
||||
runSpacing: AppTheme.spacingM,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
context,
|
||||
'Exporter les données',
|
||||
Icons.file_download_outlined,
|
||||
AppTheme.primaryColor,
|
||||
() {},
|
||||
),
|
||||
_buildActionButton(
|
||||
context,
|
||||
'Gérer les secteurs',
|
||||
Icons.map_outlined,
|
||||
AppTheme.accentColor,
|
||||
() {},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _buildSummaryCard(
|
||||
context,
|
||||
'Montant collecté',
|
||||
'${totalAmounts.toStringAsFixed(2)} €',
|
||||
Icons.euro_outlined,
|
||||
AppTheme.buttonSuccessColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: SectorDistributionCard(
|
||||
key: ValueKey('sector_distribution_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
|
||||
height: 200,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildSummaryCard(
|
||||
context,
|
||||
'Passages totaux',
|
||||
totalPassages.toString(),
|
||||
Icons.map_outlined,
|
||||
AppTheme.primaryColor,
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildSummaryCard(
|
||||
context,
|
||||
'Montant collecté',
|
||||
'${totalAmounts.toStringAsFixed(2)} €',
|
||||
Icons.euro_outlined,
|
||||
AppTheme.buttonSuccessColor,
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
SectorDistributionCard(
|
||||
key: ValueKey('sector_distribution_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
|
||||
height: 200,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Graphique d'activité
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: ActivityChart(
|
||||
key: ValueKey('activity_chart_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
|
||||
height: 350,
|
||||
showAllPassages: true, // Tous les passages, pas seulement ceux de l'utilisateur courant
|
||||
title: 'Passages réalisés par jour (15 derniers jours)',
|
||||
daysToShow: 15,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Graphiques de répartition
|
||||
isDesktop
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildPassageTypeCard(context),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
child: _buildPaymentTypeCard(context),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildPassageTypeCard(context),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildPaymentTypeCard(context),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Actions rapides - uniquement visible sur le web
|
||||
if (kIsWeb) ...[
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Actions sur cette opération',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Wrap(
|
||||
spacing: AppTheme.spacingM,
|
||||
runSpacing: AppTheme.spacingM,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
context,
|
||||
'Exporter les données',
|
||||
Icons.file_download_outlined,
|
||||
AppTheme.primaryColor,
|
||||
() {},
|
||||
),
|
||||
_buildActionButton(
|
||||
context,
|
||||
'Gérer les secteurs',
|
||||
Icons.map_outlined,
|
||||
AppTheme.accentColor,
|
||||
() {},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
]);
|
||||
},
|
||||
);
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard(
|
||||
@@ -661,8 +438,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppTheme.borderRadiusSmall),
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusSmall),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'admin_history_page.dart';
|
||||
import 'admin_communication_page.dart';
|
||||
import 'admin_map_page.dart';
|
||||
import 'admin_amicale_page.dart';
|
||||
import 'admin_operations_page.dart';
|
||||
|
||||
/// Class pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@@ -126,7 +127,10 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
operationRepository: operationRepository,
|
||||
);
|
||||
case _PageType.operations:
|
||||
return const Scaffold(body: Center(child: Text('Page Opérations')));
|
||||
return AdminOperationsPage(
|
||||
operationRepository: operationRepository,
|
||||
userRepository: userRepository,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
817
app/lib/presentation/admin/admin_operations_page.dart
Normal file
817
app/lib/presentation/admin/admin_operations_page.dart
Normal file
@@ -0,0 +1,817 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/operation_model.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/repositories/operation_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/operation_form_dialog.dart';
|
||||
|
||||
/// Page d'administration des opérations annuelles
|
||||
/// Cette page est intégrée dans le tableau de bord administrateur
|
||||
/// FOND TRANSPARENT - le fond dégradé est géré par AdminDashboardPage
|
||||
class AdminOperationsPage extends StatefulWidget {
|
||||
final OperationRepository operationRepository;
|
||||
final UserRepository userRepository;
|
||||
|
||||
const AdminOperationsPage({
|
||||
super.key,
|
||||
required this.operationRepository,
|
||||
required this.userRepository,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AdminOperationsPage> createState() => _AdminOperationsPageState();
|
||||
}
|
||||
|
||||
class _AdminOperationsPageState extends State<AdminOperationsPage> {
|
||||
late int? _userAmicaleId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_userAmicaleId = widget.userRepository.getCurrentUser()?.fkEntite;
|
||||
debugPrint('🔧 AdminOperationsPage initialisée - UserAmicaleId: $_userAmicaleId');
|
||||
}
|
||||
|
||||
void _showCreateOperationDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => OperationFormDialog(
|
||||
title: 'Créer une nouvelle opération',
|
||||
operationRepository: widget.operationRepository,
|
||||
userRepository: widget.userRepository,
|
||||
onSuccess: () {
|
||||
// Simple callback pour rafraîchir l'interface
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditOperationDialog(OperationModel op) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => OperationFormDialog(
|
||||
title: op.isActive ? 'Modifier l\'opération active : ${op.name}' : 'Modifier l\'opération : ${op.name}',
|
||||
operation: op,
|
||||
operationRepository: widget.operationRepository,
|
||||
userRepository: widget.userRepository,
|
||||
onSuccess: () {
|
||||
// Simple callback pour rafraîchir l'interface
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Récupère les passages réalisés (fkType != 2) pour une opération
|
||||
int _getCompletedPassagesCount(int operationId) {
|
||||
try {
|
||||
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
final completedPassages = passagesBox.values.where((passage) => passage.fkOperation == operationId && passage.fkType != 2).length;
|
||||
debugPrint('🔍 Passages réalisés pour opération $operationId: $completedPassages');
|
||||
return completedPassages;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors du comptage des passages: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDelete(OperationModel op, List<OperationModel> operations) async {
|
||||
final currentUser = widget.userRepository.getCurrentUser();
|
||||
if (currentUser == null) {
|
||||
ApiException.showError(context, Exception("Utilisateur non connecté"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier qu'il reste au moins une opération
|
||||
if (operations.length <= 1) {
|
||||
ApiException.showError(context, Exception("Impossible de supprimer la dernière opération"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Cas 1: Opération inactive - Suppression simple pour role > 1
|
||||
if (!op.isActive && currentUser.role > 1) {
|
||||
final confirmed = await _showSimpleDeleteDialog(op);
|
||||
if (confirmed == true) {
|
||||
await _performSimpleDelete(op);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cas 2: Opération active avec role = 2 - Vérification des passages
|
||||
if (op.isActive && currentUser.role == 2) {
|
||||
final completedPassagesCount = _getCompletedPassagesCount(op.id);
|
||||
|
||||
if (completedPassagesCount > 0) {
|
||||
// Il y a des passages réalisés - Dialog d'avertissement avec confirmation par nom
|
||||
final confirmed = await _showActiveDeleteWithPassagesDialog(op, completedPassagesCount);
|
||||
if (confirmed == true) {
|
||||
await _performActiveDelete(op);
|
||||
}
|
||||
} else {
|
||||
// Pas de passages réalisés - Suppression simple
|
||||
final confirmed = await _showActiveDeleteDialog(op);
|
||||
if (confirmed == true) {
|
||||
await _performActiveDelete(op);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cas 3: Role > 2 - Suppression autorisée sans restrictions
|
||||
if (currentUser.role > 2) {
|
||||
final confirmed = await _showSimpleDeleteDialog(op);
|
||||
if (confirmed == true) {
|
||||
if (op.isActive) {
|
||||
await _performActiveDelete(op);
|
||||
} else {
|
||||
await _performSimpleDelete(op);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cas par défaut - Pas d'autorisation
|
||||
ApiException.showError(context, Exception("Vous n'avez pas les droits pour supprimer cette opération"));
|
||||
}
|
||||
|
||||
/// Dialog simple pour suppression d'opération inactive
|
||||
Future<bool?> _showSimpleDeleteDialog(OperationModel op) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text("Confirmer la suppression"),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Voulez-vous supprimer l'opération \"${op.name}\" ?"),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
"Cette action est définitive.",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text("Annuler"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text("Supprimer"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Dialog pour suppression d'opération active sans passages
|
||||
Future<bool?> _showActiveDeleteDialog(OperationModel op) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text("Supprimer l'opération active"),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Voulez-vous supprimer l'opération active \"${op.name}\" ?"),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.shade200),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info, color: Colors.blue, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Votre dernière opération inactive sera automatiquement réactivée.",
|
||||
style: TextStyle(color: Colors.blue),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text("Annuler"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text("Supprimer"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Dialog pour suppression d'opération active avec passages réalisés
|
||||
Future<bool?> _showActiveDeleteWithPassagesDialog(OperationModel op, int passagesCount) {
|
||||
final TextEditingController nameController = TextEditingController();
|
||||
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (context, setState) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.error, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text("ATTENTION - Passages réalisés"),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.warning, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"$passagesCount passage(s) réalisé(s) trouvé(s)",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
"La suppression de cette opération active supprimera définitivement tous les passages réalisés !",
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.shade200),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info, color: Colors.blue, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Votre dernière opération inactive sera automatiquement réactivée.",
|
||||
style: TextStyle(color: Colors.blue),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
"Pour confirmer, saisissez le nom exact de l'opération :",
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
hintText: op.name,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (value) => setState(() {}),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text("Annuler"),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: nameController.text.trim() == op.name.trim() ? () => Navigator.of(dialogContext).pop(true) : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text("Supprimer définitivement"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Suppression simple d'opération inactive
|
||||
Future<void> _performSimpleDelete(OperationModel op) async {
|
||||
try {
|
||||
final success = await widget.operationRepository.deleteOperationViaApi(op.id);
|
||||
if (success && mounted) {
|
||||
ApiException.showSuccess(context, "Opération supprimée avec succès");
|
||||
setState(() {});
|
||||
} else {
|
||||
throw Exception("Erreur lors de la suppression");
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ApiException.showError(context, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Suppression d'opération active (avec réactivation automatique)
|
||||
Future<void> _performActiveDelete(OperationModel op) async {
|
||||
try {
|
||||
final success = await widget.operationRepository.deleteActiveOperationViaApi(op.id);
|
||||
if (success && mounted) {
|
||||
ApiException.showSuccess(context, "Opération active supprimée avec succès. L'opération précédente a été réactivée.");
|
||||
setState(() {});
|
||||
} else {
|
||||
throw Exception("Erreur lors de la suppression");
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ApiException.showError(context, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleExport(OperationModel operation) async {
|
||||
try {
|
||||
// Afficher un indicateur de chargement
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text("Export Excel de l'opération \"${operation.name}\" en cours..."),
|
||||
],
|
||||
),
|
||||
duration: const Duration(seconds: 10), // Plus long pour le téléchargement
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
);
|
||||
|
||||
// Appeler l'export via le repository
|
||||
await widget.operationRepository.exportOperationToExcel(operation.id, operation.name);
|
||||
|
||||
// Masquer le SnackBar de chargement et afficher le succès
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
ApiException.showSuccess(context, "Export Excel de l'opération \"${operation.name}\" terminé avec succès !");
|
||||
}
|
||||
} catch (e) {
|
||||
// Masquer le SnackBar de chargement et afficher l'erreur
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
ApiException.showError(context, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
|
||||
}
|
||||
|
||||
Widget _buildOperationsTable(List<OperationModel> operations) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// En-tête du tableau
|
||||
_buildTableHeader(theme),
|
||||
|
||||
// Corps du tableau
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(8),
|
||||
bottomRight: Radius.circular(8),
|
||||
),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: operations.length,
|
||||
itemBuilder: (context, index) {
|
||||
final operation = operations[index];
|
||||
return _buildOperationRow(operation, index % 2 == 1, theme, operations);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTableHeader(ThemeData theme) {
|
||||
final textStyle = theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// Colonne ID
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text('ID', style: textStyle, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
// Colonne Nom
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text('Nom de l\'opération', style: textStyle, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
// Colonne Date début
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text('Date début', style: textStyle, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
// Colonne Date fin
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text('Date fin', style: textStyle, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
// Colonne Statut
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text('Statut', style: textStyle, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
// Colonne Actions
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text('Actions', style: textStyle, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOperationRow(OperationModel operation, bool isAlternate, ThemeData theme, List<OperationModel> allOperations) {
|
||||
final textStyle = theme.textTheme.bodyMedium;
|
||||
final backgroundColor = isAlternate ? theme.colorScheme.surface : theme.colorScheme.surface;
|
||||
final canDelete = allOperations.length > 1; // Peut supprimer seulement s'il y a plus d'une opération
|
||||
|
||||
return InkWell(
|
||||
onTap: operation.isActive ? () => _showEditOperationDialog(operation) : null,
|
||||
hoverColor: operation.isActive ? theme.colorScheme.primary.withOpacity(0.05) : null,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// Colonne ID
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
operation.id.toString(),
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Colonne Nom
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (operation.isActive) ...[
|
||||
Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
operation.name,
|
||||
style: textStyle?.copyWith(
|
||||
color: operation.isActive ? theme.colorScheme.primary : textStyle.color,
|
||||
fontWeight: operation.isActive ? FontWeight.w600 : textStyle.fontWeight,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Colonne Date début
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
_formatDate(operation.dateDebut),
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Colonne Date fin
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Text(
|
||||
_formatDate(operation.dateFin),
|
||||
style: textStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Colonne Statut
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
||||
decoration: BoxDecoration(
|
||||
color: operation.isActive ? Colors.green : Colors.red,
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
child: Text(
|
||||
operation.isActive ? 'Active' : 'Inactive',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Colonne Actions
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
// Bouton Delete - Affiché seulement s'il y a plus d'une opération
|
||||
if (canDelete)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.delete_forever,
|
||||
color: theme.colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: 'Supprimer',
|
||||
onPressed: () => _handleDelete(operation, allOperations),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
// Bouton Export
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.download,
|
||||
color: theme.colorScheme.secondary,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: 'Exporter',
|
||||
onPressed: () => _handleExport(operation),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
debugPrint('🎨 AdminOperationsPage.build() appelée');
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre de la page
|
||||
Text(
|
||||
'Gestion des opérations annuelles',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Contenu principal avec ValueListenableBuilder
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<Box<OperationModel>>(
|
||||
valueListenable: widget.operationRepository.operationBox.listenable(),
|
||||
builder: (context, operationBox, child) {
|
||||
debugPrint('🔄 ValueListenableBuilder - Nombre d\'opérations: ${operationBox.length}');
|
||||
|
||||
// Filtrer et trier les opérations
|
||||
final allOperations = operationBox.values.toList();
|
||||
allOperations.sort((a, b) => b.id.compareTo(a.id));
|
||||
final operations = allOperations.take(10).toList(); // Limiter à 10 opérations récentes
|
||||
|
||||
debugPrint('📊 Opérations affichées: ${operations.length}');
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header avec bouton d'ajout
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Opérations récentes (${operations.length})',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _showCreateOperationDialog,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Nouvelle opération'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Tableau des opérations
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: operations.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today_outlined,
|
||||
size: 64,
|
||||
color: theme.colorScheme.primary.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"Aucune opération créée",
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Cliquez sur 'Nouvelle opération' pour commencer",
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _buildOperationsTable(operations),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/services/app_info_service.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/data/models/client_model.dart';
|
||||
import 'package:geosector_app/core/data/models/operation_model.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/core/data/models/user_sector_model.dart';
|
||||
import 'package:geosector_app/chat/models/conversation_model.dart';
|
||||
import 'package:geosector_app/chat/models/message_model.dart';
|
||||
import 'package:geosector_app/core/services/hive_service.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
@@ -108,337 +97,66 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
}
|
||||
|
||||
void _startInitialization() async {
|
||||
// Table rase complète et recréation propre
|
||||
await _completeReset();
|
||||
|
||||
// Finalisation
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Application prête !";
|
||||
_progress = 1.0;
|
||||
_isInitializing = false;
|
||||
_showButtons = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// RESET COMPLET : Destruction totale et recréation propre
|
||||
Future<void> _completeReset() async {
|
||||
try {
|
||||
debugPrint('🧹 RESET COMPLET : Destruction totale des données Hive...');
|
||||
|
||||
// Étape 1: Sauvegarder les utilisateurs existants (optionnel)
|
||||
Map<dynamic, UserModel>? existingUsers;
|
||||
debugPrint('🚀 Début de l\'initialisation complète de l\'application...');
|
||||
|
||||
// Étape 1: Initialisation complète de Hive avec HiveService
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Sauvegarde des utilisateurs...";
|
||||
_progress = 0.05;
|
||||
_statusMessage = "Initialisation de la base de données...";
|
||||
_progress = 0.1;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (Hive.isBoxOpen(AppKeys.userBoxName)) {
|
||||
final userBox = Hive.box<UserModel>(AppKeys.userBoxName);
|
||||
existingUsers = Map.from(userBox.toMap());
|
||||
debugPrint('📦 ${existingUsers.length} utilisateurs sauvegardés');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur sauvegarde utilisateurs: $e');
|
||||
existingUsers = null;
|
||||
}
|
||||
|
||||
// Étape 2: DESTRUCTION RADICALE - Fermer tout ce qui peut être ouvert
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Fermeture de toutes les bases de données...";
|
||||
_progress = 0.15;
|
||||
});
|
||||
}
|
||||
|
||||
await _closeAllKnownBoxes();
|
||||
|
||||
// Étape 3: DESTRUCTION RADICALE - Supprimer tout Hive du disque
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Suppression complète des anciennes données...";
|
||||
_progress = 0.25;
|
||||
});
|
||||
}
|
||||
|
||||
await _nukeHiveCompletely();
|
||||
|
||||
// Étape 4: RECRÉATION PROPRE
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Création des nouvelles bases de données...";
|
||||
_progress = 0.40;
|
||||
});
|
||||
}
|
||||
|
||||
await _createAllBoxesFresh();
|
||||
|
||||
// Étape 5: Restaurer les utilisateurs (optionnel)
|
||||
if (existingUsers != null && existingUsers.isNotEmpty) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Restauration des utilisateurs...";
|
||||
_progress = 0.80;
|
||||
});
|
||||
}
|
||||
|
||||
await _restoreUsers(existingUsers);
|
||||
}
|
||||
|
||||
// Étape 6: Vérification finale
|
||||
// HiveService fait TOUT le travail lourd (adaptateurs, destruction, recréation)
|
||||
await HiveService.instance.initializeAndResetHive();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Vérification des bases de données...";
|
||||
_progress = 0.90;
|
||||
_progress = 0.7;
|
||||
});
|
||||
}
|
||||
|
||||
debugPrint('✅ RESET COMPLET terminé avec succès');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors du reset complet: $e');
|
||||
// Étape 2: S'assurer que toutes les Box sont ouvertes
|
||||
await HiveService.instance.ensureBoxesAreOpen();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Erreur critique - Redémarrage recommandé";
|
||||
_statusMessage = "Finalisation...";
|
||||
_progress = 0.9;
|
||||
});
|
||||
}
|
||||
|
||||
// Étape 3: Vérification finale
|
||||
final allBoxesOpen = HiveService.instance.areAllBoxesOpen();
|
||||
if (!allBoxesOpen) {
|
||||
final diagnostic = HiveService.instance.getDiagnostic();
|
||||
debugPrint('❌ Diagnostic des Box: $diagnostic');
|
||||
throw Exception('Certaines bases de données ne sont pas accessibles');
|
||||
}
|
||||
|
||||
// Finalisation
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Application prête !";
|
||||
_progress = 1.0;
|
||||
_isInitializing = false;
|
||||
_showButtons = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ferme toutes les boîtes connues
|
||||
Future<void> _closeAllKnownBoxes() async {
|
||||
try {
|
||||
final allKnownBoxes = [
|
||||
AppKeys.userBoxName,
|
||||
AppKeys.amicaleBoxName,
|
||||
AppKeys.clientsBoxName,
|
||||
AppKeys.regionsBoxName,
|
||||
AppKeys.operationsBoxName,
|
||||
AppKeys.sectorsBoxName,
|
||||
AppKeys.passagesBoxName,
|
||||
AppKeys.membresBoxName,
|
||||
AppKeys.userSectorBoxName,
|
||||
AppKeys.settingsBoxName,
|
||||
AppKeys.chatConversationsBoxName,
|
||||
AppKeys.chatMessagesBoxName,
|
||||
// Boîtes potentiellement problématiques
|
||||
'auth', 'locations', 'messages', 'temp'
|
||||
];
|
||||
|
||||
debugPrint('🔒 Fermeture de ${allKnownBoxes.length} boîtes connues...');
|
||||
|
||||
for (final boxName in allKnownBoxes) {
|
||||
try {
|
||||
if (Hive.isBoxOpen(boxName)) {
|
||||
await Hive.box(boxName).close();
|
||||
debugPrint('✅ Boîte $boxName fermée');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur fermeture $boxName: $e');
|
||||
// Continuer même en cas d'erreur
|
||||
}
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
debugPrint('✅ Initialisation complète de l\'application terminée avec succès');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur fermeture des boîtes: $e');
|
||||
}
|
||||
}
|
||||
debugPrint('❌ Erreur lors de l\'initialisation: $e');
|
||||
|
||||
/// Suppression RADICALE de tout Hive
|
||||
Future<void> _nukeHiveCompletely() async {
|
||||
try {
|
||||
debugPrint('💥 DESTRUCTION NUCLÉAIRE de Hive...');
|
||||
|
||||
if (kIsWeb) {
|
||||
// En version web, supprimer toutes les boîtes possibles une par une
|
||||
final allPossibleBoxes = [
|
||||
AppKeys.userBoxName,
|
||||
AppKeys.amicaleBoxName,
|
||||
AppKeys.clientsBoxName,
|
||||
AppKeys.regionsBoxName,
|
||||
AppKeys.operationsBoxName,
|
||||
AppKeys.sectorsBoxName,
|
||||
AppKeys.passagesBoxName,
|
||||
AppKeys.membresBoxName,
|
||||
AppKeys.userSectorBoxName,
|
||||
AppKeys.settingsBoxName,
|
||||
AppKeys.chatConversationsBoxName,
|
||||
AppKeys.chatMessagesBoxName,
|
||||
// Toutes les boîtes potentiellement corrompues
|
||||
'auth', 'locations', 'messages', 'temp', 'cache', 'data'
|
||||
];
|
||||
|
||||
for (final boxName in allPossibleBoxes) {
|
||||
try {
|
||||
await Hive.deleteBoxFromDisk(boxName);
|
||||
debugPrint('✅ Boîte $boxName DÉTRUITE');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur destruction $boxName: $e');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Sur mobile/desktop, destruction totale
|
||||
try {
|
||||
await Hive.deleteFromDisk();
|
||||
debugPrint('✅ Hive COMPLÈTEMENT DÉTRUIT');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur destruction totale: $e');
|
||||
// Fallback : supprimer boîte par boîte
|
||||
await _deleteBoxesOneByOne();
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Erreur d'initialisation - Redémarrage recommandé";
|
||||
_progress = 1.0;
|
||||
_isInitializing = false;
|
||||
_showButtons = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Attendre pour s'assurer que tout est détruit
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur destruction Hive: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback : supprimer les boîtes une par une
|
||||
Future<void> _deleteBoxesOneByOne() async {
|
||||
final allBoxes = [
|
||||
AppKeys.userBoxName,
|
||||
AppKeys.amicaleBoxName,
|
||||
AppKeys.clientsBoxName,
|
||||
AppKeys.regionsBoxName,
|
||||
AppKeys.operationsBoxName,
|
||||
AppKeys.sectorsBoxName,
|
||||
AppKeys.passagesBoxName,
|
||||
AppKeys.membresBoxName,
|
||||
AppKeys.userSectorBoxName,
|
||||
AppKeys.settingsBoxName,
|
||||
AppKeys.chatConversationsBoxName,
|
||||
AppKeys.chatMessagesBoxName,
|
||||
];
|
||||
|
||||
for (final boxName in allBoxes) {
|
||||
try {
|
||||
await Hive.deleteBoxFromDisk(boxName);
|
||||
debugPrint('✅ Boîte $boxName supprimée (fallback)');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur suppression fallback $boxName: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recrée toutes les boîtes VIDES et PROPRES
|
||||
Future<void> _createAllBoxesFresh() async {
|
||||
try {
|
||||
debugPrint('🆕 Création de toutes les boîtes vides...');
|
||||
|
||||
final boxesToCreate = [
|
||||
{'name': AppKeys.userBoxName, 'type': 'UserModel'},
|
||||
{'name': AppKeys.amicaleBoxName, 'type': 'AmicaleModel'},
|
||||
{'name': AppKeys.clientsBoxName, 'type': 'ClientModel'},
|
||||
{'name': AppKeys.regionsBoxName, 'type': 'dynamic'},
|
||||
{'name': AppKeys.operationsBoxName, 'type': 'OperationModel'},
|
||||
{'name': AppKeys.sectorsBoxName, 'type': 'SectorModel'},
|
||||
{'name': AppKeys.passagesBoxName, 'type': 'PassageModel'},
|
||||
{'name': AppKeys.membresBoxName, 'type': 'MembreModel'},
|
||||
{'name': AppKeys.userSectorBoxName, 'type': 'UserSectorModel'},
|
||||
{'name': AppKeys.settingsBoxName, 'type': 'dynamic'},
|
||||
{'name': AppKeys.chatConversationsBoxName, 'type': 'ConversationModel'},
|
||||
{'name': AppKeys.chatMessagesBoxName, 'type': 'MessageModel'},
|
||||
];
|
||||
|
||||
final progressIncrement = 0.35 / boxesToCreate.length; // De 0.40 à 0.75
|
||||
|
||||
for (int i = 0; i < boxesToCreate.length; i++) {
|
||||
final boxInfo = boxesToCreate[i];
|
||||
final boxName = boxInfo['name'] as String;
|
||||
final boxType = boxInfo['type'] as String;
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Création de $boxName...";
|
||||
_progress = 0.40 + (progressIncrement * i);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Créer la boîte avec le bon type
|
||||
switch (boxType) {
|
||||
case 'UserModel':
|
||||
await Hive.openBox<UserModel>(boxName);
|
||||
break;
|
||||
case 'AmicaleModel':
|
||||
await Hive.openBox<AmicaleModel>(boxName);
|
||||
break;
|
||||
case 'ClientModel':
|
||||
await Hive.openBox<ClientModel>(boxName);
|
||||
break;
|
||||
case 'OperationModel':
|
||||
await Hive.openBox<OperationModel>(boxName);
|
||||
break;
|
||||
case 'SectorModel':
|
||||
await Hive.openBox<SectorModel>(boxName);
|
||||
break;
|
||||
case 'PassageModel':
|
||||
await Hive.openBox<PassageModel>(boxName);
|
||||
break;
|
||||
case 'MembreModel':
|
||||
await Hive.openBox<MembreModel>(boxName);
|
||||
break;
|
||||
case 'UserSectorModel':
|
||||
await Hive.openBox<UserSectorModel>(boxName);
|
||||
break;
|
||||
case 'ConversationModel':
|
||||
await Hive.openBox<ConversationModel>(boxName);
|
||||
break;
|
||||
case 'MessageModel':
|
||||
await Hive.openBox<MessageModel>(boxName);
|
||||
break;
|
||||
default:
|
||||
await Hive.openBox(boxName);
|
||||
}
|
||||
|
||||
debugPrint('✅ Boîte $boxName créée (type: $boxType)');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur création $boxName: $e');
|
||||
// En cas d'erreur, essayer sans type
|
||||
try {
|
||||
await Hive.openBox(boxName);
|
||||
debugPrint('⚠️ Boîte $boxName créée sans type');
|
||||
} catch (e2) {
|
||||
debugPrint('❌ Échec total création $boxName: $e2');
|
||||
}
|
||||
}
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur création des boîtes: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Restaure les utilisateurs sauvegardés
|
||||
Future<void> _restoreUsers(Map<dynamic, UserModel> users) async {
|
||||
try {
|
||||
if (Hive.isBoxOpen(AppKeys.userBoxName)) {
|
||||
final userBox = Hive.box<UserModel>(AppKeys.userBoxName);
|
||||
|
||||
for (final entry in users.entries) {
|
||||
try {
|
||||
await userBox.put(entry.key, entry.value);
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur restauration utilisateur ${entry.key}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('✅ ${users.length} utilisateurs restaurés');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur restauration utilisateurs: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,51 +2,47 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class CustomTextField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final TextEditingController? controller;
|
||||
final String label;
|
||||
final String? hintText;
|
||||
final String? helperText;
|
||||
final IconData? prefixIcon;
|
||||
final Widget? suffixIcon;
|
||||
final bool obscureText;
|
||||
final TextInputType keyboardType;
|
||||
final String? Function(String?)? validator;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final int? maxLines;
|
||||
final int? minLines;
|
||||
final bool readOnly;
|
||||
final VoidCallback? onTap;
|
||||
final Function(String)? onChanged;
|
||||
final bool isRequired;
|
||||
final bool autofocus;
|
||||
final FocusNode? focusNode;
|
||||
final String? errorText;
|
||||
final Color? fillColor;
|
||||
final String? helperText;
|
||||
final String? Function(String?)? validator;
|
||||
final VoidCallback? onTap;
|
||||
final TextInputType? keyboardType;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final int? maxLines;
|
||||
final int? maxLength;
|
||||
final bool obscureText;
|
||||
final Function(String)? onChanged;
|
||||
final Function(String)? onFieldSubmitted;
|
||||
final bool isRequired;
|
||||
|
||||
const CustomTextField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.controller,
|
||||
required this.label,
|
||||
this.hintText,
|
||||
this.helperText,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.obscureText = false,
|
||||
this.keyboardType = TextInputType.text,
|
||||
this.validator,
|
||||
this.inputFormatters,
|
||||
this.maxLines = 1,
|
||||
this.minLines,
|
||||
this.readOnly = false,
|
||||
this.onTap,
|
||||
this.onChanged,
|
||||
this.isRequired = false,
|
||||
this.autofocus = false,
|
||||
this.focusNode,
|
||||
this.errorText,
|
||||
this.fillColor,
|
||||
this.helperText,
|
||||
this.validator,
|
||||
this.onTap,
|
||||
this.keyboardType,
|
||||
this.inputFormatters,
|
||||
this.maxLines = 1,
|
||||
this.maxLength,
|
||||
this.obscureText = false,
|
||||
this.onChanged,
|
||||
this.onFieldSubmitted,
|
||||
this.isRequired = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -56,124 +52,105 @@ class CustomTextField extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Label avec indicateur de champ requis
|
||||
if (label.isNotEmpty) ...[
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
if (isRequired) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'*',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.error,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// Ajouter un Container avec une ombre pour créer un effet d'élévation
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
obscureText: obscureText,
|
||||
keyboardType: keyboardType,
|
||||
validator: validator,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: maxLines,
|
||||
minLines: minLines,
|
||||
readOnly: readOnly,
|
||||
onTap: onTap,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
autofocus: autofocus,
|
||||
focusNode: focusNode,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground.withOpacity(0.5),
|
||||
),
|
||||
errorText: errorText,
|
||||
helperText: helperText,
|
||||
helperStyle: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
prefixIcon: prefixIcon != null
|
||||
? Icon(prefixIcon, color: theme.colorScheme.primary)
|
||||
: null,
|
||||
suffixIcon: suffixIcon,
|
||||
// Couleur de fond différente selon l'état (lecture seule ou éditable)
|
||||
fillColor: fillColor ??
|
||||
(readOnly
|
||||
? const Color(
|
||||
0xFFF8F9FA) // Gris plus clair pour readOnly
|
||||
: const Color(
|
||||
0xFFECEFF1)), // Gris plus foncé pour éditable
|
||||
filled: true,
|
||||
// Ajouter une élévation avec une petite ombre
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
// Ajouter une ombre pour créer un effet d'élévation
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
gapPadding: 0,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
|
||||
// Champ de texte
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
readOnly: readOnly,
|
||||
autofocus: autofocus,
|
||||
onTap: onTap,
|
||||
validator: validator,
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: maxLines,
|
||||
maxLength: maxLength,
|
||||
obscureText: obscureText,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
helperText: helperText,
|
||||
prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
|
||||
suffixIcon: suffixIcon,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.outline,
|
||||
),
|
||||
),
|
||||
// Point rouge en haut à droite pour indiquer que le champ est obligatoire
|
||||
if (isRequired)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
margin: const EdgeInsets.only(top: 8, right: 8),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.outline.withOpacity(0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: readOnly ? theme.colorScheme.surfaceContainerHighest.withOpacity(0.3) : theme.colorScheme.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
buildCounter: maxLength != null
|
||||
? (context, {required currentLength, required isFocused, maxLength}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'$currentLength/${maxLength ?? 0}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: currentLength > (maxLength ?? 0) * 0.8 ? theme.colorScheme.error : theme.colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
491
app/lib/presentation/widgets/operation_form_dialog.dart
Normal file
491
app/lib/presentation/widgets/operation_form_dialog.dart
Normal file
@@ -0,0 +1,491 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:geosector_app/core/data/models/operation_model.dart';
|
||||
import 'package:geosector_app/core/repositories/operation_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
|
||||
class OperationFormDialog extends StatefulWidget {
|
||||
final OperationModel? operation;
|
||||
final String title;
|
||||
final bool readOnly;
|
||||
final OperationRepository operationRepository;
|
||||
final UserRepository userRepository;
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const OperationFormDialog({
|
||||
super.key,
|
||||
this.operation,
|
||||
required this.title,
|
||||
this.readOnly = false,
|
||||
required this.operationRepository,
|
||||
required this.userRepository,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
State<OperationFormDialog> createState() => _OperationFormDialogState();
|
||||
}
|
||||
|
||||
class _OperationFormDialogState extends State<OperationFormDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isSubmitting = false;
|
||||
// Controllers
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _dateDebutController;
|
||||
late final TextEditingController _dateFinController;
|
||||
|
||||
// Form values
|
||||
DateTime? _dateDebut;
|
||||
DateTime? _dateFin;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Initialize controllers with operation data if available
|
||||
final operation = widget.operation;
|
||||
_nameController = TextEditingController(text: operation?.name ?? '');
|
||||
|
||||
_dateDebut = operation?.dateDebut;
|
||||
_dateFin = operation?.dateFin;
|
||||
|
||||
_dateDebutController = TextEditingController(
|
||||
text: _dateDebut != null ? DateFormat('dd/MM/yyyy').format(_dateDebut!) : '',
|
||||
);
|
||||
|
||||
_dateFinController = TextEditingController(
|
||||
text: _dateFin != null ? DateFormat('dd/MM/yyyy').format(_dateFin!) : '',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_dateDebutController.dispose();
|
||||
_dateFinController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Méthode pour sélectionner une date
|
||||
void _selectDate(BuildContext context, bool isDateDebut) {
|
||||
try {
|
||||
final DateTime initialDate;
|
||||
final DateTime firstDate;
|
||||
final DateTime lastDate;
|
||||
|
||||
if (isDateDebut) {
|
||||
// Pour la date de début
|
||||
initialDate = _dateDebut ?? DateTime.now();
|
||||
firstDate = DateTime(DateTime.now().year - 2);
|
||||
lastDate = _dateFin ?? DateTime(DateTime.now().year + 5);
|
||||
} else {
|
||||
// Pour la date de fin
|
||||
initialDate = _dateFin ?? (_dateDebut ?? DateTime.now());
|
||||
firstDate = _dateDebut ?? DateTime(DateTime.now().year - 2);
|
||||
lastDate = DateTime(DateTime.now().year + 5);
|
||||
}
|
||||
|
||||
showDatePicker(
|
||||
context: context,
|
||||
initialDate: initialDate,
|
||||
firstDate: firstDate,
|
||||
lastDate: lastDate,
|
||||
).then((DateTime? picked) {
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
if (isDateDebut) {
|
||||
_dateDebut = picked;
|
||||
_dateDebutController.text = DateFormat('dd/MM/yyyy').format(picked);
|
||||
|
||||
// Si la date de fin est antérieure à la nouvelle date de début, la réinitialiser
|
||||
if (_dateFin != null && _dateFin!.isBefore(picked)) {
|
||||
_dateFin = null;
|
||||
_dateFinController.clear();
|
||||
}
|
||||
} else {
|
||||
_dateFin = picked;
|
||||
_dateFinController.text = DateFormat('dd/MM/yyyy').format(picked);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Exception lors de l\'affichage du sélecteur de date: $e');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Impossible d\'afficher le sélecteur de date'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSubmit() async {
|
||||
debugPrint('=== _handleSubmit APPELÉ ===');
|
||||
if (_isSubmitting) {
|
||||
debugPrint('=== ARRÊT: En cours de soumission ===');
|
||||
return;
|
||||
}
|
||||
|
||||
// Valider le formulaire uniquement au submit
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
debugPrint('=== ARRÊT: Formulaire invalide ===');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('=== DÉBUT SOUMISSION ===');
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Récupérer l'utilisateur actuel pour le fkEntite
|
||||
final currentUser = widget.userRepository.getCurrentUser();
|
||||
final userFkEntite = currentUser?.fkEntite ?? 0;
|
||||
|
||||
final operationData = widget.operation?.copyWith(
|
||||
name: _nameController.text.trim(),
|
||||
dateDebut: _dateDebut!,
|
||||
dateFin: _dateFin!,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
) ??
|
||||
OperationModel(
|
||||
id: 0,
|
||||
name: _nameController.text.trim(),
|
||||
dateDebut: _dateDebut!,
|
||||
dateFin: _dateFin!,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
fkEntite: userFkEntite, // ← Utiliser le fkEntite de l'utilisateur
|
||||
isActive: false,
|
||||
isSynced: false,
|
||||
);
|
||||
|
||||
debugPrint('=== OPERATION DATA ===');
|
||||
debugPrint('operation.id: ${operationData.id}');
|
||||
debugPrint('operation.fkEntite: ${operationData.fkEntite}');
|
||||
debugPrint('user.fkEntite: $userFkEntite');
|
||||
|
||||
debugPrint('=== APPEL REPOSITORY ===');
|
||||
|
||||
// Appel direct du repository - la dialog gère tout
|
||||
final success = await widget.operationRepository.saveOperationFromModel(operationData);
|
||||
|
||||
if (success && mounted) {
|
||||
debugPrint('=== SUCCÈS - AUTO-FERMETURE ===');
|
||||
debugPrint('=== context.mounted: ${context.mounted} ===');
|
||||
|
||||
// Délai pour laisser le temps à Hive de se synchroniser
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) {
|
||||
debugPrint('=== FERMETURE DIFFÉRÉE ===');
|
||||
|
||||
// Auto-fermeture de la dialog
|
||||
try {
|
||||
debugPrint('=== AVANT Navigator.pop() ===');
|
||||
Navigator.of(context).pop();
|
||||
debugPrint('=== APRÈS Navigator.pop() ===');
|
||||
} catch (e) {
|
||||
debugPrint('=== ERREUR Navigator.pop(): $e ===');
|
||||
}
|
||||
|
||||
// Notifier la page parente pour setState()
|
||||
debugPrint('=== AVANT onSuccess?.call() ===');
|
||||
widget.onSuccess?.call();
|
||||
debugPrint('=== APRÈS onSuccess?.call() ===');
|
||||
|
||||
// Message de succès
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
debugPrint('=== AFFICHAGE MESSAGE SUCCÈS ===');
|
||||
ApiException.showSuccess(context, widget.operation == null ? "Nouvelle opération créée avec succès" : "Opération modifiée avec succès");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (mounted) {
|
||||
debugPrint('=== ÉCHEC - AFFICHAGE ERREUR ===');
|
||||
ApiException.showError(context, Exception(widget.operation == null ? "Échec de la création de l'opération" : "Échec de la mise à jour de l'opération"));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('=== ERREUR dans _handleSubmit: $e ===');
|
||||
if (mounted) {
|
||||
ApiException.showError(context, e);
|
||||
}
|
||||
} finally {
|
||||
// Réinitialiser l'état de soumission seulement si le widget est encore monté
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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.4,
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 500,
|
||||
maxHeight: 700,
|
||||
),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
widget.operation == null ? Icons.add_circle : Icons.edit,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
|
||||
// Contenu du formulaire
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Nom de l'opération
|
||||
CustomTextField(
|
||||
controller: _nameController,
|
||||
label: "Nom de l'opération",
|
||||
readOnly: widget.readOnly,
|
||||
prefixIcon: Icons.event,
|
||||
isRequired: true,
|
||||
maxLength: 100,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return "Veuillez entrer le nom de l'opération";
|
||||
}
|
||||
if (value.trim().length < 5) {
|
||||
return "Le nom doit contenir au moins 5 caractères";
|
||||
}
|
||||
if (value.trim().length > 100) {
|
||||
return "Le nom ne peut pas dépasser 100 caractères";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
hintText: "Ex: Calendriers 2024, Opération Noël...",
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section des dates
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline.withOpacity(0.5)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: theme.colorScheme.surface.withOpacity(0.3),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.date_range,
|
||||
color: theme.colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Période de l'opération",
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Date de début
|
||||
CustomTextField(
|
||||
controller: _dateDebutController,
|
||||
label: "Date de début",
|
||||
readOnly: true,
|
||||
isRequired: true,
|
||||
onTap: widget.readOnly ? null : () => _selectDate(context, true),
|
||||
suffixIcon: Icon(
|
||||
Icons.calendar_today,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
validator: (value) {
|
||||
if (_dateDebut == null) {
|
||||
return "Veuillez sélectionner la date de début";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
hintText: "Cliquez pour sélectionner la date",
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Date de fin
|
||||
CustomTextField(
|
||||
controller: _dateFinController,
|
||||
label: "Date de fin",
|
||||
readOnly: true,
|
||||
isRequired: true,
|
||||
onTap: widget.readOnly ? null : () => _selectDate(context, false),
|
||||
suffixIcon: Icon(
|
||||
Icons.calendar_today,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
validator: (value) {
|
||||
if (_dateFin == null) {
|
||||
return "Veuillez sélectionner la date de fin";
|
||||
}
|
||||
if (_dateDebut != null && _dateFin!.isBefore(_dateDebut!)) {
|
||||
return "La date de fin doit être postérieure à la date de début";
|
||||
}
|
||||
if (_dateDebut != null && _dateFin!.isAtSameMomentAs(_dateDebut!)) {
|
||||
return "La date de fin doit être différente de la date de début";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
hintText: "Cliquez pour sélectionner la date",
|
||||
),
|
||||
|
||||
// Indicateur de durée
|
||||
if (_dateDebut != null && _dateFin != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
"Durée: ${_dateFin!.difference(_dateDebut!).inDays + 1} jour(s)",
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Informations supplémentaires pour les nouvelles opérations
|
||||
if (widget.operation == null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.secondaryContainer.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.lightbulb_outline,
|
||||
color: Colors.black87,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"La nouvelle opération sera activée automatiquement et remplacera l'opération active actuelle.",
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Footer avec boutons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
if (!widget.readOnly)
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isSubmitting ? null : _handleSubmit,
|
||||
icon: _isSubmitting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(widget.operation == null ? Icons.add : Icons.save),
|
||||
label: Text(_isSubmitting ? 'Enregistrement...' : (widget.operation == null ? 'Créer' : 'Enregistrer')),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.5
|
||||
version: 0.4.0
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
Reference in New Issue
Block a user