Livraison d ela gestion des opérations v0.4.0

This commit is contained in:
d6soft
2025-06-24 13:01:43 +02:00
parent b9672a6228
commit 7763d02fae
819 changed files with 306790 additions and 145462 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -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

View File

@@ -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"]}

View File

@@ -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

View File

@@ -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"]}

View File

@@ -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"
}

View File

@@ -5,7 +5,7 @@
"packages": [
{
"name": "geosector_app",
"version": "0.3.5",
"version": "0.4.0",
"dependencies": [
"connectivity_plus",
"cupertino_icons",

View File

@@ -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
View 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"
}

File diff suppressed because it is too large Load Diff

View File

@@ -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. 🚀

View File

@@ -1 +1 @@
41acb28aedc1da36af63ba5cb8859018
01af3ba6904766cfc820f0897fc71456

View File

@@ -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"
}
});

View File

@@ -3,13 +3,13 @@ const MANIFEST = 'flutter-app-manifest';
const TEMP = 'flutter-temp-cache';
const CACHE_NAME = 'flutter-app-cache';
const RESOURCES = {"flutter_bootstrap.js": "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",

File diff suppressed because one or more lines are too long

View File

@@ -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"}

View File

@@ -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

View File

@@ -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,
);
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}
}
}

View File

@@ -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();

View File

@@ -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;

View File

@@ -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');
}

View 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);
}

View File

@@ -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;

View File

@@ -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(),
),
],
),
),
],
),
],
),
);
}
}

View File

@@ -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,

View File

@@ -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,
);
}
}

View 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),
],
);
},
),
),
],
),
);
}
}

View File

@@ -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');
}
}

View File

@@ -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,
),
],
);

View 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,
),
),
],
),
],
),
),
);
}
}

View File

@@ -1,7 +1,7 @@
name: geosector_app
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
publish_to: 'none'
version: 0.3.5
version: 0.4.0
environment:
sdk: '>=3.0.0 <4.0.0'