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:
pierre
2025-10-06 15:32:32 +02:00
parent b6584c83fa
commit 2b3d05c981
31 changed files with 1982 additions and 1442 deletions

View File

@@ -1 +1 @@
3.3.4
3.3.5

View File

@@ -90,16 +90,41 @@ INSERT INTO ope_pass_backup (
- Index sur les tables volumineuses
- 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
#### 4. Documentation API
#### 5. Documentation API
- Génération automatique OpenAPI/Swagger
- Documentation interactive
- Exemples de code pour chaque endpoint
#### 5. Tests automatisés
#### 6. Tests automatisés
- Tests unitaires pour les services critiques
- Tests d'intégration pour les endpoints
- Tests de charge

View File

@@ -981,19 +981,52 @@ Content-Type: application/json
### Configuration environnement
#### Variables Stripe par environnement :
#### Architecture des clés Stripe
| Environnement | Clés | Webhooks |
|---------------|------|----------|
| **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 |
Depuis janvier 2025, les clés Stripe sont **séparées par environnement** dans `src/Config/AppConfig.php` :
#### 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)
- Pays : France (FR)
- Devise : Euro (EUR)
- Frais : Standard Stripe Connect
- Pas de commission plateforme (100% pour l'amicale)
### Gestion des appareils certifiés Tap to Pay

View File

@@ -68,7 +68,9 @@ CREATE TABLE `email_queue` (
`headers` text DEFAULT NULL,
`created_at` timestamp NULL DEFAULT current_timestamp(),
`status` enum('pending','sent','failed') DEFAULT 'pending',
`sent_at` timestamp NULL DEFAULT NULL,
`attempts` int(10) unsigned DEFAULT 0,
`error_message` text DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `PAGE_COMPRESSED`='ON';

View File

@@ -324,7 +324,9 @@ CREATE TABLE `email_queue` (
`headers` text COLLATE utf8mb4_unicode_ci,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`status` enum('pending','sent','failed') COLLATE utf8mb4_unicode_ci DEFAULT 'pending',
`sent_at` timestamp NULL DEFAULT NULL,
`attempts` int unsigned DEFAULT '0',
`error_message` text COLLATE utf8mb4_unicode_ci DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;

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

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

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

View File

@@ -63,18 +63,10 @@ class AppConfig {
'mapbox' => [
'api_key' => '', // À remplir avec la clé API Mapbox
],
'stripe' => [
'public_key_test' => 'pk_test_51QwoVN00pblGEgsXkf8qlXmLGEpxDQcG0KLRpjrGLjJHd7AVZ4Iwd6ChgdjO0w0n3vRqwNCEW8KnHUe5eh3uIlkV00k07kCBmd', // À remplacer par votre clé publique TEST
'secret_key_test' => 'sk_test_51QwoVN00pblGEgsXnvqi8qfYpzHtesWWclvK3lzQjPNoHY0dIyOpJmxIkoLqsbmRMEUZpKS5MQ7iFDRlSqVyTo9c006yWetbsd', // À remplacer par votre clé secrète TEST
'public_key_live' => 'pk_live_XXXXXXXXXXXX', // À remplacer par votre clé publique LIVE
'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'
],
// NOTE : La configuration Stripe est définie par environnement (voir plus bas)
// - DEV : Clés TEST Pierre (développement)
// - REC : Clés TEST Client (recette)
// - PROD : Clés LIVE Client (production)
'sms' => [
'provider' => 'ovh', // Comme mentionné dans le cahier des charges
'api_key' => '', // À remplir avec la clé API SMS OVH
@@ -103,6 +95,19 @@ class AppConfig {
'username' => 'adr_geo_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
@@ -127,7 +132,19 @@ class AppConfig {
'username' => 'adr_geo_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
@@ -152,6 +169,19 @@ class AppConfig {
'username' => 'adr_geo_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
'debug' => true,
]);

View File

@@ -576,6 +576,43 @@ class PassageController {
'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
Response::json([
'status' => 'success',
@@ -595,39 +632,7 @@ class PassageController {
fastcgi_finish_request();
}
// Générer automatiquement un reçu si c'est un don (fk_type = 1) avec email valide
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
return; // Fin de la méthode
} catch (Exception $e) {
LogService::log('Erreur lors de la création du passage', [
'level' => 'error',
@@ -792,6 +797,53 @@ class PassageController {
'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
Response::json([
'status' => 'success',
@@ -810,47 +862,7 @@ class PassageController {
fastcgi_finish_request();
}
// Maintenant générer le reçu en arrière-plan après avoir envoyé la réponse
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
return; // Fin de la méthode
} catch (Exception $e) {
LogService::log('Erreur lors de la mise à jour du passage', [
'level' => 'error',

View File

@@ -51,9 +51,10 @@ class ReceiptService {
return false;
}
// Vérifier que c'est un don effectué (fk_type = 1) avec email valide
if ((int)$passageData['fk_type'] !== 1) {
return false; // Pas un don, pas de reçu
// Vérifier que c'est un don effectué (fk_type = 1) ou un lot (fk_type = 5) avec email valide
$fkType = (int)$passageData['fk_type'];
if ($fkType !== 1 && $fkType !== 5) {
return false; // Ni don ni lot, pas de reçu
}
// Déchiffrer et vérifier l'email

View File

@@ -219,15 +219,9 @@
},
{
"name": "dio_cache_interceptor",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/dio_cache_interceptor-3.5.1",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/dio_cache_interceptor-4.0.5",
"packageUri": "lib/",
"languageVersion": "2.14"
},
{
"name": "dio_cache_interceptor_hive_store",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/dio_cache_interceptor_hive_store-3.2.2",
"packageUri": "lib/",
"languageVersion": "2.14"
"languageVersion": "3.0"
},
{
"name": "dio_web_adapter",
@@ -351,7 +345,7 @@
},
{
"name": "flutter_map_cache",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/flutter_map_cache-1.5.2",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/flutter_map_cache-2.0.0+1",
"packageUri": "lib/",
"languageVersion": "3.6"
},
@@ -463,6 +457,12 @@
"packageUri": "lib/",
"languageVersion": "2.12"
},
{
"name": "hive_ce",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/hive_ce-2.14.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "hive_flutter",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/hive_flutter-1.1.0",
@@ -487,6 +487,18 @@
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "http_cache_core",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/http_cache_core-1.1.2",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "http_cache_hive_store",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/http_cache_hive_store-5.0.1",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "http_multi_server",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/http_multi_server-3.2.2",
@@ -565,6 +577,12 @@
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "isolate_channel",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/isolate_channel-0.2.2+1",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "js",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/js-0.6.7",
@@ -615,7 +633,7 @@
},
{
"name": "logger",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/logger-2.6.1",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/logger-2.6.2",
"packageUri": "lib/",
"languageVersion": "2.17"
},

View File

@@ -5,14 +5,13 @@
"packages": [
{
"name": "geosector_app",
"version": "3.3.4+334",
"version": "3.3.5+335",
"dependencies": [
"battery_plus",
"connectivity_plus",
"cupertino_icons",
"device_info_plus",
"dio",
"dio_cache_interceptor_hive_store",
"fl_chart",
"flutter",
"flutter_local_notifications",
@@ -26,6 +25,7 @@
"google_fonts",
"hive",
"hive_flutter",
"http_cache_hive_store",
"image_picker",
"intl",
"latlong2",
@@ -314,16 +314,16 @@
]
},
{
"name": "dio_cache_interceptor_hive_store",
"version": "3.2.2",
"name": "http_cache_hive_store",
"version": "5.0.1",
"dependencies": [
"dio_cache_interceptor",
"hive"
"hive_ce",
"http_cache_core"
]
},
{
"name": "flutter_map_cache",
"version": "1.5.2",
"version": "2.0.0+1",
"dependencies": [
"dio",
"dio_cache_interceptor",
@@ -1391,14 +1391,33 @@
]
},
{
"name": "dio_cache_interceptor",
"version": "3.5.1",
"name": "hive_ce",
"version": "2.14.0",
"dependencies": [
"dio",
"crypto",
"isolate_channel",
"json_annotation",
"meta",
"web"
]
},
{
"name": "http_cache_core",
"version": "1.1.2",
"dependencies": [
"collection",
"string_scanner",
"uuid"
]
},
{
"name": "dio_cache_interceptor",
"version": "4.0.5",
"dependencies": [
"dio",
"http_cache_core"
]
},
{
"name": "proj4dart",
"version": "2.1.0",
@@ -1417,7 +1436,7 @@
},
{
"name": "logger",
"version": "2.6.1",
"version": "2.6.2",
"dependencies": [
"meta"
]
@@ -1785,6 +1804,11 @@
"version": "3.1.6",
"dependencies": []
},
{
"name": "isolate_channel",
"version": "0.2.2+1",
"dependencies": []
},
{
"name": "wkt_parser",
"version": "2.0.0",

File diff suppressed because one or more lines are too long

View File

@@ -950,6 +950,107 @@ Chaque amicale dispose de son **propre compte Stripe Connect** :
9. Vérification statut compte
10. Affichage "✅ Compte actif"
### 🔑 Configuration des clés API Stripe par environnement
GEOSECTOR utilise des **clés Stripe différentes** selon l'environnement pour séparer les données de test et de production.
#### **Fichier de configuration**
`/home/pierre/dev/geosector/api/src/Config/AppConfig.php`
#### **Répartition des clés par environnement**
| Environnement | URL | Plateforme Stripe | Clés utilisées | Mode | Usage |
|---------------|-----|-------------------|----------------|------|-------|
| **DEV** | `dapp.geosector.fr` | Test Pierre | `pk_test_51QwoVN...`<br>`sk_test_51QwoVN...` | `test` | Développement |
| **REC** | `rapp.geosector.fr` | Test Client | `CLIENT_PK_TEST_A_REMPLACER`<br>`CLIENT_SK_TEST_A_REMPLACER` | `test` | Recette |
| **PROD** | `app.geosector.fr` | Live Client | `CLIENT_PK_LIVE_A_REMPLACER`<br>`CLIENT_SK_LIVE_A_REMPLACER` | `live` | Production |
#### **Types de clés Stripe**
**Clés obligatoires (2) :**
| Clé | Format | Où la trouver | Utilisation |
|-----|--------|---------------|-------------|
| **Publishable key** | `pk_test_51XXXXX...` ou `pk_live_XXXXX...` | Dashboard → Developers → API Keys | Client-side (Flutter app) |
| **Secret key** | `sk_test_51XXXXX...` ou `sk_live_XXXXX...` | Dashboard → Developers → API Keys (révéler) | Server-side (API PHP) |
**Clé optionnelle :**
| Clé | Format | Où la trouver | Utilisation |
|-----|--------|---------------|-------------|
| **Webhook secret** | `whsec_test_XXXXX...` ou `whsec_live_XXXXX...` | Dashboard → Webhooks → Endpoint → Signing secret | Validation webhooks (non utilisé actuellement) |
#### **Récupération des clés client**
Pour configurer REC et PROD, le client doit fournir ses clés depuis son **Dashboard Stripe** :
**Pour REC (clés TEST) :**
1. Se connecter sur https://dashboard.stripe.com/test/apikeys
2. Copier la **Publishable key**`CLIENT_PK_TEST_A_REMPLACER`
3. Révéler et copier la **Secret key**`CLIENT_SK_TEST_A_REMPLACER`
**Pour PROD (clés LIVE) :**
1. Se connecter sur https://dashboard.stripe.com/apikeys (mode live)
2. Copier la **Publishable key**`CLIENT_PK_LIVE_A_REMPLACER`
3. Révéler et copier la **Secret key**`CLIENT_SK_LIVE_A_REMPLACER`
#### **Configuration dans AppConfig.php**
**Structure du fichier :**
```php
// Configuration DÉVELOPPEMENT (dapp.geosector.fr)
'stripe' => [
'public_key_test' => 'pk_test_51QwoVN...', // Clés Pierre (opérationnel)
'secret_key_test' => 'sk_test_51QwoVN...',
'mode' => 'test',
],
// Configuration RECETTE (rapp.geosector.fr)
'stripe' => [
'public_key_test' => 'CLIENT_PK_TEST_A_REMPLACER', // À remplacer
'secret_key_test' => 'CLIENT_SK_TEST_A_REMPLACER', // À remplacer
'mode' => 'test',
],
// Configuration PRODUCTION (app.geosector.fr)
'stripe' => [
'public_key_live' => 'CLIENT_PK_LIVE_A_REMPLACER', // À remplacer
'secret_key_live' => 'CLIENT_SK_LIVE_A_REMPLACER', // À remplacer
'mode' => 'live',
],
```
#### **Points importants**
⚠️ **Isolation des environnements** :
- DEV utilise la plateforme de test de Pierre (développement isolé)
- REC utilise la plateforme de test du client (tests en conditions réelles)
- PROD utilise la plateforme live du client (vraies transactions)
⚠️ **Sécurité** :
- Ne JAMAIS commiter les vraies clés dans Git
- Vérifier que `AppConfig.php` est dans `.gitignore`
- Les clés secrètes ne doivent jamais être exposées côté client
⚠️ **Mode de fonctionnement** :
- L'API détecte automatiquement l'environnement via l'URL
- Le `mode` (`test` ou `live`) détermine quelle paire de clés utiliser
- En mode `test` : utilise `public_key_test` et `secret_key_test`
- En mode `live` : utilise `public_key_live` et `secret_key_live`
#### **Déploiement après modification**
Après avoir remplacé les placeholders par les vraies clés :
```bash
cd /home/pierre/dev/geosector/api
./deploy-api.sh
```
L'API sera redéployée sur l'environnement correspondant avec les nouvelles clés.
### 📱 Tap to Pay V2 - Paiement sans contact
#### **Fonctionnalités prévues**

View File

@@ -1008,3 +1008,368 @@ Pour vérifier que le cache est désactivé en DEV/REC :
**Date d'ajout** : 2025-09-23
**Auteur** : Solution de gestion du cache
**Version** : 1.0.0
## ✅ Améliorations de l'interactivité des graphiques - v3.3.5
**Date** : 06/10/2025
**Version** : 3.3.5
**Statut** : ✅ Complété
### 📋 Vue d'ensemble
Amélioration majeure de l'expérience utilisateur avec l'ajout d'interactivité sur tous les graphiques et cartes du tableau de bord, permettant une navigation intelligente vers l'historique avec filtres pré-appliqués.
### 🎯 Modifications apportées
#### 1. **Réinitialisation des filtres lors des clics sur les graphiques**
**Fichiers modifiés** :
- `lib/presentation/widgets/charts/passage_summary_card.dart`
- `lib/presentation/widgets/charts/payment_summary_card.dart`
- `lib/presentation/widgets/sector_distribution_card.dart`
**Implémentation** :
```dart
// Réinitialiser TOUS les filtres avant de sauvegarder le nouveau
settingsBox.delete('history_selectedPaymentTypeId');
settingsBox.delete('history_selectedSectorId');
settingsBox.delete('history_selectedSectorName');
settingsBox.delete('history_selectedMemberId');
settingsBox.delete('history_startDate');
settingsBox.delete('history_endDate');
// Sauvegarder uniquement le critère sélectionné
settingsBox.put('history_selectedTypeId', typeId);
```
**Bénéfice** : L'utilisateur voit uniquement les passages correspondant au critère cliqué, sans interférence d'anciens filtres.
---
#### 2. **Navigation directe vers les pages d'historique**
**Correction** : Changement des routes de navigation de `/admin` et `/user` vers `/admin/history` et `/user/history`.
**Code** :
```dart
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
context.go(isAdmin ? '/admin/history' : '/user/history');
```
**Bénéfice** : Navigation immédiate vers la page cible sans étape intermédiaire.
---
#### 3. **Chargement des filtres pour tous les utilisateurs**
**Fichier** : `lib/presentation/pages/history_page.dart` (lignes 143-151)
**Problème** : La méthode `_loadPreselectedFilters()` n'était appelée que pour les admins.
**Solution** :
```dart
} else {
_loadPreselectedFilters(); // Maintenant appelé pour tous
if (!isAdmin) {
selectedMemberId = currentUserId;
}
}
```
**Bénéfice** : Les filtres fonctionnent correctement en mode utilisateur.
---
#### 4. **Correction du dropdown des membres (admin)**
**Fichier** : `lib/presentation/pages/history_page.dart` (lignes 537-542)
**Problème** : Utilisation de `Hive.box<UserModel>` qui ne contient que le currentUser.
**Solution** : Utiliser la liste `_users` construite depuis `membreRepository.getAllMembres()`.
```dart
..._users.map((UserModel user) {
return DropdownMenuItem<int?>(
value: user.id,
child: Text('${user.firstName ?? ''} ${user.name ?? ''}'),
);
}),
```
**Bénéfice** : Affichage correct de tous les membres de l'amicale.
---
#### 5. **Adaptation dynamique de la hauteur des cartes**
**Fichiers** :
- `lib/presentation/widgets/sector_distribution_card.dart`
- `lib/presentation/widgets/members_board_passages.dart`
**Modification** : Suppression des contraintes de hauteur fixe.
```dart
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: sectorStats.length,
itemBuilder: (context, index) => ...,
)
```
**Bénéfice** : Les cartes s'adaptent à leur contenu sans espace vide inutile.
---
#### 6. **Correction du bug ActivityChart (secteurs utilisateur)**
**Fichier** : `lib/presentation/widgets/charts/activity_chart.dart` (lignes 196-201)
**Problème** : Logique incorrecte de récupération des secteurs utilisateur.
**Code AVANT (bugué)** :
```dart
userSectorIds = userSectorBox.values
.where((us) => us.id == currentUser.id)
.map((us) => us.fkSector)
.toSet();
```
**Code APRÈS (corrigé)** :
```dart
final userSectors = userRepository.getUserSectors();
userSectorIds = userSectors.map((sector) => sector.id).toSet();
```
**Bénéfice** : Le graphique affiche correctement les passages des secteurs assignés à l'utilisateur.
---
#### 7. **Ajout de boutons de période (7j/14j/21j)**
**Fichier** : `lib/presentation/widgets/charts/activity_chart.dart`
**Implémentation** :
- Ajout d'un état `_selectedDays` (par défaut 7 jours)
- Création de la méthode `_buildPeriodButton(int days)`
- Affichage conditionnel via paramètre `showPeriodButtons`
```dart
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildPeriodButton(7),
const SizedBox(width: 4),
_buildPeriodButton(14),
const SizedBox(width: 4),
_buildPeriodButton(21),
],
)
```
**Bénéfice** : L'utilisateur peut rapidement changer la période d'affichage.
---
#### 8. **Affichage conditionnel des boutons de période**
**Paramètre ajouté** : `showPeriodButtons` (par défaut `false`)
**Usage** :
- `home_page.dart` : `showPeriodButtons: true`
- `history_page.dart` : non utilisé (donc `false`)
**Bénéfice** : Les boutons n'apparaissent que sur la page d'accueil.
---
#### 9. **Passages type 2 éditables par tous les utilisateurs**
**Fichier** : `lib/presentation/pages/history_page.dart` (ligne 1606)
**Modification** :
```dart
if (isAdmin || passage.fkUser == currentUserId || passage.fkType == 2) {
_handlePassageEdit(passage);
}
```
**Bénéfice** : Tous les utilisateurs peuvent finaliser les passages de type 2 (À finaliser).
---
#### 10. **Noms de secteurs cliquables pour les utilisateurs**
**Fichier** : `lib/presentation/widgets/sector_distribution_card.dart` (lignes 321-342)
**Implémentation** :
```dart
Expanded(
child: InkWell(
onTap: () {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
if (isAdmin) {
// Admin : naviguer vers la carte
settingsBox.put('selectedSectorId', sectorId);
settingsBox.put('selectedPageIndex', 4);
context.go('/admin');
} else {
// User : naviguer vers l'historique avec filtre secteur
settingsBox.delete('history_selectedTypeId');
settingsBox.delete('history_selectedPaymentTypeId');
// ... autres suppressions
settingsBox.put('history_selectedSectorId', sectorId);
settingsBox.put('history_selectedSectorName', name);
context.go('/user/history');
}
},
child: Text(name, ...),
),
),
```
**Bénéfice** : Les utilisateurs peuvent cliquer sur un nom de secteur pour voir ses passages dans l'historique.
---
#### 11. **Interactivité des segments de barres ActivityChart**
**Fichier** : `lib/presentation/widgets/charts/activity_chart.dart`
**Nouvelles dépendances** :
```dart
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:go_router/go_router.dart';
```
**Implémentation** :
##### a) Callback `onPointTap` dans StackedColumnSeries
```dart
onPointTap: widget.showPeriodButtons ? (ChartPointDetails details) {
_handlePointTap(details, typeId);
} : null,
```
##### b) Méthode `_handlePointTap` (lignes 532-573)
```dart
void _handlePointTap(ChartPointDetails details, int typeId) {
if (details.pointIndex == null || details.pointIndex! < 0) return;
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final chartData = _calculateActivityData(passageBox, _selectedDays);
if (details.pointIndex! >= chartData.length) return;
final clickedData = chartData[details.pointIndex!];
final clickedDate = clickedData.date;
final settingsBox = Hive.box(AppKeys.settingsBoxName);
// Réinitialiser tous les autres filtres
settingsBox.delete('history_selectedPaymentTypeId');
settingsBox.delete('history_selectedSectorId');
settingsBox.delete('history_selectedSectorName');
settingsBox.delete('history_selectedMemberId');
// Appliquer le filtre de type
settingsBox.put('history_selectedTypeId', typeId);
// Définir la plage de dates pour la journée complète
final startDateTime = DateTime(
clickedDate.year,
clickedDate.month,
clickedDate.day,
0, 0, 0
);
settingsBox.put('history_startDate', startDateTime.millisecondsSinceEpoch);
final endDateTime = DateTime(
clickedDate.year,
clickedDate.month,
clickedDate.day,
23, 59, 59
);
settingsBox.put('history_endDate', endDateTime.millisecondsSinceEpoch);
// Naviguer vers l'historique
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
context.go(isAdmin ? '/admin/history' : '/user/history');
}
```
**Fonctionnalités** :
- Clic sur un segment de barre → filtre par type ET date exacte
- Date de début : jour cliqué à 00:00:00
- Date de fin : jour cliqué à 23:59:59
- Réinitialisation de tous les autres filtres
- Navigation contextuelle (admin/user)
**Bénéfice** : Navigation ultra-précise vers les passages d'un type spécifique pour une journée donnée.
---
### 📊 Impact UX
| Fonctionnalité | Avant | Après |
|----------------|-------|-------|
| **Clics sur graphiques** | Non fonctionnel | ✅ Navigation avec filtres |
| **Filtres utilisateurs** | ❌ Ne marchait pas | ✅ Fonctionnels |
| **Dropdown membres** | ❌ Vide en admin | ✅ Tous les membres |
| **Hauteur des cartes** | Fixe (espace vide) | ✅ Adaptative |
| **ActivityChart users** | ❌ Pas de données | ✅ Affichage correct |
| **Boutons de période** | Absents | ✅ 7j/14j/21j |
| **Édition type 2** | Admin seulement | ✅ Tous les users |
| **Secteurs cliquables** | Admin uniquement | ✅ Admin et users |
| **Segments de barres** | Non cliquables | ✅ Filtrage par type+date |
### 🎨 Expérience utilisateur améliorée
1. **Navigation intuitive** : Cliquer sur n'importe quel élément visuel (graphique, secteur, barre) filtre automatiquement l'historique
2. **Filtres intelligents** : Réinitialisation automatique pour éviter les conflits
3. **Contexte préservé** : Admin et utilisateurs ont des comportements adaptés
4. **Période flexible** : Choix rapide entre 7, 14 ou 21 jours
5. **Précision temporelle** : Sélection jour par jour via les segments de barres
### 🔍 Fichiers modifiés
```
lib/presentation/widgets/charts/
├── passage_summary_card.dart ✏️ Filtres + navigation
├── payment_summary_card.dart ✏️ Filtres + navigation
└── activity_chart.dart ✏️ Boutons période + clic segments
lib/presentation/widgets/
└── sector_distribution_card.dart ✏️ Filtres + hauteur + clics users
lib/presentation/pages/
├── home_page.dart ✏️ Paramètres ActivityChart
└── history_page.dart ✏️ Filtres users + dropdown membres
```
### 🧪 Tests effectués
- ✅ Clics sur PassageSummaryCard → historique filtré
- ✅ Clics sur PaymentSummaryCard → historique filtré
- ✅ Clics sur SectorDistributionCard → historique filtré
- ✅ Clics sur segments ActivityChart → historique avec type + date
- ✅ Boutons de période 7j/14j/21j fonctionnels
- ✅ Affichage correct en mode admin et user
- ✅ Dropdown membres affiche tous les membres
- ✅ Hauteur des cartes adaptative
- ✅ Édition passages type 2 par tous
### 🚀 Prochaines étapes suggérées
- [ ] Ajouter un indicateur visuel sur les éléments cliquables (cursor: pointer)
- [ ] Animation de transition lors de la navigation vers l'historique
- [ ] Tooltip sur les segments de barres pour prévisualiser les données
- [ ] Export des données filtrées depuis l'historique
- [ ] Mémorisation des périodes préférées par utilisateur
---
**Date de complétion** : 06/10/2025
**Testé par** : Équipe de développement
**Statut** : ✅ Prêt pour production

View File

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

View File

@@ -126,7 +126,7 @@ class AppKeys {
},
2: {
'titre': 'Chèque',
'couleur': 0xFFD8D5EC, // Violet clair (Figma)
'couleur': 0xFF7E57C2, // Violet foncé (Material Design Deep Purple 400)
'icon_data': Icons.account_balance_wallet_outlined,
},
3: {
@@ -186,9 +186,9 @@ class AppKeys {
6: {
'titres': 'Maisons vides',
'titre': 'Maison vide',
'couleur1': 0xFFB8B8B8, // Gris (Figma)
'couleur2': 0xFFB8B8B8, // Gris (Figma)
'couleur3': 0xFFB8B8B8, // Gris (Figma)
'couleur1': 0xFF757575, // Gris foncé (Material Design 600)
'couleur2': 0xFF757575, // Gris foncé (Material Design 600)
'couleur3': 0xFF757575, // Gris foncé (Material Design 600)
'icon_data': Icons.home_outlined,
},
};

View File

@@ -140,12 +140,14 @@ class _HistoryContentState extends State<HistoryContent> {
// Sauvegarder aussi dans Hive pour la persistance
_saveMemberFilter(widget.memberId!);
} else if (!isAdmin) {
// Pour un user standard, toujours filtrer sur son propre ID
selectedMemberId = currentUserId;
} else {
// Admin sans memberId spécifique, charger les filtres depuis Hive
// Pour tous les autres cas (admin et user), charger les filtres depuis Hive
_loadPreselectedFilters();
// Pour un user standard, toujours filtrer sur son propre ID
if (!isAdmin) {
selectedMemberId = currentUserId;
}
}
_initializeNewFilters();
@@ -385,7 +387,7 @@ class _HistoryContentState extends State<HistoryContent> {
// Filtre Type de passage
Expanded(
child: DropdownButtonFormField<String>(
initialValue: _selectedTypeFilter,
value: _selectedTypeFilter,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -418,7 +420,7 @@ class _HistoryContentState extends State<HistoryContent> {
// Filtre Mode de règlement
Expanded(
child: DropdownButtonFormField<String>(
initialValue: _selectedPaymentFilter,
value: _selectedPaymentFilter,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -473,7 +475,7 @@ class _HistoryContentState extends State<HistoryContent> {
final sectors = sectorsBox.values.toList();
return DropdownButtonFormField<int?>(
initialValue: _selectedSectorId,
value: _selectedSectorId,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -520,37 +522,30 @@ class _HistoryContentState extends State<HistoryContent> {
const SizedBox(width: 12),
if (isAdmin)
Expanded(
child: ValueListenableBuilder<Box<UserModel>>(
valueListenable: Hive.box<UserModel>(AppKeys.userBoxName).listenable(),
builder: (context, usersBox, child) {
final users = usersBox.values.where((user) => user.role == 1).toList();
return DropdownButtonFormField<int?>(
initialValue: _selectedUserId,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
),
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('Membres'),
),
...users.map((UserModel user) {
return DropdownMenuItem<int?>(
value: user.id,
child: Text('${user.firstName ?? ''} ${user.name ?? ''}'),
);
}),
],
onChanged: (int? newValue) {
setState(() {
_selectedUserId = newValue;
});
_notifyFiltersChanged();
},
);
child: DropdownButtonFormField<int?>(
value: _selectedUserId,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
),
items: [
const DropdownMenuItem<int?>(
value: null,
child: Text('Membres'),
),
..._users.map((UserModel user) {
return DropdownMenuItem<int?>(
value: user.id,
child: Text('${user.firstName ?? ''} ${user.name ?? ''}'),
);
}),
],
onChanged: (int? newValue) {
setState(() {
_selectedUserId = newValue;
});
_notifyFiltersChanged();
},
),
)
@@ -896,6 +891,7 @@ class _HistoryContentState extends State<HistoryContent> {
if (memberId != null && memberId is int) {
setState(() {
selectedMemberId = memberId;
_selectedUserId = memberId; // Synchroniser avec le nouveau filtre
});
debugPrint('HistoryPage: Membre présélectionné chargé: $memberId');
}
@@ -906,6 +902,7 @@ class _HistoryContentState extends State<HistoryContent> {
if (sectorId != null && sectorId is int) {
setState(() {
selectedSectorId = sectorId;
_selectedSectorId = sectorId; // Synchroniser avec le nouveau filtre
});
debugPrint('HistoryPage: Secteur présélectionné chargé: $sectorId');
}
@@ -917,6 +914,10 @@ class _HistoryContentState extends State<HistoryContent> {
selectedTypeId = typeId;
final typeInfo = AppKeys.typesPassages[typeId];
selectedType = typeInfo != null ? typeInfo['titre'] as String : 'Inconnu';
// Synchroniser avec le nouveau filtre
if (typeInfo != null) {
_selectedTypeFilter = typeInfo['titre'] as String;
}
});
debugPrint('HistoryPage: Type de passage présélectionné: $typeId');
}
@@ -926,6 +927,12 @@ class _HistoryContentState extends State<HistoryContent> {
if (paymentTypeId != null && paymentTypeId is int) {
setState(() {
selectedPaymentTypeId = paymentTypeId;
_selectedPaymentTypeId = paymentTypeId; // Synchroniser avec le nouveau filtre
// Mettre à jour aussi le label du filtre
final paymentInfo = AppKeys.typesReglements[paymentTypeId];
if (paymentInfo != null) {
_selectedPaymentFilter = paymentInfo['titre'] as String;
}
});
debugPrint('HistoryPage: Type de règlement présélectionné: $paymentTypeId');
}
@@ -1592,8 +1599,11 @@ class _HistoryContentState extends State<HistoryContent> {
orElse: () => PassageModel.fromJson(passageMap),
);
// Vérifier les permissions : admin peut tout éditer, user seulement ses propres passages
if (isAdmin || passage.fkUser == currentUserId) {
// Vérifier les permissions :
// - Admin peut tout éditer
// - User peut éditer ses propres passages
// - Type 2 (À finaliser) : éditable par tous les utilisateurs
if (isAdmin || passage.fkUser == currentUserId || passage.fkType == 2) {
_handlePassageEdit(passage);
} else {
ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -93,13 +93,11 @@ class _HomeContentState extends State<HomeContent> {
// Tableau détaillé des membres - uniquement pour admin sur Web
if (isAdmin && kIsWeb) ...[
const MembersBoardPassages(
height: 700,
),
const MembersBoardPassages(),
const SizedBox(height: AppTheme.spacingL),
],
// LIGNE 2 : Carte de répartition par secteur
// LIGNE 2 : Carte de répartition par secteur (uniquement si > 1 secteur)
// Le widget filtre automatiquement selon le rôle de l'utilisateur
ValueListenableBuilder<Box<SectorModel>>(
valueListenable: Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
@@ -113,9 +111,13 @@ class _HomeContentState extends State<HomeContent> {
sectorCount = userSectors.length;
}
// N'afficher que s'il y a plus d'un secteur
if (sectorCount <= 1) {
return const SizedBox.shrink();
}
return SectorDistributionCard(
title: '$sectorCount secteur${sectorCount > 1 ? 's' : ''}',
height: 500,
title: '$sectorCount secteurs',
);
},
),
@@ -132,10 +134,9 @@ class _HomeContentState extends State<HomeContent> {
child: ActivityChart(
height: 350,
showAllPassages: isAdmin, // Admin voit tout, user voit tous les passages de ses secteurs
title: isAdmin
? 'Passages réalisés par jour (15 derniers jours)'
: 'Passages de mes secteurs par jour (15 derniers jours)',
daysToShow: 15,
title: isAdmin ? 'Passages' : 'Mes passages',
daysToShow: 7,
showPeriodButtons: true,
),
),

View File

@@ -7,6 +7,8 @@ import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/user_sector_model.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:go_router/go_router.dart';
/// Widget de graphique d'activité affichant les passages
class ActivityChart extends StatefulWidget {
@@ -51,6 +53,9 @@ class ActivityChart extends StatefulWidget {
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
final bool useValueListenable;
/// Afficher les boutons de sélection de période (7j, 14j, 21j)
final bool showPeriodButtons;
const ActivityChart({
super.key,
this.passageData,
@@ -66,6 +71,7 @@ class ActivityChart extends StatefulWidget {
this.columnSpacing = 0.2,
this.showAllPassages = false,
this.useValueListenable = true,
this.showPeriodButtons = false,
});
@override
@@ -94,9 +100,14 @@ class _ActivityChartState extends State<ActivityChart>
// Contrôleur de zoom pour le graphique
late ZoomPanBehavior _zoomPanBehavior;
// Période sélectionnée pour le filtre (7, 14 ou 21 jours)
late int _selectedDays;
@override
void initState() {
super.initState();
_selectedDays = widget.daysToShow;
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
@@ -157,7 +168,7 @@ class _ActivityChartState extends State<ActivityChart>
valueListenable:
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final chartData = _calculateActivityData(passagesBox);
final chartData = _calculateActivityData(passagesBox, _selectedDays);
return _buildChart(chartData);
},
);
@@ -179,7 +190,7 @@ class _ActivityChartState extends State<ActivityChart>
}
/// Calcule les données d'activité depuis la Hive box
List<ActivityData> _calculateActivityData(Box<PassageModel> passagesBox) {
List<ActivityData> _calculateActivityData(Box<PassageModel> passagesBox, int daysToShow) {
try {
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
@@ -187,55 +198,63 @@ class _ActivityChartState extends State<ActivityChart>
// Pour les users : récupérer les secteurs assignés
Set<int>? userSectorIds;
if (!widget.showAllPassages && currentUser != null) {
final userSectorBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
userSectorIds = userSectorBox.values
.where((us) => us.id == currentUser.id)
.map((us) => us.fkSector)
.toSet();
final userSectors = userRepository.getUserSectors();
userSectorIds = userSectors.map((sector) => sector.id).toSet();
debugPrint('ActivityChart: Mode USER - Secteurs assignés: $userSectorIds');
} else {
debugPrint('ActivityChart: Mode ADMIN - Tous les passages');
}
// Calculer la date de début (nombre de jours en arrière)
final endDate = DateTime.now();
final startDate = endDate.subtract(Duration(days: widget.daysToShow - 1));
final startDate = endDate.subtract(Duration(days: daysToShow - 1));
debugPrint('ActivityChart: Période du ${DateFormat('yyyy-MM-dd').format(startDate)} au ${DateFormat('yyyy-MM-dd').format(endDate)}');
debugPrint('ActivityChart: Nombre total de passages: ${passages.length}');
// Préparer les données par date
final Map<String, Map<int, int>> dataByDate = {};
// Initialiser toutes les dates de la période
for (int i = 0; i < widget.daysToShow; i++) {
for (int i = 0; i < daysToShow; i++) {
final date = startDate.add(Duration(days: i));
final dateStr = DateFormat('yyyy-MM-dd').format(date);
dataByDate[dateStr] = {};
// Initialiser tous les types de passage possibles
for (final typeId in AppKeys.typesPassages.keys) {
if (!widget.excludePassageTypes.contains(typeId)) {
dataByDate[dateStr]![typeId] = 0;
}
dataByDate[dateStr]![typeId] = 0;
}
}
// Parcourir les passages et les compter par date et type
int includedCount = 0;
for (final passage in passages) {
// Appliquer les filtres
bool shouldInclude = true;
String excludeReason = '';
// Filtrer par secteurs assignés si nécessaire (pour les users)
if (userSectorIds != null && !userSectorIds.contains(passage.fkSector)) {
shouldInclude = false;
excludeReason = 'Secteur ${passage.fkSector} non assigné';
}
// Exclure certains types
if (widget.excludePassageTypes.contains(passage.fkType)) {
// Exclure les passages de type 2 (À finaliser) avec nbPassages = 0
if (shouldInclude && passage.fkType == 2 && passage.nbPassages == 0) {
shouldInclude = false;
excludeReason = 'Type 2 avec nbPassages=0';
}
// Vérifier si le passage est dans la période
final passageDate = passage.passedAt;
if (passageDate == null ||
if (shouldInclude && (passageDate == null ||
passageDate.isBefore(startDate) ||
passageDate.isAfter(endDate)) {
passageDate.isAfter(endDate))) {
shouldInclude = false;
excludeReason = passageDate == null
? 'Date null'
: 'Hors période (${DateFormat('yyyy-MM-dd').format(passageDate)})';
}
if (shouldInclude && passageDate != null) {
@@ -243,10 +262,15 @@ class _ActivityChartState extends State<ActivityChart>
if (dataByDate.containsKey(dateStr)) {
dataByDate[dateStr]![passage.fkType] =
(dataByDate[dateStr]![passage.fkType] ?? 0) + 1;
includedCount++;
}
} else if (!shouldInclude && userSectorIds != null) {
debugPrint('ActivityChart: Passage #${passage.id} exclu - $excludeReason (type=${passage.fkType}, secteur=${passage.fkSector}, date=${passageDate != null ? DateFormat('yyyy-MM-dd').format(passageDate) : 'null'})');
}
}
debugPrint('ActivityChart: Passages inclus dans le graphique: $includedCount');
// Convertir en liste d'ActivityData
final List<ActivityData> chartData = [];
dataByDate.forEach((dateStr, passagesByType) {
@@ -367,16 +391,32 @@ class _ActivityChartState extends State<ActivityChart>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre
// En-tête avec titre et boutons de filtre
if (widget.title.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: 16.0, right: 16.0, top: 16.0, bottom: 8.0),
child: Text(
widget.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (widget.showPeriodButtons)
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildPeriodButton(7),
const SizedBox(width: 4),
_buildPeriodButton(14),
const SizedBox(width: 4),
_buildPeriodButton(21),
],
),
],
),
),
// Graphique
@@ -434,10 +474,8 @@ class _ActivityChartState extends State<ActivityChart>
return series;
}
// Obtenir tous les types de passage (sauf ceux exclus)
final passageTypes = AppKeys.typesPassages.keys
.where((typeId) => !widget.excludePassageTypes.contains(typeId))
.toList();
// Obtenir tous les types de passage
final passageTypes = AppKeys.typesPassages.keys.toList();
// Créer les séries pour les passages (colonnes empilées)
for (final typeId in passageTypes) {
@@ -481,6 +519,10 @@ class _ActivityChartState extends State<ActivityChart>
),
markerSettings: const MarkerSettings(isVisible: false),
animationDuration: 1500,
// Ajouter le callback de clic uniquement depuis home_page
onPointTap: widget.showPeriodButtons ? (ChartPointDetails details) {
_handlePointTap(details, typeId);
} : null,
),
);
}
@@ -488,4 +530,86 @@ class _ActivityChartState extends State<ActivityChart>
return series;
}
/// Gère le clic sur un point du graphique
void _handlePointTap(ChartPointDetails details, int typeId) {
if (details.pointIndex == null || details.pointIndex! < 0) return;
// Récupérer les données du point cliqué
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final passages = passageBox.values.toList();
// Calculer la date de début (nombre de jours en arrière)
final endDate = DateTime.now();
final startDate = endDate.subtract(Duration(days: _selectedDays - 1));
// Créer les données d'activité
final chartData = _calculateActivityData(passageBox, _selectedDays);
if (details.pointIndex! >= chartData.length) return;
final clickedData = chartData[details.pointIndex!];
final clickedDate = clickedData.date;
// Réinitialiser tous les filtres sauf celui sélectionné
final settingsBox = Hive.box(AppKeys.settingsBoxName);
settingsBox.delete('history_selectedPaymentTypeId');
settingsBox.delete('history_selectedSectorId');
settingsBox.delete('history_selectedSectorName');
settingsBox.delete('history_selectedMemberId');
// Sauvegarder le type de passage et les dates (début et fin de journée)
settingsBox.put('history_selectedTypeId', typeId);
// Date de début : début de la journée cliquée
final startDateTime = DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 0, 0, 0);
settingsBox.put('history_startDate', startDateTime.millisecondsSinceEpoch);
// Date de fin : fin de la journée cliquée
final endDateTime = DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 23, 59, 59);
settingsBox.put('history_endDate', endDateTime.millisecondsSinceEpoch);
// Naviguer vers la page historique
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
context.go(isAdmin ? '/admin/history' : '/user/history');
}
/// Construit un bouton de sélection de période
Widget _buildPeriodButton(int days) {
final isSelected = _selectedDays == days;
return InkWell(
onTap: () {
setState(() {
_selectedDays = days;
_animationController.reset();
_animationController.forward();
});
widget.onPeriodChanged?.call(days);
},
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
width: 1,
),
),
child: Text(
'${days}j',
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.white : Colors.grey.shade700,
),
),
),
);
}
}

View File

@@ -2,11 +2,9 @@
library geosector_charts;
export 'payment_data.dart';
export 'payment_pie_chart.dart';
export 'payment_summary_card.dart';
export 'passage_data.dart';
export 'passage_utils.dart';
export 'passage_pie_chart.dart';
export 'passage_summary_card.dart';
export 'activity_chart.dart';
export 'combined_chart.dart';

View File

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

View File

@@ -1,14 +1,34 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart';
import 'package:flutter/foundation.dart' show listEquals;
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/app.dart';
import 'package:go_router/go_router.dart';
/// Modèle de données pour le graphique en camembert des passages
class PassageChartData {
final int typeId;
final int count;
final String title;
final Color color;
final IconData icon;
PassageChartData({
required this.typeId,
required this.count,
required this.title,
required this.color,
required this.icon,
});
}
/// Widget commun pour afficher une carte de synthèse des passages
/// avec liste des types à gauche et graphique en camembert à droite
class PassageSummaryCard extends StatelessWidget {
class PassageSummaryCard extends StatefulWidget {
/// Titre de la carte
final String title;
@@ -73,10 +93,51 @@ class PassageSummaryCard extends StatelessWidget {
this.backgroundIconSize = 180,
});
@override
State<PassageSummaryCard> createState() => _PassageSummaryCardState();
}
class _PassageSummaryCardState extends State<PassageSummaryCard>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
_animationController.forward();
}
@override
void didUpdateWidget(PassageSummaryCard oldWidget) {
super.didUpdateWidget(oldWidget);
// Relancer l'animation si les paramètres importants ont changé
final bool shouldResetAnimation = oldWidget.userId != widget.userId ||
!listEquals(
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
oldWidget.showAllPassages != widget.showAllPassages ||
oldWidget.useValueListenable != widget.useValueListenable;
if (shouldResetAnimation) {
_animationController.reset();
_animationController.forward();
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Si useValueListenable, construire avec ValueListenableBuilder centralisé
if (useValueListenable) {
if (widget.useValueListenable) {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
@@ -93,11 +154,11 @@ class PassageSummaryCard extends StatelessWidget {
);
} else {
// Données statiques
final totalPassages = passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
final totalPassages = widget.passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
return _buildCardContent(
context,
totalUserPassages: totalPassages,
passagesCounts: passagesByType ?? {},
passagesCounts: widget.passagesByType ?? {},
);
}
}
@@ -116,20 +177,20 @@ class PassageSummaryCard extends StatelessWidget {
child: Stack(
children: [
// Icône d'arrière-plan (optionnelle)
if (backgroundIcon != null)
if (widget.backgroundIcon != null)
Positioned.fill(
child: Center(
child: Icon(
backgroundIcon,
size: backgroundIconSize,
color: (backgroundIconColor ?? AppTheme.primaryColor)
.withValues(alpha: backgroundIconOpacity),
widget.backgroundIcon,
size: widget.backgroundIconSize,
color: (widget.backgroundIconColor ?? AppTheme.primaryColor)
.withValues(alpha: widget.backgroundIconOpacity),
),
),
),
// Contenu principal
Container(
height: height,
height: widget.height,
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -145,32 +206,19 @@ class PassageSummaryCard extends StatelessWidget {
children: [
// Liste des passages à gauche
Expanded(
flex: isDesktop ? 1 : 2,
flex: widget.isDesktop ? 1 : 2,
child: _buildPassagesList(context, passagesCounts),
),
// Séparateur vertical
if (isDesktop) const VerticalDivider(width: 24),
if (widget.isDesktop) const VerticalDivider(width: 24),
// Graphique en camembert à droite
Expanded(
flex: isDesktop ? 1 : 2,
flex: widget.isDesktop ? 1 : 2,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: PassagePieChart(
useValueListenable: false, // Utilise les données calculées
passagesByType: passagesCounts,
excludePassageTypes: excludePassageTypes,
showAllPassages: showAllPassages,
userId: showAllPassages ? null : userId,
size: double.infinity,
labelSize: 12,
showPercentage: true,
showIcons: false,
showLegend: false,
isDonut: true,
innerRadius: '50%',
),
child: _buildPieChart(passagesCounts),
),
),
],
@@ -189,17 +237,17 @@ class PassageSummaryCard extends StatelessWidget {
Widget _buildTitle(BuildContext context, int totalUserPassages) {
return Row(
children: [
if (titleIcon != null) ...[
if (widget.titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
widget.titleIcon,
color: widget.titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
widget.title,
style: TextStyle(
fontSize: AppTheme.r(context, 16),
fontWeight: FontWeight.bold,
@@ -207,19 +255,38 @@ class PassageSummaryCard extends StatelessWidget {
),
),
Text(
customTotalDisplay?.call(totalUserPassages) ??
widget.customTotalDisplay?.call(totalUserPassages) ??
totalUserPassages.toString(),
style: TextStyle(
fontSize: AppTheme.r(context, 20),
fontWeight: FontWeight.bold,
color: titleColor,
color: widget.titleColor,
),
),
],
);
}
/// Construction de la liste des passages
/// Gérer le clic sur un type de passage
void _handlePassageTypeClick(int typeId) {
// Réinitialiser TOUS les filtres avant de sauvegarder le nouveau
final settingsBox = Hive.box(AppKeys.settingsBoxName);
settingsBox.delete('history_selectedPaymentTypeId');
settingsBox.delete('history_selectedSectorId');
settingsBox.delete('history_selectedSectorName');
settingsBox.delete('history_selectedMemberId');
settingsBox.delete('history_startDate');
settingsBox.delete('history_endDate');
// Sauvegarder uniquement le type de passage sélectionné
settingsBox.put('history_selectedTypeId', typeId);
// Naviguer directement vers la page historique
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
context.go(isAdmin ? '/admin/history' : '/user/history');
}
/// Construction de la liste des passages (avec clics)
Widget _buildPassagesList(BuildContext context, Map<int, int> passagesCounts) {
// Vérifier si le type Lot doit être affiché
bool showLotType = true;
@@ -249,37 +316,44 @@ class PassageSummaryCard extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(
iconData,
color: Colors.white,
size: 16,
),
child: InkWell(
onTap: () => _handlePassageTypeClick(typeId),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 4.0),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(
iconData,
color: Colors.white,
size: 16,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
typeData['titres'] as String,
style: TextStyle(fontSize: AppTheme.r(context, 14)),
),
),
Text(
count.toString(),
style: TextStyle(
fontSize: AppTheme.r(context, 16),
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
const SizedBox(width: 8),
Expanded(
child: Text(
typeData['titres'] as String,
style: TextStyle(fontSize: AppTheme.r(context, 14)),
),
),
Text(
count.toString(),
style: TextStyle(
fontSize: AppTheme.r(context, 16),
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
),
);
}),
@@ -287,6 +361,95 @@ class PassageSummaryCard extends StatelessWidget {
);
}
/// Construction du graphique en camembert (avec clics)
Widget _buildPieChart(Map<int, int> passagesCounts) {
final chartData = _prepareChartDataFromMap(passagesCounts);
// Si aucune donnée, afficher un message
if (chartData.isEmpty) {
return const Center(
child: Text('Aucune donnée disponible'),
);
}
// Créer des animations
final progressAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
);
final explodeAnimation = CurvedAnimation(
parent: _animationController,
curve: const Interval(0.7, 1.0, curve: Curves.elasticOut),
);
final opacityAnimation = CurvedAnimation(
parent: _animationController,
curve: const Interval(0.1, 0.5, curve: Curves.easeIn),
);
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return SfCircularChart(
margin: EdgeInsets.zero,
legend: Legend(
isVisible: false,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
textStyle: const TextStyle(fontSize: 12),
),
tooltipBehavior: TooltipBehavior(enable: true),
onSelectionChanged: (SelectionArgs args) {
// Gérer le clic sur un segment du graphique
final pointIndex = args.pointIndex;
if (pointIndex < chartData.length) {
final selectedData = chartData[pointIndex];
_handlePassageTypeClick(selectedData.typeId);
}
},
series: <CircularSeries>[
DoughnutSeries<PassageChartData, String>(
dataSource: chartData,
xValueMapper: (PassageChartData data, _) => data.title,
yValueMapper: (PassageChartData data, _) => data.count,
pointColorMapper: (PassageChartData data, _) => data.color,
enableTooltip: true,
selectionBehavior: SelectionBehavior(
enable: true,
selectedColor: null, // Garde la couleur d'origine
unselectedOpacity: 0.5,
),
dataLabelMapper: (PassageChartData data, _) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(0, (sum, item) => sum + item.count);
final percentage = (data.count / total * 100);
return '${percentage.toStringAsFixed(1)}%';
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.outside,
textStyle: const TextStyle(fontSize: 12),
connectorLineSettings: const ConnectorLineSettings(
type: ConnectorType.curve,
length: '15%',
),
),
innerRadius: '50%',
explode: true,
explodeIndex: 0,
explodeOffset: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
opacity: opacityAnimation.value,
animationDuration: 0,
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
),
],
);
},
);
}
/// Calcule les compteurs de passages par type
Map<int, int> _calculatePassagesCounts(Box<PassageModel> passagesBox) {
final Map<int, int> counts = {};
@@ -308,7 +471,7 @@ class PassageSummaryCard extends StatelessWidget {
continue;
}
// Exclure les types non désirés
if (excludePassageTypes.contains(typeId)) {
if (widget.excludePassageTypes.contains(typeId)) {
continue;
}
counts[typeId] = 0;
@@ -322,7 +485,7 @@ class PassageSummaryCard extends StatelessWidget {
continue;
}
// Exclure les types non désirés
if (excludePassageTypes.contains(passage.fkType)) {
if (widget.excludePassageTypes.contains(passage.fkType)) {
continue;
}
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
@@ -330,4 +493,42 @@ class PassageSummaryCard extends StatelessWidget {
return counts;
}
/// Prépare les données pour le graphique en camembert à partir d'une Map
List<PassageChartData> _prepareChartDataFromMap(Map<int, int> passagesByType) {
final List<PassageChartData> chartData = [];
// Vérifier si le type Lot doit être affiché
bool showLotType = true;
final currentUser = CurrentUserService.instance.currentUser;
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
showLotType = userAmicale.chkLotActif;
}
}
// Créer les données du graphique
passagesByType.forEach((typeId, count) {
// Exclure le type Lot (5) si chkLotActif = false
if (typeId == 5 && !showLotType) {
return; // Skip ce type
}
// Vérifier que le type existe et que le compteur est positif
if (count > 0 && AppKeys.typesPassages.containsKey(typeId)) {
final typeInfo = AppKeys.typesPassages[typeId]!;
chartData.add(PassageChartData(
typeId: typeId,
count: count,
title: typeInfo['titre'] as String,
color: Color(typeInfo['couleur2'] as int),
icon: typeInfo['icon_data'] as IconData,
));
}
});
return chartData;
}
}

View File

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

View File

@@ -1,15 +1,17 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/app.dart';
import 'package:go_router/go_router.dart';
/// Widget commun pour afficher une carte de synthèse des règlements
/// avec liste des types à gauche et graphique en camembert à droite
class PaymentSummaryCard extends StatelessWidget {
class PaymentSummaryCard extends StatefulWidget {
/// Titre de la carte
final String title;
@@ -70,10 +72,49 @@ class PaymentSummaryCard extends StatelessWidget {
this.backgroundIconSize = 180,
});
@override
State<PaymentSummaryCard> createState() => _PaymentSummaryCardState();
}
class _PaymentSummaryCardState extends State<PaymentSummaryCard>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
_animationController.forward();
}
@override
void didUpdateWidget(PaymentSummaryCard oldWidget) {
super.didUpdateWidget(oldWidget);
// Relancer l'animation si les paramètres importants ont changé
final bool shouldResetAnimation = oldWidget.userId != widget.userId ||
oldWidget.showAllPayments != widget.showAllPayments ||
oldWidget.useValueListenable != widget.useValueListenable;
if (shouldResetAnimation) {
_animationController.reset();
_animationController.forward();
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Si useValueListenable, construire avec ValueListenableBuilder centralisé
if (useValueListenable) {
if (widget.useValueListenable) {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
@@ -90,11 +131,11 @@ class PaymentSummaryCard extends StatelessWidget {
);
} else {
// Données statiques
final totalAmount = paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
final totalAmount = widget.paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
return _buildCardContent(
context,
totalAmount: totalAmount,
paymentAmounts: paymentsByType ?? {},
paymentAmounts: widget.paymentsByType ?? {},
);
}
}
@@ -113,20 +154,20 @@ class PaymentSummaryCard extends StatelessWidget {
child: Stack(
children: [
// Icône d'arrière-plan (optionnelle)
if (backgroundIcon != null)
if (widget.backgroundIcon != null)
Positioned.fill(
child: Center(
child: Icon(
backgroundIcon,
size: backgroundIconSize,
color: (backgroundIconColor ?? Colors.blue)
.withValues(alpha: backgroundIconOpacity),
widget.backgroundIcon,
size: widget.backgroundIconSize,
color: (widget.backgroundIconColor ?? Colors.blue)
.withValues(alpha: widget.backgroundIconOpacity),
),
),
),
// Contenu principal
Container(
height: height,
height: widget.height,
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -142,35 +183,19 @@ class PaymentSummaryCard extends StatelessWidget {
children: [
// Liste des règlements à gauche
Expanded(
flex: isDesktop ? 1 : 2,
flex: widget.isDesktop ? 1 : 2,
child: _buildPaymentsList(context, paymentAmounts),
),
// Séparateur vertical
if (isDesktop) const VerticalDivider(width: 24),
if (widget.isDesktop) const VerticalDivider(width: 24),
// Graphique en camembert à droite
Expanded(
flex: isDesktop ? 1 : 2,
flex: widget.isDesktop ? 1 : 2,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: PaymentPieChart(
useValueListenable: false, // Utilise les données calculées
payments: _convertMapToPaymentData(paymentAmounts),
showAllPassages: showAllPayments,
userId: showAllPayments ? null : userId,
size: double.infinity,
labelSize: 12,
showPercentage: true,
showIcons: false,
showLegend: false,
isDonut: true,
innerRadius: '50%',
enable3DEffect: false,
effect3DIntensity: 0.0,
enableEnhancedExplode: false,
useGradient: false,
),
child: _buildPieChart(paymentAmounts),
),
),
],
@@ -189,17 +214,17 @@ class PaymentSummaryCard extends StatelessWidget {
Widget _buildTitle(BuildContext context, double totalAmount) {
return Row(
children: [
if (titleIcon != null) ...[
if (widget.titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
widget.titleIcon,
color: widget.titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
widget.title,
style: TextStyle(
fontSize: AppTheme.r(context, 16),
fontWeight: FontWeight.bold,
@@ -207,19 +232,38 @@ class PaymentSummaryCard extends StatelessWidget {
),
),
Text(
customTotalDisplay?.call(totalAmount) ??
widget.customTotalDisplay?.call(totalAmount) ??
'${totalAmount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: AppTheme.r(context, 20),
fontWeight: FontWeight.bold,
color: titleColor,
color: widget.titleColor,
),
),
],
);
}
/// Construction de la liste des règlements
/// Gérer le clic sur un type de règlement
void _handlePaymentTypeClick(int typeId) {
// Réinitialiser TOUS les filtres avant de sauvegarder le nouveau
final settingsBox = Hive.box(AppKeys.settingsBoxName);
settingsBox.delete('history_selectedTypeId');
settingsBox.delete('history_selectedSectorId');
settingsBox.delete('history_selectedSectorName');
settingsBox.delete('history_selectedMemberId');
settingsBox.delete('history_startDate');
settingsBox.delete('history_endDate');
// Sauvegarder uniquement le type de règlement sélectionné
settingsBox.put('history_selectedPaymentTypeId', typeId);
// Naviguer directement vers la page historique
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
context.go(isAdmin ? '/admin/history' : '/user/history');
}
/// Construction de la liste des règlements (avec clics)
Widget _buildPaymentsList(BuildContext context, Map<int, double> paymentAmounts) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -233,37 +277,44 @@ class PaymentSummaryCard extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(
iconData,
color: Colors.white,
size: 16,
),
child: InkWell(
onTap: () => _handlePaymentTypeClick(typeId),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 4.0),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(
iconData,
color: Colors.white,
size: 16,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
typeData['titre'] as String,
style: TextStyle(fontSize: AppTheme.r(context, 14)),
),
),
Text(
'${amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: AppTheme.r(context, 16),
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
const SizedBox(width: 8),
Expanded(
child: Text(
typeData['titre'] as String,
style: TextStyle(fontSize: AppTheme.r(context, 14)),
),
),
Text(
'${amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: AppTheme.r(context, 16),
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
),
);
}),
@@ -271,6 +322,95 @@ class PaymentSummaryCard extends StatelessWidget {
);
}
/// Construction du graphique en camembert (avec clics)
Widget _buildPieChart(Map<int, double> paymentAmounts) {
final chartData = _prepareChartDataFromMap(paymentAmounts);
// Si aucune donnée, afficher un message
if (chartData.isEmpty) {
return const Center(
child: Text('Aucune donnée disponible'),
);
}
// Créer des animations
final progressAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
);
final explodeAnimation = CurvedAnimation(
parent: _animationController,
curve: const Interval(0.7, 1.0, curve: Curves.elasticOut),
);
final opacityAnimation = CurvedAnimation(
parent: _animationController,
curve: const Interval(0.1, 0.5, curve: Curves.easeIn),
);
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return SfCircularChart(
margin: EdgeInsets.zero,
legend: Legend(
isVisible: false,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
textStyle: const TextStyle(fontSize: 12),
),
tooltipBehavior: TooltipBehavior(enable: true),
onSelectionChanged: (SelectionArgs args) {
// Gérer le clic sur un segment du graphique
final pointIndex = args.pointIndex;
if (pointIndex < chartData.length) {
final selectedData = chartData[pointIndex];
_handlePaymentTypeClick(selectedData.typeId);
}
},
series: <CircularSeries>[
DoughnutSeries<PaymentData, String>(
dataSource: chartData,
xValueMapper: (PaymentData data, _) => data.title,
yValueMapper: (PaymentData data, _) => data.amount,
pointColorMapper: (PaymentData data, _) => data.color,
enableTooltip: true,
selectionBehavior: SelectionBehavior(
enable: true,
selectedColor: null, // Garde la couleur d'origine
unselectedOpacity: 0.5,
),
dataLabelMapper: (PaymentData data, _) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(0.0, (sum, item) => sum + item.amount);
final percentage = (data.amount / total * 100);
return '${percentage.toStringAsFixed(1)}%';
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.inside,
textStyle: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
innerRadius: '50%',
explode: true,
explodeIndex: 0,
explodeOffset: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
opacity: opacityAnimation.value,
animationDuration: 0,
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
),
],
);
},
);
}
/// Calcule les montants par type de règlement
Map<int, double> _calculatePaymentAmounts(Box<PassageModel> passagesBox) {
final Map<int, double> paymentAmounts = {};
@@ -282,7 +422,7 @@ class PaymentSummaryCard extends StatelessWidget {
// En mode user, filtrer uniquement les passages créés par l'utilisateur (fkUser)
final currentUser = userRepository.getCurrentUser();
final int? filterUserId = showAllPayments ? null : currentUser?.id;
final int? filterUserId = widget.showAllPayments ? null : currentUser?.id;
for (final passage in passagesBox.values) {
// En mode user, ne compter que les passages de l'utilisateur
@@ -314,8 +454,8 @@ class PaymentSummaryCard extends StatelessWidget {
return paymentAmounts;
}
/// Convertit une Map<int, double> en List<PaymentData> pour les données statiques
List<PaymentData> _convertMapToPaymentData(Map<int, double> paymentsMap) {
/// Prépare les données pour le graphique en camembert à partir d'une Map
List<PaymentData> _prepareChartDataFromMap(Map<int, double> paymentsMap) {
final List<PaymentData> paymentDataList = [];
paymentsMap.forEach((typeReglement, montant) {

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_cache/flutter_map_cache.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import 'package:http_cache_hive_store/http_cache_hive_store.dart'; // Mise à jour v2.0.0 (06/10/2025)
import 'package:path_provider/path_provider.dart';
import 'package:latlong2/latlong.dart';
import 'package:geosector_app/core/constants/app_keys.dart';

View File

@@ -47,9 +47,6 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
final theme = Theme.of(context);
return Container(
constraints: BoxConstraints(
maxHeight: widget.height ?? 700, // Hauteur max, sinon s'adapte au contenu
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
@@ -57,6 +54,7 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// En-tête de la card
Container(
@@ -88,8 +86,7 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
),
// Corps avec le tableau
Expanded(
child: ValueListenableBuilder<Box<MembreModel>>(
ValueListenableBuilder<Box<MembreModel>>(
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
builder: (context, membresBox, child) {
final membres = membresBox.values.toList();
@@ -118,28 +115,24 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
..._buildRows(membres, currentOperation.id, theme),
];
// Utilise seulement le scroll vertical, le tableau s'adapte à la largeur
return SingleChildScrollView(
scrollDirection: Axis.vertical,
child: SizedBox(
width: double.infinity, // Prendre toute la largeur disponible
child: DataTable(
columnSpacing: 4, // Espacement minimal entre colonnes
horizontalMargin: 4, // Marges horizontales minimales
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
dataRowMinHeight: 42,
dataRowMaxHeight: 42,
headingRowColor: WidgetStateProperty.all(
theme.colorScheme.primary.withValues(alpha: 0.08),
),
columns: _buildColumns(theme),
rows: allRows,
// Afficher le tableau complet sans scroll interne
return SizedBox(
width: double.infinity, // Prendre toute la largeur disponible
child: DataTable(
columnSpacing: 4, // Espacement minimal entre colonnes
horizontalMargin: 4, // Marges horizontales minimales
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
dataRowMinHeight: 42,
dataRowMaxHeight: 42,
headingRowColor: WidgetStateProperty.all(
theme.colorScheme.primary.withValues(alpha: 0.08),
),
columns: _buildColumns(theme),
rows: allRows,
),
);
},
),
),
],
),
);

View File

@@ -100,7 +100,6 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
@override
Widget build(BuildContext context) {
return Container(
height: widget.height,
padding: widget.padding ?? const EdgeInsets.all(AppTheme.spacingM),
decoration: BoxDecoration(
color: Colors.white,
@@ -109,6 +108,7 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Ligne du titre avec boutons de tri
Row(
@@ -135,9 +135,7 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
],
),
const SizedBox(height: AppTheme.spacingM),
Expanded(
child: _buildAutoRefreshContent(),
),
_buildAutoRefreshContent(),
],
),
);
@@ -183,6 +181,8 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
// Liste des secteurs directement sans sous-titre
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: sectorStats.length,
itemBuilder: (context, index) {
final sector = sectorStats[index];
@@ -318,41 +318,41 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: isAdmin
? InkWell(
onTap: () {
// Sauvegarder le secteur sélectionné et l'index de la page carte dans Hive
final settingsBox = Hive.box(AppKeys.settingsBoxName);
settingsBox.put('selectedSectorId', sectorId);
settingsBox.put(
'selectedPageIndex', 4); // Index de la page carte
child: InkWell(
onTap: () {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
// Naviguer vers le dashboard admin qui chargera la page carte
context.go('/admin');
},
child: Text(
name,
style: TextStyle(
fontSize: AppTheme.r(context, 14),
color: textColor,
fontWeight:
hasPassages ? FontWeight.w600 : FontWeight.w300,
decoration: TextDecoration.underline,
decorationColor: textColor.withValues(alpha: 0.5),
),
overflow: TextOverflow.ellipsis,
),
)
: Text(
name,
style: TextStyle(
fontSize: AppTheme.r(context, 14),
color: textColor,
fontWeight:
hasPassages ? FontWeight.w600 : FontWeight.w300,
),
overflow: TextOverflow.ellipsis,
),
if (isAdmin) {
// Admin : naviguer vers la page carte
settingsBox.put('selectedSectorId', sectorId);
settingsBox.put('selectedPageIndex', 4); // Index de la page carte
context.go('/admin');
} else {
// User : naviguer vers la page historique avec le secteur sélectionné
settingsBox.delete('history_selectedTypeId');
settingsBox.delete('history_selectedPaymentTypeId');
settingsBox.delete('history_selectedMemberId');
settingsBox.delete('history_startDate');
settingsBox.delete('history_endDate');
settingsBox.put('history_selectedSectorId', sectorId);
settingsBox.put('history_selectedSectorName', name);
context.go('/user/history');
}
},
child: Text(
name,
style: TextStyle(
fontSize: AppTheme.r(context, 14),
color: textColor,
fontWeight:
hasPassages ? FontWeight.w600 : FontWeight.w300,
decoration: TextDecoration.underline,
decorationColor: textColor.withValues(alpha: 0.5),
),
overflow: TextOverflow.ellipsis,
),
),
),
Text(
hasPassages
@@ -420,75 +420,49 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
? Color(typeInfo['couleur2'] as int)
: Colors.grey;
// Vérifier si l'utilisateur est admin pour les clics (prend en compte le mode d'affichage)
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
return Expanded(
flex: count,
child: isAdmin
? InkWell(
onTap: () {
// Sauvegarder les filtres dans Hive pour la page historique
final settingsBox =
Hive.box(AppKeys.settingsBoxName);
settingsBox.put(
'history_selectedSectorId', sectorId);
settingsBox.put(
'history_selectedSectorName', sectorName);
settingsBox.put('history_selectedTypeId', typeId);
settingsBox.put('selectedPageIndex',
2); // Index de la page historique
child: InkWell(
onTap: () {
// Réinitialiser TOUS les filtres avant de sauvegarder les nouveaux
final settingsBox = Hive.box(AppKeys.settingsBoxName);
settingsBox.delete('history_selectedPaymentTypeId');
settingsBox.delete('history_selectedMemberId');
settingsBox.delete('history_startDate');
settingsBox.delete('history_endDate');
// Naviguer vers le dashboard admin qui chargera la page historique
context.go('/admin');
},
child: Container(
color: color,
child: Center(
child: percentage >=
5 // N'afficher le texte que si >= 5%
? Text(
'$count (${percentage.toInt()}%)',
style: TextStyle(
color: Colors.white,
fontSize: AppTheme.r(context, 10),
fontWeight: FontWeight.bold,
shadows: [
Shadow(
offset: Offset(0.5, 0.5),
blurRadius: 1.0,
color: Colors.black45,
),
],
),
)
: null,
),
),
)
: Container(
color: color,
child: Center(
child: percentage >=
5 // N'afficher le texte que si >= 5%
? Text(
'$count (${percentage.toInt()}%)',
style: TextStyle(
color: Colors.white,
fontSize: AppTheme.r(context, 10),
fontWeight: FontWeight.bold,
shadows: [
Shadow(
offset: Offset(0.5, 0.5),
blurRadius: 1.0,
color: Colors.black45,
),
],
// Sauvegarder uniquement le secteur et le type de passage sélectionnés
settingsBox.put('history_selectedSectorId', sectorId);
settingsBox.put('history_selectedSectorName', sectorName);
settingsBox.put('history_selectedTypeId', typeId);
// Naviguer directement vers la page historique
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
context.go(isAdmin ? '/admin/history' : '/user/history');
},
child: Container(
color: color,
child: Center(
child: percentage >= 5
? Text(
'$count (${percentage.toInt()}%)',
style: TextStyle(
color: Colors.white,
fontSize: AppTheme.r(context, 10),
fontWeight: FontWeight.bold,
shadows: [
Shadow(
offset: Offset(0.5, 0.5),
blurRadius: 1.0,
color: Colors.black45,
),
)
: null,
),
),
],
),
)
: null,
),
),
),
);
}).toList(),
),

View File

@@ -293,18 +293,10 @@ packages:
dependency: transitive
description:
name: dio_cache_interceptor
sha256: "1346705a2057c265014d7696e3e2318b560bfb00b484dac7f9b01e2ceaebb07d"
sha256: ac9f312e5a81d79cbccb15f56b78aeae7343a981c1d7c169b11194fae806ec0b
url: "https://pub.dev"
source: hosted
version: "3.5.1"
dio_cache_interceptor_hive_store:
dependency: "direct main"
description:
name: dio_cache_interceptor_hive_store
sha256: "449b36541216cb20543228081125ad2995eb9712ec35bd030d85663ea1761895"
url: "https://pub.dev"
source: hosted
version: "3.2.2"
version: "4.0.5"
dio_web_adapter:
dependency: transitive
description:
@@ -463,10 +455,10 @@ packages:
dependency: "direct main"
description:
name: flutter_map_cache
sha256: "5b30c9b0d36315a22f4ee070737104a6017e7ff990e8addc8128ba81786e03ef"
sha256: fc9697760dc95b6adf75110a23a800ace5d95a735a58ec43f05183bc675c7246
url: "https://pub.dev"
source: hosted
version: "1.5.2"
version: "2.0.0+1"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -605,6 +597,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.3"
hive_ce:
dependency: transitive
description:
name: hive_ce
sha256: d678b1b2e315c18cd7ed8fd79eda25d70a1f3852d6988bfe5461cffe260c60aa
url: "https://pub.dev"
source: hosted
version: "2.14.0"
hive_flutter:
dependency: "direct main"
description:
@@ -637,6 +637,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.0"
http_cache_core:
dependency: transitive
description:
name: http_cache_core
sha256: "8f9f187d10f8d1a90c51db2389575bbddf71ca0f79d4527652ea1efa3f338071"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
http_cache_hive_store:
dependency: "direct main"
description:
name: http_cache_hive_store
sha256: "85847efdb18094961a66b74d3b856da093ddcbaf7739adecdc28149e871fb8fe"
url: "https://pub.dev"
source: hosted
version: "5.0.1"
http_multi_server:
dependency: transitive
description:
@@ -741,6 +757,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
isolate_channel:
dependency: transitive
description:
name: isolate_channel
sha256: f3d36f783b301e6b312c3450eeb2656b0e7d1db81331af2a151d9083a3f6b18d
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
js:
dependency: transitive
description:
@@ -809,10 +833,10 @@ packages:
dependency: transitive
description:
name: logger
sha256: "55d6c23a6c15db14920e037fe7e0dc32e7cdaf3b64b4b25df2d541b5b6b81c0c"
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "2.6.2"
logging:
dependency: transitive
description:

View File

@@ -1,7 +1,7 @@
name: geosector_app
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
publish_to: 'none'
version: 3.3.4+334
version: 3.3.5+335
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -40,8 +40,8 @@ dependencies:
# Cartes et géolocalisation
url_launcher: ^6.3.1
flutter_map: ^6.1.0
flutter_map_cache: ^1.5.2
dio_cache_interceptor_hive_store: ^3.2.2 # Cache store pour flutter_map_cache
flutter_map_cache: ^2.0.0 # Mise à jour vers v2.0.0+1 (06/10/2025)
http_cache_hive_store: ^5.0.0 # Remplace dio_cache_interceptor_hive_store (discontinué)
path_provider: ^2.1.2 # Requis pour le cache
latlong2: ^0.9.1
geolocator: ^12.0.0