feat: Version 3.3.5 - Optimisations pages, améliorations ergonomie et affichages dynamiques stats
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -219,15 +219,9 @@
|
||||
},
|
||||
{
|
||||
"name": "dio_cache_interceptor",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/dio_cache_interceptor-3.5.1",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/dio_cache_interceptor-4.0.5",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.14"
|
||||
},
|
||||
{
|
||||
"name": "dio_cache_interceptor_hive_store",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/dio_cache_interceptor_hive_store-3.2.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.14"
|
||||
"languageVersion": "3.0"
|
||||
},
|
||||
{
|
||||
"name": "dio_web_adapter",
|
||||
@@ -351,7 +345,7 @@
|
||||
},
|
||||
{
|
||||
"name": "flutter_map_cache",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/flutter_map_cache-1.5.2",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/flutter_map_cache-2.0.0+1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.6"
|
||||
},
|
||||
@@ -463,6 +457,12 @@
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.12"
|
||||
},
|
||||
{
|
||||
"name": "hive_ce",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/hive_ce-2.14.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "hive_flutter",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/hive_flutter-1.1.0",
|
||||
@@ -487,6 +487,18 @@
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "http_cache_core",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/http_cache_core-1.1.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.0"
|
||||
},
|
||||
{
|
||||
"name": "http_cache_hive_store",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/http_cache_hive_store-5.0.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.0"
|
||||
},
|
||||
{
|
||||
"name": "http_multi_server",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/http_multi_server-3.2.2",
|
||||
@@ -565,6 +577,12 @@
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "isolate_channel",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/isolate_channel-0.2.2+1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.0"
|
||||
},
|
||||
{
|
||||
"name": "js",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/js-0.6.7",
|
||||
@@ -615,7 +633,7 @@
|
||||
},
|
||||
{
|
||||
"name": "logger",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/logger-2.6.1",
|
||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/logger-2.6.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.17"
|
||||
},
|
||||
|
||||
@@ -5,14 +5,13 @@
|
||||
"packages": [
|
||||
{
|
||||
"name": "geosector_app",
|
||||
"version": "3.3.4+334",
|
||||
"version": "3.3.5+335",
|
||||
"dependencies": [
|
||||
"battery_plus",
|
||||
"connectivity_plus",
|
||||
"cupertino_icons",
|
||||
"device_info_plus",
|
||||
"dio",
|
||||
"dio_cache_interceptor_hive_store",
|
||||
"fl_chart",
|
||||
"flutter",
|
||||
"flutter_local_notifications",
|
||||
@@ -26,6 +25,7 @@
|
||||
"google_fonts",
|
||||
"hive",
|
||||
"hive_flutter",
|
||||
"http_cache_hive_store",
|
||||
"image_picker",
|
||||
"intl",
|
||||
"latlong2",
|
||||
@@ -314,16 +314,16 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dio_cache_interceptor_hive_store",
|
||||
"version": "3.2.2",
|
||||
"name": "http_cache_hive_store",
|
||||
"version": "5.0.1",
|
||||
"dependencies": [
|
||||
"dio_cache_interceptor",
|
||||
"hive"
|
||||
"hive_ce",
|
||||
"http_cache_core"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "flutter_map_cache",
|
||||
"version": "1.5.2",
|
||||
"version": "2.0.0+1",
|
||||
"dependencies": [
|
||||
"dio",
|
||||
"dio_cache_interceptor",
|
||||
@@ -1391,14 +1391,33 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dio_cache_interceptor",
|
||||
"version": "3.5.1",
|
||||
"name": "hive_ce",
|
||||
"version": "2.14.0",
|
||||
"dependencies": [
|
||||
"dio",
|
||||
"crypto",
|
||||
"isolate_channel",
|
||||
"json_annotation",
|
||||
"meta",
|
||||
"web"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "http_cache_core",
|
||||
"version": "1.1.2",
|
||||
"dependencies": [
|
||||
"collection",
|
||||
"string_scanner",
|
||||
"uuid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dio_cache_interceptor",
|
||||
"version": "4.0.5",
|
||||
"dependencies": [
|
||||
"dio",
|
||||
"http_cache_core"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "proj4dart",
|
||||
"version": "2.1.0",
|
||||
@@ -1417,7 +1436,7 @@
|
||||
},
|
||||
{
|
||||
"name": "logger",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"dependencies": [
|
||||
"meta"
|
||||
]
|
||||
@@ -1785,6 +1804,11 @@
|
||||
"version": "3.1.6",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "isolate_channel",
|
||||
"version": "0.2.2+1",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "wkt_parser",
|
||||
"version": "2.0.0",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -950,6 +950,107 @@ Chaque amicale dispose de son **propre compte Stripe Connect** :
|
||||
9. Vérification statut compte
|
||||
10. Affichage "✅ Compte actif"
|
||||
|
||||
### 🔑 Configuration des clés API Stripe par environnement
|
||||
|
||||
GEOSECTOR utilise des **clés Stripe différentes** selon l'environnement pour séparer les données de test et de production.
|
||||
|
||||
#### **Fichier de configuration**
|
||||
|
||||
`/home/pierre/dev/geosector/api/src/Config/AppConfig.php`
|
||||
|
||||
#### **Répartition des clés par environnement**
|
||||
|
||||
| Environnement | URL | Plateforme Stripe | Clés utilisées | Mode | Usage |
|
||||
|---------------|-----|-------------------|----------------|------|-------|
|
||||
| **DEV** | `dapp.geosector.fr` | Test Pierre | `pk_test_51QwoVN...`<br>`sk_test_51QwoVN...` | `test` | Développement |
|
||||
| **REC** | `rapp.geosector.fr` | Test Client | `CLIENT_PK_TEST_A_REMPLACER`<br>`CLIENT_SK_TEST_A_REMPLACER` | `test` | Recette |
|
||||
| **PROD** | `app.geosector.fr` | Live Client | `CLIENT_PK_LIVE_A_REMPLACER`<br>`CLIENT_SK_LIVE_A_REMPLACER` | `live` | Production |
|
||||
|
||||
#### **Types de clés Stripe**
|
||||
|
||||
**Clés obligatoires (2) :**
|
||||
|
||||
| Clé | Format | Où la trouver | Utilisation |
|
||||
|-----|--------|---------------|-------------|
|
||||
| **Publishable key** | `pk_test_51XXXXX...` ou `pk_live_XXXXX...` | Dashboard → Developers → API Keys | Client-side (Flutter app) |
|
||||
| **Secret key** | `sk_test_51XXXXX...` ou `sk_live_XXXXX...` | Dashboard → Developers → API Keys (révéler) | Server-side (API PHP) |
|
||||
|
||||
**Clé optionnelle :**
|
||||
|
||||
| Clé | Format | Où la trouver | Utilisation |
|
||||
|-----|--------|---------------|-------------|
|
||||
| **Webhook secret** | `whsec_test_XXXXX...` ou `whsec_live_XXXXX...` | Dashboard → Webhooks → Endpoint → Signing secret | Validation webhooks (non utilisé actuellement) |
|
||||
|
||||
#### **Récupération des clés client**
|
||||
|
||||
Pour configurer REC et PROD, le client doit fournir ses clés depuis son **Dashboard Stripe** :
|
||||
|
||||
**Pour REC (clés TEST) :**
|
||||
1. Se connecter sur https://dashboard.stripe.com/test/apikeys
|
||||
2. Copier la **Publishable key** → `CLIENT_PK_TEST_A_REMPLACER`
|
||||
3. Révéler et copier la **Secret key** → `CLIENT_SK_TEST_A_REMPLACER`
|
||||
|
||||
**Pour PROD (clés LIVE) :**
|
||||
1. Se connecter sur https://dashboard.stripe.com/apikeys (mode live)
|
||||
2. Copier la **Publishable key** → `CLIENT_PK_LIVE_A_REMPLACER`
|
||||
3. Révéler et copier la **Secret key** → `CLIENT_SK_LIVE_A_REMPLACER`
|
||||
|
||||
#### **Configuration dans AppConfig.php**
|
||||
|
||||
**Structure du fichier :**
|
||||
|
||||
```php
|
||||
// Configuration DÉVELOPPEMENT (dapp.geosector.fr)
|
||||
'stripe' => [
|
||||
'public_key_test' => 'pk_test_51QwoVN...', // Clés Pierre (opérationnel)
|
||||
'secret_key_test' => 'sk_test_51QwoVN...',
|
||||
'mode' => 'test',
|
||||
],
|
||||
|
||||
// Configuration RECETTE (rapp.geosector.fr)
|
||||
'stripe' => [
|
||||
'public_key_test' => 'CLIENT_PK_TEST_A_REMPLACER', // À remplacer
|
||||
'secret_key_test' => 'CLIENT_SK_TEST_A_REMPLACER', // À remplacer
|
||||
'mode' => 'test',
|
||||
],
|
||||
|
||||
// Configuration PRODUCTION (app.geosector.fr)
|
||||
'stripe' => [
|
||||
'public_key_live' => 'CLIENT_PK_LIVE_A_REMPLACER', // À remplacer
|
||||
'secret_key_live' => 'CLIENT_SK_LIVE_A_REMPLACER', // À remplacer
|
||||
'mode' => 'live',
|
||||
],
|
||||
```
|
||||
|
||||
#### **Points importants**
|
||||
|
||||
⚠️ **Isolation des environnements** :
|
||||
- DEV utilise la plateforme de test de Pierre (développement isolé)
|
||||
- REC utilise la plateforme de test du client (tests en conditions réelles)
|
||||
- PROD utilise la plateforme live du client (vraies transactions)
|
||||
|
||||
⚠️ **Sécurité** :
|
||||
- Ne JAMAIS commiter les vraies clés dans Git
|
||||
- Vérifier que `AppConfig.php` est dans `.gitignore`
|
||||
- Les clés secrètes ne doivent jamais être exposées côté client
|
||||
|
||||
⚠️ **Mode de fonctionnement** :
|
||||
- L'API détecte automatiquement l'environnement via l'URL
|
||||
- Le `mode` (`test` ou `live`) détermine quelle paire de clés utiliser
|
||||
- En mode `test` : utilise `public_key_test` et `secret_key_test`
|
||||
- En mode `live` : utilise `public_key_live` et `secret_key_live`
|
||||
|
||||
#### **Déploiement après modification**
|
||||
|
||||
Après avoir remplacé les placeholders par les vraies clés :
|
||||
|
||||
```bash
|
||||
cd /home/pierre/dev/geosector/api
|
||||
./deploy-api.sh
|
||||
```
|
||||
|
||||
L'API sera redéployée sur l'environnement correspondant avec les nouvelles clés.
|
||||
|
||||
### 📱 Tap to Pay V2 - Paiement sans contact
|
||||
|
||||
#### **Fonctionnalités prévues**
|
||||
|
||||
@@ -1008,3 +1008,368 @@ Pour vérifier que le cache est désactivé en DEV/REC :
|
||||
**Date d'ajout** : 2025-09-23
|
||||
**Auteur** : Solution de gestion du cache
|
||||
**Version** : 1.0.0
|
||||
|
||||
## ✅ Améliorations de l'interactivité des graphiques - v3.3.5
|
||||
|
||||
**Date** : 06/10/2025
|
||||
**Version** : 3.3.5
|
||||
**Statut** : ✅ Complété
|
||||
|
||||
### 📋 Vue d'ensemble
|
||||
|
||||
Amélioration majeure de l'expérience utilisateur avec l'ajout d'interactivité sur tous les graphiques et cartes du tableau de bord, permettant une navigation intelligente vers l'historique avec filtres pré-appliqués.
|
||||
|
||||
### 🎯 Modifications apportées
|
||||
|
||||
#### 1. **Réinitialisation des filtres lors des clics sur les graphiques**
|
||||
|
||||
**Fichiers modifiés** :
|
||||
- `lib/presentation/widgets/charts/passage_summary_card.dart`
|
||||
- `lib/presentation/widgets/charts/payment_summary_card.dart`
|
||||
- `lib/presentation/widgets/sector_distribution_card.dart`
|
||||
|
||||
**Implémentation** :
|
||||
```dart
|
||||
// Réinitialiser TOUS les filtres avant de sauvegarder le nouveau
|
||||
settingsBox.delete('history_selectedPaymentTypeId');
|
||||
settingsBox.delete('history_selectedSectorId');
|
||||
settingsBox.delete('history_selectedSectorName');
|
||||
settingsBox.delete('history_selectedMemberId');
|
||||
settingsBox.delete('history_startDate');
|
||||
settingsBox.delete('history_endDate');
|
||||
|
||||
// Sauvegarder uniquement le critère sélectionné
|
||||
settingsBox.put('history_selectedTypeId', typeId);
|
||||
```
|
||||
|
||||
**Bénéfice** : L'utilisateur voit uniquement les passages correspondant au critère cliqué, sans interférence d'anciens filtres.
|
||||
|
||||
---
|
||||
|
||||
#### 2. **Navigation directe vers les pages d'historique**
|
||||
|
||||
**Correction** : Changement des routes de navigation de `/admin` et `/user` vers `/admin/history` et `/user/history`.
|
||||
|
||||
**Code** :
|
||||
```dart
|
||||
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
context.go(isAdmin ? '/admin/history' : '/user/history');
|
||||
```
|
||||
|
||||
**Bénéfice** : Navigation immédiate vers la page cible sans étape intermédiaire.
|
||||
|
||||
---
|
||||
|
||||
#### 3. **Chargement des filtres pour tous les utilisateurs**
|
||||
|
||||
**Fichier** : `lib/presentation/pages/history_page.dart` (lignes 143-151)
|
||||
|
||||
**Problème** : La méthode `_loadPreselectedFilters()` n'était appelée que pour les admins.
|
||||
|
||||
**Solution** :
|
||||
```dart
|
||||
} else {
|
||||
_loadPreselectedFilters(); // Maintenant appelé pour tous
|
||||
if (!isAdmin) {
|
||||
selectedMemberId = currentUserId;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Bénéfice** : Les filtres fonctionnent correctement en mode utilisateur.
|
||||
|
||||
---
|
||||
|
||||
#### 4. **Correction du dropdown des membres (admin)**
|
||||
|
||||
**Fichier** : `lib/presentation/pages/history_page.dart` (lignes 537-542)
|
||||
|
||||
**Problème** : Utilisation de `Hive.box<UserModel>` qui ne contient que le currentUser.
|
||||
|
||||
**Solution** : Utiliser la liste `_users` construite depuis `membreRepository.getAllMembres()`.
|
||||
|
||||
```dart
|
||||
..._users.map((UserModel user) {
|
||||
return DropdownMenuItem<int?>(
|
||||
value: user.id,
|
||||
child: Text('${user.firstName ?? ''} ${user.name ?? ''}'),
|
||||
);
|
||||
}),
|
||||
```
|
||||
|
||||
**Bénéfice** : Affichage correct de tous les membres de l'amicale.
|
||||
|
||||
---
|
||||
|
||||
#### 5. **Adaptation dynamique de la hauteur des cartes**
|
||||
|
||||
**Fichiers** :
|
||||
- `lib/presentation/widgets/sector_distribution_card.dart`
|
||||
- `lib/presentation/widgets/members_board_passages.dart`
|
||||
|
||||
**Modification** : Suppression des contraintes de hauteur fixe.
|
||||
|
||||
```dart
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: sectorStats.length,
|
||||
itemBuilder: (context, index) => ...,
|
||||
)
|
||||
```
|
||||
|
||||
**Bénéfice** : Les cartes s'adaptent à leur contenu sans espace vide inutile.
|
||||
|
||||
---
|
||||
|
||||
#### 6. **Correction du bug ActivityChart (secteurs utilisateur)**
|
||||
|
||||
**Fichier** : `lib/presentation/widgets/charts/activity_chart.dart` (lignes 196-201)
|
||||
|
||||
**Problème** : Logique incorrecte de récupération des secteurs utilisateur.
|
||||
|
||||
**Code AVANT (bugué)** :
|
||||
```dart
|
||||
userSectorIds = userSectorBox.values
|
||||
.where((us) => us.id == currentUser.id)
|
||||
.map((us) => us.fkSector)
|
||||
.toSet();
|
||||
```
|
||||
|
||||
**Code APRÈS (corrigé)** :
|
||||
```dart
|
||||
final userSectors = userRepository.getUserSectors();
|
||||
userSectorIds = userSectors.map((sector) => sector.id).toSet();
|
||||
```
|
||||
|
||||
**Bénéfice** : Le graphique affiche correctement les passages des secteurs assignés à l'utilisateur.
|
||||
|
||||
---
|
||||
|
||||
#### 7. **Ajout de boutons de période (7j/14j/21j)**
|
||||
|
||||
**Fichier** : `lib/presentation/widgets/charts/activity_chart.dart`
|
||||
|
||||
**Implémentation** :
|
||||
- Ajout d'un état `_selectedDays` (par défaut 7 jours)
|
||||
- Création de la méthode `_buildPeriodButton(int days)`
|
||||
- Affichage conditionnel via paramètre `showPeriodButtons`
|
||||
|
||||
```dart
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildPeriodButton(7),
|
||||
const SizedBox(width: 4),
|
||||
_buildPeriodButton(14),
|
||||
const SizedBox(width: 4),
|
||||
_buildPeriodButton(21),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
**Bénéfice** : L'utilisateur peut rapidement changer la période d'affichage.
|
||||
|
||||
---
|
||||
|
||||
#### 8. **Affichage conditionnel des boutons de période**
|
||||
|
||||
**Paramètre ajouté** : `showPeriodButtons` (par défaut `false`)
|
||||
|
||||
**Usage** :
|
||||
- `home_page.dart` : `showPeriodButtons: true`
|
||||
- `history_page.dart` : non utilisé (donc `false`)
|
||||
|
||||
**Bénéfice** : Les boutons n'apparaissent que sur la page d'accueil.
|
||||
|
||||
---
|
||||
|
||||
#### 9. **Passages type 2 éditables par tous les utilisateurs**
|
||||
|
||||
**Fichier** : `lib/presentation/pages/history_page.dart` (ligne 1606)
|
||||
|
||||
**Modification** :
|
||||
```dart
|
||||
if (isAdmin || passage.fkUser == currentUserId || passage.fkType == 2) {
|
||||
_handlePassageEdit(passage);
|
||||
}
|
||||
```
|
||||
|
||||
**Bénéfice** : Tous les utilisateurs peuvent finaliser les passages de type 2 (À finaliser).
|
||||
|
||||
---
|
||||
|
||||
#### 10. **Noms de secteurs cliquables pour les utilisateurs**
|
||||
|
||||
**Fichier** : `lib/presentation/widgets/sector_distribution_card.dart` (lignes 321-342)
|
||||
|
||||
**Implémentation** :
|
||||
```dart
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
if (isAdmin) {
|
||||
// Admin : naviguer vers la carte
|
||||
settingsBox.put('selectedSectorId', sectorId);
|
||||
settingsBox.put('selectedPageIndex', 4);
|
||||
context.go('/admin');
|
||||
} else {
|
||||
// User : naviguer vers l'historique avec filtre secteur
|
||||
settingsBox.delete('history_selectedTypeId');
|
||||
settingsBox.delete('history_selectedPaymentTypeId');
|
||||
// ... autres suppressions
|
||||
settingsBox.put('history_selectedSectorId', sectorId);
|
||||
settingsBox.put('history_selectedSectorName', name);
|
||||
context.go('/user/history');
|
||||
}
|
||||
},
|
||||
child: Text(name, ...),
|
||||
),
|
||||
),
|
||||
```
|
||||
|
||||
**Bénéfice** : Les utilisateurs peuvent cliquer sur un nom de secteur pour voir ses passages dans l'historique.
|
||||
|
||||
---
|
||||
|
||||
#### 11. **Interactivité des segments de barres ActivityChart**
|
||||
|
||||
**Fichier** : `lib/presentation/widgets/charts/activity_chart.dart`
|
||||
|
||||
**Nouvelles dépendances** :
|
||||
```dart
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
```
|
||||
|
||||
**Implémentation** :
|
||||
|
||||
##### a) Callback `onPointTap` dans StackedColumnSeries
|
||||
```dart
|
||||
onPointTap: widget.showPeriodButtons ? (ChartPointDetails details) {
|
||||
_handlePointTap(details, typeId);
|
||||
} : null,
|
||||
```
|
||||
|
||||
##### b) Méthode `_handlePointTap` (lignes 532-573)
|
||||
```dart
|
||||
void _handlePointTap(ChartPointDetails details, int typeId) {
|
||||
if (details.pointIndex == null || details.pointIndex! < 0) return;
|
||||
|
||||
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
final chartData = _calculateActivityData(passageBox, _selectedDays);
|
||||
|
||||
if (details.pointIndex! >= chartData.length) return;
|
||||
|
||||
final clickedData = chartData[details.pointIndex!];
|
||||
final clickedDate = clickedData.date;
|
||||
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
|
||||
// Réinitialiser tous les autres filtres
|
||||
settingsBox.delete('history_selectedPaymentTypeId');
|
||||
settingsBox.delete('history_selectedSectorId');
|
||||
settingsBox.delete('history_selectedSectorName');
|
||||
settingsBox.delete('history_selectedMemberId');
|
||||
|
||||
// Appliquer le filtre de type
|
||||
settingsBox.put('history_selectedTypeId', typeId);
|
||||
|
||||
// Définir la plage de dates pour la journée complète
|
||||
final startDateTime = DateTime(
|
||||
clickedDate.year,
|
||||
clickedDate.month,
|
||||
clickedDate.day,
|
||||
0, 0, 0
|
||||
);
|
||||
settingsBox.put('history_startDate', startDateTime.millisecondsSinceEpoch);
|
||||
|
||||
final endDateTime = DateTime(
|
||||
clickedDate.year,
|
||||
clickedDate.month,
|
||||
clickedDate.day,
|
||||
23, 59, 59
|
||||
);
|
||||
settingsBox.put('history_endDate', endDateTime.millisecondsSinceEpoch);
|
||||
|
||||
// Naviguer vers l'historique
|
||||
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
context.go(isAdmin ? '/admin/history' : '/user/history');
|
||||
}
|
||||
```
|
||||
|
||||
**Fonctionnalités** :
|
||||
- Clic sur un segment de barre → filtre par type ET date exacte
|
||||
- Date de début : jour cliqué à 00:00:00
|
||||
- Date de fin : jour cliqué à 23:59:59
|
||||
- Réinitialisation de tous les autres filtres
|
||||
- Navigation contextuelle (admin/user)
|
||||
|
||||
**Bénéfice** : Navigation ultra-précise vers les passages d'un type spécifique pour une journée donnée.
|
||||
|
||||
---
|
||||
|
||||
### 📊 Impact UX
|
||||
|
||||
| Fonctionnalité | Avant | Après |
|
||||
|----------------|-------|-------|
|
||||
| **Clics sur graphiques** | Non fonctionnel | ✅ Navigation avec filtres |
|
||||
| **Filtres utilisateurs** | ❌ Ne marchait pas | ✅ Fonctionnels |
|
||||
| **Dropdown membres** | ❌ Vide en admin | ✅ Tous les membres |
|
||||
| **Hauteur des cartes** | Fixe (espace vide) | ✅ Adaptative |
|
||||
| **ActivityChart users** | ❌ Pas de données | ✅ Affichage correct |
|
||||
| **Boutons de période** | Absents | ✅ 7j/14j/21j |
|
||||
| **Édition type 2** | Admin seulement | ✅ Tous les users |
|
||||
| **Secteurs cliquables** | Admin uniquement | ✅ Admin et users |
|
||||
| **Segments de barres** | Non cliquables | ✅ Filtrage par type+date |
|
||||
|
||||
### 🎨 Expérience utilisateur améliorée
|
||||
|
||||
1. **Navigation intuitive** : Cliquer sur n'importe quel élément visuel (graphique, secteur, barre) filtre automatiquement l'historique
|
||||
2. **Filtres intelligents** : Réinitialisation automatique pour éviter les conflits
|
||||
3. **Contexte préservé** : Admin et utilisateurs ont des comportements adaptés
|
||||
4. **Période flexible** : Choix rapide entre 7, 14 ou 21 jours
|
||||
5. **Précision temporelle** : Sélection jour par jour via les segments de barres
|
||||
|
||||
### 🔍 Fichiers modifiés
|
||||
|
||||
```
|
||||
lib/presentation/widgets/charts/
|
||||
├── passage_summary_card.dart ✏️ Filtres + navigation
|
||||
├── payment_summary_card.dart ✏️ Filtres + navigation
|
||||
└── activity_chart.dart ✏️ Boutons période + clic segments
|
||||
|
||||
lib/presentation/widgets/
|
||||
└── sector_distribution_card.dart ✏️ Filtres + hauteur + clics users
|
||||
|
||||
lib/presentation/pages/
|
||||
├── home_page.dart ✏️ Paramètres ActivityChart
|
||||
└── history_page.dart ✏️ Filtres users + dropdown membres
|
||||
```
|
||||
|
||||
### 🧪 Tests effectués
|
||||
|
||||
- ✅ Clics sur PassageSummaryCard → historique filtré
|
||||
- ✅ Clics sur PaymentSummaryCard → historique filtré
|
||||
- ✅ Clics sur SectorDistributionCard → historique filtré
|
||||
- ✅ Clics sur segments ActivityChart → historique avec type + date
|
||||
- ✅ Boutons de période 7j/14j/21j fonctionnels
|
||||
- ✅ Affichage correct en mode admin et user
|
||||
- ✅ Dropdown membres affiche tous les membres
|
||||
- ✅ Hauteur des cartes adaptative
|
||||
- ✅ Édition passages type 2 par tous
|
||||
|
||||
### 🚀 Prochaines étapes suggérées
|
||||
|
||||
- [ ] Ajouter un indicateur visuel sur les éléments cliquables (cursor: pointer)
|
||||
- [ ] Animation de transition lors de la navigation vers l'historique
|
||||
- [ ] Tooltip sur les segments de barres pour prévisualiser les données
|
||||
- [ ] Export des données filtrées depuis l'historique
|
||||
- [ ] Mémorisation des périodes préférées par utilisateur
|
||||
|
||||
---
|
||||
|
||||
**Date de complétion** : 06/10/2025
|
||||
**Testé par** : Équipe de développement
|
||||
**Statut** : ✅ Prêt pour production
|
||||
|
||||
@@ -356,11 +356,89 @@ _Bénéfice : Sécurité renforcée et meilleure traçabilité_
|
||||
|
||||
---
|
||||
|
||||
<div style="page-break-after: always;"></div>
|
||||
|
||||
## UPGRADES PACKAGES FLUTTER
|
||||
|
||||
### 📊 État des packages (Octobre 2025)
|
||||
|
||||
L'analyse `flutter pub outdated` a révélé plusieurs packages nécessitant des mises à jour, dont un package discontinué critique.
|
||||
|
||||
### 🔴 Phase 1 - Correction package discontinué (URGENT)
|
||||
|
||||
**Statut : ✅ TERMINÉ (06/10/2025)**
|
||||
|
||||
| Package | Action | Ancienne version | Nouvelle version |
|
||||
|---------|--------|------------------|------------------|
|
||||
| `dio_cache_interceptor_hive_store` | ❌ Suppression (discontinué) | 3.2.2 | - |
|
||||
| `http_cache_hive_store` | ✅ Ajout (remplacement) | - | 5.0.0 |
|
||||
| `flutter_map_cache` | ⬆️ Mise à jour | 1.5.2 | 2.0.0+1 |
|
||||
|
||||
**Fichiers modifiés :**
|
||||
- `pubspec.yaml` : Remplacement des dépendances
|
||||
- `lib/presentation/widgets/mapbox_map.dart` : Import mis à jour
|
||||
|
||||
**Tests requis :**
|
||||
- [x] Affichage carte web
|
||||
- [x] Affichage carte mobile
|
||||
- [x] Cache des tuiles mobile
|
||||
- [x] Mode terrain
|
||||
|
||||
### 🟡 Phase 2 - Mises à jour importantes (PLANIFIÉ)
|
||||
|
||||
**Statut : ⏳ EN ATTENTE**
|
||||
|
||||
#### Cartes et géolocalisation
|
||||
| Package | Actuelle | Cible | Breaking Changes |
|
||||
|---------|----------|-------|------------------|
|
||||
| `flutter_map` | 6.2.1 | 8.2.2 | ⚠️ Oui (v7, v8) |
|
||||
| `geolocator` | 12.0.0 | 14.0.2 | Possible |
|
||||
|
||||
#### Device Info & Permissions
|
||||
| Package | Actuelle | Cible | Importance |
|
||||
|---------|----------|-------|------------|
|
||||
| `device_info_plus` | 9.1.2 | 12.1.0 | ⭐⭐⭐ Tap to Pay |
|
||||
| `battery_plus` | 4.1.0 | 7.0.0 | ⭐⭐ |
|
||||
| `connectivity_plus` | 5.0.2 | 7.0.0 | ⭐⭐ |
|
||||
| `sensors_plus` | 3.1.0 | 7.0.0 | ⭐⭐⭐ Mode boussole |
|
||||
| `permission_handler` | 11.4.0 | 12.0.1 | ⭐⭐⭐ |
|
||||
|
||||
**Points d'attention :**
|
||||
- `flutter_map 8.x` : Breaking changes majeurs v6 → v8
|
||||
- `device_info_plus` : Vérifier compatibilité DeviceInfoService
|
||||
- Tests complets requis : cartes, géolocalisation, mode terrain
|
||||
|
||||
### 🟢 Phase 3 - Mises à jour secondaires (PLANIFIÉ)
|
||||
|
||||
**Statut : ⏳ EN ATTENTE**
|
||||
|
||||
| Package | Actuelle | Cible | Note |
|
||||
|---------|----------|-------|------|
|
||||
| `syncfusion_flutter_charts` | 30.2.7 | 31.1.22 | Mineure |
|
||||
| `package_info_plus` | 4.2.0 | 8.3.1 | Vérifier compatibilité |
|
||||
|
||||
**Packages à jour :**
|
||||
- ✅ `dio: 5.9.0`
|
||||
- ✅ `go_router: 16.2.4`
|
||||
- ✅ `hive: 2.2.3`
|
||||
- ✅ `flutter_stripe: 12.0.2`
|
||||
- ✅ `mek_stripe_terminal: 4.6.0`
|
||||
|
||||
### 📅 Planning des upgrades
|
||||
|
||||
| Phase | Période prévue | Priorité | Effort |
|
||||
|-------|----------------|----------|--------|
|
||||
| Phase 1 | ✅ 06/10/2025 | 🔴 Critique | 1h |
|
||||
| Phase 2 | 10-15/10/2025 | 🟡 Important | 4-6h |
|
||||
| Phase 3 | 20-25/10/2025 | 🟢 Mineur | 2-3h |
|
||||
|
||||
---
|
||||
|
||||
_Document généré le 11 septembre 2025_
|
||||
_Dernière mise à jour le 04 octobre 2025_
|
||||
_Dernière mise à jour le 06 octobre 2025_
|
||||
_Ce document sera mis à jour régulièrement avec l'avancement des développements_
|
||||
|
||||
---
|
||||
|
||||
**GEOSECTOR** - Solution de gestion des distributions de calendriers Amicales de pompiers
|
||||
**GEOSECTOR** - Solution de gestion des distributions de calendriers Amicales de pompiers
|
||||
© 2025 - Tous droits réservés
|
||||
|
||||
@@ -126,7 +126,7 @@ class AppKeys {
|
||||
},
|
||||
2: {
|
||||
'titre': 'Chèque',
|
||||
'couleur': 0xFFD8D5EC, // Violet clair (Figma)
|
||||
'couleur': 0xFF7E57C2, // Violet foncé (Material Design Deep Purple 400)
|
||||
'icon_data': Icons.account_balance_wallet_outlined,
|
||||
},
|
||||
3: {
|
||||
@@ -186,9 +186,9 @@ class AppKeys {
|
||||
6: {
|
||||
'titres': 'Maisons vides',
|
||||
'titre': 'Maison vide',
|
||||
'couleur1': 0xFFB8B8B8, // Gris (Figma)
|
||||
'couleur2': 0xFFB8B8B8, // Gris (Figma)
|
||||
'couleur3': 0xFFB8B8B8, // Gris (Figma)
|
||||
'couleur1': 0xFF757575, // Gris foncé (Material Design 600)
|
||||
'couleur2': 0xFF757575, // Gris foncé (Material Design 600)
|
||||
'couleur3': 0xFF757575, // Gris foncé (Material Design 600)
|
||||
'icon_data': Icons.home_outlined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -140,12 +140,14 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
|
||||
// Sauvegarder aussi dans Hive pour la persistance
|
||||
_saveMemberFilter(widget.memberId!);
|
||||
} else if (!isAdmin) {
|
||||
// Pour un user standard, toujours filtrer sur son propre ID
|
||||
selectedMemberId = currentUserId;
|
||||
} else {
|
||||
// Admin sans memberId spécifique, charger les filtres depuis Hive
|
||||
// Pour tous les autres cas (admin et user), charger les filtres depuis Hive
|
||||
_loadPreselectedFilters();
|
||||
|
||||
// Pour un user standard, toujours filtrer sur son propre ID
|
||||
if (!isAdmin) {
|
||||
selectedMemberId = currentUserId;
|
||||
}
|
||||
}
|
||||
|
||||
_initializeNewFilters();
|
||||
@@ -385,7 +387,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
// Filtre Type de passage
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _selectedTypeFilter,
|
||||
value: _selectedTypeFilter,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
@@ -418,7 +420,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
// Filtre Mode de règlement
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _selectedPaymentFilter,
|
||||
value: _selectedPaymentFilter,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
@@ -473,7 +475,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
final sectors = sectorsBox.values.toList();
|
||||
|
||||
return DropdownButtonFormField<int?>(
|
||||
initialValue: _selectedSectorId,
|
||||
value: _selectedSectorId,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
@@ -520,37 +522,30 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
const SizedBox(width: 12),
|
||||
if (isAdmin)
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<Box<UserModel>>(
|
||||
valueListenable: Hive.box<UserModel>(AppKeys.userBoxName).listenable(),
|
||||
builder: (context, usersBox, child) {
|
||||
final users = usersBox.values.where((user) => user.role == 1).toList();
|
||||
|
||||
return DropdownButtonFormField<int?>(
|
||||
initialValue: _selectedUserId,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
isDense: true,
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem<int?>(
|
||||
value: null,
|
||||
child: Text('Membres'),
|
||||
),
|
||||
...users.map((UserModel user) {
|
||||
return DropdownMenuItem<int?>(
|
||||
value: user.id,
|
||||
child: Text('${user.firstName ?? ''} ${user.name ?? ''}'),
|
||||
);
|
||||
}),
|
||||
],
|
||||
onChanged: (int? newValue) {
|
||||
setState(() {
|
||||
_selectedUserId = newValue;
|
||||
});
|
||||
_notifyFiltersChanged();
|
||||
},
|
||||
);
|
||||
child: DropdownButtonFormField<int?>(
|
||||
value: _selectedUserId,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
isDense: true,
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem<int?>(
|
||||
value: null,
|
||||
child: Text('Membres'),
|
||||
),
|
||||
..._users.map((UserModel user) {
|
||||
return DropdownMenuItem<int?>(
|
||||
value: user.id,
|
||||
child: Text('${user.firstName ?? ''} ${user.name ?? ''}'),
|
||||
);
|
||||
}),
|
||||
],
|
||||
onChanged: (int? newValue) {
|
||||
setState(() {
|
||||
_selectedUserId = newValue;
|
||||
});
|
||||
_notifyFiltersChanged();
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -896,6 +891,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
if (memberId != null && memberId is int) {
|
||||
setState(() {
|
||||
selectedMemberId = memberId;
|
||||
_selectedUserId = memberId; // Synchroniser avec le nouveau filtre
|
||||
});
|
||||
debugPrint('HistoryPage: Membre présélectionné chargé: $memberId');
|
||||
}
|
||||
@@ -906,6 +902,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
if (sectorId != null && sectorId is int) {
|
||||
setState(() {
|
||||
selectedSectorId = sectorId;
|
||||
_selectedSectorId = sectorId; // Synchroniser avec le nouveau filtre
|
||||
});
|
||||
debugPrint('HistoryPage: Secteur présélectionné chargé: $sectorId');
|
||||
}
|
||||
@@ -917,6 +914,10 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
selectedTypeId = typeId;
|
||||
final typeInfo = AppKeys.typesPassages[typeId];
|
||||
selectedType = typeInfo != null ? typeInfo['titre'] as String : 'Inconnu';
|
||||
// Synchroniser avec le nouveau filtre
|
||||
if (typeInfo != null) {
|
||||
_selectedTypeFilter = typeInfo['titre'] as String;
|
||||
}
|
||||
});
|
||||
debugPrint('HistoryPage: Type de passage présélectionné: $typeId');
|
||||
}
|
||||
@@ -926,6 +927,12 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
if (paymentTypeId != null && paymentTypeId is int) {
|
||||
setState(() {
|
||||
selectedPaymentTypeId = paymentTypeId;
|
||||
_selectedPaymentTypeId = paymentTypeId; // Synchroniser avec le nouveau filtre
|
||||
// Mettre à jour aussi le label du filtre
|
||||
final paymentInfo = AppKeys.typesReglements[paymentTypeId];
|
||||
if (paymentInfo != null) {
|
||||
_selectedPaymentFilter = paymentInfo['titre'] as String;
|
||||
}
|
||||
});
|
||||
debugPrint('HistoryPage: Type de règlement présélectionné: $paymentTypeId');
|
||||
}
|
||||
@@ -1592,8 +1599,11 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
orElse: () => PassageModel.fromJson(passageMap),
|
||||
);
|
||||
|
||||
// Vérifier les permissions : admin peut tout éditer, user seulement ses propres passages
|
||||
if (isAdmin || passage.fkUser == currentUserId) {
|
||||
// Vérifier les permissions :
|
||||
// - Admin peut tout éditer
|
||||
// - User peut éditer ses propres passages
|
||||
// - Type 2 (À finaliser) : éditable par tous les utilisateurs
|
||||
if (isAdmin || passage.fkUser == currentUserId || passage.fkType == 2) {
|
||||
_handlePassageEdit(passage);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -93,13 +93,11 @@ class _HomeContentState extends State<HomeContent> {
|
||||
|
||||
// Tableau détaillé des membres - uniquement pour admin sur Web
|
||||
if (isAdmin && kIsWeb) ...[
|
||||
const MembersBoardPassages(
|
||||
height: 700,
|
||||
),
|
||||
const MembersBoardPassages(),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
],
|
||||
|
||||
// LIGNE 2 : Carte de répartition par secteur
|
||||
// LIGNE 2 : Carte de répartition par secteur (uniquement si > 1 secteur)
|
||||
// Le widget filtre automatiquement selon le rôle de l'utilisateur
|
||||
ValueListenableBuilder<Box<SectorModel>>(
|
||||
valueListenable: Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
|
||||
@@ -113,9 +111,13 @@ class _HomeContentState extends State<HomeContent> {
|
||||
sectorCount = userSectors.length;
|
||||
}
|
||||
|
||||
// N'afficher que s'il y a plus d'un secteur
|
||||
if (sectorCount <= 1) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SectorDistributionCard(
|
||||
title: '$sectorCount secteur${sectorCount > 1 ? 's' : ''}',
|
||||
height: 500,
|
||||
title: '$sectorCount secteurs',
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -132,10 +134,9 @@ class _HomeContentState extends State<HomeContent> {
|
||||
child: ActivityChart(
|
||||
height: 350,
|
||||
showAllPassages: isAdmin, // Admin voit tout, user voit tous les passages de ses secteurs
|
||||
title: isAdmin
|
||||
? 'Passages réalisés par jour (15 derniers jours)'
|
||||
: 'Passages de mes secteurs par jour (15 derniers jours)',
|
||||
daysToShow: 15,
|
||||
title: isAdmin ? 'Passages' : 'Mes passages',
|
||||
daysToShow: 7,
|
||||
showPeriodButtons: true,
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/user_sector_model.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Widget de graphique d'activité affichant les passages
|
||||
class ActivityChart extends StatefulWidget {
|
||||
@@ -51,6 +53,9 @@ class ActivityChart extends StatefulWidget {
|
||||
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
|
||||
final bool useValueListenable;
|
||||
|
||||
/// Afficher les boutons de sélection de période (7j, 14j, 21j)
|
||||
final bool showPeriodButtons;
|
||||
|
||||
const ActivityChart({
|
||||
super.key,
|
||||
this.passageData,
|
||||
@@ -66,6 +71,7 @@ class ActivityChart extends StatefulWidget {
|
||||
this.columnSpacing = 0.2,
|
||||
this.showAllPassages = false,
|
||||
this.useValueListenable = true,
|
||||
this.showPeriodButtons = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -94,9 +100,14 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
// Contrôleur de zoom pour le graphique
|
||||
late ZoomPanBehavior _zoomPanBehavior;
|
||||
|
||||
// Période sélectionnée pour le filtre (7, 14 ou 21 jours)
|
||||
late int _selectedDays;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedDays = widget.daysToShow;
|
||||
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
@@ -157,7 +168,7 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final chartData = _calculateActivityData(passagesBox);
|
||||
final chartData = _calculateActivityData(passagesBox, _selectedDays);
|
||||
return _buildChart(chartData);
|
||||
},
|
||||
);
|
||||
@@ -179,7 +190,7 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
}
|
||||
|
||||
/// Calcule les données d'activité depuis la Hive box
|
||||
List<ActivityData> _calculateActivityData(Box<PassageModel> passagesBox) {
|
||||
List<ActivityData> _calculateActivityData(Box<PassageModel> passagesBox, int daysToShow) {
|
||||
try {
|
||||
final passages = passagesBox.values.toList();
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
@@ -187,55 +198,63 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
// Pour les users : récupérer les secteurs assignés
|
||||
Set<int>? userSectorIds;
|
||||
if (!widget.showAllPassages && currentUser != null) {
|
||||
final userSectorBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
|
||||
userSectorIds = userSectorBox.values
|
||||
.where((us) => us.id == currentUser.id)
|
||||
.map((us) => us.fkSector)
|
||||
.toSet();
|
||||
final userSectors = userRepository.getUserSectors();
|
||||
userSectorIds = userSectors.map((sector) => sector.id).toSet();
|
||||
debugPrint('ActivityChart: Mode USER - Secteurs assignés: $userSectorIds');
|
||||
} else {
|
||||
debugPrint('ActivityChart: Mode ADMIN - Tous les passages');
|
||||
}
|
||||
|
||||
// Calculer la date de début (nombre de jours en arrière)
|
||||
final endDate = DateTime.now();
|
||||
final startDate = endDate.subtract(Duration(days: widget.daysToShow - 1));
|
||||
final startDate = endDate.subtract(Duration(days: daysToShow - 1));
|
||||
|
||||
debugPrint('ActivityChart: Période du ${DateFormat('yyyy-MM-dd').format(startDate)} au ${DateFormat('yyyy-MM-dd').format(endDate)}');
|
||||
debugPrint('ActivityChart: Nombre total de passages: ${passages.length}');
|
||||
|
||||
// Préparer les données par date
|
||||
final Map<String, Map<int, int>> dataByDate = {};
|
||||
|
||||
// Initialiser toutes les dates de la période
|
||||
for (int i = 0; i < widget.daysToShow; i++) {
|
||||
for (int i = 0; i < daysToShow; i++) {
|
||||
final date = startDate.add(Duration(days: i));
|
||||
final dateStr = DateFormat('yyyy-MM-dd').format(date);
|
||||
dataByDate[dateStr] = {};
|
||||
|
||||
// Initialiser tous les types de passage possibles
|
||||
for (final typeId in AppKeys.typesPassages.keys) {
|
||||
if (!widget.excludePassageTypes.contains(typeId)) {
|
||||
dataByDate[dateStr]![typeId] = 0;
|
||||
}
|
||||
dataByDate[dateStr]![typeId] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Parcourir les passages et les compter par date et type
|
||||
int includedCount = 0;
|
||||
for (final passage in passages) {
|
||||
// Appliquer les filtres
|
||||
bool shouldInclude = true;
|
||||
String excludeReason = '';
|
||||
|
||||
// Filtrer par secteurs assignés si nécessaire (pour les users)
|
||||
if (userSectorIds != null && !userSectorIds.contains(passage.fkSector)) {
|
||||
shouldInclude = false;
|
||||
excludeReason = 'Secteur ${passage.fkSector} non assigné';
|
||||
}
|
||||
|
||||
// Exclure certains types
|
||||
if (widget.excludePassageTypes.contains(passage.fkType)) {
|
||||
// Exclure les passages de type 2 (À finaliser) avec nbPassages = 0
|
||||
if (shouldInclude && passage.fkType == 2 && passage.nbPassages == 0) {
|
||||
shouldInclude = false;
|
||||
excludeReason = 'Type 2 avec nbPassages=0';
|
||||
}
|
||||
|
||||
// Vérifier si le passage est dans la période
|
||||
final passageDate = passage.passedAt;
|
||||
if (passageDate == null ||
|
||||
if (shouldInclude && (passageDate == null ||
|
||||
passageDate.isBefore(startDate) ||
|
||||
passageDate.isAfter(endDate)) {
|
||||
passageDate.isAfter(endDate))) {
|
||||
shouldInclude = false;
|
||||
excludeReason = passageDate == null
|
||||
? 'Date null'
|
||||
: 'Hors période (${DateFormat('yyyy-MM-dd').format(passageDate)})';
|
||||
}
|
||||
|
||||
if (shouldInclude && passageDate != null) {
|
||||
@@ -243,10 +262,15 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
if (dataByDate.containsKey(dateStr)) {
|
||||
dataByDate[dateStr]![passage.fkType] =
|
||||
(dataByDate[dateStr]![passage.fkType] ?? 0) + 1;
|
||||
includedCount++;
|
||||
}
|
||||
} else if (!shouldInclude && userSectorIds != null) {
|
||||
debugPrint('ActivityChart: Passage #${passage.id} exclu - $excludeReason (type=${passage.fkType}, secteur=${passage.fkSector}, date=${passageDate != null ? DateFormat('yyyy-MM-dd').format(passageDate) : 'null'})');
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('ActivityChart: Passages inclus dans le graphique: $includedCount');
|
||||
|
||||
// Convertir en liste d'ActivityData
|
||||
final List<ActivityData> chartData = [];
|
||||
dataByDate.forEach((dateStr, passagesByType) {
|
||||
@@ -367,16 +391,32 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre
|
||||
// En-tête avec titre et boutons de filtre
|
||||
if (widget.title.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16.0, right: 16.0, top: 16.0, bottom: 8.0),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (widget.showPeriodButtons)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildPeriodButton(7),
|
||||
const SizedBox(width: 4),
|
||||
_buildPeriodButton(14),
|
||||
const SizedBox(width: 4),
|
||||
_buildPeriodButton(21),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Graphique
|
||||
@@ -434,10 +474,8 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
return series;
|
||||
}
|
||||
|
||||
// Obtenir tous les types de passage (sauf ceux exclus)
|
||||
final passageTypes = AppKeys.typesPassages.keys
|
||||
.where((typeId) => !widget.excludePassageTypes.contains(typeId))
|
||||
.toList();
|
||||
// Obtenir tous les types de passage
|
||||
final passageTypes = AppKeys.typesPassages.keys.toList();
|
||||
|
||||
// Créer les séries pour les passages (colonnes empilées)
|
||||
for (final typeId in passageTypes) {
|
||||
@@ -481,6 +519,10 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
),
|
||||
markerSettings: const MarkerSettings(isVisible: false),
|
||||
animationDuration: 1500,
|
||||
// Ajouter le callback de clic uniquement depuis home_page
|
||||
onPointTap: widget.showPeriodButtons ? (ChartPointDetails details) {
|
||||
_handlePointTap(details, typeId);
|
||||
} : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -488,4 +530,86 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
/// Gère le clic sur un point du graphique
|
||||
void _handlePointTap(ChartPointDetails details, int typeId) {
|
||||
if (details.pointIndex == null || details.pointIndex! < 0) return;
|
||||
|
||||
// Récupérer les données du point cliqué
|
||||
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
final passages = passageBox.values.toList();
|
||||
|
||||
// Calculer la date de début (nombre de jours en arrière)
|
||||
final endDate = DateTime.now();
|
||||
final startDate = endDate.subtract(Duration(days: _selectedDays - 1));
|
||||
|
||||
// Créer les données d'activité
|
||||
final chartData = _calculateActivityData(passageBox, _selectedDays);
|
||||
|
||||
if (details.pointIndex! >= chartData.length) return;
|
||||
|
||||
final clickedData = chartData[details.pointIndex!];
|
||||
final clickedDate = clickedData.date;
|
||||
|
||||
// Réinitialiser tous les filtres sauf celui sélectionné
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
settingsBox.delete('history_selectedPaymentTypeId');
|
||||
settingsBox.delete('history_selectedSectorId');
|
||||
settingsBox.delete('history_selectedSectorName');
|
||||
settingsBox.delete('history_selectedMemberId');
|
||||
|
||||
// Sauvegarder le type de passage et les dates (début et fin de journée)
|
||||
settingsBox.put('history_selectedTypeId', typeId);
|
||||
|
||||
// Date de début : début de la journée cliquée
|
||||
final startDateTime = DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 0, 0, 0);
|
||||
settingsBox.put('history_startDate', startDateTime.millisecondsSinceEpoch);
|
||||
|
||||
// Date de fin : fin de la journée cliquée
|
||||
final endDateTime = DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 23, 59, 59);
|
||||
settingsBox.put('history_endDate', endDateTime.millisecondsSinceEpoch);
|
||||
|
||||
// Naviguer vers la page historique
|
||||
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
context.go(isAdmin ? '/admin/history' : '/user/history');
|
||||
}
|
||||
|
||||
/// Construit un bouton de sélection de période
|
||||
Widget _buildPeriodButton(int days) {
|
||||
final isSelected = _selectedDays == days;
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedDays = days;
|
||||
_animationController.reset();
|
||||
_animationController.forward();
|
||||
});
|
||||
widget.onPeriodChanged?.call(days);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade400,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${days}j',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected ? Colors.white : Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
library geosector_charts;
|
||||
|
||||
export 'payment_data.dart';
|
||||
export 'payment_pie_chart.dart';
|
||||
export 'payment_summary_card.dart';
|
||||
export 'passage_data.dart';
|
||||
export 'passage_utils.dart';
|
||||
export 'passage_pie_chart.dart';
|
||||
export 'passage_summary_card.dart';
|
||||
export 'activity_chart.dart';
|
||||
export 'combined_chart.dart';
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
|
||||
/// Modèle de données pour le graphique en camembert des passages
|
||||
class PassageChartData {
|
||||
/// Identifiant du type de passage
|
||||
final int typeId;
|
||||
|
||||
/// Nombre de passages de ce type
|
||||
final int count;
|
||||
|
||||
/// Titre du type de passage
|
||||
final String title;
|
||||
|
||||
/// Couleur associée au type de passage
|
||||
final Color color;
|
||||
|
||||
/// Icône associée au type de passage
|
||||
final IconData icon;
|
||||
|
||||
PassageChartData({
|
||||
required this.typeId,
|
||||
required this.count,
|
||||
required this.title,
|
||||
required this.color,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
/// Widget de graphique en camembert pour représenter la répartition des passages par type
|
||||
class PassagePieChart extends StatefulWidget {
|
||||
/// Liste des données de passages par type sous forme de Map avec typeId et count
|
||||
/// Si useValueListenable est true, ce paramètre est ignoré
|
||||
final Map<int, int> passagesByType;
|
||||
|
||||
/// Taille du graphique
|
||||
final double size;
|
||||
|
||||
/// Taille des étiquettes
|
||||
final double labelSize;
|
||||
|
||||
/// Afficher les pourcentages
|
||||
final bool showPercentage;
|
||||
|
||||
/// Afficher les icônes
|
||||
final bool showIcons;
|
||||
|
||||
/// Afficher la légende
|
||||
final bool showLegend;
|
||||
|
||||
/// Format donut (anneau)
|
||||
final bool isDonut;
|
||||
|
||||
/// Rayon central pour le format donut (en pourcentage)
|
||||
final String innerRadius;
|
||||
|
||||
/// Charger les données depuis Hive (obsolète, utiliser useValueListenable)
|
||||
final bool loadFromHive;
|
||||
|
||||
/// ID de l'utilisateur pour filtrer les passages
|
||||
final int? userId;
|
||||
|
||||
/// Types de passages à exclure
|
||||
final List<int> excludePassageTypes;
|
||||
|
||||
/// Afficher tous les passages sans filtrer par utilisateur
|
||||
final bool showAllPassages;
|
||||
|
||||
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
|
||||
final bool useValueListenable;
|
||||
|
||||
const PassagePieChart({
|
||||
super.key,
|
||||
this.passagesByType = const {},
|
||||
this.size = 300,
|
||||
this.labelSize = 12,
|
||||
this.showPercentage = true,
|
||||
this.showIcons = true,
|
||||
this.showLegend = true,
|
||||
this.isDonut = false,
|
||||
this.innerRadius = '40%',
|
||||
this.loadFromHive = false,
|
||||
this.userId,
|
||||
this.excludePassageTypes = const [2],
|
||||
this.showAllPassages = false,
|
||||
this.useValueListenable = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PassagePieChart> createState() => _PassagePieChartState();
|
||||
}
|
||||
|
||||
class _PassagePieChartState extends State<PassagePieChart>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialiser le contrôleur d'animation
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
);
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PassagePieChart oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Relancer l'animation si les paramètres importants ont changé
|
||||
final bool shouldResetAnimation = oldWidget.userId != widget.userId ||
|
||||
!listEquals(
|
||||
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
|
||||
oldWidget.showAllPassages != widget.showAllPassages ||
|
||||
oldWidget.useValueListenable != widget.useValueListenable;
|
||||
|
||||
if (shouldResetAnimation) {
|
||||
_animationController.reset();
|
||||
_animationController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.useValueListenable) {
|
||||
return _buildWithValueListenable();
|
||||
} else {
|
||||
return _buildWithStaticData();
|
||||
}
|
||||
}
|
||||
|
||||
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
|
||||
Widget _buildWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final chartData = _calculatePassageData(passagesBox);
|
||||
return _buildChart(chartData);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du widget avec des données statiques (ancien système)
|
||||
Widget _buildWithStaticData() {
|
||||
// Vérifier si le type Lot doit être affiché
|
||||
bool showLotType = true;
|
||||
final currentUser = CurrentUserService.instance.currentUser;
|
||||
if (currentUser != null && currentUser.fkEntite != null) {
|
||||
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
||||
if (userAmicale != null) {
|
||||
showLotType = userAmicale.chkLotActif;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer les données pour exclure le type 5 si nécessaire
|
||||
Map<int, int> filteredData = Map.from(widget.passagesByType);
|
||||
if (!showLotType) {
|
||||
filteredData.remove(5);
|
||||
}
|
||||
|
||||
final chartData = _prepareChartDataFromMap(filteredData);
|
||||
return _buildChart(chartData);
|
||||
}
|
||||
|
||||
/// Calcule les données de passage depuis la Hive box
|
||||
List<PassageChartData> _calculatePassageData(Box<PassageModel> passagesBox) {
|
||||
try {
|
||||
final passages = passagesBox.values.toList();
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
|
||||
// Vérifier si le type Lot doit être affiché
|
||||
bool showLotType = true;
|
||||
if (currentUser != null && currentUser.fkEntite != null) {
|
||||
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
||||
if (userAmicale != null) {
|
||||
showLotType = userAmicale.chkLotActif;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculer les données selon les filtres
|
||||
final Map<int, int> passagesByType = {};
|
||||
|
||||
// Initialiser tous les types de passage possibles
|
||||
for (final typeId in AppKeys.typesPassages.keys) {
|
||||
// Exclure le type Lot (5) si chkLotActif = false
|
||||
if (typeId == 5 && !showLotType) {
|
||||
continue;
|
||||
}
|
||||
if (!widget.excludePassageTypes.contains(typeId)) {
|
||||
passagesByType[typeId] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// L'API filtre déjà les passages côté serveur
|
||||
// On compte simplement tous les passages de la box
|
||||
for (final passage in passages) {
|
||||
// Appliquer les filtres locaux uniquement
|
||||
bool shouldInclude = true;
|
||||
|
||||
// Filtrer par userId si spécifié (cas particulier pour compatibilité)
|
||||
if (widget.userId != null) {
|
||||
shouldInclude = passage.fkUser == widget.userId;
|
||||
}
|
||||
|
||||
// Exclure certains types
|
||||
if (widget.excludePassageTypes.contains(passage.fkType)) {
|
||||
shouldInclude = false;
|
||||
}
|
||||
|
||||
// Exclure le type Lot (5) si chkLotActif = false
|
||||
if (passage.fkType == 5 && !showLotType) {
|
||||
shouldInclude = false;
|
||||
}
|
||||
|
||||
if (shouldInclude) {
|
||||
passagesByType[passage.fkType] =
|
||||
(passagesByType[passage.fkType] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return _prepareChartDataFromMap(passagesByType);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du calcul des données de passage: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique en camembert à partir d'une Map
|
||||
List<PassageChartData> _prepareChartDataFromMap(
|
||||
Map<int, int> passagesByType) {
|
||||
final List<PassageChartData> chartData = [];
|
||||
|
||||
// Vérifier si le type Lot doit être affiché
|
||||
bool showLotType = true;
|
||||
final currentUser = CurrentUserService.instance.currentUser;
|
||||
if (currentUser != null && currentUser.fkEntite != null) {
|
||||
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
||||
if (userAmicale != null) {
|
||||
showLotType = userAmicale.chkLotActif;
|
||||
}
|
||||
}
|
||||
|
||||
// Créer les données du graphique
|
||||
passagesByType.forEach((typeId, count) {
|
||||
// Exclure le type Lot (5) si chkLotActif = false
|
||||
if (typeId == 5 && !showLotType) {
|
||||
return; // Skip ce type
|
||||
}
|
||||
|
||||
// Vérifier que le type existe et que le compteur est positif
|
||||
if (count > 0 && AppKeys.typesPassages.containsKey(typeId)) {
|
||||
final typeInfo = AppKeys.typesPassages[typeId]!;
|
||||
|
||||
chartData.add(PassageChartData(
|
||||
typeId: typeId,
|
||||
count: count,
|
||||
title: typeInfo['titre'] as String,
|
||||
color: Color(typeInfo['couleur2'] as int),
|
||||
icon: typeInfo['icon_data'] as IconData,
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
return chartData;
|
||||
}
|
||||
|
||||
/// Construit le graphique avec les données fournies
|
||||
Widget _buildChart(List<PassageChartData> chartData) {
|
||||
// Si aucune donnée, afficher un message
|
||||
if (chartData.isEmpty) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: const Center(
|
||||
child: Text('Aucune donnée disponible'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Créer des animations pour différents aspects du graphique
|
||||
final progressAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
final explodeAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
final opacityAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: SfCircularChart(
|
||||
margin: EdgeInsets.zero,
|
||||
legend: Legend(
|
||||
isVisible: widget.showLegend,
|
||||
position: LegendPosition.bottom,
|
||||
overflowMode: LegendItemOverflowMode.wrap,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
series: <CircularSeries>[
|
||||
widget.isDonut
|
||||
? DoughnutSeries<PassageChartData, String>(
|
||||
dataSource: chartData,
|
||||
xValueMapper: (PassageChartData data, _) => data.title,
|
||||
yValueMapper: (PassageChartData data, _) => data.count,
|
||||
pointColorMapper: (PassageChartData data, _) =>
|
||||
data.color,
|
||||
enableTooltip: true,
|
||||
dataLabelMapper: (PassageChartData data, _) {
|
||||
if (widget.showPercentage) {
|
||||
// Calculer le pourcentage avec une décimale
|
||||
final total = chartData.fold(
|
||||
0, (sum, item) => sum + item.count);
|
||||
final percentage = (data.count / total * 100);
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else {
|
||||
return data.title;
|
||||
}
|
||||
},
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition.outside,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
connectorLineSettings: const ConnectorLineSettings(
|
||||
type: ConnectorType.curve,
|
||||
length: '15%',
|
||||
),
|
||||
),
|
||||
innerRadius: widget.innerRadius,
|
||||
explode: true,
|
||||
explodeIndex: 0,
|
||||
explodeOffset:
|
||||
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
opacity: opacityAnimation.value,
|
||||
animationDuration: 0,
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
)
|
||||
: PieSeries<PassageChartData, String>(
|
||||
dataSource: chartData,
|
||||
xValueMapper: (PassageChartData data, _) => data.title,
|
||||
yValueMapper: (PassageChartData data, _) => data.count,
|
||||
pointColorMapper: (PassageChartData data, _) =>
|
||||
data.color,
|
||||
enableTooltip: true,
|
||||
dataLabelMapper: (PassageChartData data, _) {
|
||||
if (widget.showPercentage) {
|
||||
// Calculer le pourcentage avec une décimale
|
||||
final total = chartData.fold(
|
||||
0, (sum, item) => sum + item.count);
|
||||
final percentage = (data.count / total * 100);
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else {
|
||||
return data.title;
|
||||
}
|
||||
},
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition.outside,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
connectorLineSettings: const ConnectorLineSettings(
|
||||
type: ConnectorType.curve,
|
||||
length: '15%',
|
||||
),
|
||||
),
|
||||
explode: true,
|
||||
explodeIndex: 0,
|
||||
explodeOffset:
|
||||
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
opacity: opacityAnimation.value,
|
||||
animationDuration: 0,
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
),
|
||||
],
|
||||
annotations:
|
||||
widget.showIcons ? _buildIconAnnotations(chartData) : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Crée les annotations d'icônes pour le graphique
|
||||
List<CircularChartAnnotation> _buildIconAnnotations(
|
||||
List<PassageChartData> chartData) {
|
||||
final List<CircularChartAnnotation> annotations = [];
|
||||
|
||||
// Calculer le total pour les pourcentages
|
||||
int total = chartData.fold(0, (sum, item) => sum + item.count);
|
||||
if (total == 0) return []; // Éviter la division par zéro
|
||||
|
||||
// Position angulaire actuelle (en radians)
|
||||
double currentAngle = 0;
|
||||
|
||||
for (int i = 0; i < chartData.length; i++) {
|
||||
final data = chartData[i];
|
||||
final percentage = data.count / total;
|
||||
|
||||
// Calculer l'angle central de ce segment
|
||||
final segmentAngle = percentage * 2 * 3.14159;
|
||||
final midAngle = currentAngle + (segmentAngle / 2);
|
||||
|
||||
// Ajouter une annotation pour l'icône
|
||||
annotations.add(
|
||||
CircularChartAnnotation(
|
||||
widget: Icon(
|
||||
data.icon,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
radius: '50%',
|
||||
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
|
||||
),
|
||||
);
|
||||
|
||||
// Mettre à jour l'angle actuel
|
||||
currentAngle += segmentAngle;
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart';
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Modèle de données pour le graphique en camembert des passages
|
||||
class PassageChartData {
|
||||
final int typeId;
|
||||
final int count;
|
||||
final String title;
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
|
||||
PassageChartData({
|
||||
required this.typeId,
|
||||
required this.count,
|
||||
required this.title,
|
||||
required this.color,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
/// Widget commun pour afficher une carte de synthèse des passages
|
||||
/// avec liste des types à gauche et graphique en camembert à droite
|
||||
class PassageSummaryCard extends StatelessWidget {
|
||||
class PassageSummaryCard extends StatefulWidget {
|
||||
/// Titre de la carte
|
||||
final String title;
|
||||
|
||||
@@ -73,10 +93,51 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
this.backgroundIconSize = 180,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PassageSummaryCard> createState() => _PassageSummaryCardState();
|
||||
}
|
||||
|
||||
class _PassageSummaryCardState extends State<PassageSummaryCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
);
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PassageSummaryCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Relancer l'animation si les paramètres importants ont changé
|
||||
final bool shouldResetAnimation = oldWidget.userId != widget.userId ||
|
||||
!listEquals(
|
||||
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
|
||||
oldWidget.showAllPassages != widget.showAllPassages ||
|
||||
oldWidget.useValueListenable != widget.useValueListenable;
|
||||
|
||||
if (shouldResetAnimation) {
|
||||
_animationController.reset();
|
||||
_animationController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Si useValueListenable, construire avec ValueListenableBuilder centralisé
|
||||
if (useValueListenable) {
|
||||
if (widget.useValueListenable) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
@@ -93,11 +154,11 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
);
|
||||
} else {
|
||||
// Données statiques
|
||||
final totalPassages = passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
|
||||
final totalPassages = widget.passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
|
||||
return _buildCardContent(
|
||||
context,
|
||||
totalUserPassages: totalPassages,
|
||||
passagesCounts: passagesByType ?? {},
|
||||
passagesCounts: widget.passagesByType ?? {},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -116,20 +177,20 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
// Icône d'arrière-plan (optionnelle)
|
||||
if (backgroundIcon != null)
|
||||
if (widget.backgroundIcon != null)
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Icon(
|
||||
backgroundIcon,
|
||||
size: backgroundIconSize,
|
||||
color: (backgroundIconColor ?? AppTheme.primaryColor)
|
||||
.withValues(alpha: backgroundIconOpacity),
|
||||
widget.backgroundIcon,
|
||||
size: widget.backgroundIconSize,
|
||||
color: (widget.backgroundIconColor ?? AppTheme.primaryColor)
|
||||
.withValues(alpha: widget.backgroundIconOpacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Contenu principal
|
||||
Container(
|
||||
height: height,
|
||||
height: widget.height,
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -145,32 +206,19 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
children: [
|
||||
// Liste des passages à gauche
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
flex: widget.isDesktop ? 1 : 2,
|
||||
child: _buildPassagesList(context, passagesCounts),
|
||||
),
|
||||
|
||||
// Séparateur vertical
|
||||
if (isDesktop) const VerticalDivider(width: 24),
|
||||
if (widget.isDesktop) const VerticalDivider(width: 24),
|
||||
|
||||
// Graphique en camembert à droite
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
flex: widget.isDesktop ? 1 : 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: PassagePieChart(
|
||||
useValueListenable: false, // Utilise les données calculées
|
||||
passagesByType: passagesCounts,
|
||||
excludePassageTypes: excludePassageTypes,
|
||||
showAllPassages: showAllPassages,
|
||||
userId: showAllPassages ? null : userId,
|
||||
size: double.infinity,
|
||||
labelSize: 12,
|
||||
showPercentage: true,
|
||||
showIcons: false,
|
||||
showLegend: false,
|
||||
isDonut: true,
|
||||
innerRadius: '50%',
|
||||
),
|
||||
child: _buildPieChart(passagesCounts),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -189,17 +237,17 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
Widget _buildTitle(BuildContext context, int totalUserPassages) {
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
if (widget.titleIcon != null) ...[
|
||||
Icon(
|
||||
titleIcon,
|
||||
color: titleColor,
|
||||
widget.titleIcon,
|
||||
color: widget.titleColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 16),
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -207,19 +255,38 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(totalUserPassages) ??
|
||||
widget.customTotalDisplay?.call(totalUserPassages) ??
|
||||
totalUserPassages.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 20),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: titleColor,
|
||||
color: widget.titleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction de la liste des passages
|
||||
/// Gérer le clic sur un type de passage
|
||||
void _handlePassageTypeClick(int typeId) {
|
||||
// Réinitialiser TOUS les filtres avant de sauvegarder le nouveau
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
settingsBox.delete('history_selectedPaymentTypeId');
|
||||
settingsBox.delete('history_selectedSectorId');
|
||||
settingsBox.delete('history_selectedSectorName');
|
||||
settingsBox.delete('history_selectedMemberId');
|
||||
settingsBox.delete('history_startDate');
|
||||
settingsBox.delete('history_endDate');
|
||||
|
||||
// Sauvegarder uniquement le type de passage sélectionné
|
||||
settingsBox.put('history_selectedTypeId', typeId);
|
||||
|
||||
// Naviguer directement vers la page historique
|
||||
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
context.go(isAdmin ? '/admin/history' : '/user/history');
|
||||
}
|
||||
|
||||
/// Construction de la liste des passages (avec clics)
|
||||
Widget _buildPassagesList(BuildContext context, Map<int, int> passagesCounts) {
|
||||
// Vérifier si le type Lot doit être affiché
|
||||
bool showLotType = true;
|
||||
@@ -249,37 +316,44 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
iconData,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => _handlePassageTypeClick(typeId),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
iconData,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
typeData['titres'] as String,
|
||||
style: TextStyle(fontSize: AppTheme.r(context, 14)),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
count.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 16),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
typeData['titres'] as String,
|
||||
style: TextStyle(fontSize: AppTheme.r(context, 14)),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
count.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 16),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -287,6 +361,95 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du graphique en camembert (avec clics)
|
||||
Widget _buildPieChart(Map<int, int> passagesCounts) {
|
||||
final chartData = _prepareChartDataFromMap(passagesCounts);
|
||||
|
||||
// Si aucune donnée, afficher un message
|
||||
if (chartData.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Aucune donnée disponible'),
|
||||
);
|
||||
}
|
||||
|
||||
// Créer des animations
|
||||
final progressAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
final explodeAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
final opacityAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return SfCircularChart(
|
||||
margin: EdgeInsets.zero,
|
||||
legend: Legend(
|
||||
isVisible: false,
|
||||
position: LegendPosition.bottom,
|
||||
overflowMode: LegendItemOverflowMode.wrap,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
onSelectionChanged: (SelectionArgs args) {
|
||||
// Gérer le clic sur un segment du graphique
|
||||
final pointIndex = args.pointIndex;
|
||||
if (pointIndex < chartData.length) {
|
||||
final selectedData = chartData[pointIndex];
|
||||
_handlePassageTypeClick(selectedData.typeId);
|
||||
}
|
||||
},
|
||||
series: <CircularSeries>[
|
||||
DoughnutSeries<PassageChartData, String>(
|
||||
dataSource: chartData,
|
||||
xValueMapper: (PassageChartData data, _) => data.title,
|
||||
yValueMapper: (PassageChartData data, _) => data.count,
|
||||
pointColorMapper: (PassageChartData data, _) => data.color,
|
||||
enableTooltip: true,
|
||||
selectionBehavior: SelectionBehavior(
|
||||
enable: true,
|
||||
selectedColor: null, // Garde la couleur d'origine
|
||||
unselectedOpacity: 0.5,
|
||||
),
|
||||
dataLabelMapper: (PassageChartData data, _) {
|
||||
// Calculer le pourcentage avec une décimale
|
||||
final total = chartData.fold(0, (sum, item) => sum + item.count);
|
||||
final percentage = (data.count / total * 100);
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
},
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition.outside,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
connectorLineSettings: const ConnectorLineSettings(
|
||||
type: ConnectorType.curve,
|
||||
length: '15%',
|
||||
),
|
||||
),
|
||||
innerRadius: '50%',
|
||||
explode: true,
|
||||
explodeIndex: 0,
|
||||
explodeOffset: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
opacity: opacityAnimation.value,
|
||||
animationDuration: 0,
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Calcule les compteurs de passages par type
|
||||
Map<int, int> _calculatePassagesCounts(Box<PassageModel> passagesBox) {
|
||||
final Map<int, int> counts = {};
|
||||
@@ -308,7 +471,7 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
continue;
|
||||
}
|
||||
// Exclure les types non désirés
|
||||
if (excludePassageTypes.contains(typeId)) {
|
||||
if (widget.excludePassageTypes.contains(typeId)) {
|
||||
continue;
|
||||
}
|
||||
counts[typeId] = 0;
|
||||
@@ -322,7 +485,7 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
continue;
|
||||
}
|
||||
// Exclure les types non désirés
|
||||
if (excludePassageTypes.contains(passage.fkType)) {
|
||||
if (widget.excludePassageTypes.contains(passage.fkType)) {
|
||||
continue;
|
||||
}
|
||||
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
|
||||
@@ -330,4 +493,42 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique en camembert à partir d'une Map
|
||||
List<PassageChartData> _prepareChartDataFromMap(Map<int, int> passagesByType) {
|
||||
final List<PassageChartData> chartData = [];
|
||||
|
||||
// Vérifier si le type Lot doit être affiché
|
||||
bool showLotType = true;
|
||||
final currentUser = CurrentUserService.instance.currentUser;
|
||||
if (currentUser != null && currentUser.fkEntite != null) {
|
||||
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
||||
if (userAmicale != null) {
|
||||
showLotType = userAmicale.chkLotActif;
|
||||
}
|
||||
}
|
||||
|
||||
// Créer les données du graphique
|
||||
passagesByType.forEach((typeId, count) {
|
||||
// Exclure le type Lot (5) si chkLotActif = false
|
||||
if (typeId == 5 && !showLotType) {
|
||||
return; // Skip ce type
|
||||
}
|
||||
|
||||
// Vérifier que le type existe et que le compteur est positif
|
||||
if (count > 0 && AppKeys.typesPassages.containsKey(typeId)) {
|
||||
final typeInfo = AppKeys.typesPassages[typeId]!;
|
||||
|
||||
chartData.add(PassageChartData(
|
||||
typeId: typeId,
|
||||
count: count,
|
||||
title: typeInfo['titre'] as String,
|
||||
color: Color(typeInfo['couleur2'] as int),
|
||||
icon: typeInfo['icon_data'] as IconData,
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
return chartData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Widget de graphique en camembert pour représenter la répartition des règlements
|
||||
class PaymentPieChart extends StatefulWidget {
|
||||
/// Liste des données de règlement à afficher dans le graphique
|
||||
/// Si useValueListenable est true, ce paramètre est ignoré
|
||||
final List<PaymentData> payments;
|
||||
|
||||
/// Taille du graphique
|
||||
final double size;
|
||||
|
||||
/// Taille des étiquettes
|
||||
final double labelSize;
|
||||
|
||||
/// Afficher les pourcentages
|
||||
final bool showPercentage;
|
||||
|
||||
/// Afficher les icônes
|
||||
final bool showIcons;
|
||||
|
||||
/// Afficher la légende
|
||||
final bool showLegend;
|
||||
|
||||
/// Format donut (anneau)
|
||||
final bool isDonut;
|
||||
|
||||
/// Rayon central pour le format donut (en pourcentage)
|
||||
final String innerRadius;
|
||||
|
||||
/// Activer l'effet 3D
|
||||
final bool enable3DEffect;
|
||||
|
||||
/// Intensité de l'effet 3D (1.0 = normal, 2.0 = fort)
|
||||
final double effect3DIntensity;
|
||||
|
||||
/// Activer l'effet d'explosion plus prononcé
|
||||
final bool enableEnhancedExplode;
|
||||
|
||||
/// Utiliser un dégradé pour simuler l'effet 3D
|
||||
final bool useGradient;
|
||||
|
||||
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
|
||||
final bool useValueListenable;
|
||||
|
||||
/// ID de l'utilisateur pour filtrer les passages
|
||||
final int? userId;
|
||||
|
||||
/// Afficher tous les passages (admin) ou seulement ceux de l'utilisateur
|
||||
final bool showAllPassages;
|
||||
|
||||
const PaymentPieChart({
|
||||
super.key,
|
||||
this.payments = const [],
|
||||
this.size = 300,
|
||||
this.labelSize = 12,
|
||||
this.showPercentage = true,
|
||||
this.showIcons = true,
|
||||
this.showLegend = true,
|
||||
this.isDonut = false,
|
||||
this.innerRadius = '40%',
|
||||
this.enable3DEffect = false,
|
||||
this.effect3DIntensity = 1.0,
|
||||
this.enableEnhancedExplode = false,
|
||||
this.useGradient = false,
|
||||
this.useValueListenable = true,
|
||||
this.userId,
|
||||
this.showAllPassages = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentPieChart> createState() => _PaymentPieChartState();
|
||||
}
|
||||
|
||||
class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
);
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PaymentPieChart oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Relancer l'animation si les paramètres importants ont changé
|
||||
bool shouldResetAnimation = false;
|
||||
|
||||
if (widget.useValueListenable != oldWidget.useValueListenable ||
|
||||
widget.userId != oldWidget.userId ||
|
||||
widget.showAllPassages != oldWidget.showAllPassages) {
|
||||
shouldResetAnimation = true;
|
||||
} else if (!widget.useValueListenable) {
|
||||
// Pour les données statiques, comparer les éléments
|
||||
if (oldWidget.payments.length != widget.payments.length) {
|
||||
shouldResetAnimation = true;
|
||||
} else {
|
||||
for (int i = 0; i < oldWidget.payments.length; i++) {
|
||||
if (i >= widget.payments.length) break;
|
||||
if (oldWidget.payments[i].amount != widget.payments[i].amount ||
|
||||
oldWidget.payments[i].title != widget.payments[i].title) {
|
||||
shouldResetAnimation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldResetAnimation) {
|
||||
_animationController.reset();
|
||||
_animationController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.useValueListenable) {
|
||||
return _buildWithValueListenable();
|
||||
} else {
|
||||
return _buildWithStaticData();
|
||||
}
|
||||
}
|
||||
|
||||
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
|
||||
Widget _buildWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final paymentData = _calculatePaymentData(passagesBox);
|
||||
return _buildChart(paymentData);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du widget avec des données statiques
|
||||
Widget _buildWithStaticData() {
|
||||
return _buildChart(widget.payments);
|
||||
}
|
||||
|
||||
/// Calcule les données de règlement depuis la Hive box
|
||||
List<PaymentData> _calculatePaymentData(Box<PassageModel> passagesBox) {
|
||||
try {
|
||||
final passages = passagesBox.values.toList();
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
|
||||
// Initialiser les montants par type de règlement
|
||||
final Map<int, double> paymentAmounts = {
|
||||
0: 0.0, // Pas de règlement
|
||||
1: 0.0, // Espèces
|
||||
2: 0.0, // Chèques
|
||||
3: 0.0, // CB
|
||||
};
|
||||
|
||||
// Déterminer le filtre utilisateur : en mode user, on filtre par fkUser
|
||||
final int? filterUserId = widget.showAllPassages
|
||||
? null
|
||||
: (widget.userId ?? currentUser?.id);
|
||||
|
||||
for (final passage in passages) {
|
||||
// En mode user, ne compter que les passages de l'utilisateur
|
||||
if (filterUserId != null && passage.fkUser != filterUserId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final int typeReglement = passage.fkTypeReglement;
|
||||
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
// Gérer les formats possibles (virgule ou point)
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de conversion du montant: ${passage.montant}');
|
||||
}
|
||||
|
||||
// Ne compter que les passages avec un montant > 0
|
||||
if (montant > 0) {
|
||||
// Ajouter au montant total par type de règlement
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] =
|
||||
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir le Map en List<PaymentData>
|
||||
final List<PaymentData> paymentDataList = [];
|
||||
|
||||
paymentAmounts.forEach((typeReglement, montant) {
|
||||
if (montant > 0) {
|
||||
// Ne retourner que les types avec un montant > 0
|
||||
// Récupérer les informations depuis AppKeys.typesReglements
|
||||
final reglementInfo = AppKeys.typesReglements[typeReglement];
|
||||
|
||||
if (reglementInfo != null) {
|
||||
paymentDataList.add(PaymentData(
|
||||
typeId: typeReglement,
|
||||
title: reglementInfo['titre'] as String,
|
||||
amount: montant,
|
||||
color: Color(reglementInfo['couleur'] as int),
|
||||
icon: reglementInfo['icon_data'] as IconData,
|
||||
));
|
||||
} else {
|
||||
// Fallback pour les types non définis
|
||||
paymentDataList.add(PaymentData(
|
||||
typeId: typeReglement,
|
||||
title: 'Type inconnu',
|
||||
amount: montant,
|
||||
color: Colors.grey,
|
||||
icon: Icons.help_outline,
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return paymentDataList;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du calcul des données de règlement: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit le graphique avec les données fournies
|
||||
Widget _buildChart(List<PaymentData> paymentData) {
|
||||
final chartData = _prepareChartData(paymentData);
|
||||
|
||||
// Si aucune donnée, afficher un message
|
||||
if (chartData.isEmpty) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: const Center(
|
||||
child: Text('Aucune donnée disponible'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Créer des animations pour différents aspects du graphique
|
||||
final progressAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
final explodeAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
final opacityAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: SfCircularChart(
|
||||
margin: EdgeInsets.zero,
|
||||
legend: Legend(
|
||||
isVisible: widget.showLegend,
|
||||
position: LegendPosition.bottom,
|
||||
overflowMode: LegendItemOverflowMode.wrap,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
series: <CircularSeries>[
|
||||
widget.isDonut
|
||||
? DoughnutSeries<PaymentData, String>(
|
||||
dataSource: chartData,
|
||||
xValueMapper: (PaymentData data, _) => data.title,
|
||||
yValueMapper: (PaymentData data, _) => data.amount,
|
||||
pointColorMapper: (PaymentData data, _) {
|
||||
if (widget.enable3DEffect) {
|
||||
final index = chartData.indexOf(data);
|
||||
final angle =
|
||||
(index / chartData.length) * 2 * math.pi;
|
||||
return widget.useGradient
|
||||
? _createEnhanced3DColor(data.color, angle)
|
||||
: _create3DColor(
|
||||
data.color, widget.effect3DIntensity);
|
||||
}
|
||||
return data.color;
|
||||
},
|
||||
enableTooltip: true,
|
||||
dataLabelMapper: (PaymentData data, _) {
|
||||
if (widget.showPercentage) {
|
||||
final total = chartData.fold(
|
||||
0.0, (sum, item) => sum + item.amount);
|
||||
final percentage = (data.amount / total * 100);
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else {
|
||||
return data.title;
|
||||
}
|
||||
},
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition.inside,
|
||||
textStyle: TextStyle(
|
||||
fontSize: widget.labelSize,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
innerRadius: widget.innerRadius,
|
||||
explode: true,
|
||||
explodeAll: widget.enableEnhancedExplode,
|
||||
explodeIndex: widget.enableEnhancedExplode ? null : 0,
|
||||
explodeOffset: widget.enableEnhancedExplode
|
||||
? widget.enable3DEffect
|
||||
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
opacity: widget.enable3DEffect
|
||||
? 0.95 * opacityAnimation.value
|
||||
: opacityAnimation.value,
|
||||
animationDuration: 0,
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
)
|
||||
: PieSeries<PaymentData, String>(
|
||||
dataSource: chartData,
|
||||
xValueMapper: (PaymentData data, _) => data.title,
|
||||
yValueMapper: (PaymentData data, _) => data.amount,
|
||||
pointColorMapper: (PaymentData data, _) {
|
||||
if (widget.enable3DEffect) {
|
||||
final index = chartData.indexOf(data);
|
||||
final angle =
|
||||
(index / chartData.length) * 2 * math.pi;
|
||||
return widget.useGradient
|
||||
? _createEnhanced3DColor(data.color, angle)
|
||||
: _create3DColor(
|
||||
data.color, widget.effect3DIntensity);
|
||||
}
|
||||
return data.color;
|
||||
},
|
||||
enableTooltip: true,
|
||||
dataLabelMapper: (PaymentData data, _) {
|
||||
if (widget.showPercentage) {
|
||||
final total = chartData.fold(
|
||||
0.0, (sum, item) => sum + item.amount);
|
||||
final percentage = (data.amount / total * 100);
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
} else {
|
||||
return data.title;
|
||||
}
|
||||
},
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition.outside,
|
||||
textStyle: TextStyle(fontSize: widget.labelSize),
|
||||
connectorLineSettings: const ConnectorLineSettings(
|
||||
type: ConnectorType.curve,
|
||||
length: '15%',
|
||||
),
|
||||
),
|
||||
explode: true,
|
||||
explodeAll: widget.enableEnhancedExplode,
|
||||
explodeIndex: widget.enableEnhancedExplode ? null : 0,
|
||||
explodeOffset: widget.enableEnhancedExplode
|
||||
? widget.enable3DEffect
|
||||
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
opacity: widget.enable3DEffect
|
||||
? 0.95 * opacityAnimation.value
|
||||
: opacityAnimation.value,
|
||||
animationDuration: 0,
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
),
|
||||
],
|
||||
annotations:
|
||||
widget.showIcons ? _buildIconAnnotations(chartData) : null,
|
||||
palette: widget.enable3DEffect ? _create3DPalette(chartData) : null,
|
||||
borderWidth: widget.enable3DEffect ? 0.5 : 0,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique en camembert
|
||||
List<PaymentData> _prepareChartData(List<PaymentData> payments) {
|
||||
// Filtrer les règlements avec un montant > 0
|
||||
return payments.where((payment) => payment.amount > 0).toList();
|
||||
}
|
||||
|
||||
/// Crée une couleur avec effet 3D en ajustant les nuances
|
||||
Color _create3DColor(Color baseColor, double intensity) {
|
||||
final hslColor = HSLColor.fromColor(baseColor);
|
||||
final adjustedLightness =
|
||||
(hslColor.lightness + 0.15 * intensity).clamp(0.0, 1.0);
|
||||
final adjustedSaturation =
|
||||
(hslColor.saturation + 0.05 * intensity).clamp(0.0, 1.0);
|
||||
|
||||
return hslColor
|
||||
.withLightness(adjustedLightness)
|
||||
.withSaturation(adjustedSaturation)
|
||||
.toColor();
|
||||
}
|
||||
|
||||
/// Crée une palette de couleurs pour l'effet 3D
|
||||
List<Color> _create3DPalette(List<PaymentData> chartData) {
|
||||
List<Color> palette = [];
|
||||
|
||||
for (var i = 0; i < chartData.length; i++) {
|
||||
var data = chartData[i];
|
||||
final angle = (i / chartData.length) * 2 * math.pi;
|
||||
final hslColor = HSLColor.fromColor(data.color);
|
||||
|
||||
final lightAdjustment = 0.15 * widget.effect3DIntensity * math.sin(angle);
|
||||
final adjustedLightness = (hslColor.lightness -
|
||||
0.1 * widget.effect3DIntensity +
|
||||
lightAdjustment)
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
final adjustedSaturation =
|
||||
(hslColor.saturation + 0.1 * widget.effect3DIntensity)
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
final enhancedColor = hslColor
|
||||
.withLightness(adjustedLightness)
|
||||
.withSaturation(adjustedSaturation)
|
||||
.toColor();
|
||||
|
||||
palette.add(enhancedColor);
|
||||
}
|
||||
|
||||
return palette;
|
||||
}
|
||||
|
||||
/// Crée une couleur avec effet 3D plus avancé
|
||||
Color _createEnhanced3DColor(Color baseColor, double angle) {
|
||||
final hslColor = HSLColor.fromColor(baseColor);
|
||||
final adjustedLightness = hslColor.lightness +
|
||||
(0.2 * widget.effect3DIntensity * math.sin(angle)).clamp(-0.3, 0.3);
|
||||
|
||||
return hslColor.withLightness(adjustedLightness.clamp(0.0, 1.0)).toColor();
|
||||
}
|
||||
|
||||
/// Crée les annotations d'icônes pour le graphique
|
||||
List<CircularChartAnnotation> _buildIconAnnotations(
|
||||
List<PaymentData> chartData) {
|
||||
final List<CircularChartAnnotation> annotations = [];
|
||||
|
||||
double total = chartData.fold(0.0, (sum, item) => sum + item.amount);
|
||||
double currentAngle = 0;
|
||||
|
||||
for (int i = 0; i < chartData.length; i++) {
|
||||
final data = chartData[i];
|
||||
final percentage = data.amount / total;
|
||||
final segmentAngle = percentage * 2 * 3.14159;
|
||||
final midAngle = currentAngle + (segmentAngle / 2);
|
||||
|
||||
annotations.add(
|
||||
CircularChartAnnotation(
|
||||
widget: Icon(
|
||||
data.icon,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
radius: '50%',
|
||||
angle: (midAngle * (180 / 3.14159)).toInt(),
|
||||
),
|
||||
);
|
||||
|
||||
currentAngle += segmentAngle;
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Widget commun pour afficher une carte de synthèse des règlements
|
||||
/// avec liste des types à gauche et graphique en camembert à droite
|
||||
class PaymentSummaryCard extends StatelessWidget {
|
||||
class PaymentSummaryCard extends StatefulWidget {
|
||||
/// Titre de la carte
|
||||
final String title;
|
||||
|
||||
@@ -70,10 +72,49 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
this.backgroundIconSize = 180,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentSummaryCard> createState() => _PaymentSummaryCardState();
|
||||
}
|
||||
|
||||
class _PaymentSummaryCardState extends State<PaymentSummaryCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
);
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PaymentSummaryCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Relancer l'animation si les paramètres importants ont changé
|
||||
final bool shouldResetAnimation = oldWidget.userId != widget.userId ||
|
||||
oldWidget.showAllPayments != widget.showAllPayments ||
|
||||
oldWidget.useValueListenable != widget.useValueListenable;
|
||||
|
||||
if (shouldResetAnimation) {
|
||||
_animationController.reset();
|
||||
_animationController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Si useValueListenable, construire avec ValueListenableBuilder centralisé
|
||||
if (useValueListenable) {
|
||||
if (widget.useValueListenable) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
@@ -90,11 +131,11 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
);
|
||||
} else {
|
||||
// Données statiques
|
||||
final totalAmount = paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
|
||||
final totalAmount = widget.paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
|
||||
return _buildCardContent(
|
||||
context,
|
||||
totalAmount: totalAmount,
|
||||
paymentAmounts: paymentsByType ?? {},
|
||||
paymentAmounts: widget.paymentsByType ?? {},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,20 +154,20 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
// Icône d'arrière-plan (optionnelle)
|
||||
if (backgroundIcon != null)
|
||||
if (widget.backgroundIcon != null)
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Icon(
|
||||
backgroundIcon,
|
||||
size: backgroundIconSize,
|
||||
color: (backgroundIconColor ?? Colors.blue)
|
||||
.withValues(alpha: backgroundIconOpacity),
|
||||
widget.backgroundIcon,
|
||||
size: widget.backgroundIconSize,
|
||||
color: (widget.backgroundIconColor ?? Colors.blue)
|
||||
.withValues(alpha: widget.backgroundIconOpacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Contenu principal
|
||||
Container(
|
||||
height: height,
|
||||
height: widget.height,
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -142,35 +183,19 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
children: [
|
||||
// Liste des règlements à gauche
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
flex: widget.isDesktop ? 1 : 2,
|
||||
child: _buildPaymentsList(context, paymentAmounts),
|
||||
),
|
||||
|
||||
// Séparateur vertical
|
||||
if (isDesktop) const VerticalDivider(width: 24),
|
||||
if (widget.isDesktop) const VerticalDivider(width: 24),
|
||||
|
||||
// Graphique en camembert à droite
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
flex: widget.isDesktop ? 1 : 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: PaymentPieChart(
|
||||
useValueListenable: false, // Utilise les données calculées
|
||||
payments: _convertMapToPaymentData(paymentAmounts),
|
||||
showAllPassages: showAllPayments,
|
||||
userId: showAllPayments ? null : userId,
|
||||
size: double.infinity,
|
||||
labelSize: 12,
|
||||
showPercentage: true,
|
||||
showIcons: false,
|
||||
showLegend: false,
|
||||
isDonut: true,
|
||||
innerRadius: '50%',
|
||||
enable3DEffect: false,
|
||||
effect3DIntensity: 0.0,
|
||||
enableEnhancedExplode: false,
|
||||
useGradient: false,
|
||||
),
|
||||
child: _buildPieChart(paymentAmounts),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -189,17 +214,17 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
Widget _buildTitle(BuildContext context, double totalAmount) {
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
if (widget.titleIcon != null) ...[
|
||||
Icon(
|
||||
titleIcon,
|
||||
color: titleColor,
|
||||
widget.titleIcon,
|
||||
color: widget.titleColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 16),
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -207,19 +232,38 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(totalAmount) ??
|
||||
widget.customTotalDisplay?.call(totalAmount) ??
|
||||
'${totalAmount.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 20),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: titleColor,
|
||||
color: widget.titleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction de la liste des règlements
|
||||
/// Gérer le clic sur un type de règlement
|
||||
void _handlePaymentTypeClick(int typeId) {
|
||||
// Réinitialiser TOUS les filtres avant de sauvegarder le nouveau
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
settingsBox.delete('history_selectedTypeId');
|
||||
settingsBox.delete('history_selectedSectorId');
|
||||
settingsBox.delete('history_selectedSectorName');
|
||||
settingsBox.delete('history_selectedMemberId');
|
||||
settingsBox.delete('history_startDate');
|
||||
settingsBox.delete('history_endDate');
|
||||
|
||||
// Sauvegarder uniquement le type de règlement sélectionné
|
||||
settingsBox.put('history_selectedPaymentTypeId', typeId);
|
||||
|
||||
// Naviguer directement vers la page historique
|
||||
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
context.go(isAdmin ? '/admin/history' : '/user/history');
|
||||
}
|
||||
|
||||
/// Construction de la liste des règlements (avec clics)
|
||||
Widget _buildPaymentsList(BuildContext context, Map<int, double> paymentAmounts) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -233,37 +277,44 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
iconData,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => _handlePaymentTypeClick(typeId),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
iconData,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
typeData['titre'] as String,
|
||||
style: TextStyle(fontSize: AppTheme.r(context, 14)),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${amount.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 16),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
typeData['titre'] as String,
|
||||
style: TextStyle(fontSize: AppTheme.r(context, 14)),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${amount.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 16),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -271,6 +322,95 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du graphique en camembert (avec clics)
|
||||
Widget _buildPieChart(Map<int, double> paymentAmounts) {
|
||||
final chartData = _prepareChartDataFromMap(paymentAmounts);
|
||||
|
||||
// Si aucune donnée, afficher un message
|
||||
if (chartData.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Aucune donnée disponible'),
|
||||
);
|
||||
}
|
||||
|
||||
// Créer des animations
|
||||
final progressAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
final explodeAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.7, 1.0, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
final opacityAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.1, 0.5, curve: Curves.easeIn),
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return SfCircularChart(
|
||||
margin: EdgeInsets.zero,
|
||||
legend: Legend(
|
||||
isVisible: false,
|
||||
position: LegendPosition.bottom,
|
||||
overflowMode: LegendItemOverflowMode.wrap,
|
||||
textStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
onSelectionChanged: (SelectionArgs args) {
|
||||
// Gérer le clic sur un segment du graphique
|
||||
final pointIndex = args.pointIndex;
|
||||
if (pointIndex < chartData.length) {
|
||||
final selectedData = chartData[pointIndex];
|
||||
_handlePaymentTypeClick(selectedData.typeId);
|
||||
}
|
||||
},
|
||||
series: <CircularSeries>[
|
||||
DoughnutSeries<PaymentData, String>(
|
||||
dataSource: chartData,
|
||||
xValueMapper: (PaymentData data, _) => data.title,
|
||||
yValueMapper: (PaymentData data, _) => data.amount,
|
||||
pointColorMapper: (PaymentData data, _) => data.color,
|
||||
enableTooltip: true,
|
||||
selectionBehavior: SelectionBehavior(
|
||||
enable: true,
|
||||
selectedColor: null, // Garde la couleur d'origine
|
||||
unselectedOpacity: 0.5,
|
||||
),
|
||||
dataLabelMapper: (PaymentData data, _) {
|
||||
// Calculer le pourcentage avec une décimale
|
||||
final total = chartData.fold(0.0, (sum, item) => sum + item.amount);
|
||||
final percentage = (data.amount / total * 100);
|
||||
return '${percentage.toStringAsFixed(1)}%';
|
||||
},
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition.inside,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
innerRadius: '50%',
|
||||
explode: true,
|
||||
explodeIndex: 0,
|
||||
explodeOffset: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
opacity: opacityAnimation.value,
|
||||
animationDuration: 0,
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Calcule les montants par type de règlement
|
||||
Map<int, double> _calculatePaymentAmounts(Box<PassageModel> passagesBox) {
|
||||
final Map<int, double> paymentAmounts = {};
|
||||
@@ -282,7 +422,7 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
|
||||
// En mode user, filtrer uniquement les passages créés par l'utilisateur (fkUser)
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final int? filterUserId = showAllPayments ? null : currentUser?.id;
|
||||
final int? filterUserId = widget.showAllPayments ? null : currentUser?.id;
|
||||
|
||||
for (final passage in passagesBox.values) {
|
||||
// En mode user, ne compter que les passages de l'utilisateur
|
||||
@@ -314,8 +454,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
return paymentAmounts;
|
||||
}
|
||||
|
||||
/// Convertit une Map<int, double> en List<PaymentData> pour les données statiques
|
||||
List<PaymentData> _convertMapToPaymentData(Map<int, double> paymentsMap) {
|
||||
/// Prépare les données pour le graphique en camembert à partir d'une Map
|
||||
List<PaymentData> _prepareChartDataFromMap(Map<int, double> paymentsMap) {
|
||||
final List<PaymentData> paymentDataList = [];
|
||||
|
||||
paymentsMap.forEach((typeReglement, montant) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_cache/flutter_map_cache.dart';
|
||||
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
||||
import 'package:http_cache_hive_store/http_cache_hive_store.dart'; // Mise à jour v2.0.0 (06/10/2025)
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
|
||||
@@ -47,9 +47,6 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: widget.height ?? 700, // Hauteur max, sinon s'adapte au contenu
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
@@ -57,6 +54,7 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// En-tête de la card
|
||||
Container(
|
||||
@@ -88,8 +86,7 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
||||
),
|
||||
|
||||
// Corps avec le tableau
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<Box<MembreModel>>(
|
||||
ValueListenableBuilder<Box<MembreModel>>(
|
||||
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
|
||||
builder: (context, membresBox, child) {
|
||||
final membres = membresBox.values.toList();
|
||||
@@ -118,28 +115,24 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
||||
..._buildRows(membres, currentOperation.id, theme),
|
||||
];
|
||||
|
||||
// Utilise seulement le scroll vertical, le tableau s'adapte à la largeur
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.vertical,
|
||||
child: SizedBox(
|
||||
width: double.infinity, // Prendre toute la largeur disponible
|
||||
child: DataTable(
|
||||
columnSpacing: 4, // Espacement minimal entre colonnes
|
||||
horizontalMargin: 4, // Marges horizontales minimales
|
||||
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
|
||||
dataRowMinHeight: 42,
|
||||
dataRowMaxHeight: 42,
|
||||
headingRowColor: WidgetStateProperty.all(
|
||||
theme.colorScheme.primary.withValues(alpha: 0.08),
|
||||
),
|
||||
columns: _buildColumns(theme),
|
||||
rows: allRows,
|
||||
// Afficher le tableau complet sans scroll interne
|
||||
return SizedBox(
|
||||
width: double.infinity, // Prendre toute la largeur disponible
|
||||
child: DataTable(
|
||||
columnSpacing: 4, // Espacement minimal entre colonnes
|
||||
horizontalMargin: 4, // Marges horizontales minimales
|
||||
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
|
||||
dataRowMinHeight: 42,
|
||||
dataRowMaxHeight: 42,
|
||||
headingRowColor: WidgetStateProperty.all(
|
||||
theme.colorScheme.primary.withValues(alpha: 0.08),
|
||||
),
|
||||
columns: _buildColumns(theme),
|
||||
rows: allRows,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -100,7 +100,6 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: widget.height,
|
||||
padding: widget.padding ?? const EdgeInsets.all(AppTheme.spacingM),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
@@ -109,6 +108,7 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Ligne du titre avec boutons de tri
|
||||
Row(
|
||||
@@ -135,9 +135,7 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Expanded(
|
||||
child: _buildAutoRefreshContent(),
|
||||
),
|
||||
_buildAutoRefreshContent(),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -183,6 +181,8 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
|
||||
// Liste des secteurs directement sans sous-titre
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: sectorStats.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sector = sectorStats[index];
|
||||
@@ -318,41 +318,41 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: isAdmin
|
||||
? InkWell(
|
||||
onTap: () {
|
||||
// Sauvegarder le secteur sélectionné et l'index de la page carte dans Hive
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
settingsBox.put('selectedSectorId', sectorId);
|
||||
settingsBox.put(
|
||||
'selectedPageIndex', 4); // Index de la page carte
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
|
||||
// Naviguer vers le dashboard admin qui chargera la page carte
|
||||
context.go('/admin');
|
||||
},
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 14),
|
||||
color: textColor,
|
||||
fontWeight:
|
||||
hasPassages ? FontWeight.w600 : FontWeight.w300,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: textColor.withValues(alpha: 0.5),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 14),
|
||||
color: textColor,
|
||||
fontWeight:
|
||||
hasPassages ? FontWeight.w600 : FontWeight.w300,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (isAdmin) {
|
||||
// Admin : naviguer vers la page carte
|
||||
settingsBox.put('selectedSectorId', sectorId);
|
||||
settingsBox.put('selectedPageIndex', 4); // Index de la page carte
|
||||
context.go('/admin');
|
||||
} else {
|
||||
// User : naviguer vers la page historique avec le secteur sélectionné
|
||||
settingsBox.delete('history_selectedTypeId');
|
||||
settingsBox.delete('history_selectedPaymentTypeId');
|
||||
settingsBox.delete('history_selectedMemberId');
|
||||
settingsBox.delete('history_startDate');
|
||||
settingsBox.delete('history_endDate');
|
||||
|
||||
settingsBox.put('history_selectedSectorId', sectorId);
|
||||
settingsBox.put('history_selectedSectorName', name);
|
||||
context.go('/user/history');
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 14),
|
||||
color: textColor,
|
||||
fontWeight:
|
||||
hasPassages ? FontWeight.w600 : FontWeight.w300,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: textColor.withValues(alpha: 0.5),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
hasPassages
|
||||
@@ -420,75 +420,49 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
? Color(typeInfo['couleur2'] as int)
|
||||
: Colors.grey;
|
||||
|
||||
// Vérifier si l'utilisateur est admin pour les clics (prend en compte le mode d'affichage)
|
||||
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
|
||||
return Expanded(
|
||||
flex: count,
|
||||
child: isAdmin
|
||||
? InkWell(
|
||||
onTap: () {
|
||||
// Sauvegarder les filtres dans Hive pour la page historique
|
||||
final settingsBox =
|
||||
Hive.box(AppKeys.settingsBoxName);
|
||||
settingsBox.put(
|
||||
'history_selectedSectorId', sectorId);
|
||||
settingsBox.put(
|
||||
'history_selectedSectorName', sectorName);
|
||||
settingsBox.put('history_selectedTypeId', typeId);
|
||||
settingsBox.put('selectedPageIndex',
|
||||
2); // Index de la page historique
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Réinitialiser TOUS les filtres avant de sauvegarder les nouveaux
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
settingsBox.delete('history_selectedPaymentTypeId');
|
||||
settingsBox.delete('history_selectedMemberId');
|
||||
settingsBox.delete('history_startDate');
|
||||
settingsBox.delete('history_endDate');
|
||||
|
||||
// Naviguer vers le dashboard admin qui chargera la page historique
|
||||
context.go('/admin');
|
||||
},
|
||||
child: Container(
|
||||
color: color,
|
||||
child: Center(
|
||||
child: percentage >=
|
||||
5 // N'afficher le texte que si >= 5%
|
||||
? Text(
|
||||
'$count (${percentage.toInt()}%)',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: AppTheme.r(context, 10),
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(0.5, 0.5),
|
||||
blurRadius: 1.0,
|
||||
color: Colors.black45,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: color,
|
||||
child: Center(
|
||||
child: percentage >=
|
||||
5 // N'afficher le texte que si >= 5%
|
||||
? Text(
|
||||
'$count (${percentage.toInt()}%)',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: AppTheme.r(context, 10),
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(0.5, 0.5),
|
||||
blurRadius: 1.0,
|
||||
color: Colors.black45,
|
||||
),
|
||||
],
|
||||
// Sauvegarder uniquement le secteur et le type de passage sélectionnés
|
||||
settingsBox.put('history_selectedSectorId', sectorId);
|
||||
settingsBox.put('history_selectedSectorName', sectorName);
|
||||
settingsBox.put('history_selectedTypeId', typeId);
|
||||
|
||||
// Naviguer directement vers la page historique
|
||||
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
context.go(isAdmin ? '/admin/history' : '/user/history');
|
||||
},
|
||||
child: Container(
|
||||
color: color,
|
||||
child: Center(
|
||||
child: percentage >= 5
|
||||
? Text(
|
||||
'$count (${percentage.toInt()}%)',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: AppTheme.r(context, 10),
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(0.5, 0.5),
|
||||
blurRadius: 1.0,
|
||||
color: Colors.black45,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
@@ -293,18 +293,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_cache_interceptor
|
||||
sha256: "1346705a2057c265014d7696e3e2318b560bfb00b484dac7f9b01e2ceaebb07d"
|
||||
sha256: ac9f312e5a81d79cbccb15f56b78aeae7343a981c1d7c169b11194fae806ec0b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.5.1"
|
||||
dio_cache_interceptor_hive_store:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio_cache_interceptor_hive_store
|
||||
sha256: "449b36541216cb20543228081125ad2995eb9712ec35bd030d85663ea1761895"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
version: "4.0.5"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -463,10 +455,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_map_cache
|
||||
sha256: "5b30c9b0d36315a22f4ee070737104a6017e7ff990e8addc8128ba81786e03ef"
|
||||
sha256: fc9697760dc95b6adf75110a23a800ace5d95a735a58ec43f05183bc675c7246
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
version: "2.0.0+1"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -605,6 +597,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
hive_ce:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hive_ce
|
||||
sha256: d678b1b2e315c18cd7ed8fd79eda25d70a1f3852d6988bfe5461cffe260c60aa
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.14.0"
|
||||
hive_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -637,6 +637,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
http_cache_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_cache_core
|
||||
sha256: "8f9f187d10f8d1a90c51db2389575bbddf71ca0f79d4527652ea1efa3f338071"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
http_cache_hive_store:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http_cache_hive_store
|
||||
sha256: "85847efdb18094961a66b74d3b856da093ddcbaf7739adecdc28149e871fb8fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.1"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -741,6 +757,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
isolate_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: isolate_channel
|
||||
sha256: f3d36f783b301e6b312c3450eeb2656b0e7d1db81331af2a151d9083a3f6b18d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2+1"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -809,10 +833,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logger
|
||||
sha256: "55d6c23a6c15db14920e037fe7e0dc32e7cdaf3b64b4b25df2d541b5b6b81c0c"
|
||||
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
version: "2.6.2"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -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: 3.3.4+334
|
||||
version: 3.3.5+335
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -40,8 +40,8 @@ dependencies:
|
||||
# Cartes et géolocalisation
|
||||
url_launcher: ^6.3.1
|
||||
flutter_map: ^6.1.0
|
||||
flutter_map_cache: ^1.5.2
|
||||
dio_cache_interceptor_hive_store: ^3.2.2 # Cache store pour flutter_map_cache
|
||||
flutter_map_cache: ^2.0.0 # Mise à jour vers v2.0.0+1 (06/10/2025)
|
||||
http_cache_hive_store: ^5.0.0 # Remplace dio_cache_interceptor_hive_store (discontinué)
|
||||
path_provider: ^2.1.2 # Requis pour le cache
|
||||
latlong2: ^0.9.1
|
||||
geolocator: ^12.0.0
|
||||
|
||||
Reference in New Issue
Block a user