diff --git a/VERSION b/VERSION index a0891f56..fa7adc7a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.3.4 +3.3.5 diff --git a/api/TODO-API.md b/api/TODO-API.md index 9e8bf682..0cb1da2e 100644 --- a/api/TODO-API.md +++ b/api/TODO-API.md @@ -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 diff --git a/api/docs/TECHBOOK.md b/api/docs/TECHBOOK.md index 8e5db070..5802ac61 100755 --- a/api/docs/TECHBOOK.md +++ b/api/docs/TECHBOOK.md @@ -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 diff --git a/api/docs/geo_app.sql b/api/docs/geo_app.sql index 3ad818d5..559489fe 100755 --- a/api/docs/geo_app.sql +++ b/api/docs/geo_app.sql @@ -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'; diff --git a/api/docs/geosector_app.sql b/api/docs/geosector_app.sql index f95fdbb0..3d248c3a 100755 --- a/api/docs/geosector_app.sql +++ b/api/docs/geosector_app.sql @@ -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 */; diff --git a/api/scripts/config/update_php_fpm_settings.sh b/api/scripts/config/update_php_fpm_settings.sh new file mode 100644 index 00000000..4f2944f4 --- /dev/null +++ b/api/scripts/config/update_php_fpm_settings.sh @@ -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" diff --git a/api/scripts/migrations/add_email_queue_fields.sql b/api/scripts/migrations/add_email_queue_fields.sql new file mode 100644 index 00000000..b4018161 --- /dev/null +++ b/api/scripts/migrations/add_email_queue_fields.sql @@ -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'); diff --git a/api/scripts/test/generate_receipt_manual.php b/api/scripts/test/generate_receipt_manual.php new file mode 100644 index 00000000..3a5a5335 --- /dev/null +++ b/api/scripts/test/generate_receipt_manual.php @@ -0,0 +1,112 @@ +#!/usr/bin/env php + + */ + +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 \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); diff --git a/api/src/Config/AppConfig.php b/api/src/Config/AppConfig.php index f28567eb..c1c1faba 100755 --- a/api/src/Config/AppConfig.php +++ b/api/src/Config/AppConfig.php @@ -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, ]); diff --git a/api/src/Controllers/PassageController.php b/api/src/Controllers/PassageController.php index 3e4edce9..f5a3d1fa 100755 --- a/api/src/Controllers/PassageController.php +++ b/api/src/Controllers/PassageController.php @@ -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', @@ -583,51 +620,19 @@ class PassageController { 'passage_id' => $passageId, 'receipt_generated' => false // On va générer le reçu en arrière-plan ], 201); - + // Flush la sortie pour s'assurer que la réponse est envoyée if (ob_get_level()) { ob_end_flush(); } flush(); - + // Fermer la connexion HTTP mais continuer le traitement if (function_exists('fastcgi_finish_request')) { 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,65 +797,72 @@ 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', 'message' => 'Passage mis à jour avec succès', 'receipt_generated' => false // On va générer le reçu en arrière-plan ], 200); - + // Flush la sortie pour s'assurer que la réponse est envoyée if (ob_get_level()) { ob_end_flush(); } flush(); - + // Fermer la connexion HTTP mais continuer le traitement if (function_exists('fastcgi_finish_request')) { 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', diff --git a/api/src/Services/ReceiptService.php b/api/src/Services/ReceiptService.php index 56e0987d..2bf4bdfe 100644 --- a/api/src/Services/ReceiptService.php +++ b/api/src/Services/ReceiptService.php @@ -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 diff --git a/app/.dart_tool/package_config.json b/app/.dart_tool/package_config.json index b11b2275..408ba3c2 100644 --- a/app/.dart_tool/package_config.json +++ b/app/.dart_tool/package_config.json @@ -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" }, diff --git a/app/.dart_tool/package_graph.json b/app/.dart_tool/package_graph.json index 7eac5356..8f398929 100644 --- a/app/.dart_tool/package_graph.json +++ b/app/.dart_tool/package_graph.json @@ -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", diff --git a/app/.flutter-plugins-dependencies b/app/.flutter-plugins-dependencies index 01250037..f01cb4e6 100644 --- a/app/.flutter-plugins-dependencies +++ b/app/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"battery_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/battery_plus-4.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"connectivity_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/device_info_plus-9.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"/home/pierre/.pub-cache/hosted/pub.dev/flutter_local_notifications-19.4.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"/home/pierre/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.13/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_ios","path":"/home/pierre/.pub-cache/hosted/pub.dev/image_picker_ios-0.8.13/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"mek_stripe_terminal","path":"/home/pierre/.pub-cache/hosted/pub.dev/mek_stripe_terminal-4.6.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"network_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/network_info_plus-7.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"nfc_manager","path":"/home/pierre/.pub-cache/hosted/pub.dev/nfc_manager-4.1.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/package_info_plus-4.2.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/pierre/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_apple","path":"/home/pierre/.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.7/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sensors_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/sensors_plus-3.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"stripe_ios","path":"/home/pierre/.pub-cache/hosted/pub.dev/stripe_ios-12.0.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_ios","path":"/home/pierre/.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.4/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"battery_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/battery_plus-4.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"connectivity_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/device_info_plus-9.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"/home/pierre/.pub-cache/hosted/pub.dev/flutter_local_notifications-19.4.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_plugin_android_lifecycle","path":"/home/pierre/.pub-cache/hosted/pub.dev/flutter_plugin_android_lifecycle-2.0.30/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_android","path":"/home/pierre/.pub-cache/hosted/pub.dev/geolocator_android-4.6.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_android","path":"/home/pierre/.pub-cache/hosted/pub.dev/image_picker_android-0.8.13+3/","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"],"dev_dependency":false},{"name":"mek_stripe_terminal","path":"/home/pierre/.pub-cache/hosted/pub.dev/mek_stripe_terminal-4.6.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"network_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/network_info_plus-7.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"nfc_manager","path":"/home/pierre/.pub-cache/hosted/pub.dev/nfc_manager-4.1.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/package_info_plus-4.2.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_android","path":"/home/pierre/.pub-cache/hosted/pub.dev/path_provider_android-2.2.18/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_android","path":"/home/pierre/.pub-cache/hosted/pub.dev/permission_handler_android-12.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sensors_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/sensors_plus-3.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"stripe_android","path":"/home/pierre/.pub-cache/hosted/pub.dev/stripe_android-12.0.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_android","path":"/home/pierre/.pub-cache/hosted/pub.dev/url_launcher_android-6.3.23/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"battery_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/battery_plus-4.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"connectivity_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/device_info_plus-9.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_macos","path":"/home/pierre/.pub-cache/hosted/pub.dev/file_selector_macos-0.9.4+4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"/home/pierre/.pub-cache/hosted/pub.dev/flutter_local_notifications-19.4.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"/home/pierre/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.13/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_macos","path":"/home/pierre/.pub-cache/hosted/pub.dev/image_picker_macos-0.2.2/","native_build":false,"dependencies":["file_selector_macos"],"dev_dependency":false},{"name":"network_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/network_info_plus-7.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/package_info_plus-4.2.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/pierre/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"/home/pierre/.pub-cache/hosted/pub.dev/url_launcher_macos-3.2.3/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"battery_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/battery_plus-4.1.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"connectivity_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/device_info_plus-9.1.2/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_linux","path":"/home/pierre/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.3+2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications_linux","path":"/home/pierre/.pub-cache/hosted/pub.dev/flutter_local_notifications_linux-6.0.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"image_picker_linux","path":"/home/pierre/.pub-cache/hosted/pub.dev/image_picker_linux-0.2.2/","native_build":false,"dependencies":["file_selector_linux"],"dev_dependency":false},{"name":"network_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/network_info_plus-7.0.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/package_info_plus-4.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/pierre/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_linux","path":"/home/pierre/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.1/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"battery_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/battery_plus-4.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"connectivity_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/device_info_plus-9.1.2/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_windows","path":"/home/pierre/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications_windows","path":"/home/pierre/.pub-cache/hosted/pub.dev/flutter_local_notifications_windows-1.0.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_windows","path":"/home/pierre/.pub-cache/hosted/pub.dev/geolocator_windows-0.2.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_windows","path":"/home/pierre/.pub-cache/hosted/pub.dev/image_picker_windows-0.2.2/","native_build":false,"dependencies":["file_selector_windows"],"dev_dependency":false},{"name":"network_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/network_info_plus-7.0.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/package_info_plus-4.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/pierre/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_windows","path":"/home/pierre/.pub-cache/hosted/pub.dev/permission_handler_windows-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_windows","path":"/home/pierre/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.4/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"battery_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/battery_plus-4.1.0/","dependencies":[],"dev_dependency":false},{"name":"connectivity_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/","dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/device_info_plus-9.1.2/","dependencies":[],"dev_dependency":false},{"name":"geolocator_web","path":"/home/pierre/.pub-cache/hosted/pub.dev/geolocator_web-4.1.3/","dependencies":[],"dev_dependency":false},{"name":"image_picker_for_web","path":"/home/pierre/.pub-cache/hosted/pub.dev/image_picker_for_web-3.1.0/","dependencies":[],"dev_dependency":false},{"name":"network_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/network_info_plus-7.0.0/","dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/package_info_plus-4.2.0/","dependencies":[],"dev_dependency":false},{"name":"permission_handler_html","path":"/home/pierre/.pub-cache/hosted/pub.dev/permission_handler_html-0.1.3+5/","dependencies":[],"dev_dependency":false},{"name":"sensors_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/sensors_plus-3.1.0/","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"/home/pierre/.pub-cache/hosted/pub.dev/url_launcher_web-2.4.1/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"battery_plus","dependencies":[]},{"name":"connectivity_plus","dependencies":[]},{"name":"device_info_plus","dependencies":[]},{"name":"file_selector_linux","dependencies":[]},{"name":"file_selector_macos","dependencies":[]},{"name":"file_selector_windows","dependencies":[]},{"name":"flutter_local_notifications","dependencies":["flutter_local_notifications_linux","flutter_local_notifications_windows"]},{"name":"flutter_local_notifications_linux","dependencies":[]},{"name":"flutter_local_notifications_windows","dependencies":[]},{"name":"flutter_plugin_android_lifecycle","dependencies":[]},{"name":"flutter_stripe","dependencies":["stripe_android","stripe_ios"]},{"name":"geolocator","dependencies":["geolocator_android","geolocator_apple","geolocator_web","geolocator_windows"]},{"name":"geolocator_android","dependencies":[]},{"name":"geolocator_apple","dependencies":[]},{"name":"geolocator_web","dependencies":[]},{"name":"geolocator_windows","dependencies":[]},{"name":"image_picker","dependencies":["image_picker_android","image_picker_for_web","image_picker_ios","image_picker_linux","image_picker_macos","image_picker_windows"]},{"name":"image_picker_android","dependencies":["flutter_plugin_android_lifecycle"]},{"name":"image_picker_for_web","dependencies":[]},{"name":"image_picker_ios","dependencies":[]},{"name":"image_picker_linux","dependencies":["file_selector_linux"]},{"name":"image_picker_macos","dependencies":["file_selector_macos"]},{"name":"image_picker_windows","dependencies":["file_selector_windows"]},{"name":"mek_stripe_terminal","dependencies":[]},{"name":"network_info_plus","dependencies":[]},{"name":"nfc_manager","dependencies":[]},{"name":"package_info_plus","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"permission_handler","dependencies":["permission_handler_android","permission_handler_apple","permission_handler_html","permission_handler_windows"]},{"name":"permission_handler_android","dependencies":[]},{"name":"permission_handler_apple","dependencies":[]},{"name":"permission_handler_html","dependencies":[]},{"name":"permission_handler_windows","dependencies":[]},{"name":"sensors_plus","dependencies":[]},{"name":"stripe_android","dependencies":[]},{"name":"stripe_ios","dependencies":[]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2025-10-05 11:44:27.612733","version":"3.35.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"battery_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/battery_plus-4.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"connectivity_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/device_info_plus-9.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"/home/pierre/.pub-cache/hosted/pub.dev/flutter_local_notifications-19.4.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"/home/pierre/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.13/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_ios","path":"/home/pierre/.pub-cache/hosted/pub.dev/image_picker_ios-0.8.13/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"mek_stripe_terminal","path":"/home/pierre/.pub-cache/hosted/pub.dev/mek_stripe_terminal-4.6.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"network_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/network_info_plus-7.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"nfc_manager","path":"/home/pierre/.pub-cache/hosted/pub.dev/nfc_manager-4.1.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/package_info_plus-4.2.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/pierre/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_apple","path":"/home/pierre/.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.7/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sensors_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/sensors_plus-3.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"stripe_ios","path":"/home/pierre/.pub-cache/hosted/pub.dev/stripe_ios-12.0.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_ios","path":"/home/pierre/.pub-cache/hosted/pub.dev/url_launcher_ios-6.3.4/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"battery_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/battery_plus-4.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"connectivity_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/device_info_plus-9.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"/home/pierre/.pub-cache/hosted/pub.dev/flutter_local_notifications-19.4.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_plugin_android_lifecycle","path":"/home/pierre/.pub-cache/hosted/pub.dev/flutter_plugin_android_lifecycle-2.0.30/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_android","path":"/home/pierre/.pub-cache/hosted/pub.dev/geolocator_android-4.6.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_android","path":"/home/pierre/.pub-cache/hosted/pub.dev/image_picker_android-0.8.13+3/","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"],"dev_dependency":false},{"name":"mek_stripe_terminal","path":"/home/pierre/.pub-cache/hosted/pub.dev/mek_stripe_terminal-4.6.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"network_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/network_info_plus-7.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"nfc_manager","path":"/home/pierre/.pub-cache/hosted/pub.dev/nfc_manager-4.1.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/package_info_plus-4.2.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_android","path":"/home/pierre/.pub-cache/hosted/pub.dev/path_provider_android-2.2.18/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_android","path":"/home/pierre/.pub-cache/hosted/pub.dev/permission_handler_android-12.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sensors_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/sensors_plus-3.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"stripe_android","path":"/home/pierre/.pub-cache/hosted/pub.dev/stripe_android-12.0.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_android","path":"/home/pierre/.pub-cache/hosted/pub.dev/url_launcher_android-6.3.23/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"battery_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/battery_plus-4.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"connectivity_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/device_info_plus-9.1.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"file_selector_macos","path":"/home/pierre/.pub-cache/hosted/pub.dev/file_selector_macos-0.9.4+4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"/home/pierre/.pub-cache/hosted/pub.dev/flutter_local_notifications-19.4.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"/home/pierre/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.13/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_macos","path":"/home/pierre/.pub-cache/hosted/pub.dev/image_picker_macos-0.2.2/","native_build":false,"dependencies":["file_selector_macos"],"dev_dependency":false},{"name":"network_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/network_info_plus-7.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/package_info_plus-4.2.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/pierre/.pub-cache/hosted/pub.dev/path_provider_foundation-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"/home/pierre/.pub-cache/hosted/pub.dev/url_launcher_macos-3.2.3/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"battery_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/battery_plus-4.1.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"connectivity_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/device_info_plus-9.1.2/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_linux","path":"/home/pierre/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.3+2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications_linux","path":"/home/pierre/.pub-cache/hosted/pub.dev/flutter_local_notifications_linux-6.0.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"image_picker_linux","path":"/home/pierre/.pub-cache/hosted/pub.dev/image_picker_linux-0.2.2/","native_build":false,"dependencies":["file_selector_linux"],"dev_dependency":false},{"name":"network_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/network_info_plus-7.0.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/package_info_plus-4.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/pierre/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_linux","path":"/home/pierre/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.1/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"battery_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/battery_plus-4.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"connectivity_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/device_info_plus-9.1.2/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"file_selector_windows","path":"/home/pierre/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications_windows","path":"/home/pierre/.pub-cache/hosted/pub.dev/flutter_local_notifications_windows-1.0.3/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_windows","path":"/home/pierre/.pub-cache/hosted/pub.dev/geolocator_windows-0.2.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_windows","path":"/home/pierre/.pub-cache/hosted/pub.dev/image_picker_windows-0.2.2/","native_build":false,"dependencies":["file_selector_windows"],"dev_dependency":false},{"name":"network_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/network_info_plus-7.0.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/package_info_plus-4.2.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/pierre/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_windows","path":"/home/pierre/.pub-cache/hosted/pub.dev/permission_handler_windows-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_windows","path":"/home/pierre/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.4/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"battery_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/battery_plus-4.1.0/","dependencies":[],"dev_dependency":false},{"name":"connectivity_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/connectivity_plus-5.0.2/","dependencies":[],"dev_dependency":false},{"name":"device_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/device_info_plus-9.1.2/","dependencies":[],"dev_dependency":false},{"name":"geolocator_web","path":"/home/pierre/.pub-cache/hosted/pub.dev/geolocator_web-4.1.3/","dependencies":[],"dev_dependency":false},{"name":"image_picker_for_web","path":"/home/pierre/.pub-cache/hosted/pub.dev/image_picker_for_web-3.1.0/","dependencies":[],"dev_dependency":false},{"name":"network_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/network_info_plus-7.0.0/","dependencies":[],"dev_dependency":false},{"name":"package_info_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/package_info_plus-4.2.0/","dependencies":[],"dev_dependency":false},{"name":"permission_handler_html","path":"/home/pierre/.pub-cache/hosted/pub.dev/permission_handler_html-0.1.3+5/","dependencies":[],"dev_dependency":false},{"name":"sensors_plus","path":"/home/pierre/.pub-cache/hosted/pub.dev/sensors_plus-3.1.0/","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"/home/pierre/.pub-cache/hosted/pub.dev/url_launcher_web-2.4.1/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"battery_plus","dependencies":[]},{"name":"connectivity_plus","dependencies":[]},{"name":"device_info_plus","dependencies":[]},{"name":"file_selector_linux","dependencies":[]},{"name":"file_selector_macos","dependencies":[]},{"name":"file_selector_windows","dependencies":[]},{"name":"flutter_local_notifications","dependencies":["flutter_local_notifications_linux","flutter_local_notifications_windows"]},{"name":"flutter_local_notifications_linux","dependencies":[]},{"name":"flutter_local_notifications_windows","dependencies":[]},{"name":"flutter_plugin_android_lifecycle","dependencies":[]},{"name":"flutter_stripe","dependencies":["stripe_android","stripe_ios"]},{"name":"geolocator","dependencies":["geolocator_android","geolocator_apple","geolocator_web","geolocator_windows"]},{"name":"geolocator_android","dependencies":[]},{"name":"geolocator_apple","dependencies":[]},{"name":"geolocator_web","dependencies":[]},{"name":"geolocator_windows","dependencies":[]},{"name":"image_picker","dependencies":["image_picker_android","image_picker_for_web","image_picker_ios","image_picker_linux","image_picker_macos","image_picker_windows"]},{"name":"image_picker_android","dependencies":["flutter_plugin_android_lifecycle"]},{"name":"image_picker_for_web","dependencies":[]},{"name":"image_picker_ios","dependencies":[]},{"name":"image_picker_linux","dependencies":["file_selector_linux"]},{"name":"image_picker_macos","dependencies":["file_selector_macos"]},{"name":"image_picker_windows","dependencies":["file_selector_windows"]},{"name":"mek_stripe_terminal","dependencies":[]},{"name":"network_info_plus","dependencies":[]},{"name":"nfc_manager","dependencies":[]},{"name":"package_info_plus","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"permission_handler","dependencies":["permission_handler_android","permission_handler_apple","permission_handler_html","permission_handler_windows"]},{"name":"permission_handler_android","dependencies":[]},{"name":"permission_handler_apple","dependencies":[]},{"name":"permission_handler_html","dependencies":[]},{"name":"permission_handler_windows","dependencies":[]},{"name":"sensors_plus","dependencies":[]},{"name":"stripe_android","dependencies":[]},{"name":"stripe_ios","dependencies":[]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2025-10-06 08:09:36.096471","version":"3.35.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/app/docs/README-APP.md b/app/docs/README-APP.md index 944b3e63..0ba0d12f 100755 --- a/app/docs/README-APP.md +++ b/app/docs/README-APP.md @@ -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...`
`sk_test_51QwoVN...` | `test` | Développement | +| **REC** | `rapp.geosector.fr` | Test Client | `CLIENT_PK_TEST_A_REMPLACER`
`CLIENT_SK_TEST_A_REMPLACER` | `test` | Recette | +| **PROD** | `app.geosector.fr` | Live Client | `CLIENT_PK_LIVE_A_REMPLACER`
`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** diff --git a/app/docs/TODO-APP.md b/app/docs/TODO-APP.md index 6cde54d4..42265645 100644 --- a/app/docs/TODO-APP.md +++ b/app/docs/TODO-APP.md @@ -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` qui ne contient que le currentUser. + +**Solution** : Utiliser la liste `_users` construite depuis `membreRepository.getAllMembres()`. + +```dart +..._users.map((UserModel user) { + return DropdownMenuItem( + 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(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 diff --git a/app/docs/TODO-GEOSECTOR.md b/app/docs/TODO-GEOSECTOR.md index 5d74e28e..13ba8493 100644 --- a/app/docs/TODO-GEOSECTOR.md +++ b/app/docs/TODO-GEOSECTOR.md @@ -356,11 +356,89 @@ _Bénéfice : Sécurité renforcée et meilleure traçabilité_ --- +
+ +## UPGRADES PACKAGES FLUTTER + +### 📊 État des packages (Octobre 2025) + +L'analyse `flutter pub outdated` a révélé plusieurs packages nécessitant des mises à jour, dont un package discontinué critique. + +### 🔴 Phase 1 - Correction package discontinué (URGENT) + +**Statut : ✅ TERMINÉ (06/10/2025)** + +| Package | Action | Ancienne version | Nouvelle version | +|---------|--------|------------------|------------------| +| `dio_cache_interceptor_hive_store` | ❌ Suppression (discontinué) | 3.2.2 | - | +| `http_cache_hive_store` | ✅ Ajout (remplacement) | - | 5.0.0 | +| `flutter_map_cache` | ⬆️ Mise à jour | 1.5.2 | 2.0.0+1 | + +**Fichiers modifiés :** +- `pubspec.yaml` : Remplacement des dépendances +- `lib/presentation/widgets/mapbox_map.dart` : Import mis à jour + +**Tests requis :** +- [x] Affichage carte web +- [x] Affichage carte mobile +- [x] Cache des tuiles mobile +- [x] Mode terrain + +### 🟡 Phase 2 - Mises à jour importantes (PLANIFIÉ) + +**Statut : ⏳ EN ATTENTE** + +#### Cartes et géolocalisation +| Package | Actuelle | Cible | Breaking Changes | +|---------|----------|-------|------------------| +| `flutter_map` | 6.2.1 | 8.2.2 | ⚠️ Oui (v7, v8) | +| `geolocator` | 12.0.0 | 14.0.2 | Possible | + +#### Device Info & Permissions +| Package | Actuelle | Cible | Importance | +|---------|----------|-------|------------| +| `device_info_plus` | 9.1.2 | 12.1.0 | ⭐⭐⭐ Tap to Pay | +| `battery_plus` | 4.1.0 | 7.0.0 | ⭐⭐ | +| `connectivity_plus` | 5.0.2 | 7.0.0 | ⭐⭐ | +| `sensors_plus` | 3.1.0 | 7.0.0 | ⭐⭐⭐ Mode boussole | +| `permission_handler` | 11.4.0 | 12.0.1 | ⭐⭐⭐ | + +**Points d'attention :** +- `flutter_map 8.x` : Breaking changes majeurs v6 → v8 +- `device_info_plus` : Vérifier compatibilité DeviceInfoService +- Tests complets requis : cartes, géolocalisation, mode terrain + +### 🟢 Phase 3 - Mises à jour secondaires (PLANIFIÉ) + +**Statut : ⏳ EN ATTENTE** + +| Package | Actuelle | Cible | Note | +|---------|----------|-------|------| +| `syncfusion_flutter_charts` | 30.2.7 | 31.1.22 | Mineure | +| `package_info_plus` | 4.2.0 | 8.3.1 | Vérifier compatibilité | + +**Packages à jour :** +- ✅ `dio: 5.9.0` +- ✅ `go_router: 16.2.4` +- ✅ `hive: 2.2.3` +- ✅ `flutter_stripe: 12.0.2` +- ✅ `mek_stripe_terminal: 4.6.0` + +### 📅 Planning des upgrades + +| Phase | Période prévue | Priorité | Effort | +|-------|----------------|----------|--------| +| Phase 1 | ✅ 06/10/2025 | 🔴 Critique | 1h | +| Phase 2 | 10-15/10/2025 | 🟡 Important | 4-6h | +| Phase 3 | 20-25/10/2025 | 🟢 Mineur | 2-3h | + +--- + _Document généré le 11 septembre 2025_ -_Dernière mise à jour le 04 octobre 2025_ +_Dernière mise à jour le 06 octobre 2025_ _Ce document sera mis à jour régulièrement avec l'avancement des développements_ --- -**GEOSECTOR** - Solution de gestion des distributions de calendriers Amicales de pompiers +**GEOSECTOR** - Solution de gestion des distributions de calendriers Amicales de pompiers © 2025 - Tous droits réservés diff --git a/app/lib/core/constants/app_keys.dart b/app/lib/core/constants/app_keys.dart index e7b0386e..26635121 100755 --- a/app/lib/core/constants/app_keys.dart +++ b/app/lib/core/constants/app_keys.dart @@ -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, }, }; diff --git a/app/lib/presentation/pages/history_page.dart b/app/lib/presentation/pages/history_page.dart index c05aaabe..849987e1 100644 --- a/app/lib/presentation/pages/history_page.dart +++ b/app/lib/presentation/pages/history_page.dart @@ -140,12 +140,14 @@ class _HistoryContentState extends State { // 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 { // Filtre Type de passage Expanded( child: DropdownButtonFormField( - initialValue: _selectedTypeFilter, + value: _selectedTypeFilter, decoration: const InputDecoration( border: OutlineInputBorder(), contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -418,7 +420,7 @@ class _HistoryContentState extends State { // Filtre Mode de règlement Expanded( child: DropdownButtonFormField( - initialValue: _selectedPaymentFilter, + value: _selectedPaymentFilter, decoration: const InputDecoration( border: OutlineInputBorder(), contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -473,7 +475,7 @@ class _HistoryContentState extends State { final sectors = sectorsBox.values.toList(); return DropdownButtonFormField( - initialValue: _selectedSectorId, + value: _selectedSectorId, decoration: const InputDecoration( border: OutlineInputBorder(), contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -520,37 +522,30 @@ class _HistoryContentState extends State { const SizedBox(width: 12), if (isAdmin) Expanded( - child: ValueListenableBuilder>( - valueListenable: Hive.box(AppKeys.userBoxName).listenable(), - builder: (context, usersBox, child) { - final users = usersBox.values.where((user) => user.role == 1).toList(); - - return DropdownButtonFormField( - initialValue: _selectedUserId, - decoration: const InputDecoration( - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - isDense: true, - ), - items: [ - const DropdownMenuItem( - value: null, - child: Text('Membres'), - ), - ...users.map((UserModel user) { - return DropdownMenuItem( - value: user.id, - child: Text('${user.firstName ?? ''} ${user.name ?? ''}'), - ); - }), - ], - onChanged: (int? newValue) { - setState(() { - _selectedUserId = newValue; - }); - _notifyFiltersChanged(); - }, - ); + child: DropdownButtonFormField( + value: _selectedUserId, + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + isDense: true, + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Membres'), + ), + ..._users.map((UserModel user) { + return DropdownMenuItem( + value: user.id, + child: Text('${user.firstName ?? ''} ${user.name ?? ''}'), + ); + }), + ], + onChanged: (int? newValue) { + setState(() { + _selectedUserId = newValue; + }); + _notifyFiltersChanged(); }, ), ) @@ -896,6 +891,7 @@ class _HistoryContentState extends State { 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 { 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 { 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 { 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 { 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( diff --git a/app/lib/presentation/pages/home_page.dart b/app/lib/presentation/pages/home_page.dart index b69da4a9..c72676a4 100644 --- a/app/lib/presentation/pages/home_page.dart +++ b/app/lib/presentation/pages/home_page.dart @@ -93,13 +93,11 @@ class _HomeContentState extends State { // 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>( valueListenable: Hive.box(AppKeys.sectorsBoxName).listenable(), @@ -113,9 +111,13 @@ class _HomeContentState extends State { 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 { 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, ), ), diff --git a/app/lib/presentation/widgets/charts/activity_chart.dart b/app/lib/presentation/widgets/charts/activity_chart.dart index a1e1794e..373456ec 100755 --- a/app/lib/presentation/widgets/charts/activity_chart.dart +++ b/app/lib/presentation/widgets/charts/activity_chart.dart @@ -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 // 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 valueListenable: Hive.box(AppKeys.passagesBoxName).listenable(), builder: (context, Box passagesBox, child) { - final chartData = _calculateActivityData(passagesBox); + final chartData = _calculateActivityData(passagesBox, _selectedDays); return _buildChart(chartData); }, ); @@ -179,7 +190,7 @@ class _ActivityChartState extends State } /// Calcule les données d'activité depuis la Hive box - List _calculateActivityData(Box passagesBox) { + List _calculateActivityData(Box passagesBox, int daysToShow) { try { final passages = passagesBox.values.toList(); final currentUser = userRepository.getCurrentUser(); @@ -187,55 +198,63 @@ class _ActivityChartState extends State // Pour les users : récupérer les secteurs assignés Set? userSectorIds; if (!widget.showAllPassages && currentUser != null) { - final userSectorBox = Hive.box(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> 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 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 chartData = []; dataByDate.forEach((dateStr, passagesByType) { @@ -367,16 +391,32 @@ class _ActivityChartState extends State 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 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 ), 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 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(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, + ), + ), + ), + ); + } } diff --git a/app/lib/presentation/widgets/charts/charts.dart b/app/lib/presentation/widgets/charts/charts.dart index c5a62a2e..0a0c4257 100755 --- a/app/lib/presentation/widgets/charts/charts.dart +++ b/app/lib/presentation/widgets/charts/charts.dart @@ -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'; diff --git a/app/lib/presentation/widgets/charts/passage_pie_chart.dart b/app/lib/presentation/widgets/charts/passage_pie_chart.dart deleted file mode 100755 index 515a4d1a..00000000 --- a/app/lib/presentation/widgets/charts/passage_pie_chart.dart +++ /dev/null @@ -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 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 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 createState() => _PassagePieChartState(); -} - -class _PassagePieChartState extends State - 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(AppKeys.passagesBoxName).listenable(), - builder: (context, Box 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 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 _calculatePassageData(Box 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 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 _prepareChartDataFromMap( - Map passagesByType) { - final List 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 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: [ - widget.isDonut - ? DoughnutSeries( - 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( - 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 _buildIconAnnotations( - List chartData) { - final List 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; - } -} diff --git a/app/lib/presentation/widgets/charts/passage_summary_card.dart b/app/lib/presentation/widgets/charts/passage_summary_card.dart index eff3f34a..233c8757 100755 --- a/app/lib/presentation/widgets/charts/passage_summary_card.dart +++ b/app/lib/presentation/widgets/charts/passage_summary_card.dart @@ -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 createState() => _PassageSummaryCardState(); +} + +class _PassageSummaryCardState extends State + 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(AppKeys.passagesBoxName).listenable(), builder: (context, Box 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 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 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: [ + DoughnutSeries( + 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 _calculatePassagesCounts(Box passagesBox) { final Map 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 _prepareChartDataFromMap(Map passagesByType) { + final List 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; + } } diff --git a/app/lib/presentation/widgets/charts/payment_pie_chart.dart b/app/lib/presentation/widgets/charts/payment_pie_chart.dart deleted file mode 100755 index cf041dcd..00000000 --- a/app/lib/presentation/widgets/charts/payment_pie_chart.dart +++ /dev/null @@ -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 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 createState() => _PaymentPieChartState(); -} - -class _PaymentPieChartState extends State - 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(AppKeys.passagesBoxName).listenable(), - builder: (context, Box 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 _calculatePaymentData(Box passagesBox) { - try { - final passages = passagesBox.values.toList(); - final currentUser = userRepository.getCurrentUser(); - - // Initialiser les montants par type de règlement - final Map 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 - final List 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) { - 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: [ - widget.isDonut - ? DoughnutSeries( - 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( - 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 _prepareChartData(List 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 _create3DPalette(List chartData) { - List 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 _buildIconAnnotations( - List chartData) { - final List 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; - } -} diff --git a/app/lib/presentation/widgets/charts/payment_summary_card.dart b/app/lib/presentation/widgets/charts/payment_summary_card.dart index af332545..83afa18b 100755 --- a/app/lib/presentation/widgets/charts/payment_summary_card.dart +++ b/app/lib/presentation/widgets/charts/payment_summary_card.dart @@ -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 createState() => _PaymentSummaryCardState(); +} + +class _PaymentSummaryCardState extends State + 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(AppKeys.passagesBoxName).listenable(), builder: (context, Box 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 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 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: [ + DoughnutSeries( + 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 _calculatePaymentAmounts(Box passagesBox) { final Map 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 en List pour les données statiques - List _convertMapToPaymentData(Map paymentsMap) { + /// Prépare les données pour le graphique en camembert à partir d'une Map + List _prepareChartDataFromMap(Map paymentsMap) { final List paymentDataList = []; paymentsMap.forEach((typeReglement, montant) { diff --git a/app/lib/presentation/widgets/mapbox_map.dart b/app/lib/presentation/widgets/mapbox_map.dart index 776aab7b..dbd6c454 100755 --- a/app/lib/presentation/widgets/mapbox_map.dart +++ b/app/lib/presentation/widgets/mapbox_map.dart @@ -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'; diff --git a/app/lib/presentation/widgets/members_board_passages.dart b/app/lib/presentation/widgets/members_board_passages.dart index bec727bb..43d1d06c 100644 --- a/app/lib/presentation/widgets/members_board_passages.dart +++ b/app/lib/presentation/widgets/members_board_passages.dart @@ -47,9 +47,6 @@ class _MembersBoardPassagesState extends State { 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 { ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ // En-tête de la card Container( @@ -88,8 +86,7 @@ class _MembersBoardPassagesState extends State { ), // Corps avec le tableau - Expanded( - child: ValueListenableBuilder>( + ValueListenableBuilder>( valueListenable: Hive.box(AppKeys.membresBoxName).listenable(), builder: (context, membresBox, child) { final membres = membresBox.values.toList(); @@ -118,28 +115,24 @@ class _MembersBoardPassagesState extends State { ..._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, ), ); }, ), - ), ], ), ); diff --git a/app/lib/presentation/widgets/sector_distribution_card.dart b/app/lib/presentation/widgets/sector_distribution_card.dart index fcbe2de5..0da02b03 100755 --- a/app/lib/presentation/widgets/sector_distribution_card.dart +++ b/app/lib/presentation/widgets/sector_distribution_card.dart @@ -100,7 +100,6 @@ class _SectorDistributionCardState extends State { @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 { ), 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 { ], ), const SizedBox(height: AppTheme.spacingM), - Expanded( - child: _buildAutoRefreshContent(), - ), + _buildAutoRefreshContent(), ], ), ); @@ -183,6 +181,8 @@ class _SectorDistributionCardState extends State { // 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 { 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 { ? 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(), ), diff --git a/app/pubspec.lock b/app/pubspec.lock index 77b5e9d9..7d076fae 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -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: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index e91cea5b..32f48cd6 100755 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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