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:
@@ -90,16 +90,41 @@ INSERT INTO ope_pass_backup (
|
|||||||
- Index sur les tables volumineuses
|
- Index sur les tables volumineuses
|
||||||
- Pagination optimisée
|
- Pagination optimisée
|
||||||
|
|
||||||
|
#### 4. Sécurisation des clés Stripe par environnement
|
||||||
|
**Objectif :** Étudier une approche plus sécurisée pour stocker les clés Stripe
|
||||||
|
|
||||||
|
**Problème actuel :**
|
||||||
|
- Toutes les clés (DEV, REC, PROD) sont dans un seul fichier `AppConfig.php`
|
||||||
|
- Les clés PRODUCTION sont visibles dans le code DEV/REC
|
||||||
|
- Risque si accès au container DEV → exposition des clés PROD
|
||||||
|
|
||||||
|
**Solutions à étudier :**
|
||||||
|
1. **Variables d'environnement** (`.env` par container)
|
||||||
|
- Fichier `.env.dev`, `.env.rec`, `.env.prod`
|
||||||
|
- Chargement dynamique selon l'environnement
|
||||||
|
- Exclusion des `.env` du versionning Git
|
||||||
|
|
||||||
|
2. **Fichiers de config séparés**
|
||||||
|
- `config/stripe.dev.php`, `config/stripe.rec.php`, `config/stripe.prod.php`
|
||||||
|
- Déploiement sélectif selon l'environnement
|
||||||
|
- Non versionnés (ajoutés au .gitignore)
|
||||||
|
|
||||||
|
3. **Secrets management** (avancé)
|
||||||
|
- HashiCorp Vault, AWS Secrets Manager, etc.
|
||||||
|
- API de récupération sécurisée des secrets
|
||||||
|
|
||||||
|
**Recommandation :** Approche #1 (variables d'environnement) pour équilibre sécurité/simplicité
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🟢 PRIORITÉ BASSE
|
### 🟢 PRIORITÉ BASSE
|
||||||
|
|
||||||
#### 4. Documentation API
|
#### 5. Documentation API
|
||||||
- Génération automatique OpenAPI/Swagger
|
- Génération automatique OpenAPI/Swagger
|
||||||
- Documentation interactive
|
- Documentation interactive
|
||||||
- Exemples de code pour chaque endpoint
|
- Exemples de code pour chaque endpoint
|
||||||
|
|
||||||
#### 5. Tests automatisés
|
#### 6. Tests automatisés
|
||||||
- Tests unitaires pour les services critiques
|
- Tests unitaires pour les services critiques
|
||||||
- Tests d'intégration pour les endpoints
|
- Tests d'intégration pour les endpoints
|
||||||
- Tests de charge
|
- Tests de charge
|
||||||
|
|||||||
@@ -981,19 +981,52 @@ Content-Type: application/json
|
|||||||
|
|
||||||
### Configuration environnement
|
### Configuration environnement
|
||||||
|
|
||||||
#### Variables Stripe par environnement :
|
#### Architecture des clés Stripe
|
||||||
|
|
||||||
| Environnement | Clés | Webhooks |
|
Depuis janvier 2025, les clés Stripe sont **séparées par environnement** dans `src/Config/AppConfig.php` :
|
||||||
|---------------|------|----------|
|
|
||||||
| **DEV** | Test keys (pk_test_, sk_test_) | URL dev webhook |
|
|
||||||
| **RECETTE** | Test keys (pk_test_, sk_test_) | URL recette webhook |
|
|
||||||
| **PRODUCTION** | Live keys (pk_live_, sk_live_) | URL prod webhook |
|
|
||||||
|
|
||||||
#### Comptes Connect :
|
| Environnement | URL | Mode | Clés utilisées | Status |
|
||||||
|
|---------------|-----|------|----------------|--------|
|
||||||
|
| **DEV** | https://dapp.geosector.fr | `test` | Clés TEST Pierre (dev plateforme) | ✅ Opérationnel |
|
||||||
|
| **RECETTE** | https://rapp.geosector.fr | `test` | Clés TEST du client | ⏳ À configurer |
|
||||||
|
| **PRODUCTION** | https://app.geosector.fr | `live` | Clés LIVE du client | ⏳ À configurer |
|
||||||
|
|
||||||
|
**Emplacement dans le code :**
|
||||||
|
- **DEV** : `AppConfig.php` lignes 175-187 (section `dapp.geosector.fr`)
|
||||||
|
- **RECETTE** : `AppConfig.php` lignes 150-162 (section `rapp.geosector.fr`)
|
||||||
|
- **PRODUCTION** : `AppConfig.php` lignes 126-138 (section `app.geosector.fr`)
|
||||||
|
|
||||||
|
#### Configuration des clés client
|
||||||
|
|
||||||
|
Pour configurer les clés Stripe du client :
|
||||||
|
|
||||||
|
1. **Récupérer les clés depuis le Dashboard Stripe du client**
|
||||||
|
- Se connecter sur https://dashboard.stripe.com
|
||||||
|
- Aller dans **Développeurs → Clés API**
|
||||||
|
- Pour les clés TEST : Mode Test activé
|
||||||
|
- Pour les clés LIVE : Mode Live activé
|
||||||
|
|
||||||
|
2. **Remplacer les placeholders dans AppConfig.php**
|
||||||
|
- **RECETTE** (ligne 152-153) : Remplacer `CLIENT_PK_TEST_A_REMPLACER` et `CLIENT_SK_TEST_A_REMPLACER`
|
||||||
|
- **PRODUCTION** (ligne 130-131) : Remplacer `CLIENT_PK_LIVE_A_REMPLACER` et `CLIENT_SK_LIVE_A_REMPLACER`
|
||||||
|
|
||||||
|
3. **Déployer selon l'environnement**
|
||||||
|
```bash
|
||||||
|
# Déployer en RECETTE
|
||||||
|
./deploy-api.sh rca
|
||||||
|
|
||||||
|
# Déployer en PRODUCTION
|
||||||
|
./deploy-api.sh pra
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Sécurité :** Voir `TODO-API.md` section "Sécurisation des clés Stripe" pour étudier une approche plus sécurisée (variables d'environnement, fichiers séparés).
|
||||||
|
|
||||||
|
#### Comptes Connect
|
||||||
- Type : Express (simplifié pour les associations)
|
- Type : Express (simplifié pour les associations)
|
||||||
- Pays : France (FR)
|
- Pays : France (FR)
|
||||||
- Devise : Euro (EUR)
|
- Devise : Euro (EUR)
|
||||||
- Frais : Standard Stripe Connect
|
- Frais : Standard Stripe Connect
|
||||||
|
- Pas de commission plateforme (100% pour l'amicale)
|
||||||
|
|
||||||
### Gestion des appareils certifiés Tap to Pay
|
### Gestion des appareils certifiés Tap to Pay
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ CREATE TABLE `email_queue` (
|
|||||||
`headers` text DEFAULT NULL,
|
`headers` text DEFAULT NULL,
|
||||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||||
`status` enum('pending','sent','failed') DEFAULT 'pending',
|
`status` enum('pending','sent','failed') DEFAULT 'pending',
|
||||||
|
`sent_at` timestamp NULL DEFAULT NULL,
|
||||||
`attempts` int(10) unsigned DEFAULT 0,
|
`attempts` int(10) unsigned DEFAULT 0,
|
||||||
|
`error_message` text DEFAULT NULL,
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';
|
||||||
|
|
||||||
|
|||||||
@@ -324,7 +324,9 @@ CREATE TABLE `email_queue` (
|
|||||||
`headers` text COLLATE utf8mb4_unicode_ci,
|
`headers` text COLLATE utf8mb4_unicode_ci,
|
||||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`status` enum('pending','sent','failed') COLLATE utf8mb4_unicode_ci DEFAULT 'pending',
|
`status` enum('pending','sent','failed') COLLATE utf8mb4_unicode_ci DEFAULT 'pending',
|
||||||
|
`sent_at` timestamp NULL DEFAULT NULL,
|
||||||
`attempts` int unsigned DEFAULT '0',
|
`attempts` int unsigned DEFAULT '0',
|
||||||
|
`error_message` text COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
|
|||||||
165
api/scripts/config/update_php_fpm_settings.sh
Normal file
165
api/scripts/config/update_php_fpm_settings.sh
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Script de mise à jour des paramètres PHP-FPM pour GeoSector
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./update_php_fpm_settings.sh dev # Pour DVA
|
||||||
|
# ./update_php_fpm_settings.sh rec # Pour RCA
|
||||||
|
# ./update_php_fpm_settings.sh prod # Pour PRA
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Couleurs
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Déterminer l'environnement
|
||||||
|
ENV=${1:-dev}
|
||||||
|
|
||||||
|
case $ENV in
|
||||||
|
dev)
|
||||||
|
CONTAINER="dva-geo"
|
||||||
|
TIMEOUT=180
|
||||||
|
MAX_REQUESTS=1000
|
||||||
|
MEMORY_LIMIT=512M
|
||||||
|
;;
|
||||||
|
rec)
|
||||||
|
CONTAINER="rca-geo"
|
||||||
|
TIMEOUT=120
|
||||||
|
MAX_REQUESTS=2000
|
||||||
|
MEMORY_LIMIT=256M
|
||||||
|
;;
|
||||||
|
prod)
|
||||||
|
CONTAINER="pra-geo"
|
||||||
|
TIMEOUT=120
|
||||||
|
MAX_REQUESTS=2000
|
||||||
|
MEMORY_LIMIT=256M
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Erreur: Environnement invalide '$ENV'${NC}"
|
||||||
|
echo "Usage: $0 [dev|rec|prod]"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo -e "${GREEN}=== Mise à jour PHP-FPM pour $ENV ($CONTAINER) ===${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Vérifier que le container existe
|
||||||
|
if ! incus list | grep -q "$CONTAINER"; then
|
||||||
|
echo -e "${RED}Erreur: Container $CONTAINER non trouvé${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Trouver le fichier de configuration
|
||||||
|
echo "Recherche du fichier de configuration PHP-FPM..."
|
||||||
|
POOL_FILE=$(incus exec $CONTAINER -- find /etc/php* -name "www.conf" 2>/dev/null | grep fpm/pool | head -1)
|
||||||
|
|
||||||
|
if [ -z "$POOL_FILE" ]; then
|
||||||
|
echo -e "${RED}Erreur: Fichier pool PHP-FPM non trouvé${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Fichier trouvé: $POOL_FILE${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Sauvegarder le fichier original
|
||||||
|
BACKUP_FILE="${POOL_FILE}.backup.$(date +%Y%m%d_%H%M%S)"
|
||||||
|
echo "Création d'une sauvegarde..."
|
||||||
|
incus exec $CONTAINER -- cp "$POOL_FILE" "$BACKUP_FILE"
|
||||||
|
echo -e "${GREEN}✓ Sauvegarde créée: $BACKUP_FILE${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Afficher les valeurs actuelles
|
||||||
|
echo "Valeurs actuelles:"
|
||||||
|
incus exec $CONTAINER -- grep -E "^(request_terminate_timeout|pm.max_requests|memory_limit)" "$POOL_FILE" || echo " (non définies)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Créer un fichier temporaire avec les nouvelles valeurs
|
||||||
|
TMP_FILE="/tmp/php_fpm_update_$$.conf"
|
||||||
|
|
||||||
|
cat > $TMP_FILE << EOF
|
||||||
|
; === Configuration GeoSector - Modifié le $(date +%Y-%m-%d) ===
|
||||||
|
|
||||||
|
; Timeout des requêtes
|
||||||
|
request_terminate_timeout = ${TIMEOUT}s
|
||||||
|
|
||||||
|
; Nombre max de requêtes avant recyclage du worker
|
||||||
|
pm.max_requests = ${MAX_REQUESTS}
|
||||||
|
|
||||||
|
; Limite mémoire PHP
|
||||||
|
php_admin_value[memory_limit] = ${MEMORY_LIMIT}
|
||||||
|
|
||||||
|
; Log des requêtes lentes
|
||||||
|
slowlog = /var/log/php8.3-fpm-slow.log
|
||||||
|
request_slowlog_timeout = 10s
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Nouvelles valeurs à appliquer:"
|
||||||
|
cat $TMP_FILE
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Demander confirmation
|
||||||
|
read -p "Appliquer ces modifications ? (y/N) " -n 1 -r
|
||||||
|
echo ""
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Annulé."
|
||||||
|
rm $TMP_FILE
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Supprimer les anciennes valeurs si présentes
|
||||||
|
echo "Suppression des anciennes valeurs..."
|
||||||
|
incus exec $CONTAINER -- sed -i '/^request_terminate_timeout/d' "$POOL_FILE"
|
||||||
|
incus exec $CONTAINER -- sed -i '/^pm.max_requests/d' "$POOL_FILE"
|
||||||
|
incus exec $CONTAINER -- sed -i '/^php_admin_value\[memory_limit\]/d' "$POOL_FILE"
|
||||||
|
incus exec $CONTAINER -- sed -i '/^slowlog/d' "$POOL_FILE"
|
||||||
|
incus exec $CONTAINER -- sed -i '/^request_slowlog_timeout/d' "$POOL_FILE"
|
||||||
|
|
||||||
|
# Ajouter les nouvelles valeurs à la fin du fichier
|
||||||
|
echo "Ajout des nouvelles valeurs..."
|
||||||
|
incus file push $TMP_FILE $CONTAINER/tmp/php_fpm_settings.conf
|
||||||
|
incus exec $CONTAINER -- bash -c "cat /tmp/php_fpm_settings.conf >> $POOL_FILE"
|
||||||
|
incus exec $CONTAINER -- rm /tmp/php_fpm_settings.conf
|
||||||
|
|
||||||
|
rm $TMP_FILE
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Configuration mise à jour${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Tester la configuration
|
||||||
|
echo "Test de la configuration PHP-FPM..."
|
||||||
|
if incus exec $CONTAINER -- php-fpm8.3 -t; then
|
||||||
|
echo -e "${GREEN}✓ Configuration valide${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Configuration invalide !${NC}"
|
||||||
|
echo "Restauration de la sauvegarde..."
|
||||||
|
incus exec $CONTAINER -- cp "$BACKUP_FILE" "$POOL_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Redémarrage de PHP-FPM..."
|
||||||
|
incus exec $CONTAINER -- rc-service php-fpm8.3 restart
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}✓ PHP-FPM redémarré avec succès${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Erreur lors du redémarrage${NC}"
|
||||||
|
echo "Restauration de la sauvegarde..."
|
||||||
|
incus exec $CONTAINER -- cp "$BACKUP_FILE" "$POOL_FILE"
|
||||||
|
incus exec $CONTAINER -- rc-service php-fpm8.3 restart
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}=== Mise à jour terminée avec succès ===${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Vérification des nouvelles valeurs:"
|
||||||
|
incus exec $CONTAINER -- grep -E "^(request_terminate_timeout|pm.max_requests|php_admin_value\[memory_limit\])" "$POOL_FILE"
|
||||||
|
echo ""
|
||||||
|
echo "Sauvegarde disponible: $BACKUP_FILE"
|
||||||
57
api/scripts/migrations/add_email_queue_fields.sql
Normal file
57
api/scripts/migrations/add_email_queue_fields.sql
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
-- Migration : Ajout des champs manquants dans email_queue
|
||||||
|
-- Date : 2025-01-06
|
||||||
|
-- Description : Ajoute sent_at et error_message pour le bon fonctionnement du CRON
|
||||||
|
|
||||||
|
USE geo_app;
|
||||||
|
|
||||||
|
-- Vérifier si les champs existent déjà avant de les ajouter
|
||||||
|
SET @db_name = DATABASE();
|
||||||
|
SET @table_name = 'email_queue';
|
||||||
|
|
||||||
|
-- Ajouter sent_at si n'existe pas
|
||||||
|
SET @column_exists = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @db_name
|
||||||
|
AND TABLE_NAME = @table_name
|
||||||
|
AND COLUMN_NAME = 'sent_at'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @sql = IF(@column_exists = 0,
|
||||||
|
'ALTER TABLE email_queue ADD COLUMN sent_at TIMESTAMP NULL DEFAULT NULL AFTER status',
|
||||||
|
'SELECT "Column sent_at already exists" AS message'
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- Ajouter error_message si n'existe pas
|
||||||
|
SET @column_exists = (
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @db_name
|
||||||
|
AND TABLE_NAME = @table_name
|
||||||
|
AND COLUMN_NAME = 'error_message'
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @sql = IF(@column_exists = 0,
|
||||||
|
'ALTER TABLE email_queue ADD COLUMN error_message TEXT NULL DEFAULT NULL AFTER attempts',
|
||||||
|
'SELECT "Column error_message already exists" AS message'
|
||||||
|
);
|
||||||
|
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- Vérifier le résultat
|
||||||
|
SELECT
|
||||||
|
'Migration terminée' AS status,
|
||||||
|
COLUMN_NAME,
|
||||||
|
COLUMN_TYPE,
|
||||||
|
IS_NULLABLE,
|
||||||
|
COLUMN_DEFAULT
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = @db_name
|
||||||
|
AND TABLE_NAME = @table_name
|
||||||
|
AND COLUMN_NAME IN ('sent_at', 'error_message');
|
||||||
112
api/scripts/test/generate_receipt_manual.php
Normal file
112
api/scripts/test/generate_receipt_manual.php
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script de test pour générer manuellement un reçu
|
||||||
|
* Usage: php generate_receipt_manual.php <passage_id>
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Simuler l'environnement web pour AppConfig en CLI
|
||||||
|
if (php_sapi_name() === 'cli') {
|
||||||
|
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr'; // DEV
|
||||||
|
$_SERVER['HTTP_HOST'] = $_SERVER['SERVER_NAME'];
|
||||||
|
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
|
||||||
|
|
||||||
|
if (!function_exists('getallheaders')) {
|
||||||
|
function getallheaders() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chargement de l'environnement
|
||||||
|
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||||
|
require_once __DIR__ . '/../../src/Config/AppConfig.php';
|
||||||
|
require_once __DIR__ . '/../../src/Core/Database.php';
|
||||||
|
require_once __DIR__ . '/../../src/Services/LogService.php';
|
||||||
|
require_once __DIR__ . '/../../src/Services/ReceiptService.php';
|
||||||
|
|
||||||
|
// Vérifier qu'un ID de passage est fourni
|
||||||
|
if ($argc < 2) {
|
||||||
|
echo "Usage: php generate_receipt_manual.php <passage_id>\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$passageId = (int)$argv[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
echo "=== Test de génération de reçu ===\n";
|
||||||
|
echo "Passage ID: $passageId\n\n";
|
||||||
|
|
||||||
|
// Initialisation de la configuration
|
||||||
|
$appConfig = AppConfig::getInstance();
|
||||||
|
$dbConfig = $appConfig->getDatabaseConfig();
|
||||||
|
|
||||||
|
// Initialiser la base de données
|
||||||
|
Database::init($dbConfig);
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
echo "✓ Connexion à la base de données OK\n";
|
||||||
|
|
||||||
|
// Vérifier le passage
|
||||||
|
$stmt = $db->prepare('SELECT id, fk_type, encrypted_email, nom_recu FROM ope_pass WHERE id = ?');
|
||||||
|
$stmt->execute([$passageId]);
|
||||||
|
$passage = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$passage) {
|
||||||
|
echo "✗ Passage $passageId non trouvé\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "✓ Passage trouvé\n";
|
||||||
|
echo " - fk_type: " . $passage['fk_type'] . "\n";
|
||||||
|
echo " - encrypted_email: " . (!empty($passage['encrypted_email']) ? 'OUI' : 'NON') . "\n";
|
||||||
|
echo " - nom_recu: " . ($passage['nom_recu'] ?: 'vide') . "\n\n";
|
||||||
|
|
||||||
|
// Déchiffrer l'email
|
||||||
|
if (!empty($passage['encrypted_email'])) {
|
||||||
|
$email = \ApiService::decryptSearchableData($passage['encrypted_email']);
|
||||||
|
echo " - Email déchiffré: $email\n";
|
||||||
|
echo " - Email valide: " . (filter_var($email, FILTER_VALIDATE_EMAIL) ? 'OUI' : 'NON') . "\n\n";
|
||||||
|
} else {
|
||||||
|
echo "✗ Aucun email chiffré trouvé\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer le reçu
|
||||||
|
echo "Génération du reçu...\n";
|
||||||
|
$receiptService = new \App\Services\ReceiptService();
|
||||||
|
$result = $receiptService->generateReceiptForPassage($passageId);
|
||||||
|
|
||||||
|
if ($result) {
|
||||||
|
echo "✓ Reçu généré avec succès !\n\n";
|
||||||
|
|
||||||
|
// Vérifier l'email dans la queue
|
||||||
|
$stmt = $db->prepare('SELECT id, to_email, status, created_at FROM email_queue WHERE fk_pass = ? ORDER BY created_at DESC LIMIT 1');
|
||||||
|
$stmt->execute([$passageId]);
|
||||||
|
$queueEmail = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($queueEmail) {
|
||||||
|
echo "✓ Email ajouté à la queue\n";
|
||||||
|
echo " - Queue ID: " . $queueEmail['id'] . "\n";
|
||||||
|
echo " - Destinataire: " . $queueEmail['to_email'] . "\n";
|
||||||
|
echo " - Status: " . $queueEmail['status'] . "\n";
|
||||||
|
echo " - Créé: " . $queueEmail['created_at'] . "\n";
|
||||||
|
} else {
|
||||||
|
echo "✗ Aucun email trouvé dans la queue\n";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo "✗ Échec de la génération du reçu\n";
|
||||||
|
echo "Consultez /var/www/geosector/api/logs/api.log pour plus de détails\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo "✗ ERREUR: " . $e->getMessage() . "\n";
|
||||||
|
echo $e->getTraceAsString() . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n=== Fin du test ===\n";
|
||||||
|
exit(0);
|
||||||
@@ -63,18 +63,10 @@ class AppConfig {
|
|||||||
'mapbox' => [
|
'mapbox' => [
|
||||||
'api_key' => '', // À remplir avec la clé API Mapbox
|
'api_key' => '', // À remplir avec la clé API Mapbox
|
||||||
],
|
],
|
||||||
'stripe' => [
|
// NOTE : La configuration Stripe est définie par environnement (voir plus bas)
|
||||||
'public_key_test' => 'pk_test_51QwoVN00pblGEgsXkf8qlXmLGEpxDQcG0KLRpjrGLjJHd7AVZ4Iwd6ChgdjO0w0n3vRqwNCEW8KnHUe5eh3uIlkV00k07kCBmd', // À remplacer par votre clé publique TEST
|
// - DEV : Clés TEST Pierre (développement)
|
||||||
'secret_key_test' => 'sk_test_51QwoVN00pblGEgsXnvqi8qfYpzHtesWWclvK3lzQjPNoHY0dIyOpJmxIkoLqsbmRMEUZpKS5MQ7iFDRlSqVyTo9c006yWetbsd', // À remplacer par votre clé secrète TEST
|
// - REC : Clés TEST Client (recette)
|
||||||
'public_key_live' => 'pk_live_XXXXXXXXXXXX', // À remplacer par votre clé publique LIVE
|
// - PROD : Clés LIVE Client (production)
|
||||||
'secret_key_live' => 'sk_live_XXXXXXXXXXXX', // À remplacer par votre clé secrète LIVE
|
|
||||||
'webhook_secret_test' => 'whsec_test_XXXXXXXXXXXX', // À remplacer après création webhook TEST
|
|
||||||
'webhook_secret_live' => 'whsec_live_XXXXXXXXXXXX', // À remplacer après création webhook LIVE
|
|
||||||
'api_version' => '2024-06-20',
|
|
||||||
'application_fee_percent' => 0, // Pas de commission plateforme
|
|
||||||
'application_fee_minimum' => 0, // Pas de commission minimum
|
|
||||||
'mode' => 'test', // 'test' ou 'live'
|
|
||||||
],
|
|
||||||
'sms' => [
|
'sms' => [
|
||||||
'provider' => 'ovh', // Comme mentionné dans le cahier des charges
|
'provider' => 'ovh', // Comme mentionné dans le cahier des charges
|
||||||
'api_key' => '', // À remplir avec la clé API SMS OVH
|
'api_key' => '', // À remplir avec la clé API SMS OVH
|
||||||
@@ -103,6 +95,19 @@ class AppConfig {
|
|||||||
'username' => 'adr_geo_user',
|
'username' => 'adr_geo_user',
|
||||||
'password' => 'd66,AdrGeo.User',
|
'password' => 'd66,AdrGeo.User',
|
||||||
],
|
],
|
||||||
|
// Configuration Stripe PRODUCTION - Clés LIVE du CLIENT
|
||||||
|
'stripe' => [
|
||||||
|
'public_key_test' => 'pk_test_XXXXXX', // Non utilisé en PROD
|
||||||
|
'secret_key_test' => 'sk_test_XXXXXX', // Non utilisé en PROD
|
||||||
|
'public_key_live' => 'CLIENT_PK_LIVE_A_REMPLACER', // ← À REMPLACER avec pk_live_...
|
||||||
|
'secret_key_live' => 'CLIENT_SK_LIVE_A_REMPLACER', // ← À REMPLACER avec sk_live_...
|
||||||
|
'webhook_secret_test' => 'whsec_test_XXXXXXXXXXXX',
|
||||||
|
'webhook_secret_live' => 'whsec_live_XXXXXXXXXXXX',
|
||||||
|
'api_version' => '2024-06-20',
|
||||||
|
'application_fee_percent' => 0,
|
||||||
|
'application_fee_minimum' => 0,
|
||||||
|
'mode' => 'live', // ← MODE LIVE pour la production
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Configuration RECETTE
|
// Configuration RECETTE
|
||||||
@@ -127,7 +132,19 @@ class AppConfig {
|
|||||||
'username' => 'adr_geo_user',
|
'username' => 'adr_geo_user',
|
||||||
'password' => 'd66,AdrGeoRec.User',
|
'password' => 'd66,AdrGeoRec.User',
|
||||||
],
|
],
|
||||||
// Vous pouvez remplacer d'autres paramètres spécifiques à l'environnement de recette ici
|
// Configuration Stripe RECETTE - Clés TEST du CLIENT
|
||||||
|
'stripe' => [
|
||||||
|
'public_key_test' => 'pk_test_51S5oMd1tQE0jBEomd1u28D1bUujOcl87ASuGf9xulcz4rY27QfHrLBtQj20MVlWta4AGXsX0YMfeOJFE66AlGlkz00vG30U8Rr',
|
||||||
|
'secret_key_test' => 'sk_test_51S5oMd1tQE0jBEomAhzPBvUcCf0HX9ydK0xq7DagKnidp3JsovbQoVaTj24TKSUPvujQA3PP7IpIS8iWzAd15Rte00TETmbimh',
|
||||||
|
'public_key_live' => 'pk_live_XXXXXX', // Non utilisé en REC
|
||||||
|
'secret_key_live' => 'sk_live_XXXXXX', // Non utilisé en REC
|
||||||
|
'webhook_secret_test' => 'whsec_test_XXXXXXXXXXXX',
|
||||||
|
'webhook_secret_live' => 'whsec_live_XXXXXXXXXXXX',
|
||||||
|
'api_version' => '2024-06-20',
|
||||||
|
'application_fee_percent' => 0,
|
||||||
|
'application_fee_minimum' => 0,
|
||||||
|
'mode' => 'test', // ← MODE TEST pour la recette
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Configuration DÉVELOPPEMENT
|
// Configuration DÉVELOPPEMENT
|
||||||
@@ -152,6 +169,19 @@ class AppConfig {
|
|||||||
'username' => 'adr_geo_user',
|
'username' => 'adr_geo_user',
|
||||||
'password' => 'd66,AdrGeoDev.User',
|
'password' => 'd66,AdrGeoDev.User',
|
||||||
],
|
],
|
||||||
|
// Configuration Stripe DÉVELOPPEMENT - Clés TEST de Pierre (plateforme de test existante)
|
||||||
|
'stripe' => [
|
||||||
|
'public_key_test' => 'pk_test_51QwoVN00pblGEgsXkf8qlXmLGEpxDQcG0KLRpjrGLjJHd7AVZ4Iwd6ChgdjO0w0n3vRqwNCEW8KnHUe5eh3uIlkV00k07kCBmd',
|
||||||
|
'secret_key_test' => 'sk_test_51QwoVN00pblGEgsXnvqi8qfYpzHtesWWclvK3lzQjPNoHY0dIyOpJmxIkoLqsbmRMEUZpKS5MQ7iFDRlSqVyTo9c006yWetbsd',
|
||||||
|
'public_key_live' => 'pk_live_XXXXXX', // Non utilisé en DEV
|
||||||
|
'secret_key_live' => 'sk_live_XXXXXX', // Non utilisé en DEV
|
||||||
|
'webhook_secret_test' => 'whsec_test_XXXXXXXXXXXX',
|
||||||
|
'webhook_secret_live' => 'whsec_live_XXXXXXXXXXXX',
|
||||||
|
'api_version' => '2024-06-20',
|
||||||
|
'application_fee_percent' => 0,
|
||||||
|
'application_fee_minimum' => 0,
|
||||||
|
'mode' => 'test', // ← MODE TEST pour le développement
|
||||||
|
],
|
||||||
// Vous pouvez activer des fonctionnalités de débogage en développement
|
// Vous pouvez activer des fonctionnalités de débogage en développement
|
||||||
'debug' => true,
|
'debug' => true,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -576,6 +576,43 @@ class PassageController {
|
|||||||
'operationId' => $operationId
|
'operationId' => $operationId
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Enregistrer la génération du reçu dans shutdown_function pour garantir son exécution
|
||||||
|
// Même si le worker FPM est tué après fastcgi_finish_request()
|
||||||
|
$fkType = isset($data['fk_type']) ? (int)$data['fk_type'] : 0;
|
||||||
|
if ($fkType === 1 || $fkType === 5) {
|
||||||
|
// Vérifier si un email a été fourni
|
||||||
|
$hasEmail = false;
|
||||||
|
if (!empty($data['email'])) {
|
||||||
|
$hasEmail = filter_var($data['email'], FILTER_VALIDATE_EMAIL) !== false;
|
||||||
|
} elseif (!empty($encryptedEmail)) {
|
||||||
|
$hasEmail = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasEmail) {
|
||||||
|
$capturedPassageId = $passageId; // Capturer pour la closure
|
||||||
|
|
||||||
|
register_shutdown_function(function() use ($capturedPassageId) {
|
||||||
|
try {
|
||||||
|
$receiptService = new \App\Services\ReceiptService();
|
||||||
|
$receiptGenerated = $receiptService->generateReceiptForPassage($capturedPassageId);
|
||||||
|
|
||||||
|
if ($receiptGenerated) {
|
||||||
|
LogService::log('Reçu généré automatiquement pour le passage', [
|
||||||
|
'level' => 'info',
|
||||||
|
'passageId' => $capturedPassageId
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
LogService::log('Erreur lors de la génération automatique du reçu', [
|
||||||
|
'level' => 'warning',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'passageId' => $capturedPassageId
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Envoyer la réponse immédiatement pour éviter les timeouts
|
// Envoyer la réponse immédiatement pour éviter les timeouts
|
||||||
Response::json([
|
Response::json([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
@@ -595,39 +632,7 @@ class PassageController {
|
|||||||
fastcgi_finish_request();
|
fastcgi_finish_request();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Générer automatiquement un reçu si c'est un don (fk_type = 1) avec email valide
|
return; // Fin de la méthode
|
||||||
if (isset($data['fk_type']) && (int)$data['fk_type'] === 1) {
|
|
||||||
// Vérifier si un email a été fourni
|
|
||||||
$hasEmail = false;
|
|
||||||
if (!empty($data['email'])) {
|
|
||||||
$hasEmail = filter_var($data['email'], FILTER_VALIDATE_EMAIL) !== false;
|
|
||||||
} elseif (!empty($encryptedEmail)) {
|
|
||||||
// L'email a déjà été validé lors du chiffrement
|
|
||||||
$hasEmail = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hasEmail) {
|
|
||||||
try {
|
|
||||||
$receiptService = new \App\Services\ReceiptService();
|
|
||||||
$receiptGenerated = $receiptService->generateReceiptForPassage($passageId);
|
|
||||||
|
|
||||||
if ($receiptGenerated) {
|
|
||||||
LogService::log('Reçu généré automatiquement pour le passage', [
|
|
||||||
'level' => 'info',
|
|
||||||
'passageId' => $passageId
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
|
||||||
LogService::log('Erreur lors de la génération automatique du reçu', [
|
|
||||||
'level' => 'warning',
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
'passageId' => $passageId
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return; // Fin de la méthode, éviter d'exécuter le code après
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
LogService::log('Erreur lors de la création du passage', [
|
LogService::log('Erreur lors de la création du passage', [
|
||||||
'level' => 'error',
|
'level' => 'error',
|
||||||
@@ -792,6 +797,53 @@ class PassageController {
|
|||||||
'passageId' => $passageId
|
'passageId' => $passageId
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Enregistrer la génération du reçu dans shutdown_function pour garantir son exécution
|
||||||
|
// Même si le worker FPM est tué après fastcgi_finish_request()
|
||||||
|
$capturedPassageId = $passageId;
|
||||||
|
$capturedDb = $this->db;
|
||||||
|
|
||||||
|
register_shutdown_function(function() use ($capturedPassageId, $capturedDb) {
|
||||||
|
try {
|
||||||
|
// Récupérer les données actualisées du passage
|
||||||
|
$stmt = $capturedDb->prepare('SELECT fk_type, encrypted_email, nom_recu FROM ope_pass WHERE id = ?');
|
||||||
|
$stmt->execute([$capturedPassageId]);
|
||||||
|
$updatedPassage = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($updatedPassage) {
|
||||||
|
// Générer un reçu si :
|
||||||
|
// - C'est un don (fk_type = 1) ou un lot (fk_type = 5)
|
||||||
|
// - Il y a un email valide
|
||||||
|
// - Il n'y a pas encore de reçu (nom_recu est vide ou null)
|
||||||
|
$fkType = (int)$updatedPassage['fk_type'];
|
||||||
|
if (($fkType === 1 || $fkType === 5) &&
|
||||||
|
!empty($updatedPassage['encrypted_email']) &&
|
||||||
|
empty($updatedPassage['nom_recu'])) {
|
||||||
|
|
||||||
|
// Vérifier que l'email est valide en le déchiffrant
|
||||||
|
$email = ApiService::decryptSearchableData($updatedPassage['encrypted_email']);
|
||||||
|
|
||||||
|
if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$receiptService = new \App\Services\ReceiptService();
|
||||||
|
$receiptGenerated = $receiptService->generateReceiptForPassage($capturedPassageId);
|
||||||
|
|
||||||
|
if ($receiptGenerated) {
|
||||||
|
LogService::log('Reçu généré automatiquement après mise à jour du passage', [
|
||||||
|
'level' => 'info',
|
||||||
|
'passageId' => $capturedPassageId
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
LogService::log('Erreur lors de la génération automatique du reçu après mise à jour', [
|
||||||
|
'level' => 'warning',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'passageId' => $capturedPassageId
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Envoyer la réponse immédiatement pour éviter les timeouts
|
// Envoyer la réponse immédiatement pour éviter les timeouts
|
||||||
Response::json([
|
Response::json([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
@@ -810,47 +862,7 @@ class PassageController {
|
|||||||
fastcgi_finish_request();
|
fastcgi_finish_request();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maintenant générer le reçu en arrière-plan après avoir envoyé la réponse
|
return; // Fin de la méthode
|
||||||
try {
|
|
||||||
// Récupérer les données actualisées du passage
|
|
||||||
$stmt = $this->db->prepare('SELECT fk_type, encrypted_email, nom_recu FROM ope_pass WHERE id = ?');
|
|
||||||
$stmt->execute([$passageId]);
|
|
||||||
$updatedPassage = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
||||||
|
|
||||||
if ($updatedPassage) {
|
|
||||||
// Générer un reçu si :
|
|
||||||
// - C'est un don (fk_type = 1)
|
|
||||||
// - Il y a un email valide
|
|
||||||
// - Il n'y a pas encore de reçu (nom_recu est vide ou null)
|
|
||||||
if ((int)$updatedPassage['fk_type'] === 1 &&
|
|
||||||
!empty($updatedPassage['encrypted_email']) &&
|
|
||||||
empty($updatedPassage['nom_recu'])) {
|
|
||||||
|
|
||||||
// Vérifier que l'email est valide en le déchiffrant
|
|
||||||
$email = ApiService::decryptSearchableData($updatedPassage['encrypted_email']);
|
|
||||||
|
|
||||||
if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
||||||
$receiptService = new \App\Services\ReceiptService();
|
|
||||||
$receiptGenerated = $receiptService->generateReceiptForPassage($passageId);
|
|
||||||
|
|
||||||
if ($receiptGenerated) {
|
|
||||||
LogService::log('Reçu généré automatiquement après mise à jour du passage', [
|
|
||||||
'level' => 'info',
|
|
||||||
'passageId' => $passageId
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
|
||||||
LogService::log('Erreur lors de la génération automatique du reçu après mise à jour', [
|
|
||||||
'level' => 'warning',
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
'passageId' => $passageId
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return; // Fin de la méthode, éviter d'exécuter le code après
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
LogService::log('Erreur lors de la mise à jour du passage', [
|
LogService::log('Erreur lors de la mise à jour du passage', [
|
||||||
'level' => 'error',
|
'level' => 'error',
|
||||||
|
|||||||
@@ -51,9 +51,10 @@ class ReceiptService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier que c'est un don effectué (fk_type = 1) avec email valide
|
// Vérifier que c'est un don effectué (fk_type = 1) ou un lot (fk_type = 5) avec email valide
|
||||||
if ((int)$passageData['fk_type'] !== 1) {
|
$fkType = (int)$passageData['fk_type'];
|
||||||
return false; // Pas un don, pas de reçu
|
if ($fkType !== 1 && $fkType !== 5) {
|
||||||
|
return false; // Ni don ni lot, pas de reçu
|
||||||
}
|
}
|
||||||
|
|
||||||
// Déchiffrer et vérifier l'email
|
// Déchiffrer et vérifier l'email
|
||||||
|
|||||||
@@ -219,15 +219,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dio_cache_interceptor",
|
"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/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "2.14"
|
"languageVersion": "3.0"
|
||||||
},
|
|
||||||
{
|
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dio_web_adapter",
|
"name": "dio_web_adapter",
|
||||||
@@ -351,7 +345,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "flutter_map_cache",
|
"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/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.6"
|
"languageVersion": "3.6"
|
||||||
},
|
},
|
||||||
@@ -463,6 +457,12 @@
|
|||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "2.12"
|
"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",
|
"name": "hive_flutter",
|
||||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/hive_flutter-1.1.0",
|
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/hive_flutter-1.1.0",
|
||||||
@@ -487,6 +487,18 @@
|
|||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.4"
|
"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",
|
"name": "http_multi_server",
|
||||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/http_multi_server-3.2.2",
|
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/http_multi_server-3.2.2",
|
||||||
@@ -565,6 +577,12 @@
|
|||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.4"
|
"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",
|
"name": "js",
|
||||||
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/js-0.6.7",
|
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/js-0.6.7",
|
||||||
@@ -615,7 +633,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "logger",
|
"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/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "2.17"
|
"languageVersion": "2.17"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,14 +5,13 @@
|
|||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "geosector_app",
|
"name": "geosector_app",
|
||||||
"version": "3.3.4+334",
|
"version": "3.3.5+335",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"battery_plus",
|
"battery_plus",
|
||||||
"connectivity_plus",
|
"connectivity_plus",
|
||||||
"cupertino_icons",
|
"cupertino_icons",
|
||||||
"device_info_plus",
|
"device_info_plus",
|
||||||
"dio",
|
"dio",
|
||||||
"dio_cache_interceptor_hive_store",
|
|
||||||
"fl_chart",
|
"fl_chart",
|
||||||
"flutter",
|
"flutter",
|
||||||
"flutter_local_notifications",
|
"flutter_local_notifications",
|
||||||
@@ -26,6 +25,7 @@
|
|||||||
"google_fonts",
|
"google_fonts",
|
||||||
"hive",
|
"hive",
|
||||||
"hive_flutter",
|
"hive_flutter",
|
||||||
|
"http_cache_hive_store",
|
||||||
"image_picker",
|
"image_picker",
|
||||||
"intl",
|
"intl",
|
||||||
"latlong2",
|
"latlong2",
|
||||||
@@ -314,16 +314,16 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dio_cache_interceptor_hive_store",
|
"name": "http_cache_hive_store",
|
||||||
"version": "3.2.2",
|
"version": "5.0.1",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"dio_cache_interceptor",
|
"hive_ce",
|
||||||
"hive"
|
"http_cache_core"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "flutter_map_cache",
|
"name": "flutter_map_cache",
|
||||||
"version": "1.5.2",
|
"version": "2.0.0+1",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"dio",
|
"dio",
|
||||||
"dio_cache_interceptor",
|
"dio_cache_interceptor",
|
||||||
@@ -1391,14 +1391,33 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dio_cache_interceptor",
|
"name": "hive_ce",
|
||||||
"version": "3.5.1",
|
"version": "2.14.0",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"dio",
|
"crypto",
|
||||||
|
"isolate_channel",
|
||||||
|
"json_annotation",
|
||||||
|
"meta",
|
||||||
|
"web"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "http_cache_core",
|
||||||
|
"version": "1.1.2",
|
||||||
|
"dependencies": [
|
||||||
|
"collection",
|
||||||
"string_scanner",
|
"string_scanner",
|
||||||
"uuid"
|
"uuid"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "dio_cache_interceptor",
|
||||||
|
"version": "4.0.5",
|
||||||
|
"dependencies": [
|
||||||
|
"dio",
|
||||||
|
"http_cache_core"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "proj4dart",
|
"name": "proj4dart",
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
@@ -1417,7 +1436,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "logger",
|
"name": "logger",
|
||||||
"version": "2.6.1",
|
"version": "2.6.2",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"meta"
|
"meta"
|
||||||
]
|
]
|
||||||
@@ -1785,6 +1804,11 @@
|
|||||||
"version": "3.1.6",
|
"version": "3.1.6",
|
||||||
"dependencies": []
|
"dependencies": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "isolate_channel",
|
||||||
|
"version": "0.2.2+1",
|
||||||
|
"dependencies": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "wkt_parser",
|
"name": "wkt_parser",
|
||||||
"version": "2.0.0",
|
"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
|
9. Vérification statut compte
|
||||||
10. Affichage "✅ Compte actif"
|
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
|
### 📱 Tap to Pay V2 - Paiement sans contact
|
||||||
|
|
||||||
#### **Fonctionnalités prévues**
|
#### **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
|
**Date d'ajout** : 2025-09-23
|
||||||
**Auteur** : Solution de gestion du cache
|
**Auteur** : Solution de gestion du cache
|
||||||
**Version** : 1.0.0
|
**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,8 +356,86 @@ _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_
|
_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_
|
_Ce document sera mis à jour régulièrement avec l'avancement des développements_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ class AppKeys {
|
|||||||
},
|
},
|
||||||
2: {
|
2: {
|
||||||
'titre': 'Chèque',
|
'titre': 'Chèque',
|
||||||
'couleur': 0xFFD8D5EC, // Violet clair (Figma)
|
'couleur': 0xFF7E57C2, // Violet foncé (Material Design Deep Purple 400)
|
||||||
'icon_data': Icons.account_balance_wallet_outlined,
|
'icon_data': Icons.account_balance_wallet_outlined,
|
||||||
},
|
},
|
||||||
3: {
|
3: {
|
||||||
@@ -186,9 +186,9 @@ class AppKeys {
|
|||||||
6: {
|
6: {
|
||||||
'titres': 'Maisons vides',
|
'titres': 'Maisons vides',
|
||||||
'titre': 'Maison vide',
|
'titre': 'Maison vide',
|
||||||
'couleur1': 0xFFB8B8B8, // Gris (Figma)
|
'couleur1': 0xFF757575, // Gris foncé (Material Design 600)
|
||||||
'couleur2': 0xFFB8B8B8, // Gris (Figma)
|
'couleur2': 0xFF757575, // Gris foncé (Material Design 600)
|
||||||
'couleur3': 0xFFB8B8B8, // Gris (Figma)
|
'couleur3': 0xFF757575, // Gris foncé (Material Design 600)
|
||||||
'icon_data': Icons.home_outlined,
|
'icon_data': Icons.home_outlined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -140,12 +140,14 @@ class _HistoryContentState extends State<HistoryContent> {
|
|||||||
|
|
||||||
// Sauvegarder aussi dans Hive pour la persistance
|
// Sauvegarder aussi dans Hive pour la persistance
|
||||||
_saveMemberFilter(widget.memberId!);
|
_saveMemberFilter(widget.memberId!);
|
||||||
} else if (!isAdmin) {
|
|
||||||
// Pour un user standard, toujours filtrer sur son propre ID
|
|
||||||
selectedMemberId = currentUserId;
|
|
||||||
} else {
|
} 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();
|
_loadPreselectedFilters();
|
||||||
|
|
||||||
|
// Pour un user standard, toujours filtrer sur son propre ID
|
||||||
|
if (!isAdmin) {
|
||||||
|
selectedMemberId = currentUserId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_initializeNewFilters();
|
_initializeNewFilters();
|
||||||
@@ -385,7 +387,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
|||||||
// Filtre Type de passage
|
// Filtre Type de passage
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<String>(
|
child: DropdownButtonFormField<String>(
|
||||||
initialValue: _selectedTypeFilter,
|
value: _selectedTypeFilter,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
@@ -418,7 +420,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
|||||||
// Filtre Mode de règlement
|
// Filtre Mode de règlement
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<String>(
|
child: DropdownButtonFormField<String>(
|
||||||
initialValue: _selectedPaymentFilter,
|
value: _selectedPaymentFilter,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
@@ -473,7 +475,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
|||||||
final sectors = sectorsBox.values.toList();
|
final sectors = sectorsBox.values.toList();
|
||||||
|
|
||||||
return DropdownButtonFormField<int?>(
|
return DropdownButtonFormField<int?>(
|
||||||
initialValue: _selectedSectorId,
|
value: _selectedSectorId,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
@@ -520,13 +522,8 @@ class _HistoryContentState extends State<HistoryContent> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
if (isAdmin)
|
if (isAdmin)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ValueListenableBuilder<Box<UserModel>>(
|
child: DropdownButtonFormField<int?>(
|
||||||
valueListenable: Hive.box<UserModel>(AppKeys.userBoxName).listenable(),
|
value: _selectedUserId,
|
||||||
builder: (context, usersBox, child) {
|
|
||||||
final users = usersBox.values.where((user) => user.role == 1).toList();
|
|
||||||
|
|
||||||
return DropdownButtonFormField<int?>(
|
|
||||||
initialValue: _selectedUserId,
|
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
@@ -537,7 +534,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
|||||||
value: null,
|
value: null,
|
||||||
child: Text('Membres'),
|
child: Text('Membres'),
|
||||||
),
|
),
|
||||||
...users.map((UserModel user) {
|
..._users.map((UserModel user) {
|
||||||
return DropdownMenuItem<int?>(
|
return DropdownMenuItem<int?>(
|
||||||
value: user.id,
|
value: user.id,
|
||||||
child: Text('${user.firstName ?? ''} ${user.name ?? ''}'),
|
child: Text('${user.firstName ?? ''} ${user.name ?? ''}'),
|
||||||
@@ -550,8 +547,6 @@ class _HistoryContentState extends State<HistoryContent> {
|
|||||||
});
|
});
|
||||||
_notifyFiltersChanged();
|
_notifyFiltersChanged();
|
||||||
},
|
},
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -896,6 +891,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
|||||||
if (memberId != null && memberId is int) {
|
if (memberId != null && memberId is int) {
|
||||||
setState(() {
|
setState(() {
|
||||||
selectedMemberId = memberId;
|
selectedMemberId = memberId;
|
||||||
|
_selectedUserId = memberId; // Synchroniser avec le nouveau filtre
|
||||||
});
|
});
|
||||||
debugPrint('HistoryPage: Membre présélectionné chargé: $memberId');
|
debugPrint('HistoryPage: Membre présélectionné chargé: $memberId');
|
||||||
}
|
}
|
||||||
@@ -906,6 +902,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
|||||||
if (sectorId != null && sectorId is int) {
|
if (sectorId != null && sectorId is int) {
|
||||||
setState(() {
|
setState(() {
|
||||||
selectedSectorId = sectorId;
|
selectedSectorId = sectorId;
|
||||||
|
_selectedSectorId = sectorId; // Synchroniser avec le nouveau filtre
|
||||||
});
|
});
|
||||||
debugPrint('HistoryPage: Secteur présélectionné chargé: $sectorId');
|
debugPrint('HistoryPage: Secteur présélectionné chargé: $sectorId');
|
||||||
}
|
}
|
||||||
@@ -917,6 +914,10 @@ class _HistoryContentState extends State<HistoryContent> {
|
|||||||
selectedTypeId = typeId;
|
selectedTypeId = typeId;
|
||||||
final typeInfo = AppKeys.typesPassages[typeId];
|
final typeInfo = AppKeys.typesPassages[typeId];
|
||||||
selectedType = typeInfo != null ? typeInfo['titre'] as String : 'Inconnu';
|
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');
|
debugPrint('HistoryPage: Type de passage présélectionné: $typeId');
|
||||||
}
|
}
|
||||||
@@ -926,6 +927,12 @@ class _HistoryContentState extends State<HistoryContent> {
|
|||||||
if (paymentTypeId != null && paymentTypeId is int) {
|
if (paymentTypeId != null && paymentTypeId is int) {
|
||||||
setState(() {
|
setState(() {
|
||||||
selectedPaymentTypeId = paymentTypeId;
|
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');
|
debugPrint('HistoryPage: Type de règlement présélectionné: $paymentTypeId');
|
||||||
}
|
}
|
||||||
@@ -1592,8 +1599,11 @@ class _HistoryContentState extends State<HistoryContent> {
|
|||||||
orElse: () => PassageModel.fromJson(passageMap),
|
orElse: () => PassageModel.fromJson(passageMap),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Vérifier les permissions : admin peut tout éditer, user seulement ses propres passages
|
// Vérifier les permissions :
|
||||||
if (isAdmin || passage.fkUser == currentUserId) {
|
// - 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);
|
_handlePassageEdit(passage);
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -93,13 +93,11 @@ class _HomeContentState extends State<HomeContent> {
|
|||||||
|
|
||||||
// Tableau détaillé des membres - uniquement pour admin sur Web
|
// Tableau détaillé des membres - uniquement pour admin sur Web
|
||||||
if (isAdmin && kIsWeb) ...[
|
if (isAdmin && kIsWeb) ...[
|
||||||
const MembersBoardPassages(
|
const MembersBoardPassages(),
|
||||||
height: 700,
|
|
||||||
),
|
|
||||||
const SizedBox(height: AppTheme.spacingL),
|
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
|
// Le widget filtre automatiquement selon le rôle de l'utilisateur
|
||||||
ValueListenableBuilder<Box<SectorModel>>(
|
ValueListenableBuilder<Box<SectorModel>>(
|
||||||
valueListenable: Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
|
valueListenable: Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
|
||||||
@@ -113,9 +111,13 @@ class _HomeContentState extends State<HomeContent> {
|
|||||||
sectorCount = userSectors.length;
|
sectorCount = userSectors.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// N'afficher que s'il y a plus d'un secteur
|
||||||
|
if (sectorCount <= 1) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
return SectorDistributionCard(
|
return SectorDistributionCard(
|
||||||
title: '$sectorCount secteur${sectorCount > 1 ? 's' : ''}',
|
title: '$sectorCount secteurs',
|
||||||
height: 500,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -132,10 +134,9 @@ class _HomeContentState extends State<HomeContent> {
|
|||||||
child: ActivityChart(
|
child: ActivityChart(
|
||||||
height: 350,
|
height: 350,
|
||||||
showAllPassages: isAdmin, // Admin voit tout, user voit tous les passages de ses secteurs
|
showAllPassages: isAdmin, // Admin voit tout, user voit tous les passages de ses secteurs
|
||||||
title: isAdmin
|
title: isAdmin ? 'Passages' : 'Mes passages',
|
||||||
? 'Passages réalisés par jour (15 derniers jours)'
|
daysToShow: 7,
|
||||||
: 'Passages de mes secteurs par jour (15 derniers jours)',
|
showPeriodButtons: true,
|
||||||
daysToShow: 15,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import 'package:geosector_app/core/constants/app_keys.dart';
|
|||||||
import 'package:hive_flutter/hive_flutter.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/passage_model.dart';
|
||||||
import 'package:geosector_app/core/data/models/user_sector_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
|
/// Widget de graphique d'activité affichant les passages
|
||||||
class ActivityChart extends StatefulWidget {
|
class ActivityChart extends StatefulWidget {
|
||||||
@@ -51,6 +53,9 @@ class ActivityChart extends StatefulWidget {
|
|||||||
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
|
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
|
||||||
final bool useValueListenable;
|
final bool useValueListenable;
|
||||||
|
|
||||||
|
/// Afficher les boutons de sélection de période (7j, 14j, 21j)
|
||||||
|
final bool showPeriodButtons;
|
||||||
|
|
||||||
const ActivityChart({
|
const ActivityChart({
|
||||||
super.key,
|
super.key,
|
||||||
this.passageData,
|
this.passageData,
|
||||||
@@ -66,6 +71,7 @@ class ActivityChart extends StatefulWidget {
|
|||||||
this.columnSpacing = 0.2,
|
this.columnSpacing = 0.2,
|
||||||
this.showAllPassages = false,
|
this.showAllPassages = false,
|
||||||
this.useValueListenable = true,
|
this.useValueListenable = true,
|
||||||
|
this.showPeriodButtons = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -94,9 +100,14 @@ class _ActivityChartState extends State<ActivityChart>
|
|||||||
// Contrôleur de zoom pour le graphique
|
// Contrôleur de zoom pour le graphique
|
||||||
late ZoomPanBehavior _zoomPanBehavior;
|
late ZoomPanBehavior _zoomPanBehavior;
|
||||||
|
|
||||||
|
// Période sélectionnée pour le filtre (7, 14 ou 21 jours)
|
||||||
|
late int _selectedDays;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_selectedDays = widget.daysToShow;
|
||||||
|
|
||||||
_animationController = AnimationController(
|
_animationController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 1500),
|
duration: const Duration(milliseconds: 1500),
|
||||||
@@ -157,7 +168,7 @@ class _ActivityChartState extends State<ActivityChart>
|
|||||||
valueListenable:
|
valueListenable:
|
||||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||||
final chartData = _calculateActivityData(passagesBox);
|
final chartData = _calculateActivityData(passagesBox, _selectedDays);
|
||||||
return _buildChart(chartData);
|
return _buildChart(chartData);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -179,7 +190,7 @@ class _ActivityChartState extends State<ActivityChart>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Calcule les données d'activité depuis la Hive box
|
/// 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 {
|
try {
|
||||||
final passages = passagesBox.values.toList();
|
final passages = passagesBox.values.toList();
|
||||||
final currentUser = userRepository.getCurrentUser();
|
final currentUser = userRepository.getCurrentUser();
|
||||||
@@ -187,55 +198,63 @@ class _ActivityChartState extends State<ActivityChart>
|
|||||||
// Pour les users : récupérer les secteurs assignés
|
// Pour les users : récupérer les secteurs assignés
|
||||||
Set<int>? userSectorIds;
|
Set<int>? userSectorIds;
|
||||||
if (!widget.showAllPassages && currentUser != null) {
|
if (!widget.showAllPassages && currentUser != null) {
|
||||||
final userSectorBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
|
final userSectors = userRepository.getUserSectors();
|
||||||
userSectorIds = userSectorBox.values
|
userSectorIds = userSectors.map((sector) => sector.id).toSet();
|
||||||
.where((us) => us.id == currentUser.id)
|
debugPrint('ActivityChart: Mode USER - Secteurs assignés: $userSectorIds');
|
||||||
.map((us) => us.fkSector)
|
} else {
|
||||||
.toSet();
|
debugPrint('ActivityChart: Mode ADMIN - Tous les passages');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculer la date de début (nombre de jours en arrière)
|
// Calculer la date de début (nombre de jours en arrière)
|
||||||
final endDate = DateTime.now();
|
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
|
// Préparer les données par date
|
||||||
final Map<String, Map<int, int>> dataByDate = {};
|
final Map<String, Map<int, int>> dataByDate = {};
|
||||||
|
|
||||||
// Initialiser toutes les dates de la période
|
// 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 date = startDate.add(Duration(days: i));
|
||||||
final dateStr = DateFormat('yyyy-MM-dd').format(date);
|
final dateStr = DateFormat('yyyy-MM-dd').format(date);
|
||||||
dataByDate[dateStr] = {};
|
dataByDate[dateStr] = {};
|
||||||
|
|
||||||
// Initialiser tous les types de passage possibles
|
// Initialiser tous les types de passage possibles
|
||||||
for (final typeId in AppKeys.typesPassages.keys) {
|
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
|
// Parcourir les passages et les compter par date et type
|
||||||
|
int includedCount = 0;
|
||||||
for (final passage in passages) {
|
for (final passage in passages) {
|
||||||
// Appliquer les filtres
|
// Appliquer les filtres
|
||||||
bool shouldInclude = true;
|
bool shouldInclude = true;
|
||||||
|
String excludeReason = '';
|
||||||
|
|
||||||
// Filtrer par secteurs assignés si nécessaire (pour les users)
|
// Filtrer par secteurs assignés si nécessaire (pour les users)
|
||||||
if (userSectorIds != null && !userSectorIds.contains(passage.fkSector)) {
|
if (userSectorIds != null && !userSectorIds.contains(passage.fkSector)) {
|
||||||
shouldInclude = false;
|
shouldInclude = false;
|
||||||
|
excludeReason = 'Secteur ${passage.fkSector} non assigné';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclure certains types
|
// Exclure les passages de type 2 (À finaliser) avec nbPassages = 0
|
||||||
if (widget.excludePassageTypes.contains(passage.fkType)) {
|
if (shouldInclude && passage.fkType == 2 && passage.nbPassages == 0) {
|
||||||
shouldInclude = false;
|
shouldInclude = false;
|
||||||
|
excludeReason = 'Type 2 avec nbPassages=0';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier si le passage est dans la période
|
// Vérifier si le passage est dans la période
|
||||||
final passageDate = passage.passedAt;
|
final passageDate = passage.passedAt;
|
||||||
if (passageDate == null ||
|
if (shouldInclude && (passageDate == null ||
|
||||||
passageDate.isBefore(startDate) ||
|
passageDate.isBefore(startDate) ||
|
||||||
passageDate.isAfter(endDate)) {
|
passageDate.isAfter(endDate))) {
|
||||||
shouldInclude = false;
|
shouldInclude = false;
|
||||||
|
excludeReason = passageDate == null
|
||||||
|
? 'Date null'
|
||||||
|
: 'Hors période (${DateFormat('yyyy-MM-dd').format(passageDate)})';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldInclude && passageDate != null) {
|
if (shouldInclude && passageDate != null) {
|
||||||
@@ -243,9 +262,14 @@ class _ActivityChartState extends State<ActivityChart>
|
|||||||
if (dataByDate.containsKey(dateStr)) {
|
if (dataByDate.containsKey(dateStr)) {
|
||||||
dataByDate[dateStr]![passage.fkType] =
|
dataByDate[dateStr]![passage.fkType] =
|
||||||
(dataByDate[dateStr]![passage.fkType] ?? 0) + 1;
|
(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
|
// Convertir en liste d'ActivityData
|
||||||
final List<ActivityData> chartData = [];
|
final List<ActivityData> chartData = [];
|
||||||
@@ -367,17 +391,33 @@ class _ActivityChartState extends State<ActivityChart>
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Titre
|
// En-tête avec titre et boutons de filtre
|
||||||
if (widget.title.isNotEmpty)
|
if (widget.title.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
left: 16.0, right: 16.0, top: 16.0, bottom: 8.0),
|
left: 16.0, right: 16.0, top: 16.0, bottom: 8.0),
|
||||||
child: Text(
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
widget.title,
|
widget.title,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
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
|
// Graphique
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -434,10 +474,8 @@ class _ActivityChartState extends State<ActivityChart>
|
|||||||
return series;
|
return series;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obtenir tous les types de passage (sauf ceux exclus)
|
// Obtenir tous les types de passage
|
||||||
final passageTypes = AppKeys.typesPassages.keys
|
final passageTypes = AppKeys.typesPassages.keys.toList();
|
||||||
.where((typeId) => !widget.excludePassageTypes.contains(typeId))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// Créer les séries pour les passages (colonnes empilées)
|
// Créer les séries pour les passages (colonnes empilées)
|
||||||
for (final typeId in passageTypes) {
|
for (final typeId in passageTypes) {
|
||||||
@@ -481,6 +519,10 @@ class _ActivityChartState extends State<ActivityChart>
|
|||||||
),
|
),
|
||||||
markerSettings: const MarkerSettings(isVisible: false),
|
markerSettings: const MarkerSettings(isVisible: false),
|
||||||
animationDuration: 1500,
|
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;
|
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;
|
library geosector_charts;
|
||||||
|
|
||||||
export 'payment_data.dart';
|
export 'payment_data.dart';
|
||||||
export 'payment_pie_chart.dart';
|
|
||||||
export 'payment_summary_card.dart';
|
export 'payment_summary_card.dart';
|
||||||
export 'passage_data.dart';
|
export 'passage_data.dart';
|
||||||
export 'passage_utils.dart';
|
export 'passage_utils.dart';
|
||||||
export 'passage_pie_chart.dart';
|
|
||||||
export 'passage_summary_card.dart';
|
export 'passage_summary_card.dart';
|
||||||
export 'activity_chart.dart';
|
export 'activity_chart.dart';
|
||||||
export 'combined_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: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/constants/app_keys.dart';
|
||||||
import 'package:geosector_app/core/theme/app_theme.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:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||||
import 'package:geosector_app/app.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
|
/// Widget commun pour afficher une carte de synthèse des passages
|
||||||
/// avec liste des types à gauche et graphique en camembert à droite
|
/// avec liste des types à gauche et graphique en camembert à droite
|
||||||
class PassageSummaryCard extends StatelessWidget {
|
class PassageSummaryCard extends StatefulWidget {
|
||||||
/// Titre de la carte
|
/// Titre de la carte
|
||||||
final String title;
|
final String title;
|
||||||
|
|
||||||
@@ -73,10 +93,51 @@ class PassageSummaryCard extends StatelessWidget {
|
|||||||
this.backgroundIconSize = 180,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Si useValueListenable, construire avec ValueListenableBuilder centralisé
|
// Si useValueListenable, construire avec ValueListenableBuilder centralisé
|
||||||
if (useValueListenable) {
|
if (widget.useValueListenable) {
|
||||||
return ValueListenableBuilder(
|
return ValueListenableBuilder(
|
||||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||||
@@ -93,11 +154,11 @@ class PassageSummaryCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Données statiques
|
// 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(
|
return _buildCardContent(
|
||||||
context,
|
context,
|
||||||
totalUserPassages: totalPassages,
|
totalUserPassages: totalPassages,
|
||||||
passagesCounts: passagesByType ?? {},
|
passagesCounts: widget.passagesByType ?? {},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,20 +177,20 @@ class PassageSummaryCard extends StatelessWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Icône d'arrière-plan (optionnelle)
|
// Icône d'arrière-plan (optionnelle)
|
||||||
if (backgroundIcon != null)
|
if (widget.backgroundIcon != null)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
backgroundIcon,
|
widget.backgroundIcon,
|
||||||
size: backgroundIconSize,
|
size: widget.backgroundIconSize,
|
||||||
color: (backgroundIconColor ?? AppTheme.primaryColor)
|
color: (widget.backgroundIconColor ?? AppTheme.primaryColor)
|
||||||
.withValues(alpha: backgroundIconOpacity),
|
.withValues(alpha: widget.backgroundIconOpacity),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Contenu principal
|
// Contenu principal
|
||||||
Container(
|
Container(
|
||||||
height: height,
|
height: widget.height,
|
||||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -145,32 +206,19 @@ class PassageSummaryCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Liste des passages à gauche
|
// Liste des passages à gauche
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: isDesktop ? 1 : 2,
|
flex: widget.isDesktop ? 1 : 2,
|
||||||
child: _buildPassagesList(context, passagesCounts),
|
child: _buildPassagesList(context, passagesCounts),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Séparateur vertical
|
// Séparateur vertical
|
||||||
if (isDesktop) const VerticalDivider(width: 24),
|
if (widget.isDesktop) const VerticalDivider(width: 24),
|
||||||
|
|
||||||
// Graphique en camembert à droite
|
// Graphique en camembert à droite
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: isDesktop ? 1 : 2,
|
flex: widget.isDesktop ? 1 : 2,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: PassagePieChart(
|
child: _buildPieChart(passagesCounts),
|
||||||
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%',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -189,17 +237,17 @@ class PassageSummaryCard extends StatelessWidget {
|
|||||||
Widget _buildTitle(BuildContext context, int totalUserPassages) {
|
Widget _buildTitle(BuildContext context, int totalUserPassages) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
if (titleIcon != null) ...[
|
if (widget.titleIcon != null) ...[
|
||||||
Icon(
|
Icon(
|
||||||
titleIcon,
|
widget.titleIcon,
|
||||||
color: titleColor,
|
color: widget.titleColor,
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
widget.title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: AppTheme.r(context, 16),
|
fontSize: AppTheme.r(context, 16),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -207,19 +255,38 @@ class PassageSummaryCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
customTotalDisplay?.call(totalUserPassages) ??
|
widget.customTotalDisplay?.call(totalUserPassages) ??
|
||||||
totalUserPassages.toString(),
|
totalUserPassages.toString(),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: AppTheme.r(context, 20),
|
fontSize: AppTheme.r(context, 20),
|
||||||
fontWeight: FontWeight.bold,
|
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) {
|
Widget _buildPassagesList(BuildContext context, Map<int, int> passagesCounts) {
|
||||||
// Vérifier si le type Lot doit être affiché
|
// Vérifier si le type Lot doit être affiché
|
||||||
bool showLotType = true;
|
bool showLotType = true;
|
||||||
@@ -249,6 +316,11 @@ class PassageSummaryCard extends StatelessWidget {
|
|||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8.0),
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _handlePassageTypeClick(typeId),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 4.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -281,12 +353,103 @@ 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
|
/// Calcule les compteurs de passages par type
|
||||||
Map<int, int> _calculatePassagesCounts(Box<PassageModel> passagesBox) {
|
Map<int, int> _calculatePassagesCounts(Box<PassageModel> passagesBox) {
|
||||||
final Map<int, int> counts = {};
|
final Map<int, int> counts = {};
|
||||||
@@ -308,7 +471,7 @@ class PassageSummaryCard extends StatelessWidget {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Exclure les types non désirés
|
// Exclure les types non désirés
|
||||||
if (excludePassageTypes.contains(typeId)) {
|
if (widget.excludePassageTypes.contains(typeId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
counts[typeId] = 0;
|
counts[typeId] = 0;
|
||||||
@@ -322,7 +485,7 @@ class PassageSummaryCard extends StatelessWidget {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Exclure les types non désirés
|
// Exclure les types non désirés
|
||||||
if (excludePassageTypes.contains(passage.fkType)) {
|
if (widget.excludePassageTypes.contains(passage.fkType)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
|
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
|
||||||
@@ -330,4 +493,42 @@ class PassageSummaryCard extends StatelessWidget {
|
|||||||
|
|
||||||
return counts;
|
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: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/presentation/widgets/charts/payment_data.dart';
|
||||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||||
import 'package:geosector_app/core/theme/app_theme.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:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||||
import 'package:geosector_app/app.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
|
/// Widget commun pour afficher une carte de synthèse des règlements
|
||||||
/// avec liste des types à gauche et graphique en camembert à droite
|
/// avec liste des types à gauche et graphique en camembert à droite
|
||||||
class PaymentSummaryCard extends StatelessWidget {
|
class PaymentSummaryCard extends StatefulWidget {
|
||||||
/// Titre de la carte
|
/// Titre de la carte
|
||||||
final String title;
|
final String title;
|
||||||
|
|
||||||
@@ -70,10 +72,49 @@ class PaymentSummaryCard extends StatelessWidget {
|
|||||||
this.backgroundIconSize = 180,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Si useValueListenable, construire avec ValueListenableBuilder centralisé
|
// Si useValueListenable, construire avec ValueListenableBuilder centralisé
|
||||||
if (useValueListenable) {
|
if (widget.useValueListenable) {
|
||||||
return ValueListenableBuilder(
|
return ValueListenableBuilder(
|
||||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||||
@@ -90,11 +131,11 @@ class PaymentSummaryCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Données statiques
|
// 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(
|
return _buildCardContent(
|
||||||
context,
|
context,
|
||||||
totalAmount: totalAmount,
|
totalAmount: totalAmount,
|
||||||
paymentAmounts: paymentsByType ?? {},
|
paymentAmounts: widget.paymentsByType ?? {},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,20 +154,20 @@ class PaymentSummaryCard extends StatelessWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Icône d'arrière-plan (optionnelle)
|
// Icône d'arrière-plan (optionnelle)
|
||||||
if (backgroundIcon != null)
|
if (widget.backgroundIcon != null)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
backgroundIcon,
|
widget.backgroundIcon,
|
||||||
size: backgroundIconSize,
|
size: widget.backgroundIconSize,
|
||||||
color: (backgroundIconColor ?? Colors.blue)
|
color: (widget.backgroundIconColor ?? Colors.blue)
|
||||||
.withValues(alpha: backgroundIconOpacity),
|
.withValues(alpha: widget.backgroundIconOpacity),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Contenu principal
|
// Contenu principal
|
||||||
Container(
|
Container(
|
||||||
height: height,
|
height: widget.height,
|
||||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -142,35 +183,19 @@ class PaymentSummaryCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Liste des règlements à gauche
|
// Liste des règlements à gauche
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: isDesktop ? 1 : 2,
|
flex: widget.isDesktop ? 1 : 2,
|
||||||
child: _buildPaymentsList(context, paymentAmounts),
|
child: _buildPaymentsList(context, paymentAmounts),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Séparateur vertical
|
// Séparateur vertical
|
||||||
if (isDesktop) const VerticalDivider(width: 24),
|
if (widget.isDesktop) const VerticalDivider(width: 24),
|
||||||
|
|
||||||
// Graphique en camembert à droite
|
// Graphique en camembert à droite
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: isDesktop ? 1 : 2,
|
flex: widget.isDesktop ? 1 : 2,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: PaymentPieChart(
|
child: _buildPieChart(paymentAmounts),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -189,17 +214,17 @@ class PaymentSummaryCard extends StatelessWidget {
|
|||||||
Widget _buildTitle(BuildContext context, double totalAmount) {
|
Widget _buildTitle(BuildContext context, double totalAmount) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
if (titleIcon != null) ...[
|
if (widget.titleIcon != null) ...[
|
||||||
Icon(
|
Icon(
|
||||||
titleIcon,
|
widget.titleIcon,
|
||||||
color: titleColor,
|
color: widget.titleColor,
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
widget.title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: AppTheme.r(context, 16),
|
fontSize: AppTheme.r(context, 16),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -207,19 +232,38 @@ class PaymentSummaryCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
customTotalDisplay?.call(totalAmount) ??
|
widget.customTotalDisplay?.call(totalAmount) ??
|
||||||
'${totalAmount.toStringAsFixed(2)} €',
|
'${totalAmount.toStringAsFixed(2)} €',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: AppTheme.r(context, 20),
|
fontSize: AppTheme.r(context, 20),
|
||||||
fontWeight: FontWeight.bold,
|
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) {
|
Widget _buildPaymentsList(BuildContext context, Map<int, double> paymentAmounts) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -233,6 +277,11 @@ class PaymentSummaryCard extends StatelessWidget {
|
|||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8.0),
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _handlePaymentTypeClick(typeId),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 4.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -265,12 +314,103 @@ 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
|
/// Calcule les montants par type de règlement
|
||||||
Map<int, double> _calculatePaymentAmounts(Box<PassageModel> passagesBox) {
|
Map<int, double> _calculatePaymentAmounts(Box<PassageModel> passagesBox) {
|
||||||
final Map<int, double> paymentAmounts = {};
|
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)
|
// En mode user, filtrer uniquement les passages créés par l'utilisateur (fkUser)
|
||||||
final currentUser = userRepository.getCurrentUser();
|
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) {
|
for (final passage in passagesBox.values) {
|
||||||
// En mode user, ne compter que les passages de l'utilisateur
|
// En mode user, ne compter que les passages de l'utilisateur
|
||||||
@@ -314,8 +454,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
|||||||
return paymentAmounts;
|
return paymentAmounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convertit une Map<int, double> en List<PaymentData> pour les données statiques
|
/// Prépare les données pour le graphique en camembert à partir d'une Map
|
||||||
List<PaymentData> _convertMapToPaymentData(Map<int, double> paymentsMap) {
|
List<PaymentData> _prepareChartDataFromMap(Map<int, double> paymentsMap) {
|
||||||
final List<PaymentData> paymentDataList = [];
|
final List<PaymentData> paymentDataList = [];
|
||||||
|
|
||||||
paymentsMap.forEach((typeReglement, montant) {
|
paymentsMap.forEach((typeReglement, montant) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:flutter_map_cache/flutter_map_cache.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:path_provider/path_provider.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxHeight: widget.height ?? 700, // Hauteur max, sinon s'adapte au contenu
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||||
@@ -57,6 +54,7 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// En-tête de la card
|
// En-tête de la card
|
||||||
Container(
|
Container(
|
||||||
@@ -88,8 +86,7 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Corps avec le tableau
|
// Corps avec le tableau
|
||||||
Expanded(
|
ValueListenableBuilder<Box<MembreModel>>(
|
||||||
child: ValueListenableBuilder<Box<MembreModel>>(
|
|
||||||
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
|
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
|
||||||
builder: (context, membresBox, child) {
|
builder: (context, membresBox, child) {
|
||||||
final membres = membresBox.values.toList();
|
final membres = membresBox.values.toList();
|
||||||
@@ -118,10 +115,8 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
|||||||
..._buildRows(membres, currentOperation.id, theme),
|
..._buildRows(membres, currentOperation.id, theme),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Utilise seulement le scroll vertical, le tableau s'adapte à la largeur
|
// Afficher le tableau complet sans scroll interne
|
||||||
return SingleChildScrollView(
|
return SizedBox(
|
||||||
scrollDirection: Axis.vertical,
|
|
||||||
child: SizedBox(
|
|
||||||
width: double.infinity, // Prendre toute la largeur disponible
|
width: double.infinity, // Prendre toute la largeur disponible
|
||||||
child: DataTable(
|
child: DataTable(
|
||||||
columnSpacing: 4, // Espacement minimal entre colonnes
|
columnSpacing: 4, // Espacement minimal entre colonnes
|
||||||
@@ -135,11 +130,9 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
|||||||
columns: _buildColumns(theme),
|
columns: _buildColumns(theme),
|
||||||
rows: allRows,
|
rows: allRows,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
height: widget.height,
|
|
||||||
padding: widget.padding ?? const EdgeInsets.all(AppTheme.spacingM),
|
padding: widget.padding ?? const EdgeInsets.all(AppTheme.spacingM),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -109,6 +108,7 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Ligne du titre avec boutons de tri
|
// Ligne du titre avec boutons de tri
|
||||||
Row(
|
Row(
|
||||||
@@ -135,9 +135,7 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppTheme.spacingM),
|
const SizedBox(height: AppTheme.spacingM),
|
||||||
Expanded(
|
_buildAutoRefreshContent(),
|
||||||
child: _buildAutoRefreshContent(),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -183,6 +181,8 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
|||||||
|
|
||||||
// Liste des secteurs directement sans sous-titre
|
// Liste des secteurs directement sans sous-titre
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: sectorStats.length,
|
itemCount: sectorStats.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final sector = sectorStats[index];
|
final sector = sectorStats[index];
|
||||||
@@ -318,17 +318,27 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: isAdmin
|
child: InkWell(
|
||||||
? InkWell(
|
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Sauvegarder le secteur sélectionné et l'index de la page carte dans Hive
|
|
||||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||||
settingsBox.put('selectedSectorId', sectorId);
|
|
||||||
settingsBox.put(
|
|
||||||
'selectedPageIndex', 4); // Index de la page carte
|
|
||||||
|
|
||||||
// Naviguer vers le dashboard admin qui chargera la page carte
|
if (isAdmin) {
|
||||||
|
// Admin : naviguer vers la page carte
|
||||||
|
settingsBox.put('selectedSectorId', sectorId);
|
||||||
|
settingsBox.put('selectedPageIndex', 4); // Index de la page carte
|
||||||
context.go('/admin');
|
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(
|
child: Text(
|
||||||
name,
|
name,
|
||||||
@@ -342,16 +352,6 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
|||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: Text(
|
|
||||||
name,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: AppTheme.r(context, 14),
|
|
||||||
color: textColor,
|
|
||||||
fontWeight:
|
|
||||||
hasPassages ? FontWeight.w600 : FontWeight.w300,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
@@ -420,33 +420,30 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
|||||||
? Color(typeInfo['couleur2'] as int)
|
? Color(typeInfo['couleur2'] as int)
|
||||||
: Colors.grey;
|
: 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(
|
return Expanded(
|
||||||
flex: count,
|
flex: count,
|
||||||
child: isAdmin
|
child: InkWell(
|
||||||
? InkWell(
|
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Sauvegarder les filtres dans Hive pour la page historique
|
// Réinitialiser TOUS les filtres avant de sauvegarder les nouveaux
|
||||||
final settingsBox =
|
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||||
Hive.box(AppKeys.settingsBoxName);
|
settingsBox.delete('history_selectedPaymentTypeId');
|
||||||
settingsBox.put(
|
settingsBox.delete('history_selectedMemberId');
|
||||||
'history_selectedSectorId', sectorId);
|
settingsBox.delete('history_startDate');
|
||||||
settingsBox.put(
|
settingsBox.delete('history_endDate');
|
||||||
'history_selectedSectorName', sectorName);
|
|
||||||
settingsBox.put('history_selectedTypeId', typeId);
|
|
||||||
settingsBox.put('selectedPageIndex',
|
|
||||||
2); // Index de la page historique
|
|
||||||
|
|
||||||
// Naviguer vers le dashboard admin qui chargera la page historique
|
// Sauvegarder uniquement le secteur et le type de passage sélectionnés
|
||||||
context.go('/admin');
|
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(
|
child: Container(
|
||||||
color: color,
|
color: color,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: percentage >=
|
child: percentage >= 5
|
||||||
5 // N'afficher le texte que si >= 5%
|
|
||||||
? Text(
|
? Text(
|
||||||
'$count (${percentage.toInt()}%)',
|
'$count (${percentage.toInt()}%)',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -465,29 +462,6 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
|||||||
: null,
|
: 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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
|||||||
@@ -293,18 +293,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dio_cache_interceptor
|
name: dio_cache_interceptor
|
||||||
sha256: "1346705a2057c265014d7696e3e2318b560bfb00b484dac7f9b01e2ceaebb07d"
|
sha256: ac9f312e5a81d79cbccb15f56b78aeae7343a981c1d7c169b11194fae806ec0b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.5.1"
|
version: "4.0.5"
|
||||||
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"
|
|
||||||
dio_web_adapter:
|
dio_web_adapter:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -463,10 +455,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_map_cache
|
name: flutter_map_cache
|
||||||
sha256: "5b30c9b0d36315a22f4ee070737104a6017e7ff990e8addc8128ba81786e03ef"
|
sha256: fc9697760dc95b6adf75110a23a800ace5d95a735a58ec43f05183bc675c7246
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.2"
|
version: "2.0.0+1"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -605,6 +597,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.3"
|
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:
|
hive_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -637,6 +637,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.0"
|
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:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -741,6 +757,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
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:
|
js:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -809,10 +833,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: logger
|
name: logger
|
||||||
sha256: "55d6c23a6c15db14920e037fe7e0dc32e7cdaf3b64b4b25df2d541b5b6b81c0c"
|
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.1"
|
version: "2.6.2"
|
||||||
logging:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: geosector_app
|
name: geosector_app
|
||||||
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
|
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 3.3.4+334
|
version: 3.3.5+335
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
@@ -40,8 +40,8 @@ dependencies:
|
|||||||
# Cartes et géolocalisation
|
# Cartes et géolocalisation
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
flutter_map: ^6.1.0
|
flutter_map: ^6.1.0
|
||||||
flutter_map_cache: ^1.5.2
|
flutter_map_cache: ^2.0.0 # Mise à jour vers v2.0.0+1 (06/10/2025)
|
||||||
dio_cache_interceptor_hive_store: ^3.2.2 # Cache store pour flutter_map_cache
|
http_cache_hive_store: ^5.0.0 # Remplace dio_cache_interceptor_hive_store (discontinué)
|
||||||
path_provider: ^2.1.2 # Requis pour le cache
|
path_provider: ^2.1.2 # Requis pour le cache
|
||||||
latlong2: ^0.9.1
|
latlong2: ^0.9.1
|
||||||
geolocator: ^12.0.0
|
geolocator: ^12.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user