3 Commits

Author SHA1 Message Date
5b6808db25 feat: Version 3.6.3 - Carte IGN, mode boussole, corrections Flutter analyze
Nouvelles fonctionnalités:
- #215 Mode boussole + carte IGN/satellite (Mode terrain)
- #53 Définition zoom maximal pour éviter sur-zoom
- #14 Correction bug F5 déconnexion
- #204 Design couleurs flashy
- #205 Écrans utilisateurs simplifiés

Corrections Flutter analyze:
- Suppression warnings room.g.dart, chat_service.dart, api_service.dart
- 0 error, 0 warning, 30 infos (suggestions de style)

Autres:
- Intégration tuiles IGN Plan et IGN Ortho (geopf.fr)
- flutter_compass pour Android/iOS
- Réorganisation assets store

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:46:03 +01:00
232940b1eb feat: Version 3.6.2 - Correctifs tâches #17-20
- #17: Amélioration gestion des secteurs et statistiques
- #18: Optimisation services API et logs
- #19: Corrections Flutter widgets et repositories
- #20: Fix création passage - détection automatique ope_users.id vs users.id

Suppression dossier web/ (migration vers app Flutter)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 14:11:15 +01:00
pierre
7b78037175 chore: Suppression définitive du dossier opendata 2025-11-09 19:26:50 +01:00
234 changed files with 9359 additions and 10544 deletions

View File

@@ -10,6 +10,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Web: `cd web && npm run dev` - run Svelte dev server - Web: `cd web && npm run dev` - run Svelte dev server
- Web build: `cd web && npm run build` - build web app for production - Web build: `cd web && npm run build` - build web app for production
## Post-modification checks (OBLIGATOIRE)
After modifying any code file, run the appropriate linter:
- Dart/Flutter: `cd app && flutter analyze [modified_files]`
- PHP: `php -l [modified_file]` (syntax check)
## Code Style Guidelines ## Code Style Guidelines
- Flutter/Dart: Follow Flutter lint rules in analysis_options.yaml - Flutter/Dart: Follow Flutter lint rules in analysis_options.yaml
- Naming: camelCase for variables/methods, PascalCase for classes/enums - Naming: camelCase for variables/methods, PascalCase for classes/enums

153
HOWTO-PROKOV.md Normal file
View File

@@ -0,0 +1,153 @@
# Prokov - Gestion des tâches
## Vue d'ensemble
Prokov est l'outil de gestion de projets et tâches utilisé pour suivre l'avancement de tous les projets 2026.
**URL** : https://prokov.unikoffice.com
**API** : https://prokov.unikoffice.com/api/
## Compte Claude
Claude Code peut interagir directement avec l'API Prokov.
| Paramètre | Valeur |
|-----------|--------|
| Email | pierre@d6mail.fr |
| Password | d66,Pierre |
| Entity | 1 |
| Role | owner |
## Projets
| ID | Projet | Parent | Description |
|----|--------|--------|-------------|
| 1 | Prokov | - | Gestionnaire de tâches |
| 2 | Sogoms | - | API auto-générée Go |
| 4 | Geosector | - | Application Amicales Pompiers |
| 14 | Geosector-App | 4 | App Flutter |
| 15 | Geosector-API | 4 | API backend |
| 16 | Geosector-Web | 4 | Site web |
| 5 | Cleo | - | - |
| 6 | Serveurs | - | Infra |
| 8 | UnikOffice | - | - |
| 21 | 2026 | - | Plateforme micro-services |
| 22 | 2026-Go | 21 | Modules Go (Thierry) |
| 23 | 2026-Flutter | 21 | App Flutter (Pierre) |
| 24 | 2026-Infra | 21 | Infrastructure (commun) |
## Statuts
| ID | Nom | Actif |
|----|-----|-------|
| 1 | Backlog | Oui |
| 2 | À faire | Oui |
| 3 | En cours | Oui |
| 4 | À tester | Oui |
| 5 | Livré | Oui |
| 6 | Terminé | Non |
| 7 | Archivé | Non |
## Utilisation avec Claude Code
### Lire les tâches d'un projet
> "Montre-moi les tâches du projet 2026"
Claude va récupérer les tâches via l'API.
### Créer une tâche
> "Crée une tâche 'Implémenter mod-cpu' dans 2026-Go avec priorité 3"
### Mettre à jour un statut
> "Passe la tâche #170 en statut 'En cours'"
### Marquer comme terminé
> "Marque la tâche #170 comme terminée"
## API Endpoints
### Authentification
```bash
# Login (récupère le token JWT)
curl -s -X POST "https://prokov.unikoffice.com/api/auth/login" \
-H "Content-Type: application/json" \
-d '{"email":"pierre@d6mail.fr","password":"d66,Pierre"}'
```
### Projets
```bash
# Liste des projets
curl -s "https://prokov.unikoffice.com/api/projects" \
-H "Authorization: Bearer $TOKEN"
# Créer un projet
curl -s -X POST "https://prokov.unikoffice.com/api/projects" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Mon Projet","description":"...","color":"#2563eb"}'
# Créer un sous-projet
curl -s -X POST "https://prokov.unikoffice.com/api/projects" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Sous-Projet","parent_id":21}'
```
### Tâches
```bash
# Tâches d'un projet
curl -s "https://prokov.unikoffice.com/api/tasks?project_id=21" \
-H "Authorization: Bearer $TOKEN"
# Créer une tâche
curl -s -X POST "https://prokov.unikoffice.com/api/tasks" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Ma tâche","project_id":22,"status_id":2,"priority":3}'
# Mettre à jour une tâche
curl -s -X PUT "https://prokov.unikoffice.com/api/tasks/170" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status_id":3}'
```
### Statuts
```bash
# Liste des statuts
curl -s "https://prokov.unikoffice.com/api/statuses" \
-H "Authorization: Bearer $TOKEN"
```
## Workflow Git (à implémenter)
Le hook post-commit pourra détecter les `#ID` dans les messages de commit et mettre automatiquement les tâches en "À tester".
```bash
git commit -m "feat: nouvelle fonctionnalité #170 #171"
# → Tâches 170 et 171 passent en statut 4 (À tester)
```
## Structure projets 2026
```
/home/pierre/dev/2026/
├── prokov/ # ID 1 - Gestionnaire tâches
├── sogoms/ # ID 2 - API Go
├── geosector/ # ID 4 - App géospatiale
│ ├── app/ # ID 14
│ ├── api/ # ID 15
│ └── web/ # ID 16
├── resalice/ # Migration vers Sogoms
├── monipocket/ # À intégrer dans 2026
├── unikoffice/ # ID 8
└── cleo/ # ID 5
```

View File

@@ -1 +1 @@
3.5.2 3.6.3

View File

@@ -1,651 +0,0 @@
#!/bin/bash
set -uo pipefail
# Note: Removed -e to allow script to continue on errors
# Errors are handled explicitly with ERROR_COUNT
# Parse command line arguments
ONLY_DB=false
if [[ "${1:-}" == "-onlydb" ]]; then
ONLY_DB=true
echo "Mode: Database backup only"
fi
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/d6back.yaml"
LOG_DIR="$SCRIPT_DIR/logs"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/d6back-$(date +%Y%m%d).log"
ERROR_COUNT=0
RECAP_FILE="/tmp/backup_recap_$$.txt"
# Lock file to prevent concurrent executions
LOCK_FILE="/var/lock/d6back.lock"
exec 200>"$LOCK_FILE"
if ! flock -n 200; then
echo "ERROR: Another backup is already running" >&2
exit 1
fi
trap 'flock -u 200' EXIT
# Clean old log files (keep only last 10)
find "$LOG_DIR" -maxdepth 1 -name "d6back-*.log" -type f 2>/dev/null | sort -r | tail -n +11 | xargs -r rm -f || true
# Check dependencies - COMMENTED OUT
# for cmd in yq ssh tar openssl; do
# if ! command -v "$cmd" &> /dev/null; then
# echo "ERROR: $cmd is required but not installed" | tee -a "$LOG_FILE"
# exit 1
# fi
# done
# Load config
DIR_BACKUP=$(yq '.global.dir_backup' "$CONFIG_FILE" | tr -d '"')
ENC_KEY_PATH=$(yq '.global.enc_key' "$CONFIG_FILE" | tr -d '"')
BACKUP_SERVER=$(yq '.global.backup_server // "BACKUP"' "$CONFIG_FILE" | tr -d '"')
EMAIL_TO=$(yq '.global.email_to // "support@unikoffice.com"' "$CONFIG_FILE" | tr -d '"')
KEEP_DIRS=$(yq '.global.keep_dirs' "$CONFIG_FILE" | tr -d '"')
KEEP_DB=$(yq '.global.keep_db' "$CONFIG_FILE" | tr -d '"')
# Load encryption key
if [[ ! -f "$ENC_KEY_PATH" ]]; then
echo "ERROR: Encryption key not found: $ENC_KEY_PATH" | tee -a "$LOG_FILE"
exit 1
fi
ENC_KEY=$(cat "$ENC_KEY_PATH")
echo "=== Backup Started $(date) ===" | tee -a "$LOG_FILE"
echo "Backup directory: $DIR_BACKUP" | tee -a "$LOG_FILE"
# Check available disk space
DISK_USAGE=$(df "$DIR_BACKUP" | tail -1 | awk '{print $5}' | sed 's/%//')
DISK_FREE=$((100 - DISK_USAGE))
if [[ $DISK_FREE -lt 20 ]]; then
echo "WARNING: Low disk space! Only ${DISK_FREE}% free on backup partition" | tee -a "$LOG_FILE"
# Send warning email
echo "Sending DISK SPACE WARNING email to $EMAIL_TO (${DISK_FREE}% free)" | tee -a "$LOG_FILE"
if command -v msmtp &> /dev/null; then
{
echo "To: $EMAIL_TO"
echo "Subject: Backup${BACKUP_SERVER} WARNING - Low disk space (${DISK_FREE}% free)"
echo ""
echo "WARNING: Low disk space on $(hostname)"
echo ""
echo "Backup directory: $DIR_BACKUP"
echo "Disk usage: ${DISK_USAGE}%"
echo "Free space: ${DISK_FREE}%"
echo ""
echo "The backup will continue but please free up some space soon."
echo ""
echo "Date: $(date '+%d.%m.%Y %H:%M')"
} | msmtp "$EMAIL_TO"
echo "DISK SPACE WARNING email sent successfully to $EMAIL_TO" | tee -a "$LOG_FILE"
else
echo "WARNING: msmtp not found - DISK WARNING email NOT sent" | tee -a "$LOG_FILE"
fi
else
echo "Disk space OK: ${DISK_FREE}% free" | tee -a "$LOG_FILE"
fi
# Initialize recap file
echo "BACKUP REPORT - $(hostname) - $(date '+%d.%m.%Y %H')h" > "$RECAP_FILE"
echo "========================================" >> "$RECAP_FILE"
echo "" >> "$RECAP_FILE"
# Function to format size in MB with thousand separator
format_size_mb() {
local file="$1"
if [[ -f "$file" ]]; then
local size_kb=$(du -k "$file" | cut -f1)
local size_mb=$((size_kb / 1024))
# Add thousand separator with printf and sed
printf "%d" "$size_mb" | sed ':a;s/\B[0-9]\{3\}\>/\.&/;ta'
else
echo "0"
fi
}
# Function to calculate age in days
get_age_days() {
local file="$1"
local now=$(date +%s)
local file_time=$(stat -c %Y "$file" 2>/dev/null || echo 0)
echo $(( (now - file_time) / 86400 ))
}
# Function to get week number of year for a file
get_week_year() {
local file="$1"
local file_time=$(stat -c %Y "$file" 2>/dev/null || echo 0)
date -d "@$file_time" +"%Y-%W"
}
# Function to cleanup old backups according to retention policy
cleanup_old_backups() {
local DELETED_COUNT=0
local KEPT_COUNT=0
echo "" | tee -a "$LOG_FILE"
echo "=== Starting Backup Retention Cleanup ===" | tee -a "$LOG_FILE"
# Parse retention periods
local KEEP_DIRS_DAYS=${KEEP_DIRS%d} # Remove 'd' suffix
# Parse database retention (5d,3w,15m)
IFS=',' read -r KEEP_DB_DAILY KEEP_DB_WEEKLY KEEP_DB_MONTHLY <<< "$KEEP_DB"
local KEEP_DB_DAILY_DAYS=${KEEP_DB_DAILY%d}
local KEEP_DB_WEEKLY_WEEKS=${KEEP_DB_WEEKLY%w}
local KEEP_DB_MONTHLY_MONTHS=${KEEP_DB_MONTHLY%m}
# Convert to days
local KEEP_DB_WEEKLY_DAYS=$((KEEP_DB_WEEKLY_WEEKS * 7))
local KEEP_DB_MONTHLY_DAYS=$((KEEP_DB_MONTHLY_MONTHS * 30))
echo "Retention policy: dirs=${KEEP_DIRS_DAYS}d, db=${KEEP_DB_DAILY_DAYS}d/${KEEP_DB_WEEKLY_WEEKS}w/${KEEP_DB_MONTHLY_MONTHS}m" | tee -a "$LOG_FILE"
# Process each host directory
for host_dir in "$DIR_BACKUP"/*; do
if [[ ! -d "$host_dir" ]]; then
continue
fi
local host_name=$(basename "$host_dir")
echo " Cleaning host: $host_name" | tee -a "$LOG_FILE"
# Clean directory backups (*.tar.gz but not *.sql.gz.enc)
while IFS= read -r -d '' file; do
if [[ $(basename "$file") == *".sql.gz.enc" ]]; then
continue # Skip SQL files
fi
local age_days=$(get_age_days "$file")
if [[ $age_days -gt $KEEP_DIRS_DAYS ]]; then
rm -f "$file"
echo " Deleted: $(basename "$file") (${age_days}d > ${KEEP_DIRS_DAYS}d)" | tee -a "$LOG_FILE"
((DELETED_COUNT++))
else
((KEPT_COUNT++))
fi
done < <(find "$host_dir" -name "*.tar.gz" -type f -print0 2>/dev/null)
# Clean database backups with retention policy
declare -A db_files
while IFS= read -r -d '' file; do
local filename=$(basename "$file")
local db_name=${filename%%_*}
if [[ -z "${db_files[$db_name]:-}" ]]; then
db_files[$db_name]="$file"
else
db_files[$db_name]+=$'\n'"$file"
fi
done < <(find "$host_dir" -name "*.sql.gz.enc" -type f -print0 2>/dev/null)
# Process each database
for db_name in "${!db_files[@]}"; do
# Sort files by age (newest first)
mapfile -t files < <(echo "${db_files[$db_name]}" | while IFS= read -r f; do
echo "$f"
done | xargs -I {} stat -c "%Y {}" {} 2>/dev/null | sort -rn | cut -d' ' -f2-)
# Track which files to keep
declare -A keep_daily
declare -A keep_weekly
for file in "${files[@]}"; do
local age_days=$(get_age_days "$file")
if [[ $age_days -le $KEEP_DB_DAILY_DAYS ]]; then
# Keep all files within daily retention
((KEPT_COUNT++))
elif [[ $age_days -le $KEEP_DB_WEEKLY_DAYS ]]; then
# Weekly retention: keep one per day
local file_date=$(date -d "@$(stat -c %Y "$file")" +"%Y-%m-%d")
if [[ -z "${keep_daily[$file_date]:-}" ]]; then
keep_daily[$file_date]="$file"
((KEPT_COUNT++))
else
rm -f "$file"
((DELETED_COUNT++))
fi
elif [[ $age_days -le $KEEP_DB_MONTHLY_DAYS ]]; then
# Monthly retention: keep one per week
local week_year=$(get_week_year "$file")
if [[ -z "${keep_weekly[$week_year]:-}" ]]; then
keep_weekly[$week_year]="$file"
((KEPT_COUNT++))
else
rm -f "$file"
((DELETED_COUNT++))
fi
else
# Beyond retention period
rm -f "$file"
echo " Deleted: $(basename "$file") (${age_days}d > ${KEEP_DB_MONTHLY_DAYS}d)" | tee -a "$LOG_FILE"
((DELETED_COUNT++))
fi
done
unset keep_daily keep_weekly
done
unset db_files
done
echo "Cleanup completed: ${DELETED_COUNT} deleted, ${KEPT_COUNT} kept" | tee -a "$LOG_FILE"
# Add cleanup summary to recap file
echo "" >> "$RECAP_FILE"
echo "CLEANUP SUMMARY:" >> "$RECAP_FILE"
echo " Files deleted: $DELETED_COUNT" >> "$RECAP_FILE"
echo " Files kept: $KEPT_COUNT" >> "$RECAP_FILE"
}
# Function to backup a single database (must be defined before use)
backup_database() {
local database="$1"
local timestamp="$(date +%Y%m%d_%H)"
local backup_file="$backup_dir/sql/${database}_${timestamp}.sql.gz.enc"
echo " Backing up database: $database" | tee -a "$LOG_FILE"
if [[ "$ssh_user" != "root" ]]; then
CMD_PREFIX="sudo"
else
CMD_PREFIX=""
fi
# Execute backup with encryption
# First test MySQL connection to get clear error messages (|| true to continue on error)
MYSQL_TEST=$(ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
"$CMD_PREFIX incus exec $container_name -- bash -c 'cat > /tmp/d6back.cnf << EOF
[client]
user=$db_user
password=$db_pass
host=$db_host
EOF
chmod 600 /tmp/d6back.cnf
mariadb --defaults-extra-file=/tmp/d6back.cnf -e \"SELECT 1\" 2>&1
rm -f /tmp/d6back.cnf'" 2>/dev/null || true)
if ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
"$CMD_PREFIX incus exec $container_name -- bash -c 'cat > /tmp/d6back.cnf << EOF
[client]
user=$db_user
password=$db_pass
host=$db_host
EOF
chmod 600 /tmp/d6back.cnf
mariadb-dump --defaults-extra-file=/tmp/d6back.cnf --single-transaction --lock-tables=false --add-drop-table --create-options --databases $database 2>/dev/null | sed -e \"/^CREATE DATABASE/s/\\\`$database\\\`/\\\`${database}_${timestamp}\\\`/\" -e \"/^USE/s/\\\`$database\\\`/\\\`${database}_${timestamp}\\\`/\" | gzip
rm -f /tmp/d6back.cnf'" | \
openssl enc -aes-256-cbc -salt -pass pass:"$ENC_KEY" -pbkdf2 > "$backup_file" 2>/dev/null; then
# Validate backup file size (encrypted SQL should be > 100 bytes)
if [[ -f "$backup_file" ]]; then
file_size=$(stat -c%s "$backup_file" 2>/dev/null || echo 0)
if [[ $file_size -lt 100 ]]; then
# Analyze MySQL connection test results
if [[ "$MYSQL_TEST" == *"Access denied"* ]]; then
echo " ERROR: MySQL authentication failed for $database on $host_name/$container_name" | tee -a "$LOG_FILE"
echo " User: $db_user@$db_host - Check password in configuration" | tee -a "$LOG_FILE"
elif [[ "$MYSQL_TEST" == *"Unknown database"* ]]; then
echo " ERROR: Database '$database' does not exist on $host_name/$container_name" | tee -a "$LOG_FILE"
elif [[ "$MYSQL_TEST" == *"Can't connect"* ]]; then
echo " ERROR: Cannot connect to MySQL server at $db_host in $container_name" | tee -a "$LOG_FILE"
else
echo " ERROR: Backup file too small (${file_size} bytes): $database on $host_name/$container_name" | tee -a "$LOG_FILE"
fi
((ERROR_COUNT++))
rm -f "$backup_file"
else
size=$(du -h "$backup_file" | cut -f1)
size_mb=$(format_size_mb "$backup_file")
echo " ✓ Saved (encrypted): $(basename "$backup_file") ($size)" | tee -a "$LOG_FILE"
echo " SQL: $(basename "$backup_file") - ${size_mb} Mo" >> "$RECAP_FILE"
# Test backup integrity
if ! openssl enc -aes-256-cbc -d -pass pass:"$ENC_KEY" -pbkdf2 -in "$backup_file" | gunzip -t 2>/dev/null; then
echo " ERROR: Backup integrity check failed for $database" | tee -a "$LOG_FILE"
((ERROR_COUNT++))
fi
fi
else
echo " ERROR: Backup file not created: $database" | tee -a "$LOG_FILE"
((ERROR_COUNT++))
fi
else
# Analyze MySQL connection test for failed backup
if [[ "$MYSQL_TEST" == *"Access denied"* ]]; then
echo " ERROR: MySQL authentication failed for $database on $host_name/$container_name" | tee -a "$LOG_FILE"
echo " User: $db_user@$db_host - Check password in configuration" | tee -a "$LOG_FILE"
elif [[ "$MYSQL_TEST" == *"Unknown database"* ]]; then
echo " ERROR: Database '$database' does not exist on $host_name/$container_name" | tee -a "$LOG_FILE"
elif [[ "$MYSQL_TEST" == *"Can't connect"* ]]; then
echo " ERROR: Cannot connect to MySQL server at $db_host in $container_name" | tee -a "$LOG_FILE"
else
echo " ERROR: Failed to backup database $database on $host_name/$container_name" | tee -a "$LOG_FILE"
fi
((ERROR_COUNT++))
rm -f "$backup_file"
fi
}
# Process each host
host_count=$(yq '.hosts | length' "$CONFIG_FILE")
for ((i=0; i<$host_count; i++)); do
host_name=$(yq ".hosts[$i].name" "$CONFIG_FILE" | tr -d '"')
host_ip=$(yq ".hosts[$i].ip" "$CONFIG_FILE" | tr -d '"')
ssh_user=$(yq ".hosts[$i].user" "$CONFIG_FILE" | tr -d '"')
ssh_key=$(yq ".hosts[$i].key" "$CONFIG_FILE" | tr -d '"')
ssh_port=$(yq ".hosts[$i].port // 22" "$CONFIG_FILE" | tr -d '"')
echo "Processing host: $host_name ($host_ip)" | tee -a "$LOG_FILE"
echo "" >> "$RECAP_FILE"
echo "HOST: $host_name ($host_ip)" >> "$RECAP_FILE"
echo "----------------------------" >> "$RECAP_FILE"
# Test SSH connection
if ! ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 -o StrictHostKeyChecking=no "$ssh_user@$host_ip" "true" 2>/dev/null; then
echo " ERROR: Cannot connect to $host_name ($host_ip)" | tee -a "$LOG_FILE"
((ERROR_COUNT++))
continue
fi
# Process containers
container_count=$(yq ".hosts[$i].containers | length" "$CONFIG_FILE" 2>/dev/null || echo "0")
for ((c=0; c<$container_count; c++)); do
container_name=$(yq ".hosts[$i].containers[$c].name" "$CONFIG_FILE" | tr -d '"')
echo " Processing container: $container_name" | tee -a "$LOG_FILE"
# Add container to recap
echo "" >> "$RECAP_FILE"
echo " Container: $container_name" >> "$RECAP_FILE"
# Create backup directories
backup_dir="$DIR_BACKUP/$host_name/$container_name"
mkdir -p "$backup_dir"
mkdir -p "$backup_dir/sql"
# Backup directories (skip if -onlydb mode)
if [[ "$ONLY_DB" == "false" ]]; then
dir_count=$(yq ".hosts[$i].containers[$c].dirs | length" "$CONFIG_FILE" 2>/dev/null || echo "0")
for ((d=0; d<$dir_count; d++)); do
dir_path=$(yq ".hosts[$i].containers[$c].dirs[$d]" "$CONFIG_FILE" | sed 's/^"\|"$//g')
# Use sudo if not root
if [[ "$ssh_user" != "root" ]]; then
CMD_PREFIX="sudo"
else
CMD_PREFIX=""
fi
# Special handling for /var/www - backup each subdirectory separately
if [[ "$dir_path" == "/var/www" ]]; then
echo " Backing up subdirectories of $dir_path" | tee -a "$LOG_FILE"
# Get list of subdirectories
subdirs=$(ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
"$CMD_PREFIX incus exec $container_name -- find /var/www -maxdepth 1 -type d ! -path /var/www" 2>/dev/null || echo "")
for subdir in $subdirs; do
subdir_name=$(basename "$subdir" | tr '/' '_')
backup_file="$backup_dir/www_${subdir_name}_$(date +%Y%m%d_%H).tar.gz"
echo " Backing up: $subdir" | tee -a "$LOG_FILE"
if ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
"$CMD_PREFIX incus exec $container_name -- tar czf - $subdir 2>/dev/null" > "$backup_file"; then
# Validate backup file size (tar.gz should be > 1KB)
if [[ -f "$backup_file" ]]; then
file_size=$(stat -c%s "$backup_file" 2>/dev/null || echo 0)
if [[ $file_size -lt 1024 ]]; then
echo " WARNING: Backup file very small (${file_size} bytes): $subdir" | tee -a "$LOG_FILE"
# Keep the file but note it's small
size=$(du -h "$backup_file" | cut -f1)
size_mb=$(format_size_mb "$backup_file")
echo " ✓ Saved (small): $(basename "$backup_file") ($size)" | tee -a "$LOG_FILE"
echo " DIR: $(basename "$backup_file") - ${size_mb} Mo (WARNING: small)" >> "$RECAP_FILE"
else
size=$(du -h "$backup_file" | cut -f1)
size_mb=$(format_size_mb "$backup_file")
echo " ✓ Saved: $(basename "$backup_file") ($size)" | tee -a "$LOG_FILE"
echo " DIR: $(basename "$backup_file") - ${size_mb} Mo" >> "$RECAP_FILE"
fi
# Test tar integrity
if ! tar tzf "$backup_file" >/dev/null 2>&1; then
echo " ERROR: Tar integrity check failed" | tee -a "$LOG_FILE"
((ERROR_COUNT++))
fi
else
echo " ERROR: Backup file not created: $subdir" | tee -a "$LOG_FILE"
((ERROR_COUNT++))
fi
else
echo " ERROR: Failed to backup $subdir" | tee -a "$LOG_FILE"
((ERROR_COUNT++))
rm -f "$backup_file"
fi
done
else
# Normal backup for other directories
dir_name=$(basename "$dir_path" | tr '/' '_')
backup_file="$backup_dir/${dir_name}_$(date +%Y%m%d_%H).tar.gz"
echo " Backing up: $dir_path" | tee -a "$LOG_FILE"
if ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
"$CMD_PREFIX incus exec $container_name -- tar czf - $dir_path 2>/dev/null" > "$backup_file"; then
# Validate backup file size (tar.gz should be > 1KB)
if [[ -f "$backup_file" ]]; then
file_size=$(stat -c%s "$backup_file" 2>/dev/null || echo 0)
if [[ $file_size -lt 1024 ]]; then
echo " WARNING: Backup file very small (${file_size} bytes): $dir_path" | tee -a "$LOG_FILE"
# Keep the file but note it's small
size=$(du -h "$backup_file" | cut -f1)
size_mb=$(format_size_mb "$backup_file")
echo " ✓ Saved (small): $(basename "$backup_file") ($size)" | tee -a "$LOG_FILE"
echo " DIR: $(basename "$backup_file") - ${size_mb} Mo (WARNING: small)" >> "$RECAP_FILE"
else
size=$(du -h "$backup_file" | cut -f1)
size_mb=$(format_size_mb "$backup_file")
echo " ✓ Saved: $(basename "$backup_file") ($size)" | tee -a "$LOG_FILE"
echo " DIR: $(basename "$backup_file") - ${size_mb} Mo" >> "$RECAP_FILE"
fi
# Test tar integrity
if ! tar tzf "$backup_file" >/dev/null 2>&1; then
echo " ERROR: Tar integrity check failed" | tee -a "$LOG_FILE"
((ERROR_COUNT++))
fi
else
echo " ERROR: Backup file not created: $dir_path" | tee -a "$LOG_FILE"
((ERROR_COUNT++))
fi
else
echo " ERROR: Failed to backup $dir_path" | tee -a "$LOG_FILE"
((ERROR_COUNT++))
rm -f "$backup_file"
fi
fi
done
fi # End of directory backup section
# Backup databases
db_user=$(yq ".hosts[$i].containers[$c].db_user" "$CONFIG_FILE" 2>/dev/null | tr -d '"')
db_pass=$(yq ".hosts[$i].containers[$c].db_pass" "$CONFIG_FILE" 2>/dev/null | tr -d '"')
db_host=$(yq ".hosts[$i].containers[$c].db_host // \"localhost\"" "$CONFIG_FILE" 2>/dev/null | tr -d '"')
# Check if we're in onlydb mode
if [[ "$ONLY_DB" == "true" ]]; then
# Use onlydb list if it exists
onlydb_count=$(yq ".hosts[$i].containers[$c].onlydb | length" "$CONFIG_FILE" 2>/dev/null || echo "0")
if [[ "$onlydb_count" != "0" ]] && [[ "$onlydb_count" != "null" ]]; then
db_count="$onlydb_count"
use_onlydb=true
else
# No onlydb list, skip this container in onlydb mode
continue
fi
else
# Normal mode - use databases list
db_count=$(yq ".hosts[$i].containers[$c].databases | length" "$CONFIG_FILE" 2>/dev/null || echo "0")
use_onlydb=false
fi
if [[ -n "$db_user" ]] && [[ -n "$db_pass" ]] && [[ "$db_count" != "0" ]]; then
for ((db=0; db<$db_count; db++)); do
if [[ "$use_onlydb" == "true" ]]; then
db_name=$(yq ".hosts[$i].containers[$c].onlydb[$db]" "$CONFIG_FILE" | tr -d '"')
else
db_name=$(yq ".hosts[$i].containers[$c].databases[$db]" "$CONFIG_FILE" | tr -d '"')
fi
if [[ "$db_name" == "ALL" ]]; then
echo " Fetching all databases..." | tee -a "$LOG_FILE"
# Get database list
if [[ "$ssh_user" != "root" ]]; then
db_list=$(ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
"sudo incus exec $container_name -- bash -c 'cat > /tmp/d6back.cnf << EOF
[client]
user=$db_user
password=$db_pass
host=$db_host
EOF
chmod 600 /tmp/d6back.cnf
mariadb --defaults-extra-file=/tmp/d6back.cnf -e \"SHOW DATABASES;\" 2>/dev/null
rm -f /tmp/d6back.cnf'" | \
grep -Ev '^(Database|information_schema|performance_schema|mysql|sys)$' || echo "")
else
db_list=$(ssh -i "$ssh_key" -p "$ssh_port" -o ConnectTimeout=20 "$ssh_user@$host_ip" \
"incus exec $container_name -- bash -c 'cat > /tmp/d6back.cnf << EOF
[client]
user=$db_user
password=$db_pass
host=$db_host
EOF
chmod 600 /tmp/d6back.cnf
mariadb --defaults-extra-file=/tmp/d6back.cnf -e \"SHOW DATABASES;\" 2>/dev/null
rm -f /tmp/d6back.cnf'" | \
grep -Ev '^(Database|information_schema|performance_schema|mysql|sys)$' || echo "")
fi
# Backup each database
for single_db in $db_list; do
backup_database "$single_db"
done
else
backup_database "$db_name"
fi
done
fi
done
done
echo "=== Backup Completed $(date) ===" | tee -a "$LOG_FILE"
# Cleanup old backups according to retention policy
cleanup_old_backups
# Show summary
total_size=$(du -sh "$DIR_BACKUP" 2>/dev/null | cut -f1)
echo "Total backup size: $total_size" | tee -a "$LOG_FILE"
# Add summary to recap
echo "" >> "$RECAP_FILE"
echo "========================================" >> "$RECAP_FILE"
# Add size details per host/container
echo "BACKUP SIZES:" >> "$RECAP_FILE"
for host_dir in "$DIR_BACKUP"/*; do
if [[ -d "$host_dir" ]]; then
host_name=$(basename "$host_dir")
host_size=$(du -sh "$host_dir" 2>/dev/null | cut -f1)
echo "" >> "$RECAP_FILE"
echo " $host_name: $host_size" >> "$RECAP_FILE"
# Size per container
for container_dir in "$host_dir"/*; do
if [[ -d "$container_dir" ]]; then
container_name=$(basename "$container_dir")
container_size=$(du -sh "$container_dir" 2>/dev/null | cut -f1)
echo " - $container_name: $container_size" >> "$RECAP_FILE"
fi
done
fi
done
echo "" >> "$RECAP_FILE"
echo "TOTAL SIZE: $total_size" >> "$RECAP_FILE"
echo "COMPLETED: $(date '+%d.%m.%Y %H:%M')" >> "$RECAP_FILE"
# Prepare email subject with date format
DATE_SUBJECT=$(date '+%d.%m.%Y %H')
# Send recap email
if [[ $ERROR_COUNT -gt 0 ]]; then
echo "Total errors: $ERROR_COUNT" | tee -a "$LOG_FILE"
# Add errors to recap
echo "" >> "$RECAP_FILE"
echo "ERRORS DETECTED: $ERROR_COUNT" >> "$RECAP_FILE"
echo "----------------------------" >> "$RECAP_FILE"
grep -i "ERROR" "$LOG_FILE" >> "$RECAP_FILE"
# Send email with ERROR in subject
echo "Sending ERROR email to $EMAIL_TO (Errors found: $ERROR_COUNT)" | tee -a "$LOG_FILE"
if command -v msmtp &> /dev/null; then
{
echo "To: $EMAIL_TO"
echo "Subject: Backup${BACKUP_SERVER} ERROR $DATE_SUBJECT"
echo ""
cat "$RECAP_FILE"
} | msmtp "$EMAIL_TO"
echo "ERROR email sent successfully to $EMAIL_TO" | tee -a "$LOG_FILE"
else
echo "WARNING: msmtp not found - ERROR email NOT sent" | tee -a "$LOG_FILE"
fi
else
echo "Backup completed successfully with no errors" | tee -a "$LOG_FILE"
# Send success recap email
echo "Sending SUCCESS recap email to $EMAIL_TO" | tee -a "$LOG_FILE"
if command -v msmtp &> /dev/null; then
{
echo "To: $EMAIL_TO"
echo "Subject: Backup${BACKUP_SERVER} $DATE_SUBJECT"
echo ""
cat "$RECAP_FILE"
} | msmtp "$EMAIL_TO"
echo "SUCCESS recap email sent successfully to $EMAIL_TO" | tee -a "$LOG_FILE"
else
echo "WARNING: msmtp not found - SUCCESS recap email NOT sent" | tee -a "$LOG_FILE"
fi
fi
# Clean up recap file
rm -f "$RECAP_FILE"
# Exit with error code if there were errors
if [[ $ERROR_COUNT -gt 0 ]]; then
exit 1
fi

View File

@@ -1,112 +0,0 @@
# Configuration for MariaDB and directories backup
# Backup structure: $dir_backup/$hostname/$containername/ for dirs
# $dir_backup/$hostname/$containername/sql/ for databases
# Global parameters
global:
backup_server: PM7 # Nom du serveur de backup (PM7, PM1, etc.)
email_to: support@unikoffice.com # Email de notification
dir_backup: /var/pierre/back # Base backup directory
enc_key: /home/pierre/.key_enc # Encryption key for SQL backups
keep_dirs: 7d # Garde 7 jours pour les dirs
keep_db: 5d,3w,15m # 5 jours complets, 3 semaines (1/jour), 15 mois (1/semaine)
# Hosts configuration
hosts:
- name: IN2
ip: 145.239.9.105
user: debian
key: /home/pierre/.ssh/backup_key
port: 22
dirs:
- /etc/nginx
containers:
- name: nx4
db_user: root
db_pass: MyDebServer,90b
db_host: localhost
dirs:
- /etc/nginx
- /var/www
databases:
- ALL # Backup all databases
onlydb: # Used only with -onlydb parameter (optional)
- turing
- name: IN3
ip: 195.154.80.116
user: pierre
key: /home/pierre/.ssh/backup_key
port: 22
dirs:
- /etc/nginx
containers:
- name: nx4
db_user: root
db_pass: MyAlpLocal,90b
db_host: localhost
dirs:
- /etc/nginx
- /var/www
databases:
- ALL # Backup all databases
onlydb: # Used only with -onlydb parameter (optional)
- geosector
- name: rca-geo
dirs:
- /etc/nginx
- /var/www
- name: dva-res
db_user: root
db_pass: MyAlpineDb.90b
db_host: localhost
dirs:
- /etc/nginx
- /var/www
databases:
- ALL
onlydb:
- resalice
- name: dva-front
dirs:
- /etc/nginx
- /var/www
- name: maria3
db_user: root
db_pass: MyAlpLocal,90b
db_host: localhost
dirs:
- /etc/my.cnf.d
- /var/osm
- /var/log
databases:
- ALL
onlydb:
- cleo
- rca_geo
- name: IN4
ip: 51.159.7.190
user: pierre
key: /home/pierre/.ssh/backup_key
port: 22
dirs:
- /etc/nginx
containers:
- name: maria4
db_user: root
db_pass: MyAlpLocal,90b
db_host: localhost
dirs:
- /etc/my.cnf.d
- /var/osm
- /var/log
databases:
- ALL
onlydb:
- cleo
- pra_geo

View File

@@ -1,118 +0,0 @@
#!/bin/bash
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
CONFIG_FILE="backpm7.yaml"
# Check if file argument is provided
if [ $# -eq 0 ]; then
echo -e "${RED}Error: No input file specified${NC}"
echo "Usage: $0 <database.sql.gz.enc>"
echo "Example: $0 wordpress_20250905_14.sql.gz.enc"
exit 1
fi
INPUT_FILE="$1"
# Check if input file exists
if [ ! -f "$INPUT_FILE" ]; then
echo -e "${RED}Error: File not found: $INPUT_FILE${NC}"
exit 1
fi
# Function to load encryption key from config
load_key_from_config() {
if [ ! -f "$CONFIG_FILE" ]; then
echo -e "${YELLOW}Warning: $CONFIG_FILE not found${NC}"
return 1
fi
# Check for yq
if ! command -v yq &> /dev/null; then
echo -e "${RED}Error: yq is required to read config file${NC}"
echo "Install with: sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 && sudo chmod +x /usr/local/bin/yq"
return 1
fi
local key_path=$(yq '.global.enc_key' "$CONFIG_FILE" | tr -d '"')
if [ -z "$key_path" ]; then
echo -e "${RED}Error: enc_key not found in $CONFIG_FILE${NC}"
return 1
fi
if [ ! -f "$key_path" ]; then
echo -e "${RED}Error: Encryption key file not found: $key_path${NC}"
return 1
fi
ENC_KEY=$(cat "$key_path")
echo -e "${GREEN}Encryption key loaded from: $key_path${NC}"
return 0
}
# Check file type early - accept both old and new naming
if [[ "$INPUT_FILE" != *.sql.gz.enc ]] && [[ "$INPUT_FILE" != *.sql.tar.gz.enc ]]; then
echo -e "${RED}Error: File must be a .sql.gz.enc or .sql.tar.gz.enc file${NC}"
echo "This tool only decrypts SQL backup files created by backpm7.sh"
exit 1
fi
# Get encryption key from config
if ! load_key_from_config; then
echo -e "${RED}Error: Cannot load encryption key${NC}"
echo "Make sure $CONFIG_FILE exists and contains enc_key path"
exit 1
fi
# Process SQL backup file
echo -e "${BLUE}Decrypting SQL backup: $INPUT_FILE${NC}"
# Determine output file - extract just the filename and put in current directory
BASENAME=$(basename "$INPUT_FILE")
if [[ "$BASENAME" == *.sql.tar.gz.enc ]]; then
OUTPUT_FILE="${BASENAME%.sql.tar.gz.enc}.sql"
else
OUTPUT_FILE="${BASENAME%.sql.gz.enc}.sql"
fi
# Decrypt and decompress in one command
echo "Decrypting to: $OUTPUT_FILE"
# Decrypt and decompress in one pipeline
if openssl enc -aes-256-cbc -d -salt -pass pass:"$ENC_KEY" -pbkdf2 -in "$INPUT_FILE" | gunzip > "$OUTPUT_FILE" 2>/dev/null; then
# Get file size
size=$(du -h "$OUTPUT_FILE" | cut -f1)
echo -e "${GREEN}✓ Successfully decrypted: $OUTPUT_FILE ($size)${NC}"
# Show first few lines of SQL
echo -e "${BLUE}First 5 lines of SQL:${NC}"
head -n 5 "$OUTPUT_FILE"
else
echo -e "${RED}✗ Decryption failed${NC}"
echo "Possible causes:"
echo " - Wrong encryption key"
echo " - Corrupted file"
echo " - File was encrypted differently"
# Try to help debug
echo -e "\n${YELLOW}Debug info:${NC}"
echo "File size: $(du -h "$INPUT_FILE" | cut -f1)"
echo "First bytes (should start with 'Salted__'):"
hexdump -C "$INPUT_FILE" | head -n 1
# Let's also check what key we're using (first 10 chars)
echo "Key begins with: ${ENC_KEY:0:10}..."
exit 1
fi
echo -e "${GREEN}Operation completed successfully${NC}"

View File

@@ -1,248 +0,0 @@
#!/bin/bash
#
# sync_geosector.sh - Synchronise les backups geosector depuis PM7 vers maria3 (IN3) et maria4 (IN4)
#
# Ce script :
# 1. Trouve le dernier backup chiffré de geosector sur PM7
# 2. Le déchiffre et décompresse localement
# 3. Le transfère et l'importe dans IN3/maria3/geosector
# 4. Le transfère et l'importe dans IN4/maria4/geosector
#
# Installation: /var/pierre/bat/sync_geosector.sh
# Usage: ./sync_geosector.sh [--force] [--date YYYYMMDD_HH]
#
set -uo pipefail
# Note: Removed -e to allow script to continue on sync errors
# Errors are handled explicitly with ERROR_COUNT
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/d6back.yaml"
BACKUP_DIR="/var/pierre/back/IN3/nx4/sql"
ENC_KEY_FILE="/home/pierre/.key_enc"
SSH_KEY="/home/pierre/.ssh/backup_key"
TEMP_DIR="/tmp/geosector_sync"
LOG_FILE="/var/pierre/bat/logs/sync_geosector.log"
RECAP_FILE="/tmp/sync_geosector_recap_$$.txt"
# Load email config from d6back.yaml
if [[ -f "$CONFIG_FILE" ]]; then
EMAIL_TO=$(yq '.global.email_to // "support@unikoffice.com"' "$CONFIG_FILE" | tr -d '"')
BACKUP_SERVER=$(yq '.global.backup_server // "BACKUP"' "$CONFIG_FILE" | tr -d '"')
else
EMAIL_TO="support@unikoffice.com"
BACKUP_SERVER="BACKUP"
fi
# Serveurs cibles
IN3_HOST="195.154.80.116"
IN3_USER="pierre"
IN3_CONTAINER="maria3"
IN4_HOST="51.159.7.190"
IN4_USER="pierre"
IN4_CONTAINER="maria4"
# Credentials MariaDB
DB_USER="root"
IN3_DB_PASS="MyAlpLocal,90b" # maria3
IN4_DB_PASS="MyAlpLocal,90b" # maria4
DB_NAME="geosector"
# Fonctions utilitaires
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
error() {
log "ERROR: $*"
exit 1
}
cleanup() {
if [[ -d "$TEMP_DIR" ]]; then
log "Nettoyage de $TEMP_DIR"
rm -rf "$TEMP_DIR"
fi
rm -f "$RECAP_FILE"
}
trap cleanup EXIT
# Lecture de la clé de chiffrement
if [[ ! -f "$ENC_KEY_FILE" ]]; then
error "Clé de chiffrement non trouvée: $ENC_KEY_FILE"
fi
ENC_KEY=$(cat "$ENC_KEY_FILE")
# Parsing des arguments
FORCE=0
SPECIFIC_DATE=""
while [[ $# -gt 0 ]]; do
case $1 in
--force)
FORCE=1
shift
;;
--date)
SPECIFIC_DATE="$2"
shift 2
;;
*)
echo "Usage: $0 [--force] [--date YYYYMMDD_HH]"
exit 1
;;
esac
done
# Trouver le fichier backup
if [[ -n "$SPECIFIC_DATE" ]]; then
BACKUP_FILE="$BACKUP_DIR/geosector_${SPECIFIC_DATE}.sql.gz.enc"
if [[ ! -f "$BACKUP_FILE" ]]; then
error "Backup non trouvé: $BACKUP_FILE"
fi
else
# Chercher le plus récent
BACKUP_FILE=$(find "$BACKUP_DIR" -name "geosector_*.sql.gz.enc" -type f -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2-)
if [[ -z "$BACKUP_FILE" ]]; then
error "Aucun backup geosector trouvé dans $BACKUP_DIR"
fi
fi
BACKUP_BASENAME=$(basename "$BACKUP_FILE")
log "Backup sélectionné: $BACKUP_BASENAME"
# Initialiser le fichier récapitulatif
echo "SYNC GEOSECTOR REPORT - $(hostname) - $(date '+%d.%m.%Y %H')h" > "$RECAP_FILE"
echo "========================================" >> "$RECAP_FILE"
echo "" >> "$RECAP_FILE"
echo "Backup source: $BACKUP_BASENAME" >> "$RECAP_FILE"
echo "" >> "$RECAP_FILE"
# Créer le répertoire temporaire
mkdir -p "$TEMP_DIR"
DECRYPTED_FILE="$TEMP_DIR/geosector.sql"
# Étape 1: Déchiffrer et décompresser
log "Déchiffrement et décompression du backup..."
if ! openssl enc -aes-256-cbc -d -pass pass:"$ENC_KEY" -pbkdf2 -in "$BACKUP_FILE" | gunzip > "$DECRYPTED_FILE"; then
error "Échec du déchiffrement/décompression"
fi
FILE_SIZE=$(du -h "$DECRYPTED_FILE" | cut -f1)
log "Fichier SQL déchiffré: $FILE_SIZE"
echo "Decrypted SQL size: $FILE_SIZE" >> "$RECAP_FILE"
echo "" >> "$RECAP_FILE"
# Compteur d'erreurs
ERROR_COUNT=0
# Fonction pour synchroniser vers un serveur
sync_to_server() {
local HOST=$1
local USER=$2
local CONTAINER=$3
local DB_PASS=$4
local SERVER_NAME=$5
log "=== Synchronisation vers $SERVER_NAME ($HOST) ==="
echo "TARGET: $SERVER_NAME ($HOST/$CONTAINER)" >> "$RECAP_FILE"
# Test de connexion SSH
if ! ssh -i "$SSH_KEY" -o ConnectTimeout=10 "$USER@$HOST" "echo 'SSH OK'" &>/dev/null; then
log "ERROR: Impossible de se connecter à $HOST via SSH"
echo " ✗ SSH connection FAILED" >> "$RECAP_FILE"
((ERROR_COUNT++))
return 1
fi
# Import dans MariaDB
log "Import dans $SERVER_NAME/$CONTAINER/geosector..."
# Drop et recréer la base sur le serveur distant
if ! ssh -i "$SSH_KEY" "$USER@$HOST" "incus exec $CONTAINER --project default -- mariadb -u root -p'$DB_PASS' -e 'DROP DATABASE IF EXISTS $DB_NAME; CREATE DATABASE $DB_NAME CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'"; then
log "ERROR: Échec de la création de la base sur $SERVER_NAME"
echo " ✗ Database creation FAILED" >> "$RECAP_FILE"
((ERROR_COUNT++))
return 1
fi
# Filtrer et importer le SQL (enlever CREATE DATABASE et USE avec timestamp)
log "Filtrage et import du SQL..."
if ! sed -e '/^CREATE DATABASE.*geosector_[0-9]/d' \
-e '/^USE.*geosector_[0-9]/d' \
"$DECRYPTED_FILE" | \
ssh -i "$SSH_KEY" "$USER@$HOST" "incus exec $CONTAINER --project default -- mariadb -u root -p'$DB_PASS' $DB_NAME"; then
log "ERROR: Échec de l'import sur $SERVER_NAME"
echo " ✗ SQL import FAILED" >> "$RECAP_FILE"
((ERROR_COUNT++))
return 1
fi
log "$SERVER_NAME: Import réussi"
echo " ✓ Import SUCCESS" >> "$RECAP_FILE"
echo "" >> "$RECAP_FILE"
}
# Synchronisation vers IN3/maria3
sync_to_server "$IN3_HOST" "$IN3_USER" "$IN3_CONTAINER" "$IN3_DB_PASS" "IN3/maria3"
# Synchronisation vers IN4/maria4
sync_to_server "$IN4_HOST" "$IN4_USER" "$IN4_CONTAINER" "$IN4_DB_PASS" "IN4/maria4"
# Finaliser le récapitulatif
echo "========================================" >> "$RECAP_FILE"
echo "COMPLETED: $(date '+%d.%m.%Y %H:%M')" >> "$RECAP_FILE"
# Préparer le sujet email avec date
DATE_SUBJECT=$(date '+%d.%m.%Y %H')
# Envoyer l'email récapitulatif
if [[ $ERROR_COUNT -gt 0 ]]; then
log "Total errors: $ERROR_COUNT"
# Ajouter les erreurs au récap
echo "" >> "$RECAP_FILE"
echo "ERRORS DETECTED: $ERROR_COUNT" >> "$RECAP_FILE"
echo "----------------------------" >> "$RECAP_FILE"
grep -i "ERROR" "$LOG_FILE" | tail -20 >> "$RECAP_FILE"
# Envoyer email avec ERROR dans le sujet
log "Sending ERROR email to $EMAIL_TO (Errors found: $ERROR_COUNT)"
if command -v msmtp &> /dev/null; then
{
echo "To: $EMAIL_TO"
echo "Subject: Sync${BACKUP_SERVER} ERROR $DATE_SUBJECT"
echo ""
cat "$RECAP_FILE"
} | msmtp "$EMAIL_TO"
log "ERROR email sent successfully to $EMAIL_TO"
else
log "WARNING: msmtp not found - ERROR email NOT sent"
fi
log "=== Synchronisation terminée avec des erreurs ==="
exit 1
else
log "=== Synchronisation terminée avec succès ==="
log "Les bases geosector sur maria3 et maria4 sont à jour avec le backup $BACKUP_BASENAME"
# Envoyer email de succès
log "Sending SUCCESS recap email to $EMAIL_TO"
if command -v msmtp &> /dev/null; then
{
echo "To: $EMAIL_TO"
echo "Subject: Sync${BACKUP_SERVER} $DATE_SUBJECT"
echo ""
cat "$RECAP_FILE"
} | msmtp "$EMAIL_TO"
log "SUCCESS recap email sent successfully to $EMAIL_TO"
else
log "WARNING: msmtp not found - SUCCESS recap email NOT sent"
fi
exit 0
fi

View File

@@ -1225,6 +1225,86 @@ php scripts/php/migrate_from_backup.php \
--- ---
#### 7. Statistiques Events pour Admin Flutter
**Demandé le :** 22/12/2025
**Objectif :** Permettre aux admins Flutter de consulter les logs Events avec des stats quotidiennes, hebdomadaires et mensuelles, et drill-down vers le détail.
**Architecture choisie :** Stats pré-agrégées en SQL + détail JSONL à la demande
**Pourquoi cette approche :**
- Évite de parser les fichiers JSONL à chaque requête Flutter
- Transfert minimal (~1-10 KB par requête)
- Calculs hebdo/mensuel à la volée depuis `daily` (pas de tables supplémentaires)
- Détail paginé uniquement sur demande
**Phase 1 : Base de données** ✅ (22/12/2025)
- [x] Créer la table `event_stats_daily`
- Colonnes : `stat_date`, `entity_id`, `event_type`, `count`, `sum_amount`, `unique_users`, `metadata`
- Index : `(entity_id, stat_date)`, unique `(stat_date, entity_id, event_type)`
- [x] Script SQL de création : `scripts/sql/create_event_stats_daily.sql`
**Phase 2 : CRON d'agrégation** ✅ (22/12/2025)
- [x] Créer `scripts/cron/aggregate_event_stats.php`
- Parse le fichier JSONL de J-1 (ou date passée en paramètre)
- Agrège par entity_id et event_type
- INSERT/UPDATE dans `event_stats_daily`
- Calcule `unique_users` (COUNT DISTINCT sur user_id)
- Calcule `sum_amount` pour les passages
- Stocke metadata JSON (top 5 secteurs, erreurs fréquentes, etc.)
- [x] Ajouter au crontab : exécution à 01h00 chaque nuit (via deploy-api.sh)
- [x] Script de rattrapage : `php aggregate_event_stats.php --from=2025-01-01 --to=2025-12-21`
**Phase 3 : Service EventStatsService** ✅ (22/12/2025)
- [x] Créer `src/Services/EventStatsService.php`
- `getSummary(?int $entityId, ?string $date)` : Stats du jour
- `getDaily(?int $entityId, string $from, string $to, array $eventTypes)` : Stats journalières
- `getWeekly(?int $entityId, string $from, string $to, array $eventTypes)` : Calculé depuis daily
- `getMonthly(?int $entityId, int $year, array $eventTypes)` : Calculé depuis daily
- `getDetails(?int $entityId, string $date, ?string $eventType, int $limit, int $offset)` : Lecture JSONL paginée
- `getEventTypes()` : Liste des types d'événements disponibles
- `hasStatsForDate(string $date)` : Vérifie si stats existent
**Phase 4 : Controller et Routes** ✅ (22/12/2025)
- [x] Créer `src/Controllers/EventStatsController.php`
- `summary()` : GET /api/events/stats/summary?date=
- `daily()` : GET /api/events/stats/daily?from=&to=&events=
- `weekly()` : GET /api/events/stats/weekly?from=&to=&events=
- `monthly()` : GET /api/events/stats/monthly?year=&events=
- `details()` : GET /api/events/stats/details?date=&event=&limit=&offset=
- `types()` : GET /api/events/stats/types
- [x] Ajouter les routes dans `Router.php`
- [x] Vérification des droits : Admin entité (role_id = 2) ou Super-admin (role_id = 1)
- [x] Super-admin : peut voir toutes les entités (entity_id = NULL ou ?entity_id=X)
**Phase 5 : Optimisations** ✅ (22/12/2025)
- [x] Compression gzip sur les réponses JSON (si >1KB et client supporte)
- [x] Header `ETag` sur /summary et /daily (cache 5 min, 304 Not Modified)
- [x] Filtrage des champs sensibles dans /details (IP tronquée, user_agent supprimé)
- [x] Limite max 100 events par requête /details
**Phase 6 : Tests et documentation**
- [ ] Tests unitaires EventStatsService
- [ ] Tests endpoints avec différents rôles
- [ ] Documentation Postman/Swagger des endpoints
- [ ] Mise à jour TECHBOOK.md avec exemples de réponses JSON
**Estimation :** 2-3 jours de développement
**Dépendances :**
- EventLogService déjà en place ✅
- Fichiers JSONL générés quotidiennement ✅
---
### 🟢 PRIORITÉ BASSE ### 🟢 PRIORITÉ BASSE
#### 7. Amélioration de la suppression des utilisateurs #### 7. Amélioration de la suppression des utilisateurs

View File

@@ -36,7 +36,7 @@ FINAL_OWNER_LOGS="nobody"
FINAL_GROUP_LOGS="nginx" FINAL_GROUP_LOGS="nginx"
# Configuration de sauvegarde # Configuration de sauvegarde
BACKUP_DIR="/data/backup/geosector/api" BACKUP_DIR="/home/pierre/samba/back/geosector/api"
# Couleurs pour les messages # Couleurs pour les messages
GREEN='\033[0;32m' GREEN='\033[0;32m'
@@ -179,6 +179,14 @@ if [ "$SOURCE_TYPE" = "local_code" ]; then
--exclude='*.swp' \ --exclude='*.swp' \
--exclude='*.swo' \ --exclude='*.swo' \
--exclude='*~' \ --exclude='*~' \
--exclude='docs/*.geojson' \
--exclude='docs/*.sql' \
--exclude='docs/*.pdf' \
--exclude='composer.phar' \
--exclude='scripts/migration*' \
--exclude='scripts/php' \
--exclude='CLAUDE.md' \
--exclude='TODO-API.md' \
-czf "${ARCHIVE_PATH}" . 2>/dev/null || echo_error "Failed to create archive" -czf "${ARCHIVE_PATH}" . 2>/dev/null || echo_error "Failed to create archive"
echo_info "Archive created: ${ARCHIVE_PATH}" echo_info "Archive created: ${ARCHIVE_PATH}"
@@ -198,6 +206,16 @@ elif [ "$SOURCE_TYPE" = "remote_container" ]; then
--exclude='uploads' \ --exclude='uploads' \
--exclude='sessions' \ --exclude='sessions' \
--exclude='opendata' \ --exclude='opendata' \
--exclude='docs/*.geojson' \
--exclude='docs/*.sql' \
--exclude='docs/*.pdf' \
--exclude='composer.phar' \
--exclude='scripts/migration*' \
--exclude='scripts/php' \
--exclude='CLAUDE.md' \
--exclude='TODO-API.md' \
--exclude='*.tar.gz' \
--exclude='vendor' \
-czf /tmp/${ARCHIVE_NAME} -C ${API_PATH} . -czf /tmp/${ARCHIVE_NAME} -C ${API_PATH} .
" || echo_error "Failed to create archive on remote" " || echo_error "Failed to create archive on remote"
@@ -288,11 +306,11 @@ if [ "$DEST_HOST" != "local" ]; then
incus exec ${DEST_CONTAINER} -- find ${API_PATH} -type d -exec chmod 755 {} \; && incus exec ${DEST_CONTAINER} -- find ${API_PATH} -type d -exec chmod 755 {} \; &&
incus exec ${DEST_CONTAINER} -- find ${API_PATH} -type f -exec chmod 644 {} \; && incus exec ${DEST_CONTAINER} -- find ${API_PATH} -type f -exec chmod 644 {} \; &&
# Permissions spéciales pour logs # Permissions spéciales pour logs (PHP-FPM tourne sous nobody)
incus exec ${DEST_CONTAINER} -- mkdir -p ${API_PATH}/logs/events && incus exec ${DEST_CONTAINER} -- mkdir -p ${API_PATH}/logs/events &&
incus exec ${DEST_CONTAINER} -- chown -R ${FINAL_OWNER_LOGS}:${FINAL_GROUP} ${API_PATH}/logs && incus exec ${DEST_CONTAINER} -- chown -R ${FINAL_OWNER_LOGS}:${FINAL_GROUP} ${API_PATH}/logs &&
incus exec ${DEST_CONTAINER} -- find ${API_PATH}/logs -type d -exec chmod 750 {} \; && incus exec ${DEST_CONTAINER} -- find ${API_PATH}/logs -type d -exec chmod 775 {} \; &&
incus exec ${DEST_CONTAINER} -- find ${API_PATH}/logs -type f -exec chmod 640 {} \; && incus exec ${DEST_CONTAINER} -- find ${API_PATH}/logs -type f -exec chmod 664 {} \; &&
# Permissions spéciales pour uploads # Permissions spéciales pour uploads
incus exec ${DEST_CONTAINER} -- mkdir -p ${API_PATH}/uploads && incus exec ${DEST_CONTAINER} -- mkdir -p ${API_PATH}/uploads &&
@@ -342,8 +360,8 @@ if [ "$DEST_HOST" != "local" ]; then
# GEOSECTOR API - Security data cleanup (daily at 2am) # GEOSECTOR API - Security data cleanup (daily at 2am)
0 2 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/cleanup_security_data.php >> /var/www/geosector/api/logs/cleanup_security.log 2>&1 0 2 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/cleanup_security_data.php >> /var/www/geosector/api/logs/cleanup_security.log 2>&1
# GEOSECTOR API - Stripe devices update (weekly Sunday at 3am) # GEOSECTOR API - Event stats aggregation (daily at 1am)
0 3 * * 0 /usr/bin/php /var/www/geosector/api/scripts/cron/update_stripe_devices.php >> /var/www/geosector/api/logs/stripe_devices.log 2>&1 0 1 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/aggregate_event_stats.php >> /var/www/geosector/api/logs/aggregate_stats.log 2>&1
EOF EOF
# Installer le nouveau crontab # Installer le nouveau crontab

View File

@@ -89,7 +89,75 @@ PUT /api/users/123 // users.id
1. **Reçus fiscaux** : PDF auto (<5KB) pour dons, envoi email queue 1. **Reçus fiscaux** : PDF auto (<5KB) pour dons, envoi email queue
2. **Logos entités** : Upload PNG/JPG, redimensionnement 250x250px, base64 2. **Logos entités** : Upload PNG/JPG, redimensionnement 250x250px, base64
3. **Migration** : Endpoints REST par entité (9 phases) 3. **Migration** : Endpoints REST par entité (9 phases)
4. **CRONs** : Email queue (*/5), cleanup sécurité (2h), Stripe devices (dim 3h) 4. **CRONs** : Email queue (*/5), cleanup sécurité (2h)
## 📊 Statistiques Events (Admin Flutter)
### Architecture
**Principe** : Stats pré-agrégées en SQL + détail JSONL à la demande
| Source | Usage | Performance |
|--------|-------|-------------|
| Table `event_stats_daily` | Dashboard, graphiques, tendances | Instantané (~1ms) |
| Fichiers JSONL | Détail événements (clic sur stat) | Paginé (~50-100ms) |
### Flux de données
1. **EventLogService** écrit les événements dans `/logs/events/YYYY-MM-DD.jsonl`
2. **CRON nightly** agrège J-1 dans `event_stats_daily`
3. **API** sert les stats agrégées (SQL) ou le détail paginé (JSONL)
4. **Flutter Admin** affiche dashboard avec drill-down
### Table d'agrégation
**`event_stats_daily`** : Une ligne par (date, entité, type d'événement)
| Colonne | Description |
|---------|-------------|
| `stat_date` | Date des stats |
| `entity_id` | Entité (NULL = global super-admin) |
| `event_type` | Type événement (login_success, passage_created, etc.) |
| `count` | Nombre d'occurrences |
| `sum_amount` | Somme montants (passages) |
| `unique_users` | Utilisateurs distincts |
| `metadata` | JSON agrégé (top secteurs, erreurs fréquentes, etc.) |
### Endpoints API
| Endpoint | Période | Source | Taille réponse |
|----------|---------|--------|----------------|
| `GET /events/stats/summary` | Jour courant | SQL | ~1 KB |
| `GET /events/stats/daily` | Plage dates | SQL | ~5 KB |
| `GET /events/stats/weekly` | Calculé depuis daily | SQL | ~2 KB |
| `GET /events/stats/monthly` | Calculé depuis daily | SQL | ~1 KB |
| `GET /events/details` | Détail paginé | JSONL | ~10 KB |
### Optimisations transfert Flutter
- **Pagination** : 50 events max par requête détail
- **Champs filtrés** : Pas d'IP ni user_agent complet dans les réponses
- **Compression gzip** : -70% sur JSON
- **Cache HTTP** : ETag sur stats (changent 1x/jour)
- **Calcul hebdo/mensuel** : À la volée depuis `daily` (pas de tables supplémentaires)
### Types d'événements agrégés
| Catégorie | Events |
|-----------|--------|
| **Auth** | login_success, login_failed, logout |
| **Passages** | passage_created, passage_updated, passage_deleted |
| **Secteurs** | sector_created, sector_updated, sector_deleted |
| **Users** | user_created, user_updated, user_deleted |
| **Entités** | entity_created, entity_updated, entity_deleted |
| **Opérations** | operation_created, operation_updated, operation_deleted |
| **Stripe** | stripe_payment_created, stripe_payment_success, stripe_payment_failed, stripe_payment_cancelled, stripe_terminal_error |
### Accès et sécurité
- **Rôle requis** : Admin entité (role_id = 2) ou Super-admin (role_id = 1)
- **Isolation** : Admin voit uniquement les stats de son entité
- **Super-admin** : Accès global (entity_id = NULL dans requêtes)
## 🚀 Déploiement ## 🚀 Déploiement
@@ -172,4 +240,4 @@ DELETE FROM operations WHERE id = 850;
--- ---
**Mis à jour : 26 Octobre 2025** **Mis à jour : 22 Décembre 2025**

View File

@@ -157,12 +157,21 @@ register_shutdown_function(function() use ($requestUri, $requestMethod) {
// Alerter sur les erreurs 500 // Alerter sur les erreurs 500
if ($statusCode >= 500) { if ($statusCode >= 500) {
$error = error_get_last(); $error = error_get_last();
$errorMessage = $error['message'] ?? null;
// Si pas d'erreur PHP, c'est probablement une exception capturée
// Le détail de l'erreur sera dans les logs applicatifs
if ($errorMessage === null) {
$errorMessage = 'Exception capturée (voir logs/app.log pour détails)';
}
AlertService::trigger('HTTP_500', [ AlertService::trigger('HTTP_500', [
'endpoint' => $requestUri, 'endpoint' => $requestUri,
'method' => $requestMethod, 'method' => $requestMethod,
'error_message' => $error['message'] ?? 'Unknown error', 'error_message' => $errorMessage,
'error_file' => $error['file'] ?? 'Unknown', 'error_file' => $error['file'] ?? 'N/A',
'error_line' => $error['line'] ?? 0, 'error_line' => $error['line'] ?? 0,
'stack_trace' => 'Consulter logs/app.log pour le stack trace complet',
'message' => "Erreur serveur 500 sur $requestUri" 'message' => "Erreur serveur 500 sur $requestUri"
], 'ERROR'); ], 'ERROR');
} }

View File

@@ -94,30 +94,7 @@ Ce dossier contient les scripts automatisés de maintenance et de traitement pou
--- ---
### 5. `update_stripe_devices.php` ### 5. `sync_databases.php`
**Fonction** : Met à jour la liste des appareils Android certifiés pour Tap to Pay
**Caractéristiques** :
- Liste de 95+ devices intégrée
- Ajoute les nouveaux appareils certifiés
- Met à jour les versions Android minimales
- Désactive les appareils obsolètes
- Notification email si changements importants
- Possibilité de personnaliser via `/data/stripe_certified_devices.json`
**Fréquence recommandée** : Hebdomadaire le dimanche à 3h
**Ligne crontab** :
```bash
0 3 * * 0 /usr/bin/php /var/www/geosector/api/scripts/cron/update_stripe_devices.php >> /var/www/geosector/api/logs/stripe_devices.log 2>&1
```
---
### 6. `sync_databases.php`
**Fonction** : Synchronise les bases de données entre environnements **Fonction** : Synchronise les bases de données entre environnements
@@ -175,9 +152,6 @@ crontab -e
# Rotation des logs événements (mensuel le 1er à 3h) # Rotation des logs événements (mensuel le 1er à 3h)
0 3 1 * * /usr/bin/php /var/www/geosector/api/scripts/cron/rotate_event_logs.php >> /var/www/geosector/api/logs/rotation_events.log 2>&1 0 3 1 * * /usr/bin/php /var/www/geosector/api/scripts/cron/rotate_event_logs.php >> /var/www/geosector/api/logs/rotation_events.log 2>&1
# Mise à jour des devices Stripe (hebdomadaire dimanche à 3h)
0 3 * * 0 /usr/bin/php /var/www/geosector/api/scripts/cron/update_stripe_devices.php >> /var/www/geosector/api/logs/stripe_devices.log 2>&1
``` ```
### 4. Vérifier que les CRONs sont actifs ### 4. Vérifier que les CRONs sont actifs
@@ -203,7 +177,6 @@ Tous les logs CRON sont stockés dans `/var/www/geosector/api/logs/` :
- `cleanup_security.log` : Nettoyage des données de sécurité - `cleanup_security.log` : Nettoyage des données de sécurité
- `cleanup_logs.log` : Nettoyage des anciens fichiers logs - `cleanup_logs.log` : Nettoyage des anciens fichiers logs
- `rotation_events.log` : Rotation des logs événements JSONL - `rotation_events.log` : Rotation des logs événements JSONL
- `stripe_devices.log` : Mise à jour des devices Tap to Pay
### Vérification de l'exécution ### Vérification de l'exécution
@@ -216,9 +189,6 @@ tail -n 50 /var/www/geosector/api/logs/cleanup_logs.log
# Voir les dernières rotations des logs événements # Voir les dernières rotations des logs événements
tail -n 50 /var/www/geosector/api/logs/rotation_events.log tail -n 50 /var/www/geosector/api/logs/rotation_events.log
# Voir les dernières mises à jour Stripe
tail -n 50 /var/www/geosector/api/logs/stripe_devices.log
``` ```
--- ---

View File

@@ -0,0 +1,456 @@
#!/usr/bin/env php
<?php
/**
* Script CRON pour agrégation des statistiques d'événements
*
* Parse les fichiers JSONL et agrège les données dans event_stats_daily
*
* Usage:
* php aggregate_event_stats.php # Agrège J-1
* php aggregate_event_stats.php --date=2025-12-20 # Agrège une date spécifique
* php aggregate_event_stats.php --from=2025-12-01 --to=2025-12-21 # Rattrapage plage
*
* À exécuter quotidiennement via crontab (1h du matin) :
* 0 1 * * * /usr/bin/php /var/www/geosector/api/scripts/cron/aggregate_event_stats.php >> /var/www/geosector/api/logs/aggregate_stats.log 2>&1
*/
declare(strict_types=1);
// Configuration
define('LOCK_FILE', '/tmp/aggregate_event_stats.lock');
define('EVENT_LOG_DIR', __DIR__ . '/../../logs/events');
// Empêcher l'exécution multiple simultanée
if (file_exists(LOCK_FILE)) {
$lockTime = filemtime(LOCK_FILE);
if (time() - $lockTime > 3600) {
unlink(LOCK_FILE);
} else {
die("[" . date('Y-m-d H:i:s') . "] Le processus est déjà en cours d'exécution\n");
}
}
file_put_contents(LOCK_FILE, (string) getmypid());
register_shutdown_function(function () {
if (file_exists(LOCK_FILE)) {
unlink(LOCK_FILE);
}
});
// Simuler l'environnement web pour AppConfig en CLI
if (php_sapi_name() === 'cli') {
$hostname = gethostname();
if (strpos($hostname, 'pra') !== false) {
$_SERVER['SERVER_NAME'] = 'app3.geosector.fr';
} elseif (strpos($hostname, 'rca') !== false) {
$_SERVER['SERVER_NAME'] = 'rapp.geosector.fr';
} else {
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr';
}
$_SERVER['HTTP_HOST'] = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'];
$_SERVER['REMOTE_ADDR'] = $_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';
use App\Services\LogService;
/**
* Parse les arguments CLI
*/
function parseArgs(array $argv): array
{
$args = [
'date' => null,
'from' => null,
'to' => null,
];
foreach ($argv as $arg) {
if (strpos($arg, '--date=') === 0) {
$args['date'] = substr($arg, 7);
} elseif (strpos($arg, '--from=') === 0) {
$args['from'] = substr($arg, 7);
} elseif (strpos($arg, '--to=') === 0) {
$args['to'] = substr($arg, 5);
}
}
return $args;
}
/**
* Génère la liste des dates à traiter
*/
function getDatesToProcess(array $args): array
{
$dates = [];
if ($args['date']) {
$dates[] = $args['date'];
} elseif ($args['from'] && $args['to']) {
$current = new DateTime($args['from']);
$end = new DateTime($args['to']);
while ($current <= $end) {
$dates[] = $current->format('Y-m-d');
$current->modify('+1 day');
}
} else {
// Par défaut : J-1
$dates[] = date('Y-m-d', strtotime('-1 day'));
}
return $dates;
}
/**
* Parse un fichier JSONL et retourne les événements
*/
function parseJsonlFile(string $filePath): array
{
$events = [];
if (!file_exists($filePath)) {
return $events;
}
$handle = fopen($filePath, 'r');
if (!$handle) {
return $events;
}
while (($line = fgets($handle)) !== false) {
$line = trim($line);
if (empty($line)) {
continue;
}
$event = json_decode($line, true);
if ($event && isset($event['event'])) {
$events[] = $event;
}
}
fclose($handle);
return $events;
}
/**
* Agrège les événements par entity_id et event_type
*/
function aggregateEvents(array $events): array
{
$stats = [];
foreach ($events as $event) {
$entityId = $event['entity_id'] ?? null;
$eventType = $event['event'] ?? 'unknown';
$userId = $event['user_id'] ?? null;
// Clé d'agrégation : entity_id peut être NULL (stats globales)
$key = ($entityId ?? 'NULL') . '|' . $eventType;
if (!isset($stats[$key])) {
$stats[$key] = [
'entity_id' => $entityId,
'event_type' => $eventType,
'count' => 0,
'sum_amount' => 0.0,
'user_ids' => [],
'metadata' => [],
];
}
$stats[$key]['count']++;
// Collecter les user_ids pour unique_users
if ($userId !== null) {
$stats[$key]['user_ids'][$userId] = true;
}
// Somme des montants pour les passages et paiements Stripe
if (in_array($eventType, ['passage_created', 'passage_updated'])) {
$amount = $event['amount'] ?? 0;
$stats[$key]['sum_amount'] += (float) $amount;
} elseif (in_array($eventType, ['stripe_payment_success', 'stripe_payment_created'])) {
// Montant en centimes -> euros
$amount = ($event['amount'] ?? 0) / 100;
$stats[$key]['sum_amount'] += (float) $amount;
}
// Collecter metadata spécifiques
collectMetadata($stats[$key], $event);
}
// Convertir user_ids en count
foreach ($stats as &$stat) {
$stat['unique_users'] = count($stat['user_ids']);
unset($stat['user_ids']);
// Finaliser les metadata
$stat['metadata'] = finalizeMetadata($stat['metadata'], $stat['event_type']);
}
return $stats;
}
/**
* Collecte les métadonnées spécifiques par type d'événement
*/
function collectMetadata(array &$stat, array $event): void
{
$eventType = $event['event'] ?? '';
switch ($eventType) {
case 'login_failed':
$reason = $event['reason'] ?? 'unknown';
$stat['metadata']['reasons'][$reason] = ($stat['metadata']['reasons'][$reason] ?? 0) + 1;
break;
case 'passage_created':
$sectorId = $event['sector_id'] ?? null;
if ($sectorId) {
$stat['metadata']['sectors'][$sectorId] = ($stat['metadata']['sectors'][$sectorId] ?? 0) + 1;
}
$paymentType = $event['payment_type'] ?? 'unknown';
$stat['metadata']['payment_types'][$paymentType] = ($stat['metadata']['payment_types'][$paymentType] ?? 0) + 1;
break;
case 'stripe_payment_failed':
$errorCode = $event['error_code'] ?? 'unknown';
$stat['metadata']['error_codes'][$errorCode] = ($stat['metadata']['error_codes'][$errorCode] ?? 0) + 1;
break;
case 'stripe_terminal_error':
$errorCode = $event['error_code'] ?? 'unknown';
$stat['metadata']['error_codes'][$errorCode] = ($stat['metadata']['error_codes'][$errorCode] ?? 0) + 1;
break;
}
}
/**
* Finalise les métadonnées (top 5, tri, etc.)
*/
function finalizeMetadata(array $metadata, string $eventType): ?array
{
if (empty($metadata)) {
return null;
}
$result = [];
// Top 5 secteurs
if (isset($metadata['sectors'])) {
arsort($metadata['sectors']);
$result['top_sectors'] = array_slice($metadata['sectors'], 0, 5, true);
}
// Raisons d'échec login
if (isset($metadata['reasons'])) {
arsort($metadata['reasons']);
$result['failure_reasons'] = $metadata['reasons'];
}
// Types de paiement
if (isset($metadata['payment_types'])) {
arsort($metadata['payment_types']);
$result['payment_types'] = $metadata['payment_types'];
}
// Codes d'erreur
if (isset($metadata['error_codes'])) {
arsort($metadata['error_codes']);
$result['error_codes'] = $metadata['error_codes'];
}
return empty($result) ? null : $result;
}
/**
* Insère ou met à jour les stats dans la base de données
*/
function upsertStats(PDO $db, string $date, array $stats): int
{
$upsertedCount = 0;
$sql = "
INSERT INTO event_stats_daily
(stat_date, entity_id, event_type, count, sum_amount, unique_users, metadata)
VALUES
(:stat_date, :entity_id, :event_type, :count, :sum_amount, :unique_users, :metadata)
ON DUPLICATE KEY UPDATE
count = VALUES(count),
sum_amount = VALUES(sum_amount),
unique_users = VALUES(unique_users),
metadata = VALUES(metadata),
updated_at = CURRENT_TIMESTAMP
";
$stmt = $db->prepare($sql);
foreach ($stats as $stat) {
try {
$stmt->execute([
'stat_date' => $date,
'entity_id' => $stat['entity_id'],
'event_type' => $stat['event_type'],
'count' => $stat['count'],
'sum_amount' => $stat['sum_amount'],
'unique_users' => $stat['unique_users'],
'metadata' => $stat['metadata'] ? json_encode($stat['metadata'], JSON_UNESCAPED_UNICODE) : null,
]);
$upsertedCount++;
} catch (PDOException $e) {
echo " ERREUR insertion {$stat['event_type']}: " . $e->getMessage() . "\n";
}
}
return $upsertedCount;
}
/**
* Génère également les stats globales (entity_id = NULL)
*/
function generateGlobalStats(array $statsByEntity): array
{
$globalStats = [];
foreach ($statsByEntity as $stat) {
$eventType = $stat['event_type'];
if (!isset($globalStats[$eventType])) {
$globalStats[$eventType] = [
'entity_id' => null,
'event_type' => $eventType,
'count' => 0,
'sum_amount' => 0.0,
'unique_users' => 0,
'metadata' => null,
];
}
$globalStats[$eventType]['count'] += $stat['count'];
$globalStats[$eventType]['sum_amount'] += $stat['sum_amount'];
$globalStats[$eventType]['unique_users'] += $stat['unique_users'];
}
return array_values($globalStats);
}
// ============================================================
// MAIN
// ============================================================
try {
echo "[" . date('Y-m-d H:i:s') . "] Démarrage de l'agrégation des statistiques\n";
// Initialisation
$appConfig = AppConfig::getInstance();
$config = $appConfig->getFullConfig();
$environment = $appConfig->getEnvironment();
echo "Environnement: {$environment}\n";
Database::init($config['database']);
$db = Database::getInstance();
// Parser les arguments
$args = parseArgs($argv);
$dates = getDatesToProcess($args);
echo "Dates à traiter: " . implode(', ', $dates) . "\n\n";
$totalStats = 0;
$totalEvents = 0;
foreach ($dates as $date) {
$jsonlFile = EVENT_LOG_DIR . '/' . $date . '.jsonl';
echo "--- Traitement de {$date} ---\n";
if (!file_exists($jsonlFile)) {
echo " Fichier non trouvé: {$jsonlFile}\n";
continue;
}
$fileSize = filesize($jsonlFile);
echo " Fichier: " . basename($jsonlFile) . " (" . number_format($fileSize / 1024, 2) . " KB)\n";
// Parser le fichier
$events = parseJsonlFile($jsonlFile);
$eventCount = count($events);
echo " Événements parsés: {$eventCount}\n";
if ($eventCount === 0) {
echo " Aucun événement à agréger\n";
continue;
}
$totalEvents += $eventCount;
// Agréger par entity/event_type
$stats = aggregateEvents($events);
echo " Agrégations par entité: " . count($stats) . "\n";
// Générer les stats globales
$globalStats = generateGlobalStats($stats);
echo " Agrégations globales: " . count($globalStats) . "\n";
// Fusionner stats entités + globales
$allStats = array_merge(array_values($stats), $globalStats);
// Insérer en base
$upserted = upsertStats($db, $date, $allStats);
echo " Stats insérées/mises à jour: {$upserted}\n";
$totalStats += $upserted;
}
// Résumé
echo "\n=== RÉSUMÉ ===\n";
echo "Dates traitées: " . count($dates) . "\n";
echo "Événements traités: {$totalEvents}\n";
echo "Stats agrégées: {$totalStats}\n";
// Log
LogService::log('Agrégation des statistiques terminée', [
'level' => 'info',
'script' => 'aggregate_event_stats.php',
'environment' => $environment,
'dates_count' => count($dates),
'events_count' => $totalEvents,
'stats_count' => $totalStats,
]);
echo "\n[" . date('Y-m-d H:i:s') . "] Agrégation terminée avec succès\n";
} catch (Exception $e) {
$errorMsg = 'Erreur lors de l\'agrégation: ' . $e->getMessage();
LogService::log($errorMsg, [
'level' => 'error',
'script' => 'aggregate_event_stats.php',
'trace' => $e->getTraceAsString(),
]);
echo "\n❌ ERREUR: {$errorMsg}\n";
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
exit(1);
}
exit(0);

View File

@@ -1,444 +0,0 @@
#!/usr/bin/env php
<?php
/**
* Script CRON pour mettre à jour la liste des appareils certifiés Stripe Tap to Pay
*
* Ce script récupère et met à jour la liste des appareils Android certifiés
* pour Tap to Pay en France dans la table stripe_android_certified_devices
*
* À exécuter hebdomadairement via crontab :
* Exemple: 0 3 * * 0 /usr/bin/php /path/to/api/scripts/cron/update_stripe_devices.php
*/
declare(strict_types=1);
// Configuration
define('LOCK_FILE', '/tmp/update_stripe_devices.lock');
define('DEVICES_JSON_URL', 'https://raw.githubusercontent.com/stripe/stripe-terminal-android/master/tap-to-pay/certified-devices.json');
define('DEVICES_LOCAL_FILE', __DIR__ . '/../../data/stripe_certified_devices.json');
// Empêcher l'exécution multiple simultanée
if (file_exists(LOCK_FILE)) {
$lockTime = filemtime(LOCK_FILE);
if (time() - $lockTime > 3600) { // Lock de plus d'1 heure = processus bloqué
unlink(LOCK_FILE);
} else {
die("[" . date('Y-m-d H:i:s') . "] Le processus est déjà en cours d'exécution\n");
}
}
// Créer le fichier de lock
file_put_contents(LOCK_FILE, getmypid());
// Enregistrer un handler pour supprimer le lock en cas d'arrêt
register_shutdown_function(function() {
if (file_exists(LOCK_FILE)) {
unlink(LOCK_FILE);
}
});
// Simuler l'environnement web pour AppConfig en CLI
if (php_sapi_name() === 'cli') {
$hostname = gethostname();
if (strpos($hostname, 'prod') !== false || strpos($hostname, 'pra') !== false) {
$_SERVER['SERVER_NAME'] = 'app3.geosector.fr';
} elseif (strpos($hostname, 'rec') !== false || strpos($hostname, 'rca') !== false) {
$_SERVER['SERVER_NAME'] = 'rapp.geosector.fr';
} else {
$_SERVER['SERVER_NAME'] = 'dapp.geosector.fr'; // DVA par défaut
}
$_SERVER['REQUEST_URI'] = '/cron/update_stripe_devices';
$_SERVER['REQUEST_METHOD'] = 'CLI';
$_SERVER['HTTP_HOST'] = $_SERVER['SERVER_NAME'];
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
// Définir getallheaders si elle n'existe pas (CLI)
if (!function_exists('getallheaders')) {
function getallheaders() {
return [];
}
}
}
// Charger l'environnement
require_once dirname(dirname(__DIR__)) . '/vendor/autoload.php';
require_once dirname(dirname(__DIR__)) . '/src/Config/AppConfig.php';
require_once dirname(dirname(__DIR__)) . '/src/Core/Database.php';
require_once dirname(dirname(__DIR__)) . '/src/Services/LogService.php';
use App\Services\LogService;
try {
echo "[" . date('Y-m-d H:i:s') . "] Début de la mise à jour des devices Stripe certifiés\n";
// Initialiser la configuration et la base de données
$appConfig = AppConfig::getInstance();
$dbConfig = $appConfig->getDatabaseConfig();
Database::init($dbConfig);
$db = Database::getInstance();
// Logger le début
LogService::log("Début de la mise à jour des devices Stripe certifiés", [
'source' => 'cron',
'script' => 'update_stripe_devices.php'
]);
// Étape 1: Récupérer la liste des devices
$devicesData = fetchCertifiedDevices();
if (empty($devicesData)) {
echo "[" . date('Y-m-d H:i:s') . "] Aucune donnée de devices récupérée\n";
LogService::log("Aucune donnée de devices récupérée", ['level' => 'warning']);
exit(1);
}
// Étape 2: Traiter et mettre à jour la base de données
$stats = updateDatabase($db, $devicesData);
// Étape 3: Logger les résultats
$message = sprintf(
"Mise à jour terminée : %d ajoutés, %d modifiés, %d désactivés, %d inchangés",
$stats['added'],
$stats['updated'],
$stats['disabled'],
$stats['unchanged']
);
echo "[" . date('Y-m-d H:i:s') . "] $message\n";
LogService::log($message, [
'source' => 'cron',
'stats' => $stats
]);
// Étape 4: Envoyer une notification si changements significatifs
if ($stats['added'] > 0 || $stats['disabled'] > 0) {
sendNotification($stats);
}
echo "[" . date('Y-m-d H:i:s') . "] Mise à jour terminée avec succès\n";
} catch (Exception $e) {
$errorMsg = "Erreur lors de la mise à jour des devices: " . $e->getMessage();
echo "[" . date('Y-m-d H:i:s') . "] $errorMsg\n";
LogService::log($errorMsg, [
'level' => 'error',
'trace' => $e->getTraceAsString()
]);
exit(1);
}
/**
* Récupère la liste des devices certifiés
* Essaie d'abord depuis une URL externe, puis depuis un fichier local en fallback
*/
function fetchCertifiedDevices(): array {
// Liste maintenue manuellement des devices certifiés en France
// Source: Documentation Stripe Terminal et tests confirmés
$frenchCertifiedDevices = [
// Samsung Galaxy S Series
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21', 'model_identifier' => 'SM-G991B', 'min_android_version' => 11],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21+', 'model_identifier' => 'SM-G996B', 'min_android_version' => 11],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21 Ultra', 'model_identifier' => 'SM-G998B', 'min_android_version' => 11],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S21 FE', 'model_identifier' => 'SM-G990B', 'min_android_version' => 11],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22', 'model_identifier' => 'SM-S901B', 'min_android_version' => 12],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22+', 'model_identifier' => 'SM-S906B', 'min_android_version' => 12],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S22 Ultra', 'model_identifier' => 'SM-S908B', 'min_android_version' => 12],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23', 'model_identifier' => 'SM-S911B', 'min_android_version' => 13],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23+', 'model_identifier' => 'SM-S916B', 'min_android_version' => 13],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23 Ultra', 'model_identifier' => 'SM-S918B', 'min_android_version' => 13],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S23 FE', 'model_identifier' => 'SM-S711B', 'min_android_version' => 13],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24', 'model_identifier' => 'SM-S921B', 'min_android_version' => 14],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24+', 'model_identifier' => 'SM-S926B', 'min_android_version' => 14],
['manufacturer' => 'Samsung', 'model' => 'Galaxy S24 Ultra', 'model_identifier' => 'SM-S928B', 'min_android_version' => 14],
// Samsung Galaxy Note
['manufacturer' => 'Samsung', 'model' => 'Galaxy Note 20', 'model_identifier' => 'SM-N980F', 'min_android_version' => 10],
['manufacturer' => 'Samsung', 'model' => 'Galaxy Note 20 Ultra', 'model_identifier' => 'SM-N986B', 'min_android_version' => 10],
// Samsung Galaxy Z Fold
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold3', 'model_identifier' => 'SM-F926B', 'min_android_version' => 11],
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold4', 'model_identifier' => 'SM-F936B', 'min_android_version' => 12],
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold5', 'model_identifier' => 'SM-F946B', 'min_android_version' => 13],
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Fold6', 'model_identifier' => 'SM-F956B', 'min_android_version' => 14],
// Samsung Galaxy Z Flip
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip3', 'model_identifier' => 'SM-F711B', 'min_android_version' => 11],
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip4', 'model_identifier' => 'SM-F721B', 'min_android_version' => 12],
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip5', 'model_identifier' => 'SM-F731B', 'min_android_version' => 13],
['manufacturer' => 'Samsung', 'model' => 'Galaxy Z Flip6', 'model_identifier' => 'SM-F741B', 'min_android_version' => 14],
// Samsung Galaxy A Series (haut de gamme)
['manufacturer' => 'Samsung', 'model' => 'Galaxy A54', 'model_identifier' => 'SM-A546B', 'min_android_version' => 13],
['manufacturer' => 'Samsung', 'model' => 'Galaxy A73', 'model_identifier' => 'SM-A736B', 'min_android_version' => 12],
// Google Pixel
['manufacturer' => 'Google', 'model' => 'Pixel 6', 'model_identifier' => 'oriole', 'min_android_version' => 12],
['manufacturer' => 'Google', 'model' => 'Pixel 6 Pro', 'model_identifier' => 'raven', 'min_android_version' => 12],
['manufacturer' => 'Google', 'model' => 'Pixel 6a', 'model_identifier' => 'bluejay', 'min_android_version' => 12],
['manufacturer' => 'Google', 'model' => 'Pixel 7', 'model_identifier' => 'panther', 'min_android_version' => 13],
['manufacturer' => 'Google', 'model' => 'Pixel 7 Pro', 'model_identifier' => 'cheetah', 'min_android_version' => 13],
['manufacturer' => 'Google', 'model' => 'Pixel 7a', 'model_identifier' => 'lynx', 'min_android_version' => 13],
['manufacturer' => 'Google', 'model' => 'Pixel 8', 'model_identifier' => 'shiba', 'min_android_version' => 14],
['manufacturer' => 'Google', 'model' => 'Pixel 8 Pro', 'model_identifier' => 'husky', 'min_android_version' => 14],
['manufacturer' => 'Google', 'model' => 'Pixel 8a', 'model_identifier' => 'akita', 'min_android_version' => 14],
['manufacturer' => 'Google', 'model' => 'Pixel 9', 'model_identifier' => 'tokay', 'min_android_version' => 14],
['manufacturer' => 'Google', 'model' => 'Pixel 9 Pro', 'model_identifier' => 'caiman', 'min_android_version' => 14],
['manufacturer' => 'Google', 'model' => 'Pixel 9 Pro XL', 'model_identifier' => 'komodo', 'min_android_version' => 14],
['manufacturer' => 'Google', 'model' => 'Pixel Fold', 'model_identifier' => 'felix', 'min_android_version' => 13],
['manufacturer' => 'Google', 'model' => 'Pixel Tablet', 'model_identifier' => 'tangorpro', 'min_android_version' => 13],
// OnePlus
['manufacturer' => 'OnePlus', 'model' => '9', 'model_identifier' => 'LE2113', 'min_android_version' => 11],
['manufacturer' => 'OnePlus', 'model' => '9 Pro', 'model_identifier' => 'LE2123', 'min_android_version' => 11],
['manufacturer' => 'OnePlus', 'model' => '10 Pro', 'model_identifier' => 'NE2213', 'min_android_version' => 12],
['manufacturer' => 'OnePlus', 'model' => '10T', 'model_identifier' => 'CPH2413', 'min_android_version' => 12],
['manufacturer' => 'OnePlus', 'model' => '11', 'model_identifier' => 'CPH2449', 'min_android_version' => 13],
['manufacturer' => 'OnePlus', 'model' => '11R', 'model_identifier' => 'CPH2487', 'min_android_version' => 13],
['manufacturer' => 'OnePlus', 'model' => '12', 'model_identifier' => 'CPH2581', 'min_android_version' => 14],
['manufacturer' => 'OnePlus', 'model' => '12R', 'model_identifier' => 'CPH2585', 'min_android_version' => 14],
['manufacturer' => 'OnePlus', 'model' => 'Open', 'model_identifier' => 'CPH2551', 'min_android_version' => 13],
// Xiaomi
['manufacturer' => 'Xiaomi', 'model' => 'Mi 11', 'model_identifier' => 'M2011K2G', 'min_android_version' => 11],
['manufacturer' => 'Xiaomi', 'model' => 'Mi 11 Ultra', 'model_identifier' => 'M2102K1G', 'min_android_version' => 11],
['manufacturer' => 'Xiaomi', 'model' => '12', 'model_identifier' => '2201123G', 'min_android_version' => 12],
['manufacturer' => 'Xiaomi', 'model' => '12 Pro', 'model_identifier' => '2201122G', 'min_android_version' => 12],
['manufacturer' => 'Xiaomi', 'model' => '12T Pro', 'model_identifier' => '2207122MC', 'min_android_version' => 12],
['manufacturer' => 'Xiaomi', 'model' => '13', 'model_identifier' => '2211133G', 'min_android_version' => 13],
['manufacturer' => 'Xiaomi', 'model' => '13 Pro', 'model_identifier' => '2210132G', 'min_android_version' => 13],
['manufacturer' => 'Xiaomi', 'model' => '13T Pro', 'model_identifier' => '23078PND5G', 'min_android_version' => 13],
['manufacturer' => 'Xiaomi', 'model' => '14', 'model_identifier' => '23127PN0CG', 'min_android_version' => 14],
['manufacturer' => 'Xiaomi', 'model' => '14 Pro', 'model_identifier' => '23116PN5BG', 'min_android_version' => 14],
['manufacturer' => 'Xiaomi', 'model' => '14 Ultra', 'model_identifier' => '24030PN60G', 'min_android_version' => 14],
// OPPO
['manufacturer' => 'OPPO', 'model' => 'Find X3 Pro', 'model_identifier' => 'CPH2173', 'min_android_version' => 11],
['manufacturer' => 'OPPO', 'model' => 'Find X5 Pro', 'model_identifier' => 'CPH2305', 'min_android_version' => 12],
['manufacturer' => 'OPPO', 'model' => 'Find X6 Pro', 'model_identifier' => 'CPH2449', 'min_android_version' => 13],
['manufacturer' => 'OPPO', 'model' => 'Find N2', 'model_identifier' => 'CPH2399', 'min_android_version' => 13],
['manufacturer' => 'OPPO', 'model' => 'Find N3', 'model_identifier' => 'CPH2499', 'min_android_version' => 13],
// Realme
['manufacturer' => 'Realme', 'model' => 'GT 2 Pro', 'model_identifier' => 'RMX3301', 'min_android_version' => 12],
['manufacturer' => 'Realme', 'model' => 'GT 3', 'model_identifier' => 'RMX3709', 'min_android_version' => 13],
['manufacturer' => 'Realme', 'model' => 'GT 5 Pro', 'model_identifier' => 'RMX3888', 'min_android_version' => 14],
// Honor
['manufacturer' => 'Honor', 'model' => 'Magic5 Pro', 'model_identifier' => 'PGT-N19', 'min_android_version' => 13],
['manufacturer' => 'Honor', 'model' => 'Magic6 Pro', 'model_identifier' => 'BVL-N49', 'min_android_version' => 14],
['manufacturer' => 'Honor', 'model' => '90', 'model_identifier' => 'REA-NX9', 'min_android_version' => 13],
// ASUS
['manufacturer' => 'ASUS', 'model' => 'Zenfone 9', 'model_identifier' => 'AI2202', 'min_android_version' => 12],
['manufacturer' => 'ASUS', 'model' => 'Zenfone 10', 'model_identifier' => 'AI2302', 'min_android_version' => 13],
['manufacturer' => 'ASUS', 'model' => 'ROG Phone 7', 'model_identifier' => 'AI2205', 'min_android_version' => 13],
// Nothing
['manufacturer' => 'Nothing', 'model' => 'Phone (1)', 'model_identifier' => 'A063', 'min_android_version' => 12],
['manufacturer' => 'Nothing', 'model' => 'Phone (2)', 'model_identifier' => 'A065', 'min_android_version' => 13],
['manufacturer' => 'Nothing', 'model' => 'Phone (2a)', 'model_identifier' => 'A142', 'min_android_version' => 14],
];
// Essayer de charger depuis un fichier JSON local si présent
if (file_exists(DEVICES_LOCAL_FILE)) {
$localData = json_decode(file_get_contents(DEVICES_LOCAL_FILE), true);
if (!empty($localData)) {
echo "[" . date('Y-m-d H:i:s') . "] Données chargées depuis le fichier local\n";
return array_merge($frenchCertifiedDevices, $localData);
}
}
echo "[" . date('Y-m-d H:i:s') . "] Utilisation de la liste intégrée des devices certifiés\n";
return $frenchCertifiedDevices;
}
/**
* Met à jour la base de données avec les nouvelles données
*/
function updateDatabase($db, array $devices): array {
$stats = [
'added' => 0,
'updated' => 0,
'disabled' => 0,
'unchanged' => 0,
'total' => 0
];
// Récupérer tous les devices existants
$stmt = $db->prepare("SELECT * FROM stripe_android_certified_devices WHERE country = 'FR'");
$stmt->execute();
$existingDevices = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$key = $row['manufacturer'] . '|' . $row['model'] . '|' . $row['model_identifier'];
$existingDevices[$key] = $row;
}
// Marquer tous les devices pour tracking
$processedKeys = [];
// Traiter chaque device de la nouvelle liste
foreach ($devices as $device) {
$key = $device['manufacturer'] . '|' . $device['model'] . '|' . $device['model_identifier'];
$processedKeys[$key] = true;
if (isset($existingDevices[$key])) {
// Le device existe, vérifier s'il faut le mettre à jour
$existing = $existingDevices[$key];
// Vérifier si des champs ont changé
$needsUpdate = false;
if ($existing['min_android_version'] != $device['min_android_version']) {
$needsUpdate = true;
}
if ($existing['tap_to_pay_certified'] != 1) {
$needsUpdate = true;
}
if ($needsUpdate) {
$stmt = $db->prepare("
UPDATE stripe_android_certified_devices
SET min_android_version = :min_version,
tap_to_pay_certified = 1,
last_verified = NOW(),
updated_at = NOW()
WHERE manufacturer = :manufacturer
AND model = :model
AND model_identifier = :model_identifier
AND country = 'FR'
");
$stmt->execute([
'min_version' => $device['min_android_version'],
'manufacturer' => $device['manufacturer'],
'model' => $device['model'],
'model_identifier' => $device['model_identifier']
]);
$stats['updated']++;
LogService::log("Device mis à jour", [
'device' => $device['manufacturer'] . ' ' . $device['model']
]);
} else {
// Juste mettre à jour last_verified
$stmt = $db->prepare("
UPDATE stripe_android_certified_devices
SET last_verified = NOW()
WHERE manufacturer = :manufacturer
AND model = :model
AND model_identifier = :model_identifier
AND country = 'FR'
");
$stmt->execute([
'manufacturer' => $device['manufacturer'],
'model' => $device['model'],
'model_identifier' => $device['model_identifier']
]);
$stats['unchanged']++;
}
} else {
// Nouveau device, l'ajouter
$stmt = $db->prepare("
INSERT INTO stripe_android_certified_devices
(manufacturer, model, model_identifier, tap_to_pay_certified,
certification_date, min_android_version, country, notes, last_verified)
VALUES
(:manufacturer, :model, :model_identifier, 1,
NOW(), :min_version, 'FR', 'Ajouté automatiquement via CRON', NOW())
");
$stmt->execute([
'manufacturer' => $device['manufacturer'],
'model' => $device['model'],
'model_identifier' => $device['model_identifier'],
'min_version' => $device['min_android_version']
]);
$stats['added']++;
LogService::log("Nouveau device ajouté", [
'device' => $device['manufacturer'] . ' ' . $device['model']
]);
}
}
// Désactiver les devices qui ne sont plus dans la liste
foreach ($existingDevices as $key => $existing) {
if (!isset($processedKeys[$key]) && $existing['tap_to_pay_certified'] == 1) {
$stmt = $db->prepare("
UPDATE stripe_android_certified_devices
SET tap_to_pay_certified = 0,
notes = CONCAT(IFNULL(notes, ''), ' | Désactivé le ', NOW(), ' (non présent dans la mise à jour)'),
updated_at = NOW()
WHERE id = :id
");
$stmt->execute(['id' => $existing['id']]);
$stats['disabled']++;
LogService::log("Device désactivé", [
'device' => $existing['manufacturer'] . ' ' . $existing['model'],
'reason' => 'Non présent dans la liste mise à jour'
]);
}
}
$stats['total'] = count($devices);
return $stats;
}
/**
* Envoie une notification email aux administrateurs si changements importants
*/
function sendNotification(array $stats): void {
try {
// Récupérer la configuration
$appConfig = AppConfig::getInstance();
$emailConfig = $appConfig->getEmailConfig();
if (empty($emailConfig['admin_email'])) {
return; // Pas d'email admin configuré
}
$db = Database::getInstance();
// Préparer le contenu de l'email
$subject = "Mise à jour des devices Stripe Tap to Pay";
$body = "Bonjour,\n\n";
$body .= "La mise à jour automatique de la liste des appareils certifiés Stripe Tap to Pay a été effectuée.\n\n";
$body .= "Résumé des changements :\n";
$body .= "- Nouveaux appareils ajoutés : " . $stats['added'] . "\n";
$body .= "- Appareils mis à jour : " . $stats['updated'] . "\n";
$body .= "- Appareils désactivés : " . $stats['disabled'] . "\n";
$body .= "- Appareils inchangés : " . $stats['unchanged'] . "\n";
$body .= "- Total d'appareils traités : " . $stats['total'] . "\n\n";
if ($stats['added'] > 0) {
$body .= "Les nouveaux appareils ont été automatiquement ajoutés à la base de données.\n";
}
if ($stats['disabled'] > 0) {
$body .= "Certains appareils ont été désactivés car ils ne sont plus certifiés.\n";
}
$body .= "\nConsultez les logs pour plus de détails.\n";
$body .= "\nCordialement,\nLe système GeoSector";
// Insérer dans la queue d'emails
$stmt = $db->prepare("
INSERT INTO email_queue
(to_email, subject, body, status, created_at, attempts)
VALUES
(:to_email, :subject, :body, 'pending', NOW(), 0)
");
$stmt->execute([
'to_email' => $emailConfig['admin_email'],
'subject' => $subject,
'body' => $body
]);
echo "[" . date('Y-m-d H:i:s') . "] Notification ajoutée à la queue d'emails\n";
} catch (Exception $e) {
// Ne pas faire échouer le script si l'email ne peut pas être envoyé
echo "[" . date('Y-m-d H:i:s') . "] Impossible d'envoyer la notification: " . $e->getMessage() . "\n";
}
}

View File

@@ -0,0 +1,19 @@
-- Migration: Modifier fk_sector pour avoir DEFAULT NULL au lieu de DEFAULT 0
-- Raison: La FK vers ope_sectors(id) ne permet pas la valeur 0 (aucun secteur avec id=0)
-- Date: 2026-01-16
-- 1. D'abord, mettre à NULL les passages qui ont fk_sector = 0
UPDATE ope_pass SET fk_sector = NULL WHERE fk_sector = 0;
-- 2. Modifier la colonne pour avoir DEFAULT NULL
ALTER TABLE ope_pass MODIFY COLUMN fk_sector INT UNSIGNED DEFAULT NULL;
-- Vérification
SELECT
COLUMN_NAME,
COLUMN_DEFAULT,
IS_NULLABLE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ope_pass'
AND COLUMN_NAME = 'fk_sector';

View File

@@ -0,0 +1,57 @@
-- ============================================================
-- Table event_stats_daily - Statistiques d'événements agrégées
-- Version: 1.0
-- Date: 2025-12-22
-- ============================================================
--
-- Usage: Exécuter dans les 3 environnements (DEV, REC, PROD)
-- mysql -u user -p database < create_event_stats_daily.sql
--
-- Description:
-- Stocke les statistiques quotidiennes agrégées depuis les
-- fichiers JSONL (/logs/events/YYYY-MM-DD.jsonl)
-- Alimentée par le CRON aggregate_event_stats.php (1x/nuit)
--
-- ============================================================
CREATE TABLE IF NOT EXISTS event_stats_daily (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
-- Clés d'agrégation
stat_date DATE NOT NULL COMMENT 'Date des statistiques',
entity_id INT UNSIGNED NULL COMMENT 'ID entité (NULL = stats globales super-admin)',
event_type VARCHAR(50) NOT NULL COMMENT 'Type événement (login_success, passage_created, etc.)',
-- Compteurs
count INT UNSIGNED DEFAULT 0 COMMENT 'Nombre d\'occurrences',
sum_amount DECIMAL(12,2) DEFAULT 0.00 COMMENT 'Somme des montants (passages/paiements)',
unique_users INT UNSIGNED DEFAULT 0 COMMENT 'Nombre d\'utilisateurs distincts',
-- Métadonnées agrégées (JSON)
metadata JSON NULL COMMENT 'Données agrégées: top secteurs, erreurs fréquentes, etc.',
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Contraintes
UNIQUE KEY uk_date_entity_event (stat_date, entity_id, event_type),
INDEX idx_entity_date (entity_id, stat_date),
INDEX idx_date (stat_date),
INDEX idx_event_type (event_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Statistiques quotidiennes agrégées des événements (EventLogService)';
-- ============================================================
-- Exemples de données attendues
-- ============================================================
--
-- | stat_date | entity_id | event_type | count | sum_amount | unique_users |
-- |------------|-----------|------------------|-------|------------|--------------|
-- | 2025-12-22 | 5 | login_success | 45 | 0.00 | 12 |
-- | 2025-12-22 | 5 | passage_created | 128 | 2450.00 | 8 |
-- | 2025-12-22 | 5 | stripe_payment_success | 12 | 890.00 | 6 |
-- | 2025-12-22 | NULL | login_success | 320 | 0.00 | 85 | <- Global
--
-- ============================================================

View File

@@ -0,0 +1,5 @@
-- Suppression de la table stripe_android_certified_devices
-- Cette table n'est plus utilisée : la vérification de compatibilité Tap to Pay
-- se fait maintenant directement côté client via le SDK Stripe Terminal
DROP TABLE IF EXISTS stripe_android_certified_devices;

View File

@@ -0,0 +1,484 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/EventStatsService.php';
use PDO;
use Database;
use Response;
use Session;
use App\Services\LogService;
use App\Services\EventStatsService;
use Exception;
/**
* EventStatsController - Contrôleur des statistiques d'événements
*
* Endpoints pour consulter les stats agrégées et le détail des événements.
* Accès réservé aux Admin entité (role_id = 2) et Super-admin (role_id = 1).
*/
class EventStatsController
{
private PDO $db;
private EventStatsService $statsService;
/** @var array Rôles autorisés à consulter les stats */
private const ALLOWED_ROLES = [1, 2]; // Super-admin, Admin
public function __construct()
{
$this->db = Database::getInstance();
$this->statsService = new EventStatsService();
}
/**
* GET /api/events/stats/summary
* Récupère le résumé des stats pour une date
*
* Query params:
* - date: Date (YYYY-MM-DD), défaut = aujourd'hui
*/
public function summary(): void
{
try {
if (!$this->checkAccess()) {
return;
}
$entityId = $this->getEntityIdForQuery();
$date = $_GET['date'] ?? date('Y-m-d');
// Validation de la date
if (!$this->isValidDate($date)) {
Response::json([
'status' => 'error',
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
], 400);
return;
}
$summary = $this->statsService->getSummary($entityId, $date);
$this->jsonWithCache([
'status' => 'success',
'data' => $summary,
], true);
} catch (Exception $e) {
LogService::error('Erreur lors de la récupération du résumé des stats', [
'error' => $e->getMessage(),
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la récupération des statistiques',
], 500);
}
}
/**
* GET /api/events/stats/daily
* Récupère les stats journalières sur une plage de dates
*
* Query params:
* - from: Date début (YYYY-MM-DD), requis
* - to: Date fin (YYYY-MM-DD), requis
* - events: Types d'événements (comma-separated), optionnel
*/
public function daily(): void
{
try {
if (!$this->checkAccess()) {
return;
}
$entityId = $this->getEntityIdForQuery();
$from = $_GET['from'] ?? null;
$to = $_GET['to'] ?? null;
$eventTypes = isset($_GET['events']) ? explode(',', $_GET['events']) : [];
// Validation des dates
if (!$from || !$to) {
Response::json([
'status' => 'error',
'message' => 'Les paramètres from et to sont requis',
], 400);
return;
}
if (!$this->isValidDate($from) || !$this->isValidDate($to)) {
Response::json([
'status' => 'error',
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
], 400);
return;
}
if ($from > $to) {
Response::json([
'status' => 'error',
'message' => 'La date de début doit être antérieure à la date de fin',
], 400);
return;
}
// Limiter la plage à 90 jours
$daysDiff = (strtotime($to) - strtotime($from)) / 86400;
if ($daysDiff > 90) {
Response::json([
'status' => 'error',
'message' => 'La plage de dates ne peut pas dépasser 90 jours',
], 400);
return;
}
$daily = $this->statsService->getDaily($entityId, $from, $to, $eventTypes);
$this->jsonWithCache([
'status' => 'success',
'data' => [
'from' => $from,
'to' => $to,
'days' => $daily,
],
], true);
} catch (Exception $e) {
LogService::error('Erreur lors de la récupération des stats journalières', [
'error' => $e->getMessage(),
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la récupération des statistiques',
], 500);
}
}
/**
* GET /api/events/stats/weekly
* Récupère les stats hebdomadaires sur une plage de dates
*
* Query params:
* - from: Date début (YYYY-MM-DD), requis
* - to: Date fin (YYYY-MM-DD), requis
* - events: Types d'événements (comma-separated), optionnel
*/
public function weekly(): void
{
try {
if (!$this->checkAccess()) {
return;
}
$entityId = $this->getEntityIdForQuery();
$from = $_GET['from'] ?? null;
$to = $_GET['to'] ?? null;
$eventTypes = isset($_GET['events']) ? explode(',', $_GET['events']) : [];
// Validation des dates
if (!$from || !$to) {
Response::json([
'status' => 'error',
'message' => 'Les paramètres from et to sont requis',
], 400);
return;
}
if (!$this->isValidDate($from) || !$this->isValidDate($to)) {
Response::json([
'status' => 'error',
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
], 400);
return;
}
// Limiter la plage à 1 an
$daysDiff = (strtotime($to) - strtotime($from)) / 86400;
if ($daysDiff > 365) {
Response::json([
'status' => 'error',
'message' => 'La plage de dates ne peut pas dépasser 1 an',
], 400);
return;
}
$weekly = $this->statsService->getWeekly($entityId, $from, $to, $eventTypes);
Response::json([
'status' => 'success',
'data' => [
'from' => $from,
'to' => $to,
'weeks' => $weekly,
],
]);
} catch (Exception $e) {
LogService::error('Erreur lors de la récupération des stats hebdomadaires', [
'error' => $e->getMessage(),
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la récupération des statistiques',
], 500);
}
}
/**
* GET /api/events/stats/monthly
* Récupère les stats mensuelles pour une année
*
* Query params:
* - year: Année (YYYY), défaut = année courante
* - events: Types d'événements (comma-separated), optionnel
*/
public function monthly(): void
{
try {
if (!$this->checkAccess()) {
return;
}
$entityId = $this->getEntityIdForQuery();
$year = isset($_GET['year']) ? (int) $_GET['year'] : (int) date('Y');
$eventTypes = isset($_GET['events']) ? explode(',', $_GET['events']) : [];
// Validation de l'année
if ($year < 2020 || $year > (int) date('Y') + 1) {
Response::json([
'status' => 'error',
'message' => 'Année invalide',
], 400);
return;
}
$monthly = $this->statsService->getMonthly($entityId, $year, $eventTypes);
Response::json([
'status' => 'success',
'data' => [
'year' => $year,
'months' => $monthly,
],
]);
} catch (Exception $e) {
LogService::error('Erreur lors de la récupération des stats mensuelles', [
'error' => $e->getMessage(),
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la récupération des statistiques',
], 500);
}
}
/**
* GET /api/events/stats/details
* Récupère le détail des événements (lecture JSONL paginée)
*
* Query params:
* - date: Date (YYYY-MM-DD), requis
* - event: Type d'événement, optionnel
* - limit: Nombre max (défaut 50, max 100)
* - offset: Décalage pour pagination (défaut 0)
*/
public function details(): void
{
try {
if (!$this->checkAccess()) {
return;
}
$entityId = $this->getEntityIdForQuery();
$date = $_GET['date'] ?? null;
$eventType = $_GET['event'] ?? null;
$limit = isset($_GET['limit']) ? min((int) $_GET['limit'], 100) : 50;
$offset = isset($_GET['offset']) ? max((int) $_GET['offset'], 0) : 0;
// Validation de la date
if (!$date) {
Response::json([
'status' => 'error',
'message' => 'Le paramètre date est requis',
], 400);
return;
}
if (!$this->isValidDate($date)) {
Response::json([
'status' => 'error',
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
], 400);
return;
}
$details = $this->statsService->getDetails($entityId, $date, $eventType, $limit, $offset);
Response::json([
'status' => 'success',
'data' => $details,
]);
} catch (Exception $e) {
LogService::error('Erreur lors de la récupération du détail des événements', [
'error' => $e->getMessage(),
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la récupération des événements',
], 500);
}
}
/**
* GET /api/events/stats/types
* Récupère la liste des types d'événements disponibles
*/
public function types(): void
{
try {
if (!$this->checkAccess()) {
return;
}
$types = $this->statsService->getEventTypes();
Response::json([
'status' => 'success',
'data' => $types,
]);
} catch (Exception $e) {
LogService::error('Erreur lors de la récupération des types d\'événements', [
'error' => $e->getMessage(),
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la récupération des types',
], 500);
}
}
// ==================== MÉTHODES PRIVÉES ====================
/**
* Vérifie si l'utilisateur a accès aux stats
*/
private function checkAccess(): bool
{
$roleId = Session::getRole();
if (!in_array($roleId, self::ALLOWED_ROLES)) {
Response::json([
'status' => 'error',
'message' => 'Accès non autorisé. Rôle Admin ou Super-admin requis.',
], 403);
return false;
}
return true;
}
/**
* Détermine l'entity_id à utiliser pour la requête
* Super-admin (role_id = 1) peut voir toutes les entités (null)
* Admin (role_id = 2) voit uniquement son entité
*/
private function getEntityIdForQuery(): ?int
{
$roleId = Session::getRole();
// Super-admin : accès global
if ($roleId === 1) {
// Permettre de filtrer par entité si spécifié
if (isset($_GET['entity_id'])) {
return (int) $_GET['entity_id'];
}
return null; // Stats globales
}
// Admin : uniquement son entité
return Session::getEntityId();
}
/**
* Valide le format d'une date
*/
private function isValidDate(string $date): bool
{
$d = \DateTime::createFromFormat('Y-m-d', $date);
return $d && $d->format('Y-m-d') === $date;
}
/**
* Envoie une réponse JSON avec support ETag et compression gzip
*
* @param array $data Données à envoyer
* @param bool $useCache Activer le cache ETag
*/
private function jsonWithCache(array $data, bool $useCache = true): void
{
// Nettoyer tout buffer existant
while (ob_get_level() > 0) {
ob_end_clean();
}
// Encoder en JSON
$jsonResponse = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($jsonResponse === false) {
Response::json([
'status' => 'error',
'message' => 'Erreur d\'encodage de la réponse',
], 500);
return;
}
// Headers CORS
$origin = $_SERVER['HTTP_ORIGIN'] ?? '*';
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Origin, Content-Type, X-Auth-Token, Authorization, X-Requested-With');
header('Access-Control-Expose-Headers: Content-Length, ETag');
// Content-Type
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
// ETag pour le cache
if ($useCache) {
$etag = '"' . md5($jsonResponse) . '"';
header('ETag: ' . $etag);
header('Cache-Control: private, max-age=300'); // 5 minutes
// Vérifier If-None-Match
$ifNoneMatch = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
if ($ifNoneMatch === $etag) {
http_response_code(304); // Not Modified
exit;
}
}
// Compression gzip si supportée
$supportsGzip = isset($_SERVER['HTTP_ACCEPT_ENCODING'])
&& strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== false;
if ($supportsGzip && strlen($jsonResponse) > 1024) {
$compressed = gzencode($jsonResponse, 6);
if ($compressed !== false) {
header('Content-Encoding: gzip');
header('Content-Length: ' . strlen($compressed));
http_response_code(200);
echo $compressed;
flush();
exit;
}
}
// Réponse non compressée
header('Content-Length: ' . strlen($jsonResponse));
http_response_code(200);
echo $jsonResponse;
flush();
exit;
}
}

View File

@@ -2086,18 +2086,38 @@ class LoginController {
], 201); ], 201);
} }
} catch (Exception $e) { } catch (Exception $e) {
// Vérifier si une transaction est active avant de faire rollback
if ($this->db->inTransaction()) {
$this->db->rollBack(); $this->db->rollBack();
LogService::log('Erreur lors de la création du compte GeoSector', [ }
// Construire un message d'erreur détaillé pour le logging
$errorDetails = [
'level' => 'error', 'level' => 'error',
'error' => $e->getMessage(), 'exception_class' => get_class($e),
'email' => $email, 'error_message' => $e->getMessage(),
'amicaleName' => $amicaleName, 'error_code' => $e->getCode(),
'postalCode' => $postalCode 'file' => $e->getFile(),
]); 'line' => $e->getLine(),
'email' => $email ?? 'non disponible',
'amicaleName' => $amicaleName ?? 'non disponible',
'postalCode' => $postalCode ?? 'non disponible',
'trace' => $e->getTraceAsString()
];
// Si c'est une PDOException, ajouter les infos SQL
if ($e instanceof PDOException) {
$errorDetails['pdo_error_info'] = $this->db->errorInfo();
}
LogService::log('Erreur lors de la création du compte GeoSector', $errorDetails);
// Retourner un message utilisateur clair (ne pas exposer les détails techniques)
$userMessage = 'Une erreur est survenue lors de la création du compte. Veuillez réessayer ou contacter le support.';
Response::json([ Response::json([
'status' => 'error', 'status' => 'error',
'message' => $e->getMessage() 'message' => $userMessage
], 500); ], 500);
return; return;
} }

View File

@@ -8,6 +8,7 @@ require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/EventLogService.php'; require_once __DIR__ . '/../Services/EventLogService.php';
require_once __DIR__ . '/../Services/ApiService.php'; require_once __DIR__ . '/../Services/ApiService.php';
require_once __DIR__ . '/../Services/ReceiptService.php'; require_once __DIR__ . '/../Services/ReceiptService.php';
require_once __DIR__ . '/../Services/SectorService.php';
use PDO; use PDO;
use PDOException; use PDOException;
@@ -19,6 +20,7 @@ use Session;
use App\Services\LogService; use App\Services\LogService;
use App\Services\EventLogService; use App\Services\EventLogService;
use App\Services\ApiService; use App\Services\ApiService;
use App\Services\SectorService;
use Exception; use Exception;
use DateTime; use DateTime;
@@ -516,14 +518,26 @@ class PassageController {
} }
// Récupérer ope_users.id pour l'utilisateur du passage // Récupérer ope_users.id pour l'utilisateur du passage
// $data['fk_user'] contient users.id, on doit le convertir en ope_users.id // $data['fk_user'] peut contenir soit users.id soit ope_users.id
$passageUserId = (int)$data['fk_user']; $passageUserId = (int)$data['fk_user'];
// Vérifier d'abord si c'est déjà un ope_users.id valide
$stmtCheckOpeUser = $this->db->prepare('
SELECT id FROM ope_users
WHERE id = ? AND fk_operation = ?
');
$stmtCheckOpeUser->execute([$passageUserId, $operationId]);
$opeUserId = $stmtCheckOpeUser->fetchColumn();
if (!$opeUserId) {
// Ce n'est pas un ope_users.id, essayer de le convertir depuis users.id
$stmtOpeUser = $this->db->prepare(' $stmtOpeUser = $this->db->prepare('
SELECT id FROM ope_users SELECT id FROM ope_users
WHERE fk_user = ? AND fk_operation = ? WHERE fk_user = ? AND fk_operation = ?
'); ');
$stmtOpeUser->execute([$passageUserId, $operationId]); $stmtOpeUser->execute([$passageUserId, $operationId]);
$opeUserId = $stmtOpeUser->fetchColumn(); $opeUserId = $stmtOpeUser->fetchColumn();
}
if (!$opeUserId) { if (!$opeUserId) {
Response::json([ Response::json([
@@ -533,6 +547,88 @@ class PassageController {
return; return;
} }
// Détermination automatique du secteur
$sectorId = null;
$gpsLat = isset($data['gps_lat']) && $data['gps_lat'] !== '' ? (float)$data['gps_lat'] : null;
$gpsLng = isset($data['gps_lng']) && $data['gps_lng'] !== '' ? (float)$data['gps_lng'] : null;
// 1. Si fk_sector > 0 fourni → l'utiliser directement
if (isset($data['fk_sector']) && (int)$data['fk_sector'] > 0) {
$sectorId = (int)$data['fk_sector'];
LogService::info('[PassageController] Secteur fourni par le client', [
'sector_id' => $sectorId
]);
}
// 2. Si pas de secteur et GPS valide (différent de 0.0) → recherche par GPS
if ($sectorId === null && $gpsLat !== null && $gpsLng !== null && ($gpsLat != 0.0 || $gpsLng != 0.0)) {
$sectorService = new SectorService();
$sectorId = $sectorService->findSectorByGps($operationId, $gpsLat, $gpsLng);
LogService::info('[PassageController] Recherche secteur par GPS', [
'operation_id' => $operationId,
'lat' => $gpsLat,
'lng' => $gpsLng,
'sector_found' => $sectorId
]);
}
// 3. Si toujours pas de secteur et adresse fournie → géocodage + recherche
if ($sectorId === null && !empty($data['numero']) && !empty($data['rue']) && !empty($data['ville'])) {
// Récupérer le code postal de l'entité pour la vérification du département
$stmtEntite = $this->db->prepare('
SELECT e.code_postal FROM entites e
INNER JOIN operations o ON o.fk_entite = e.id
WHERE o.id = ?
');
$stmtEntite->execute([$operationId]);
$entiteCp = $stmtEntite->fetchColumn() ?: '';
$sectorService = new SectorService();
$result = $sectorService->findSectorByAddress(
$operationId,
trim($data['numero']),
$data['rue_bis'] ?? '',
trim($data['rue']),
trim($data['ville']),
$entiteCp
);
if ($result) {
$sectorId = $result['sector_id'];
// Mettre à jour les coordonnées GPS si le géocodage les a trouvées
if ($result['gps_lat'] && $result['gps_lng']) {
$gpsLat = $result['gps_lat'];
$gpsLng = $result['gps_lng'];
}
LogService::info('[PassageController] Recherche secteur par adresse', [
'operation_id' => $operationId,
'adresse' => $data['numero'] . ' ' . $data['rue'] . ' ' . $data['ville'],
'sector_found' => $sectorId,
'gps_geocoded' => ($result['gps_lat'] && $result['gps_lng'])
]);
}
}
// 4. Fallback : si toujours pas de secteur, prendre le 1er secteur de l'opération
if ($sectorId === null) {
$stmtFirstSector = $this->db->prepare('
SELECT id FROM ope_sectors
WHERE fk_operation = ? AND chk_active = 1
ORDER BY id ASC
LIMIT 1
');
$stmtFirstSector->execute([$operationId]);
$firstSectorId = $stmtFirstSector->fetchColumn();
if ($firstSectorId) {
$sectorId = (int)$firstSectorId;
LogService::info('[PassageController] Fallback: premier secteur de l\'opération', [
'operation_id' => $operationId,
'sector_id' => $sectorId
]);
}
}
// Chiffrement des données sensibles // Chiffrement des données sensibles
$encryptedName = ''; $encryptedName = '';
if (isset($data['name']) && !empty(trim($data['name']))) { if (isset($data['name']) && !empty(trim($data['name']))) {
@@ -549,7 +645,7 @@ class PassageController {
// Préparation des données pour l'insertion // Préparation des données pour l'insertion
$insertData = [ $insertData = [
'fk_operation' => $operationId, 'fk_operation' => $operationId,
'fk_sector' => isset($data['fk_sector']) ? (int)$data['fk_sector'] : 0, 'fk_sector' => $sectorId, // Peut être NULL si aucun secteur trouvé
'fk_user' => $opeUserId, 'fk_user' => $opeUserId,
'fk_adresse' => $data['fk_adresse'] ?? '', 'fk_adresse' => $data['fk_adresse'] ?? '',
'passed_at' => isset($data['passed_at']) ? $data['passed_at'] : null, 'passed_at' => isset($data['passed_at']) ? $data['passed_at'] : null,
@@ -562,8 +658,8 @@ class PassageController {
'appt' => $data['appt'] ?? '', 'appt' => $data['appt'] ?? '',
'niveau' => $data['niveau'] ?? '', 'niveau' => $data['niveau'] ?? '',
'residence' => $data['residence'] ?? '', 'residence' => $data['residence'] ?? '',
'gps_lat' => $data['gps_lat'] ?? '', 'gps_lat' => $gpsLat ?? '',
'gps_lng' => $data['gps_lng'] ?? '', 'gps_lng' => $gpsLng ?? '',
'encrypted_name' => $encryptedName, 'encrypted_name' => $encryptedName,
'montant' => isset($data['montant']) ? (float)$data['montant'] : 0.00, 'montant' => isset($data['montant']) ? (float)$data['montant'] : 0.00,
'fk_type_reglement' => isset($data['fk_type_reglement']) ? (int)$data['fk_type_reglement'] : 1, 'fk_type_reglement' => isset($data['fk_type_reglement']) ? (int)$data['fk_type_reglement'] : 1,
@@ -596,7 +692,7 @@ class PassageController {
EventLogService::logPassageCreated( EventLogService::logPassageCreated(
(int)$passageId, (int)$passageId,
$insertData['fk_operation'], $insertData['fk_operation'],
$insertData['fk_sector'], $insertData['fk_sector'] ?? 0, // 0 si secteur non trouvé
$insertData['montant'], $insertData['montant'],
(string)$insertData['fk_type_reglement'] (string)$insertData['fk_type_reglement']
); );

View File

@@ -15,14 +15,12 @@ require_once __DIR__ . '/../Services/ApiService.php';
class SectorController class SectorController
{ {
private \PDO $db; private \PDO $db;
private LogService $logService;
private AddressService $addressService; private AddressService $addressService;
private DepartmentBoundaryService $boundaryService; private DepartmentBoundaryService $boundaryService;
public function __construct() public function __construct()
{ {
$this->db = Database::getInstance(); $this->db = Database::getInstance();
$this->logService = new LogService();
$this->addressService = new AddressService(); $this->addressService = new AddressService();
$this->boundaryService = new DepartmentBoundaryService(); $this->boundaryService = new DepartmentBoundaryService();
} }
@@ -72,7 +70,7 @@ class SectorController
Response::json(['status' => 'success', 'data' => $sectors]); Response::json(['status' => 'success', 'data' => $sectors]);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logService->error('Erreur lors de la récupération des secteurs', [ LogService::error('Erreur lors de la récupération des secteurs', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'trace' => $e->getTraceAsString() 'trace' => $e->getTraceAsString()
]); ]);
@@ -152,14 +150,14 @@ class SectorController
$departmentsTouched = $this->boundaryService->getDepartmentsForSector($coordinates); $departmentsTouched = $this->boundaryService->getDepartmentsForSector($coordinates);
if (empty($departmentsTouched)) { if (empty($departmentsTouched)) {
$this->logService->warning('Aucun département trouvé pour le secteur', [ LogService::warning('Aucun département trouvé pour le secteur', [
'libelle' => $data['libelle'], 'libelle' => $data['libelle'],
'entity_id' => $entityId, 'entity_id' => $entityId,
'entity_dept' => $departement 'entity_dept' => $departement
]); ]);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logService->warning('Impossible de vérifier les limites départementales', [ LogService::warning('Impossible de vérifier les limites départementales', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'libelle' => $data['libelle'] 'libelle' => $data['libelle']
]); ]);
@@ -169,7 +167,7 @@ class SectorController
try { try {
$addressCount = $this->addressService->countAddressesInPolygon($coordinates, $entityId); $addressCount = $this->addressService->countAddressesInPolygon($coordinates, $entityId);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logService->warning('Impossible de récupérer les adresses du secteur', [ LogService::warning('Impossible de récupérer les adresses du secteur', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'libelle' => $data['libelle'], 'libelle' => $data['libelle'],
'entity_id' => $entityId 'entity_id' => $entityId
@@ -208,7 +206,7 @@ class SectorController
$opeUserId = $stmtOpeUser->fetchColumn(); $opeUserId = $stmtOpeUser->fetchColumn();
if (!$opeUserId) { if (!$opeUserId) {
$this->logService->warning('ope_users.id non trouvé pour cette opération', [ LogService::warning('ope_users.id non trouvé pour cette opération', [
'ope_users_id' => $memberId, 'ope_users_id' => $memberId,
'operation_id' => $operationId 'operation_id' => $operationId
]); ]);
@@ -275,7 +273,7 @@ class SectorController
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logService->warning('Erreur lors de la récupération des passages orphelins', [ LogService::warning('Erreur lors de la récupération des passages orphelins', [
'sector_id' => $sectorId, 'sector_id' => $sectorId,
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);
@@ -335,7 +333,7 @@ class SectorController
$firstOpeUserId = $stmtFirstOpeUser->fetchColumn(); $firstOpeUserId = $stmtFirstOpeUser->fetchColumn();
if (!$firstOpeUserId) { if (!$firstOpeUserId) {
$this->logService->warning('Premier ope_users.id non trouvé pour cette opération', [ LogService::warning('Premier ope_users.id non trouvé pour cette opération', [
'ope_users_id' => $users[0], 'ope_users_id' => $users[0],
'operation_id' => $operationId 'operation_id' => $operationId
]); ]);
@@ -401,7 +399,7 @@ class SectorController
// Log pour vérifier l'uniformisation GPS (surtout pour immeubles) // Log pour vérifier l'uniformisation GPS (surtout pour immeubles)
if ($fkHabitat == 2 && $nbLog > 1) { if ($fkHabitat == 2 && $nbLog > 1) {
$this->logService->info('[SectorController] Création passages immeuble avec GPS uniformisés', [ LogService::info('[SectorController] Création passages immeuble avec GPS uniformisés', [
'address_id' => $address['id'], 'address_id' => $address['id'],
'nb_passages' => $nbLog, 'nb_passages' => $nbLog,
'gps_lat' => $gpsLat, 'gps_lat' => $gpsLat,
@@ -410,7 +408,7 @@ class SectorController
]); ]);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logService->warning('Erreur lors de la création d\'un passage', [ LogService::warning('Erreur lors de la création d\'un passage', [
'address_id' => $address['id'], 'address_id' => $address['id'],
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);
@@ -421,7 +419,7 @@ class SectorController
} }
} catch (\Exception $e) { } catch (\Exception $e) {
// En cas d'erreur avec les adresses, on ne bloque pas la création du secteur // En cas d'erreur avec les adresses, on ne bloque pas la création du secteur
$this->logService->error('Erreur lors du stockage des adresses du secteur', [ LogService::error('Erreur lors du stockage des adresses du secteur', [
'sector_id' => $sectorId, 'sector_id' => $sectorId,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'entity_id' => $entityId 'entity_id' => $entityId
@@ -525,7 +523,7 @@ class SectorController
$responseData['users_sectors'][] = $userData; $responseData['users_sectors'][] = $userData;
} }
$this->logService->info('Secteur créé', [ LogService::info('Secteur créé', [
'sector_id' => $sectorId, 'sector_id' => $sectorId,
'libelle' => $sectorData['libelle'], 'libelle' => $sectorData['libelle'],
'entity_id' => $entityId, 'entity_id' => $entityId,
@@ -567,7 +565,7 @@ class SectorController
if ($this->db->inTransaction()) { if ($this->db->inTransaction()) {
$this->db->rollBack(); $this->db->rollBack();
} }
$this->logService->error('Erreur lors de la création du secteur', [ LogService::error('Erreur lors de la création du secteur', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'data' => $data ?? null 'data' => $data ?? null
]); ]);
@@ -634,7 +632,7 @@ class SectorController
// Gestion des membres (reçus comme 'users' depuis Flutter) // Gestion des membres (reçus comme 'users' depuis Flutter)
if (isset($data['users'])) { if (isset($data['users'])) {
$this->logService->info('[UPDATE USERS] Début modification des membres', [ LogService::info('[UPDATE USERS] Début modification des membres', [
'sector_id' => $id, 'sector_id' => $id,
'users_demandes' => $data['users'], 'users_demandes' => $data['users'],
'nb_users' => count($data['users']) 'nb_users' => count($data['users'])
@@ -642,27 +640,27 @@ class SectorController
// Récupérer l'opération du secteur pour l'INSERT // Récupérer l'opération du secteur pour l'INSERT
$opQuery = "SELECT fk_operation FROM ope_sectors WHERE id = :sector_id"; $opQuery = "SELECT fk_operation FROM ope_sectors WHERE id = :sector_id";
$this->logService->info('[UPDATE USERS] SQL - Récupération fk_operation', [ LogService::info('[UPDATE USERS] SQL - Récupération fk_operation', [
'query' => $opQuery, 'query' => $opQuery,
'params' => ['sector_id' => $id] 'params' => ['sector_id' => $id]
]); ]);
$opStmt = $this->db->prepare($opQuery); $opStmt = $this->db->prepare($opQuery);
$opStmt->execute(['sector_id' => $id]); $opStmt->execute(['sector_id' => $id]);
$operationId = $opStmt->fetch()['fk_operation']; $operationId = $opStmt->fetch()['fk_operation'];
$this->logService->info('[UPDATE USERS] fk_operation récupéré', [ LogService::info('[UPDATE USERS] fk_operation récupéré', [
'operation_id' => $operationId 'operation_id' => $operationId
]); ]);
// Supprimer les affectations existantes // Supprimer les affectations existantes
$deleteQuery = "DELETE FROM ope_users_sectors WHERE fk_sector = :sector_id"; $deleteQuery = "DELETE FROM ope_users_sectors WHERE fk_sector = :sector_id";
$this->logService->info('[UPDATE USERS] SQL - Suppression des anciens membres', [ LogService::info('[UPDATE USERS] SQL - Suppression des anciens membres', [
'query' => $deleteQuery, 'query' => $deleteQuery,
'params' => ['sector_id' => $id] 'params' => ['sector_id' => $id]
]); ]);
$deleteStmt = $this->db->prepare($deleteQuery); $deleteStmt = $this->db->prepare($deleteQuery);
$deleteStmt->execute(['sector_id' => $id]); $deleteStmt->execute(['sector_id' => $id]);
$deletedCount = $deleteStmt->rowCount(); $deletedCount = $deleteStmt->rowCount();
$this->logService->info('[UPDATE USERS] Membres supprimés', [ LogService::info('[UPDATE USERS] Membres supprimés', [
'nb_deleted' => $deletedCount 'nb_deleted' => $deletedCount
]); ]);
@@ -670,7 +668,7 @@ class SectorController
if (!empty($data['users'])) { if (!empty($data['users'])) {
$insertQuery = "INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, created_at, updated_at, fk_user_creat, chk_active) $insertQuery = "INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, created_at, updated_at, fk_user_creat, chk_active)
VALUES (:operation_id, :user_id, :sector_id, NOW(), NOW(), :user_creat, 1)"; VALUES (:operation_id, :user_id, :sector_id, NOW(), NOW(), :user_creat, 1)";
$this->logService->info('[UPDATE USERS] SQL - Requête INSERT préparée', [ LogService::info('[UPDATE USERS] SQL - Requête INSERT préparée', [
'query' => $insertQuery 'query' => $insertQuery
]); ]);
$insertStmt = $this->db->prepare($insertQuery); $insertStmt = $this->db->prepare($insertQuery);
@@ -689,7 +687,7 @@ class SectorController
$opeUserId = $stmtOpeUser->fetchColumn(); $opeUserId = $stmtOpeUser->fetchColumn();
if (!$opeUserId) { if (!$opeUserId) {
$this->logService->warning('[UPDATE USERS] ope_users.id non trouvé pour cette opération', [ LogService::warning('[UPDATE USERS] ope_users.id non trouvé pour cette opération', [
'ope_users_id' => $memberId, 'ope_users_id' => $memberId,
'operation_id' => $operationId 'operation_id' => $operationId
]); ]);
@@ -703,17 +701,17 @@ class SectorController
'sector_id' => $id, 'sector_id' => $id,
'user_creat' => $_SESSION['user_id'] ?? null 'user_creat' => $_SESSION['user_id'] ?? null
]; ];
$this->logService->info('[UPDATE USERS] SQL - INSERT user', [ LogService::info('[UPDATE USERS] SQL - INSERT user', [
'params' => $params 'params' => $params
]); ]);
$insertStmt->execute($params); $insertStmt->execute($params);
$insertedUsers[] = $memberId; $insertedUsers[] = $memberId;
$this->logService->info('[UPDATE USERS] User inséré avec succès', [ LogService::info('[UPDATE USERS] User inséré avec succès', [
'user_id' => $memberId 'user_id' => $memberId
]); ]);
} catch (\PDOException $e) { } catch (\PDOException $e) {
$failedUsers[] = $memberId; $failedUsers[] = $memberId;
$this->logService->warning('[UPDATE USERS] ERREUR insertion user', [ LogService::warning('[UPDATE USERS] ERREUR insertion user', [
'sector_id' => $id, 'sector_id' => $id,
'user_id' => $memberId, 'user_id' => $memberId,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
@@ -722,7 +720,7 @@ class SectorController
} }
} }
$this->logService->info('[UPDATE USERS] Résultat des insertions', [ LogService::info('[UPDATE USERS] Résultat des insertions', [
'users_demandes' => $data['users'], 'users_demandes' => $data['users'],
'users_inseres' => $insertedUsers, 'users_inseres' => $insertedUsers,
'users_echoues' => $failedUsers, 'users_echoues' => $failedUsers,
@@ -744,7 +742,7 @@ class SectorController
$chkAdressesChange = $data['chk_adresses_change'] ?? 1; $chkAdressesChange = $data['chk_adresses_change'] ?? 1;
if (isset($data['sector']) && $chkAdressesChange == 0) { if (isset($data['sector']) && $chkAdressesChange == 0) {
$this->logService->info('[UPDATE] Modification secteur sans recalcul adresses/passages', [ LogService::info('[UPDATE] Modification secteur sans recalcul adresses/passages', [
'sector_id' => $id, 'sector_id' => $id,
'chk_adresses_change' => $chkAdressesChange 'chk_adresses_change' => $chkAdressesChange
]); ]);
@@ -770,7 +768,7 @@ class SectorController
} }
// Récupérer et stocker les nouvelles adresses // Récupérer et stocker les nouvelles adresses
$this->logService->info('[UPDATE] Récupération des adresses', [ LogService::info('[UPDATE] Récupération des adresses', [
'sector_id' => $id, 'sector_id' => $id,
'entity_id' => $entityId, 'entity_id' => $entityId,
'nb_points' => count($coordinates) 'nb_points' => count($coordinates)
@@ -781,7 +779,7 @@ class SectorController
// Enrichir les adresses avec les données bâtiments // Enrichir les adresses avec les données bâtiments
$addresses = $this->addressService->enrichAddressesWithBuildings($addresses, $entityId); $addresses = $this->addressService->enrichAddressesWithBuildings($addresses, $entityId);
$this->logService->info('[UPDATE] Adresses récupérées', [ LogService::info('[UPDATE] Adresses récupérées', [
'sector_id' => $id, 'sector_id' => $id,
'nb_addresses' => count($addresses) 'nb_addresses' => count($addresses)
]); ]);
@@ -815,12 +813,12 @@ class SectorController
]); ]);
} }
$this->logService->info('[UPDATE] Adresses stockées dans sectors_adresses', [ LogService::info('[UPDATE] Adresses stockées dans sectors_adresses', [
'sector_id' => $id, 'sector_id' => $id,
'nb_stored' => count($addresses) 'nb_stored' => count($addresses)
]); ]);
} else { } else {
$this->logService->warning('[UPDATE] Aucune adresse trouvée pour le secteur', [ LogService::warning('[UPDATE] Aucune adresse trouvée pour le secteur', [
'sector_id' => $id, 'sector_id' => $id,
'entity_id' => $entityId 'entity_id' => $entityId
]); ]);
@@ -828,19 +826,19 @@ class SectorController
// Vérifier si c'est un problème de connexion à la base d'adresses // Vérifier si c'est un problème de connexion à la base d'adresses
if (!$this->addressService->isConnected()) { if (!$this->addressService->isConnected()) {
$this->logService->warning('[UPDATE] Base d\'adresses non accessible - passages créés sans adresses', [ LogService::warning('[UPDATE] Base d\'adresses non accessible - passages créés sans adresses', [
'sector_id' => $id 'sector_id' => $id
]); ]);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logService->error('[UPDATE] Erreur lors de la mise à jour des adresses du secteur', [ LogService::error('[UPDATE] Erreur lors de la mise à jour des adresses du secteur', [
'sector_id' => $id, 'sector_id' => $id,
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);
} }
// Maintenant que les adresses sont mises à jour, traiter les passages // Maintenant que les adresses sont mises à jour, traiter les passages
$this->logService->info('[UPDATE] Début mise à jour des passages', ['sector_id' => $id]); LogService::info('[UPDATE] Début mise à jour des passages', ['sector_id' => $id]);
$passageCounters = $this->updatePassagesForSector($id, $data['sector']); $passageCounters = $this->updatePassagesForSector($id, $data['sector']);
} }
@@ -934,7 +932,7 @@ class SectorController
WHERE ous.fk_sector = :sector_id WHERE ous.fk_sector = :sector_id
ORDER BY u.id"; ORDER BY u.id";
$this->logService->info('[UPDATE USERS] SQL - Récupération finale des users', [ LogService::info('[UPDATE USERS] SQL - Récupération finale des users', [
'query' => $usersQuery, 'query' => $usersQuery,
'params' => ['sector_id' => $id] 'params' => ['sector_id' => $id]
]); ]);
@@ -944,7 +942,7 @@ class SectorController
$usersSectors = $usersStmt->fetchAll(\PDO::FETCH_ASSOC); $usersSectors = $usersStmt->fetchAll(\PDO::FETCH_ASSOC);
$userIds = array_column($usersSectors, 'id'); $userIds = array_column($usersSectors, 'id');
$this->logService->info('[UPDATE USERS] Users récupérés après commit', [ LogService::info('[UPDATE USERS] Users récupérés après commit', [
'sector_id' => $id, 'sector_id' => $id,
'users_ids' => $userIds, 'users_ids' => $userIds,
'nb_users' => count($userIds), 'nb_users' => count($userIds),
@@ -971,7 +969,7 @@ class SectorController
$usersDecrypted[] = $userData; $usersDecrypted[] = $userData;
} }
$this->logService->info('Secteur modifié', [ LogService::info('Secteur modifié', [
'sector_id' => $id, 'sector_id' => $id,
'updates' => array_keys($data), 'updates' => array_keys($data),
'passage_counters' => $passageCounters, 'passage_counters' => $passageCounters,
@@ -999,7 +997,7 @@ class SectorController
if ($this->db->inTransaction()) { if ($this->db->inTransaction()) {
$this->db->rollBack(); $this->db->rollBack();
} }
$this->logService->error('Erreur lors de la modification du secteur', [ LogService::error('Erreur lors de la modification du secteur', [
'sector_id' => $id, 'sector_id' => $id,
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);
@@ -1065,7 +1063,7 @@ class SectorController
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logService->error('Erreur lors de la récupération des adresses du secteur', [ LogService::error('Erreur lors de la récupération des adresses du secteur', [
'sector_id' => $id, 'sector_id' => $id,
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);
@@ -1198,7 +1196,7 @@ class SectorController
$passagesDecrypted[] = $passage; $passagesDecrypted[] = $passage;
} }
$this->logService->info('Secteur supprimé', [ LogService::info('Secteur supprimé', [
'sector_id' => $id, 'sector_id' => $id,
'libelle' => $sector['libelle'], 'libelle' => $sector['libelle'],
'passages_deleted' => $passagesToDelete, 'passages_deleted' => $passagesToDelete,
@@ -1216,7 +1214,7 @@ class SectorController
} catch (\Exception $e) { } catch (\Exception $e) {
$this->db->rollBack(); $this->db->rollBack();
$this->logService->error('Erreur lors de la suppression du secteur', [ LogService::error('Erreur lors de la suppression du secteur', [
'sector_id' => $id, 'sector_id' => $id,
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);
@@ -1238,7 +1236,7 @@ class SectorController
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logService->error('Erreur lors de la vérification des contours départementaux', [ LogService::error('Erreur lors de la vérification des contours départementaux', [
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);
Response::json(['status' => 'error', 'message' => 'Erreur lors de la vérification'], 500); Response::json(['status' => 'error', 'message' => 'Erreur lors de la vérification'], 500);
@@ -1298,7 +1296,7 @@ class SectorController
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logService->error('Erreur lors de la vérification des limites', [ LogService::error('Erreur lors de la vérification des limites', [
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);
Response::json(['status' => 'error', 'message' => 'Erreur lors de la vérification'], 500); Response::json(['status' => 'error', 'message' => 'Erreur lors de la vérification'], 500);
@@ -1422,7 +1420,7 @@ class SectorController
$addressesStmt->execute(['sector_id' => $sectorId]); $addressesStmt->execute(['sector_id' => $sectorId]);
$addresses = $addressesStmt->fetchAll(); $addresses = $addressesStmt->fetchAll();
$this->logService->info('[updatePassagesForSector] Adresses dans sectors_adresses', [ LogService::info('[updatePassagesForSector] Adresses dans sectors_adresses', [
'sector_id' => $sectorId, 'sector_id' => $sectorId,
'nb_addresses' => count($addresses) 'nb_addresses' => count($addresses)
]); ]);
@@ -1435,7 +1433,7 @@ class SectorController
$firstUserId = $firstUser ? $firstUser['fk_user'] : null; $firstUserId = $firstUser ? $firstUser['fk_user'] : null;
if ($firstUserId && !empty($addresses)) { if ($firstUserId && !empty($addresses)) {
$this->logService->info('[updatePassagesForSector] Traitement des passages', [ LogService::info('[updatePassagesForSector] Traitement des passages', [
'user_id' => $firstUserId, 'user_id' => $firstUserId,
'nb_addresses' => count($addresses) 'nb_addresses' => count($addresses)
]); ]);
@@ -1594,7 +1592,7 @@ class SectorController
$insertStmt->execute($insertParams); $insertStmt->execute($insertParams);
$counters['passages_created'] = count($toInsert); $counters['passages_created'] = count($toInsert);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logService->error('Erreur lors de l\'insertion multiple des passages', [ LogService::error('Erreur lors de l\'insertion multiple des passages', [
'sector_id' => $sectorId, 'sector_id' => $sectorId,
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);
@@ -1658,12 +1656,12 @@ class SectorController
$counters['passages_updated'] = count($toUpdate); $counters['passages_updated'] = count($toUpdate);
// Log pour vérifier l'uniformisation GPS (surtout pour immeubles) // Log pour vérifier l'uniformisation GPS (surtout pour immeubles)
$this->logService->info('[updatePassagesForSector] Passages mis à jour avec GPS uniformisés', [ LogService::info('[updatePassagesForSector] Passages mis à jour avec GPS uniformisés', [
'nb_updated' => count($toUpdate), 'nb_updated' => count($toUpdate),
'sector_id' => $sectorId 'sector_id' => $sectorId
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logService->error('Erreur lors de la mise à jour multiple des passages', [ LogService::error('Erreur lors de la mise à jour multiple des passages', [
'sector_id' => $sectorId, 'sector_id' => $sectorId,
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);
@@ -1680,7 +1678,7 @@ class SectorController
$deleteStmt->execute($toDelete); $deleteStmt->execute($toDelete);
$counters['passages_deleted'] += count($toDelete); $counters['passages_deleted'] += count($toDelete);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logService->error('Erreur lors de la suppression multiple des passages', [ LogService::error('Erreur lors de la suppression multiple des passages', [
'sector_id' => $sectorId, 'sector_id' => $sectorId,
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);
@@ -1688,7 +1686,7 @@ class SectorController
} }
} else { } else {
$this->logService->warning('[updatePassagesForSector] Pas de création de passages', [ LogService::warning('[updatePassagesForSector] Pas de création de passages', [
'reason' => !$firstUserId ? 'Pas d\'utilisateur affecté' : 'Pas d\'adresses', 'reason' => !$firstUserId ? 'Pas d\'utilisateur affecté' : 'Pas d\'adresses',
'first_user_id' => $firstUserId, 'first_user_id' => $firstUserId,
'nb_addresses' => count($addresses) 'nb_addresses' => count($addresses)
@@ -1697,14 +1695,14 @@ class SectorController
// Retourner les compteurs détaillés // Retourner les compteurs détaillés
$this->logService->info('[updatePassagesForSector] Fin traitement', [ LogService::info('[updatePassagesForSector] Fin traitement', [
'sector_id' => $sectorId, 'sector_id' => $sectorId,
'counters' => $counters 'counters' => $counters
]); ]);
return $counters; return $counters;
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logService->error('Erreur lors de la mise à jour des passages', [ LogService::error('Erreur lors de la mise à jour des passages', [
'sector_id' => $sectorId, 'sector_id' => $sectorId,
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);

View File

@@ -196,7 +196,8 @@ class StripeController extends Controller {
SELECT p.*, o.fk_entite, o.id as operation_id SELECT p.*, o.fk_entite, o.id as operation_id
FROM ope_pass p FROM ope_pass p
JOIN operations o ON p.fk_operation = o.id JOIN operations o ON p.fk_operation = o.id
WHERE p.id = ? AND p.fk_user = ? JOIN ope_users ou ON p.fk_user = ou.id
WHERE p.id = ? AND ou.fk_user = ?
'); ');
$stmt->execute([$passageId, Session::getUserId()]); $stmt->execute([$passageId, Session::getUserId()]);
$passage = $stmt->fetch(); $passage = $stmt->fetch();
@@ -469,70 +470,6 @@ class StripeController extends Controller {
} }
} }
/**
* POST /api/stripe/devices/check-tap-to-pay
* Vérifier la compatibilité Tap to Pay d'un appareil
*/
public function checkTapToPayCapability(): void {
try {
$data = $this->getJsonInput();
$platform = $data['platform'] ?? '';
if ($platform === 'ios') {
// Pour iOS, on vérifie côté client (iPhone XS+ avec iOS 16.4+)
$this->sendSuccess([
'message' => 'Vérification iOS à faire côté client',
'requirements' => 'iPhone XS ou plus récent avec iOS 16.4+',
'details' => 'iOS 16.4 minimum requis pour le support PIN complet'
]);
return;
}
if ($platform === 'android') {
$manufacturer = $data['manufacturer'] ?? '';
$model = $data['model'] ?? '';
if (!$manufacturer || !$model) {
$this->sendError('Manufacturer et model requis pour Android', 400);
return;
}
$result = $this->stripeService->checkAndroidTapToPayCompatibility($manufacturer, $model);
if ($result['success']) {
$this->sendSuccess($result);
} else {
$this->sendError($result['message'], 400);
}
} else {
$this->sendError('Platform doit être ios ou android', 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* GET /api/stripe/devices/certified-android
* Récupérer la liste des appareils Android certifiés
*/
public function getCertifiedAndroidDevices(): void {
try {
$result = $this->stripeService->getCertifiedAndroidDevices();
if ($result['success']) {
$this->sendSuccess(['devices' => $result['devices']]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/** /**
* GET /api/stripe/config * GET /api/stripe/config
* Récupérer la configuration publique Stripe * Récupérer la configuration publique Stripe
@@ -784,4 +721,117 @@ class StripeController extends Controller {
$this->sendError('Erreur lors de la création de la location: ' . $e->getMessage()); $this->sendError('Erreur lors de la création de la location: ' . $e->getMessage());
} }
} }
/**
* POST /api/stripe/terminal/connection-token
* Créer un Connection Token pour Stripe Terminal/Tap to Pay
* Requis par le SDK Stripe Terminal pour se connecter aux readers
*/
public function createConnectionToken(): void {
try {
$this->requireAuth();
$data = $this->getJsonInput();
$entiteId = $data['amicale_id'] ?? Session::getEntityId();
if (!$entiteId) {
$this->sendError('ID entité requis', 400);
return;
}
// Vérifier les droits sur cette entité
$userRole = Session::getRole() ?? 0;
if (Session::getEntityId() != $entiteId && $userRole < 3) {
$this->sendError('Non autorisé pour cette entité', 403);
return;
}
$result = $this->stripeService->createConnectionToken($entiteId);
if ($result['success']) {
$this->sendSuccess([
'secret' => $result['secret']
]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur lors de la création du connection token: ' . $e->getMessage());
}
}
/**
* POST /api/stripe/payments/cancel
* Annuler un PaymentIntent Stripe
*
* Payload:
* {
* "payment_intent_id": "pi_3SWvho2378xpV4Rn1J43Ks7M"
* }
*/
public function cancelPayment(): void {
try {
$this->requireAuth();
$data = $this->getJsonInput();
// Validation
if (!isset($data['payment_intent_id'])) {
$this->sendError('payment_intent_id requis', 400);
return;
}
$paymentIntentId = $data['payment_intent_id'];
// Vérifier que le passage existe et appartient à l'utilisateur
$stmt = $this->db->prepare('
SELECT p.*, o.fk_entite, ou.fk_user as ope_user_id
FROM ope_pass p
JOIN operations o ON p.fk_operation = o.id
JOIN ope_users ou ON p.fk_user = ou.id
WHERE p.stripe_payment_id = ?
');
$stmt->execute([$paymentIntentId]);
$passage = $stmt->fetch();
if (!$passage) {
$this->sendError('Paiement non trouvé', 404);
return;
}
// Vérifier les droits
$userId = Session::getUserId();
$userEntityId = Session::getEntityId();
if ($passage['ope_user_id'] != $userId && $passage['fk_entite'] != $userEntityId) {
$this->sendError('Non autorisé', 403);
return;
}
// Annuler le PaymentIntent via StripeService
$result = $this->stripeService->cancelPaymentIntent($paymentIntentId);
if ($result['success']) {
// Retirer le stripe_payment_id du passage
$stmt = $this->db->prepare('
UPDATE ope_pass
SET stripe_payment_id = NULL, updated_at = NOW()
WHERE id = ?
');
$stmt->execute([$passage['id']]);
$this->sendSuccess([
'status' => 'canceled',
'payment_intent_id' => $paymentIntentId,
'passage_id' => $passage['id']
]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
} }

View File

@@ -202,86 +202,47 @@ class StripeWebhookController extends Controller {
* Gérer un paiement réussi * Gérer un paiement réussi
*/ */
private function handlePaymentIntentSucceeded($paymentIntent): void { private function handlePaymentIntentSucceeded($paymentIntent): void {
// Mettre à jour le statut en base // Pour Tap to Pay, le paiement est instantané et déjà enregistré dans ope_pass
$stmt = $this->db->prepare( // Ce webhook sert principalement pour les Payment Links (QR Code) asynchrones
"UPDATE stripe_payment_intents
SET status = :status, updated_at = NOW()
WHERE stripe_payment_intent_id = :pi_id"
);
$stmt->execute([
'status' => 'succeeded',
'pi_id' => $paymentIntent->id
]);
// Enregistrer dans l'historique // Vérifier si le passage existe et mettre à jour si nécessaire
$stmt = $this->db->prepare( $stmt = $this->db->prepare("
"SELECT id FROM stripe_payment_intents WHERE stripe_payment_intent_id = :pi_id" SELECT id FROM ope_pass
); WHERE stripe_payment_id = :pi_id
");
$stmt->execute(['pi_id' => $paymentIntent->id]); $stmt->execute(['pi_id' => $paymentIntent->id]);
$localPayment = $stmt->fetch(); $passage = $stmt->fetch();
if ($localPayment) { if ($passage) {
$stmt = $this->db->prepare( error_log("Payment succeeded: {$paymentIntent->id} for {$paymentIntent->amount} {$paymentIntent->currency} - Passage {$passage['id']}");
"INSERT INTO stripe_payment_history
(fk_payment_intent, event_type, event_data, created_at)
VALUES (:fk_pi, 'succeeded', :data, NOW())"
);
$stmt->execute([
'fk_pi' => $localPayment['id'],
'data' => json_encode([
'amount' => $paymentIntent->amount,
'currency' => $paymentIntent->currency,
'payment_method' => $paymentIntent->payment_method,
'charges' => $paymentIntent->charges->data
])
]);
}
// TODO: Envoyer un reçu par email // TODO: Envoyer un reçu par email
// TODO: Mettre à jour les statistiques en temps réel // TODO: Mettre à jour les statistiques en temps réel
} else {
error_log("Payment succeeded: {$paymentIntent->id} for {$paymentIntent->amount} {$paymentIntent->currency}"); error_log("Payment succeeded: {$paymentIntent->id} but no passage found in ope_pass");
}
} }
/** /**
* Gérer un paiement échoué * Gérer un paiement échoué
*/ */
private function handlePaymentIntentFailed($paymentIntent): void { private function handlePaymentIntentFailed($paymentIntent): void {
// Mettre à jour le statut // Vérifier si le passage existe
$stmt = $this->db->prepare( $stmt = $this->db->prepare("
"UPDATE stripe_payment_intents SELECT id FROM ope_pass
SET status = :status, updated_at = NOW() WHERE stripe_payment_id = :pi_id
WHERE stripe_payment_intent_id = :pi_id" ");
);
$stmt->execute([
'status' => 'failed',
'pi_id' => $paymentIntent->id
]);
// Enregistrer dans l'historique avec la raison de l'échec
$stmt = $this->db->prepare(
"SELECT id FROM stripe_payment_intents WHERE stripe_payment_intent_id = :pi_id"
);
$stmt->execute(['pi_id' => $paymentIntent->id]); $stmt->execute(['pi_id' => $paymentIntent->id]);
$localPayment = $stmt->fetch(); $passage = $stmt->fetch();
if ($localPayment) {
$stmt = $this->db->prepare(
"INSERT INTO stripe_payment_history
(fk_payment_intent, event_type, event_data, created_at)
VALUES (:fk_pi, 'failed', :data, NOW())"
);
$stmt->execute([
'fk_pi' => $localPayment['id'],
'data' => json_encode([
'error' => $paymentIntent->last_payment_error,
'cancellation_reason' => $paymentIntent->cancellation_reason
])
]);
}
if ($passage) {
// Optionnel : Marquer le passage comme échec ou supprimer le stripe_payment_id
// Pour l'instant on log seulement
error_log("Payment failed: {$paymentIntent->id} for passage {$passage['id']}, reason: " . json_encode($paymentIntent->last_payment_error));
} else {
error_log("Payment failed: {$paymentIntent->id}, reason: " . json_encode($paymentIntent->last_payment_error)); error_log("Payment failed: {$paymentIntent->id}, reason: " . json_encode($paymentIntent->last_payment_error));
} }
}
/** /**
* Gérer la complétion d'une session de paiement (Payment Link / Checkout) * Gérer la complétion d'une session de paiement (Payment Link / Checkout)
@@ -358,17 +319,8 @@ class StripeWebhookController extends Controller {
* Gérer une action réussie sur un Terminal reader * Gérer une action réussie sur un Terminal reader
*/ */
private function handleTerminalReaderActionSucceeded($reader): void { private function handleTerminalReaderActionSucceeded($reader): void {
// Mettre à jour le statut du reader // Note: Pour Tap to Pay, il n'y a pas de readers physiques
$stmt = $this->db->prepare( // Ce webhook concerne uniquement les terminaux externes (non utilisés pour l'instant)
"UPDATE stripe_terminal_readers
SET status = :status, last_seen_at = NOW()
WHERE stripe_reader_id = :reader_id"
);
$stmt->execute([
'status' => 'online',
'reader_id' => $reader->id
]);
error_log("Terminal reader action succeeded: {$reader->id}"); error_log("Terminal reader action succeeded: {$reader->id}");
} }
@@ -376,17 +328,8 @@ class StripeWebhookController extends Controller {
* Gérer une action échouée sur un Terminal reader * Gérer une action échouée sur un Terminal reader
*/ */
private function handleTerminalReaderActionFailed($reader): void { private function handleTerminalReaderActionFailed($reader): void {
// Mettre à jour le statut du reader // Note: Pour Tap to Pay, il n'y a pas de readers physiques
$stmt = $this->db->prepare( // Ce webhook concerne uniquement les terminaux externes (non utilisés pour l'instant)
"UPDATE stripe_terminal_readers
SET status = :status, last_seen_at = NOW()
WHERE stripe_reader_id = :reader_id"
);
$stmt->execute([
'status' => 'error',
'reader_id' => $reader->id
]);
error_log("Terminal reader action failed: {$reader->id}"); error_log("Terminal reader action failed: {$reader->id}");
} }
} }

View File

@@ -135,13 +135,13 @@ class Router {
$this->get('stripe/accounts/:entityId/status', ['StripeController', 'getAccountStatus']); $this->get('stripe/accounts/:entityId/status', ['StripeController', 'getAccountStatus']);
$this->post('stripe/accounts/:accountId/onboarding-link', ['StripeController', 'createOnboardingLink']); $this->post('stripe/accounts/:accountId/onboarding-link', ['StripeController', 'createOnboardingLink']);
// Tap to Pay - Vérification compatibilité et configuration // Tap to Pay - Configuration
$this->post('stripe/devices/check-tap-to-pay', ['StripeController', 'checkTapToPayCapability']);
$this->get('stripe/devices/certified-android', ['StripeController', 'getCertifiedAndroidDevices']);
$this->post('stripe/locations', ['StripeController', 'createLocation']); $this->post('stripe/locations', ['StripeController', 'createLocation']);
$this->post('stripe/terminal/connection-token', ['StripeController', 'createConnectionToken']);
// Paiements // Paiements
$this->post('stripe/payments/create-intent', ['StripeController', 'createPaymentIntent']); $this->post('stripe/payments/create-intent', ['StripeController', 'createPaymentIntent']);
$this->post('stripe/payments/cancel', ['StripeController', 'cancelPayment']);
$this->post('stripe/payment-links', ['StripeController', 'createPaymentLink']); $this->post('stripe/payment-links', ['StripeController', 'createPaymentLink']);
$this->get('stripe/payments/:paymentIntentId', ['StripeController', 'getPaymentStatus']); $this->get('stripe/payments/:paymentIntentId', ['StripeController', 'getPaymentStatus']);
@@ -152,6 +152,14 @@ class Router {
// Webhook (IMPORTANT: pas d'authentification requise pour les webhooks Stripe) // Webhook (IMPORTANT: pas d'authentification requise pour les webhooks Stripe)
$this->post('stripe/webhooks', ['StripeWebhookController', 'handleWebhook']); $this->post('stripe/webhooks', ['StripeWebhookController', 'handleWebhook']);
// Routes Statistiques Events (Admin uniquement)
$this->get('events/stats/summary', ['EventStatsController', 'summary']);
$this->get('events/stats/daily', ['EventStatsController', 'daily']);
$this->get('events/stats/weekly', ['EventStatsController', 'weekly']);
$this->get('events/stats/monthly', ['EventStatsController', 'monthly']);
$this->get('events/stats/details', ['EventStatsController', 'details']);
$this->get('events/stats/types', ['EventStatsController', 'types']);
// Routes Migration (Admin uniquement) // Routes Migration (Admin uniquement)
$this->get('migrations/test-connections', ['MigrationController', 'testConnections']); $this->get('migrations/test-connections', ['MigrationController', 'testConnections']);
$this->get('migrations/entities/available', ['MigrationController', 'getAvailableEntities']); $this->get('migrations/entities/available', ['MigrationController', 'getAvailableEntities']);

View File

@@ -21,19 +21,16 @@ class AddressService
{ {
private ?PDO $addressesDb = null; private ?PDO $addressesDb = null;
private PDO $mainDb; private PDO $mainDb;
private $logService;
private $buildingService; private $buildingService;
public function __construct() public function __construct()
{ {
$this->logService = new LogService();
try { try {
$this->addressesDb = \AddressesDatabase::getInstance(); $this->addressesDb = \AddressesDatabase::getInstance();
$this->logService->info('[AddressService] Connexion à la base d\'adresses réussie'); LogService::info('[AddressService] Connexion à la base d\'adresses réussie');
} catch (\Exception $e) { } catch (\Exception $e) {
// Si la connexion échoue, on continue sans la base d'adresses // Si la connexion échoue, on continue sans la base d'adresses
$this->logService->error('[AddressService] Connexion à la base d\'adresses impossible', [ LogService::error('[AddressService] Connexion à la base d\'adresses impossible', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'trace' => $e->getTraceAsString() 'trace' => $e->getTraceAsString()
]); ]);
@@ -94,13 +91,13 @@ class AddressService
{ {
// Si pas de connexion à la base d'adresses, retourner un tableau vide // Si pas de connexion à la base d'adresses, retourner un tableau vide
if (!$this->addressesDb) { if (!$this->addressesDb) {
$this->logService->error('[AddressService] Pas de connexion à la base d\'adresses externe', [ LogService::error('[AddressService] Pas de connexion à la base d\'adresses externe', [
'entity_id' => $entityId 'entity_id' => $entityId
]); ]);
return []; return [];
} }
$this->logService->info('[AddressService] Début recherche adresses', [ LogService::info('[AddressService] Début recherche adresses', [
'entity_id' => $entityId, 'entity_id' => $entityId,
'nb_coordinates' => count($coordinates) 'nb_coordinates' => count($coordinates)
]); ]);
@@ -117,11 +114,11 @@ class AddressService
// Si aucun département n'est trouvé par analyse spatiale, // Si aucun département n'est trouvé par analyse spatiale,
// chercher d'abord dans le département de l'entité et ses limitrophes // chercher d'abord dans le département de l'entité et ses limitrophes
$entityDept = $this->getDepartmentForEntity($entityId); $entityDept = $this->getDepartmentForEntity($entityId);
$this->logService->info('[AddressService] Département de l\'entité', [ LogService::info('[AddressService] Département de l\'entité', [
'departement' => $entityDept 'departement' => $entityDept
]); ]);
if (!$entityDept) { if (!$entityDept) {
$this->logService->error('[AddressService] Impossible de déterminer le département de l\'entité', [ LogService::error('[AddressService] Impossible de déterminer le département de l\'entité', [
'entity_id' => $entityId 'entity_id' => $entityId
]); ]);
throw new RuntimeException("Impossible de déterminer le département"); throw new RuntimeException("Impossible de déterminer le département");
@@ -131,7 +128,7 @@ class AddressService
$priorityDepts = $boundaryService->getPriorityDepartments($entityDept); $priorityDepts = $boundaryService->getPriorityDepartments($entityDept);
// Log pour debug // Log pour debug
$this->logService->warning('[AddressService] Aucun département trouvé par analyse spatiale', [ LogService::warning('[AddressService] Aucun département trouvé par analyse spatiale', [
'departements_prioritaires' => implode(', ', $priorityDepts) 'departements_prioritaires' => implode(', ', $priorityDepts)
]); ]);
@@ -204,7 +201,7 @@ class AddressService
} }
// Log pour debug // Log pour debug
$this->logService->info('[AddressService] Recherche dans table', [ LogService::info('[AddressService] Recherche dans table', [
'table' => $tableName, 'table' => $tableName,
'departement' => $deptCode, 'departement' => $deptCode,
'nb_adresses' => count($addresses) 'nb_adresses' => count($addresses)
@@ -212,7 +209,7 @@ class AddressService
} catch (PDOException $e) { } catch (PDOException $e) {
// Log l'erreur mais continue avec les autres départements // Log l'erreur mais continue avec les autres départements
$this->logService->error('[AddressService] Erreur SQL', [ LogService::error('[AddressService] Erreur SQL', [
'table' => $tableName, 'table' => $tableName,
'departement' => $deptCode, 'departement' => $deptCode,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
@@ -221,7 +218,7 @@ class AddressService
} }
} }
$this->logService->info('[AddressService] Fin recherche adresses', [ LogService::info('[AddressService] Fin recherche adresses', [
'total_adresses' => count($allAddresses) 'total_adresses' => count($allAddresses)
]); ]);
return $allAddresses; return $allAddresses;
@@ -243,7 +240,7 @@ class AddressService
return []; return [];
} }
$this->logService->info('[AddressService] Début enrichissement avec bâtiments', [ LogService::info('[AddressService] Début enrichissement avec bâtiments', [
'entity_id' => $entityId, 'entity_id' => $entityId,
'nb_addresses' => count($addresses) 'nb_addresses' => count($addresses)
]); ]);
@@ -262,7 +259,7 @@ class AddressService
} }
} }
$this->logService->info('[AddressService] Fin enrichissement avec bâtiments', [ LogService::info('[AddressService] Fin enrichissement avec bâtiments', [
'total_adresses' => count($enrichedAddresses), 'total_adresses' => count($enrichedAddresses),
'nb_immeubles' => $nbImmeubles, 'nb_immeubles' => $nbImmeubles,
'nb_maisons' => $nbMaisons 'nb_maisons' => $nbMaisons
@@ -271,7 +268,7 @@ class AddressService
return $enrichedAddresses; return $enrichedAddresses;
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logService->error('[AddressService] Erreur lors de l\'enrichissement', [ LogService::error('[AddressService] Erreur lors de l\'enrichissement', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'trace' => $e->getTraceAsString() 'trace' => $e->getTraceAsString()
]); ]);

View File

@@ -231,7 +231,7 @@ class ApiService {
* @param int $minLength Longueur minimale du nom d'utilisateur (par défaut 10) * @param int $minLength Longueur minimale du nom d'utilisateur (par défaut 10)
* @return string Nom d'utilisateur généré * @return string Nom d'utilisateur généré
*/ */
public static function generateUserName(PDO $db, string $name, string $postalCode, string $cityName, int $minLength = 10): string { public static function generateUserName(\PDO $db, string $name, string $postalCode, string $cityName, int $minLength = 10): string {
// Nettoyer et préparer les chaînes // Nettoyer et préparer les chaînes
$name = preg_replace('/[^a-zA-Z0-9]/', '', strtolower($name)); $name = preg_replace('/[^a-zA-Z0-9]/', '', strtolower($name));
$postalCode = preg_replace('/[^0-9]/', '', $postalCode); $postalCode = preg_replace('/[^0-9]/', '', $postalCode);
@@ -277,7 +277,7 @@ class ApiService {
// Vérifier si le nom d'utilisateur existe déjà // Vérifier si le nom d'utilisateur existe déjà
$stmt = $db->prepare('SELECT COUNT(*) as count FROM users WHERE encrypted_user_name = ?'); $stmt = $db->prepare('SELECT COUNT(*) as count FROM users WHERE encrypted_user_name = ?');
$stmt->execute([$encryptedUsername]); $stmt->execute([$encryptedUsername]);
$result = $stmt->fetch(PDO::FETCH_ASSOC); $result = $stmt->fetch(\PDO::FETCH_ASSOC);
if ($result && $result['count'] == 0) { if ($result && $result['count'] == 0) {
$isUnique = true; $isUnique = true;

View File

@@ -14,9 +14,9 @@ class EmailTemplates {
Votre compte a été créé avec succès sur <b>GeoSector</b>.<br><br> Votre compte a été créé avec succès sur <b>GeoSector</b>.<br><br>
<b>Identifiant :</b> $username<br> <b>Identifiant :</b> $username<br>
<b>Mot de passe :</b> $password<br><br> <b>Mot de passe :</b> $password<br><br>
Vous pouvez vous connecter dès maintenant sur <a href=\"https://app3.geosector.fr\">app3.geosector.fr</a><br><br>
À très bientôt,<br> À très bientôt,<br>
L'équipe GeoSector"; L'équipe GeoSector<br>
<span style='color:#666; font-size:12px;'>Support : support@geosector.fr</span>";
} }
/** /**
@@ -80,9 +80,9 @@ class EmailTemplates {
Bonjour $name,<br><br> Bonjour $name,<br><br>
Vous avez demandé la réinitialisation de votre mot de passe sur <b>GeoSector</b>.<br><br> Vous avez demandé la réinitialisation de votre mot de passe sur <b>GeoSector</b>.<br><br>
<b>Nouveau mot de passe :</b> $password<br><br> <b>Nouveau mot de passe :</b> $password<br><br>
Vous pouvez vous connecter avec ce nouveau mot de passe sur <a href=\"https://app3.geosector.fr\">app3.geosector.fr</a><br><br>
À très bientôt,<br> À très bientôt,<br>
L'équipe GeoSector"; L'équipe GeoSector<br>
<span style='color:#666; font-size:12px;'>Support : support@geosector.fr</span>";
} }
/** /**

View File

@@ -305,6 +305,141 @@ class EventLogService
]); ]);
} }
// ==================== MÉTHODES STRIPE ====================
/**
* Log la création d'un PaymentIntent
*
* @param string $paymentIntentId ID Stripe du PaymentIntent
* @param int $passageId ID du passage
* @param int $amount Montant en centimes
* @param string $method Méthode (tap_to_pay, qr_code, web)
*/
public static function logStripePaymentCreated(
string $paymentIntentId,
int $passageId,
int $amount,
string $method
): void {
$entityId = Session::getEntityId();
self::writeEvent('stripe_payment_created', [
'payment_intent_id' => $paymentIntentId,
'passage_id' => $passageId,
'entity_id' => $entityId,
'amount' => $amount,
'method' => $method
]);
}
/**
* Log un paiement Stripe réussi
*
* @param string $paymentIntentId ID Stripe du PaymentIntent
* @param int $passageId ID du passage
* @param int $amount Montant en centimes
* @param string $method Méthode (tap_to_pay, qr_code, web)
*/
public static function logStripePaymentSuccess(
string $paymentIntentId,
int $passageId,
int $amount,
string $method
): void {
$entityId = Session::getEntityId();
self::writeEvent('stripe_payment_success', [
'payment_intent_id' => $paymentIntentId,
'passage_id' => $passageId,
'entity_id' => $entityId,
'amount' => $amount,
'method' => $method
]);
}
/**
* Log un paiement Stripe échoué
*
* @param string|null $paymentIntentId ID Stripe (peut être null si création échouée)
* @param int|null $passageId ID du passage (peut être null)
* @param int|null $amount Montant en centimes (peut être null)
* @param string $method Méthode tentée
* @param string $errorCode Code d'erreur
* @param string $errorMessage Message d'erreur
*/
public static function logStripePaymentFailed(
?string $paymentIntentId,
?int $passageId,
?int $amount,
string $method,
string $errorCode,
string $errorMessage
): void {
$entityId = Session::getEntityId();
$data = [
'entity_id' => $entityId,
'method' => $method,
'error_code' => $errorCode,
'error_message' => $errorMessage
];
if ($paymentIntentId !== null) {
$data['payment_intent_id'] = $paymentIntentId;
}
if ($passageId !== null) {
$data['passage_id'] = $passageId;
}
if ($amount !== null) {
$data['amount'] = $amount;
}
self::writeEvent('stripe_payment_failed', $data);
}
/**
* Log l'annulation d'un paiement Stripe
*
* @param string $paymentIntentId ID Stripe du PaymentIntent
* @param int|null $passageId ID du passage (peut être null)
* @param string $reason Raison (user_cancelled, timeout, error, etc.)
*/
public static function logStripePaymentCancelled(
string $paymentIntentId,
?int $passageId,
string $reason
): void {
$entityId = Session::getEntityId();
$data = [
'payment_intent_id' => $paymentIntentId,
'entity_id' => $entityId,
'reason' => $reason
];
if ($passageId !== null) {
$data['passage_id'] = $passageId;
}
self::writeEvent('stripe_payment_cancelled', $data);
}
/**
* Log une erreur du Terminal Tap to Pay
*
* @param string $errorCode Code d'erreur (cardReadTimedOut, device_not_compatible, etc.)
* @param string $errorMessage Message d'erreur
* @param array $metadata Métadonnées supplémentaires (device_model, is_simulated, etc.)
*/
public static function logStripeTerminalError(
string $errorCode,
string $errorMessage,
array $metadata = []
): void {
$entityId = Session::getEntityId();
self::writeEvent('stripe_terminal_error', array_merge([
'entity_id' => $entityId,
'error_code' => $errorCode,
'error_message' => $errorMessage
], $metadata));
}
// ==================== MÉTHODES OPÉRATIONS ==================== // ==================== MÉTHODES OPÉRATIONS ====================
/** /**

View File

@@ -0,0 +1,535 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Database;
use PDO;
/**
* EventStatsService - Service de statistiques d'événements
*
* Fournit des méthodes pour récupérer les statistiques agrégées
* depuis la table event_stats_daily et le détail depuis les fichiers JSONL.
*
* @see docs/TECHBOOK.md section "Statistiques Events"
*/
class EventStatsService
{
/** @var string Chemin du dossier des logs événements */
private const EVENT_LOG_DIR = __DIR__ . '/../../logs/events';
/** @var int Limite max pour le détail */
private const MAX_DETAILS_LIMIT = 100;
/** @var PDO Instance de la base de données */
private PDO $db;
public function __construct()
{
$this->db = Database::getInstance();
}
// ==================== MÉTHODES PUBLIQUES ====================
/**
* Récupère le résumé des stats pour une date donnée
*
* @param int|null $entityId ID entité (null = toutes entités pour super-admin)
* @param string|null $date Date (YYYY-MM-DD), défaut = aujourd'hui
* @return array Stats résumées par catégorie
*/
public function getSummary(?int $entityId, ?string $date = null): array
{
$date = $date ?? date('Y-m-d');
$sql = "
SELECT event_type, count, sum_amount, unique_users, metadata
FROM event_stats_daily
WHERE stat_date = :date
";
$params = ['date' => $date];
if ($entityId !== null) {
$sql .= " AND entity_id = :entity_id";
$params['entity_id'] = $entityId;
} else {
$sql .= " AND entity_id IS NULL";
}
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return $this->formatSummary($date, $rows);
}
/**
* Récupère les stats journalières sur une plage de dates
*
* @param int|null $entityId ID entité (null = toutes entités)
* @param string $from Date début (YYYY-MM-DD)
* @param string $to Date fin (YYYY-MM-DD)
* @param array $eventTypes Filtrer par types d'événements (optionnel)
* @return array Stats par jour
*/
public function getDaily(?int $entityId, string $from, string $to, array $eventTypes = []): array
{
$sql = "
SELECT stat_date, event_type, count, sum_amount, unique_users
FROM event_stats_daily
WHERE stat_date BETWEEN :from AND :to
";
$params = ['from' => $from, 'to' => $to];
if ($entityId !== null) {
$sql .= " AND entity_id = :entity_id";
$params['entity_id'] = $entityId;
} else {
$sql .= " AND entity_id IS NULL";
}
if (!empty($eventTypes)) {
$placeholders = [];
foreach ($eventTypes as $i => $type) {
$key = "event_type_{$i}";
$placeholders[] = ":{$key}";
$params[$key] = $type;
}
$sql .= " AND event_type IN (" . implode(', ', $placeholders) . ")";
}
$sql .= " ORDER BY stat_date ASC, event_type ASC";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return $this->formatDaily($rows);
}
/**
* Récupère les stats hebdomadaires (calculées depuis daily)
*
* @param int|null $entityId ID entité
* @param string $from Date début
* @param string $to Date fin
* @param array $eventTypes Filtrer par types d'événements
* @return array Stats par semaine
*/
public function getWeekly(?int $entityId, string $from, string $to, array $eventTypes = []): array
{
$daily = $this->getDaily($entityId, $from, $to, $eventTypes);
$weekly = [];
foreach ($daily as $day) {
$date = new \DateTime($day['date']);
$weekStart = clone $date;
$weekStart->modify('monday this week');
$weekKey = $weekStart->format('Y-m-d');
if (!isset($weekly[$weekKey])) {
$weekly[$weekKey] = [
'week_start' => $weekKey,
'week_number' => (int) $date->format('W'),
'year' => (int) $date->format('Y'),
'events' => [],
'totals' => [
'count' => 0,
'sum_amount' => 0.0,
],
];
}
// Agréger les événements
foreach ($day['events'] as $eventType => $stats) {
if (!isset($weekly[$weekKey]['events'][$eventType])) {
$weekly[$weekKey]['events'][$eventType] = [
'count' => 0,
'sum_amount' => 0.0,
'unique_users' => 0,
];
}
$weekly[$weekKey]['events'][$eventType]['count'] += $stats['count'];
$weekly[$weekKey]['events'][$eventType]['sum_amount'] += $stats['sum_amount'];
$weekly[$weekKey]['events'][$eventType]['unique_users'] += $stats['unique_users'];
}
$weekly[$weekKey]['totals']['count'] += $day['totals']['count'];
$weekly[$weekKey]['totals']['sum_amount'] += $day['totals']['sum_amount'];
}
return array_values($weekly);
}
/**
* Récupère les stats mensuelles (calculées depuis daily)
*
* @param int|null $entityId ID entité
* @param int $year Année
* @param array $eventTypes Filtrer par types d'événements
* @return array Stats par mois
*/
public function getMonthly(?int $entityId, int $year, array $eventTypes = []): array
{
$from = "{$year}-01-01";
$to = "{$year}-12-31";
$daily = $this->getDaily($entityId, $from, $to, $eventTypes);
$monthly = [];
foreach ($daily as $day) {
$monthKey = substr($day['date'], 0, 7); // YYYY-MM
if (!isset($monthly[$monthKey])) {
$monthly[$monthKey] = [
'month' => $monthKey,
'year' => $year,
'month_number' => (int) substr($monthKey, 5, 2),
'events' => [],
'totals' => [
'count' => 0,
'sum_amount' => 0.0,
],
];
}
// Agréger les événements
foreach ($day['events'] as $eventType => $stats) {
if (!isset($monthly[$monthKey]['events'][$eventType])) {
$monthly[$monthKey]['events'][$eventType] = [
'count' => 0,
'sum_amount' => 0.0,
'unique_users' => 0,
];
}
$monthly[$monthKey]['events'][$eventType]['count'] += $stats['count'];
$monthly[$monthKey]['events'][$eventType]['sum_amount'] += $stats['sum_amount'];
$monthly[$monthKey]['events'][$eventType]['unique_users'] += $stats['unique_users'];
}
$monthly[$monthKey]['totals']['count'] += $day['totals']['count'];
$monthly[$monthKey]['totals']['sum_amount'] += $day['totals']['sum_amount'];
}
return array_values($monthly);
}
/**
* Récupère le détail des événements depuis le fichier JSONL
*
* @param int|null $entityId ID entité (null = tous)
* @param string $date Date (YYYY-MM-DD)
* @param string|null $eventType Filtrer par type d'événement
* @param int $limit Nombre max de résultats
* @param int $offset Décalage pour pagination
* @return array Événements détaillés avec pagination
*/
public function getDetails(
?int $entityId,
string $date,
?string $eventType = null,
int $limit = 50,
int $offset = 0
): array {
$limit = min($limit, self::MAX_DETAILS_LIMIT);
$filePath = self::EVENT_LOG_DIR . '/' . $date . '.jsonl';
if (!file_exists($filePath)) {
return [
'date' => $date,
'events' => [],
'pagination' => [
'total' => 0,
'limit' => $limit,
'offset' => $offset,
'has_more' => false,
],
];
}
$events = [];
$total = 0;
$currentIndex = 0;
$handle = fopen($filePath, 'r');
if (!$handle) {
return [
'date' => $date,
'events' => [],
'pagination' => [
'total' => 0,
'limit' => $limit,
'offset' => $offset,
'has_more' => false,
],
'error' => 'Impossible de lire le fichier',
];
}
while (($line = fgets($handle)) !== false) {
$line = trim($line);
if (empty($line)) {
continue;
}
$event = json_decode($line, true);
if (!$event || !isset($event['event'])) {
continue;
}
// Filtrer par entity_id
if ($entityId !== null) {
$eventEntityId = $event['entity_id'] ?? null;
if ($eventEntityId !== $entityId) {
continue;
}
}
// Filtrer par event_type
if ($eventType !== null && ($event['event'] ?? '') !== $eventType) {
continue;
}
$total++;
// Pagination
if ($currentIndex >= $offset && count($events) < $limit) {
$events[] = $this->sanitizeEventForOutput($event);
}
$currentIndex++;
}
fclose($handle);
return [
'date' => $date,
'events' => $events,
'pagination' => [
'total' => $total,
'limit' => $limit,
'offset' => $offset,
'has_more' => ($offset + $limit) < $total,
],
];
}
/**
* Récupère les types d'événements disponibles
*
* @return array Liste des types d'événements
*/
public function getEventTypes(): array
{
return [
'auth' => ['login_success', 'login_failed', 'logout'],
'passages' => ['passage_created', 'passage_updated', 'passage_deleted'],
'sectors' => ['sector_created', 'sector_updated', 'sector_deleted'],
'users' => ['user_created', 'user_updated', 'user_deleted'],
'entities' => ['entity_created', 'entity_updated', 'entity_deleted'],
'operations' => ['operation_created', 'operation_updated', 'operation_deleted'],
'stripe' => [
'stripe_payment_created',
'stripe_payment_success',
'stripe_payment_failed',
'stripe_payment_cancelled',
'stripe_terminal_error',
],
];
}
/**
* Vérifie si des stats existent pour une date
*
* @param string $date Date à vérifier
* @return bool
*/
public function hasStatsForDate(string $date): bool
{
$stmt = $this->db->prepare("
SELECT COUNT(*) FROM event_stats_daily WHERE stat_date = :date
");
$stmt->execute(['date' => $date]);
return (int) $stmt->fetchColumn() > 0;
}
// ==================== MÉTHODES PRIVÉES ====================
/**
* Formate le résumé des stats par catégorie
*/
private function formatSummary(string $date, array $rows): array
{
$summary = [
'date' => $date,
'stats' => [
'auth' => ['success' => 0, 'failed' => 0, 'logout' => 0],
'passages' => ['created' => 0, 'updated' => 0, 'deleted' => 0, 'amount' => 0.0],
'users' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
'sectors' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
'entities' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
'operations' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
'stripe' => ['created' => 0, 'success' => 0, 'failed' => 0, 'cancelled' => 0, 'amount' => 0.0],
],
'totals' => [
'events' => 0,
'unique_users' => 0,
],
];
$uniqueUsersSet = [];
foreach ($rows as $row) {
$eventType = $row['event_type'];
$count = (int) $row['count'];
$amount = (float) $row['sum_amount'];
$uniqueUsers = (int) $row['unique_users'];
$summary['totals']['events'] += $count;
$uniqueUsersSet[$eventType] = $uniqueUsers;
// Mapper vers les catégories
switch ($eventType) {
case 'login_success':
$summary['stats']['auth']['success'] = $count;
break;
case 'login_failed':
$summary['stats']['auth']['failed'] = $count;
break;
case 'logout':
$summary['stats']['auth']['logout'] = $count;
break;
case 'passage_created':
$summary['stats']['passages']['created'] = $count;
$summary['stats']['passages']['amount'] += $amount;
break;
case 'passage_updated':
$summary['stats']['passages']['updated'] = $count;
break;
case 'passage_deleted':
$summary['stats']['passages']['deleted'] = $count;
break;
case 'user_created':
$summary['stats']['users']['created'] = $count;
break;
case 'user_updated':
$summary['stats']['users']['updated'] = $count;
break;
case 'user_deleted':
$summary['stats']['users']['deleted'] = $count;
break;
case 'sector_created':
$summary['stats']['sectors']['created'] = $count;
break;
case 'sector_updated':
$summary['stats']['sectors']['updated'] = $count;
break;
case 'sector_deleted':
$summary['stats']['sectors']['deleted'] = $count;
break;
case 'entity_created':
$summary['stats']['entities']['created'] = $count;
break;
case 'entity_updated':
$summary['stats']['entities']['updated'] = $count;
break;
case 'entity_deleted':
$summary['stats']['entities']['deleted'] = $count;
break;
case 'operation_created':
$summary['stats']['operations']['created'] = $count;
break;
case 'operation_updated':
$summary['stats']['operations']['updated'] = $count;
break;
case 'operation_deleted':
$summary['stats']['operations']['deleted'] = $count;
break;
case 'stripe_payment_created':
$summary['stats']['stripe']['created'] = $count;
break;
case 'stripe_payment_success':
$summary['stats']['stripe']['success'] = $count;
$summary['stats']['stripe']['amount'] += $amount;
break;
case 'stripe_payment_failed':
$summary['stats']['stripe']['failed'] = $count;
break;
case 'stripe_payment_cancelled':
$summary['stats']['stripe']['cancelled'] = $count;
break;
}
}
// Estimation des utilisateurs uniques (max des catégories car overlap possible)
$summary['totals']['unique_users'] = !empty($uniqueUsersSet) ? max($uniqueUsersSet) : 0;
return $summary;
}
/**
* Formate les stats journalières
*/
private function formatDaily(array $rows): array
{
$daily = [];
foreach ($rows as $row) {
$date = $row['stat_date'];
if (!isset($daily[$date])) {
$daily[$date] = [
'date' => $date,
'events' => [],
'totals' => [
'count' => 0,
'sum_amount' => 0.0,
],
];
}
$eventType = $row['event_type'];
$count = (int) $row['count'];
$amount = (float) $row['sum_amount'];
$daily[$date]['events'][$eventType] = [
'count' => $count,
'sum_amount' => $amount,
'unique_users' => (int) $row['unique_users'],
];
$daily[$date]['totals']['count'] += $count;
$daily[$date]['totals']['sum_amount'] += $amount;
}
return array_values($daily);
}
/**
* Nettoie un événement pour l'affichage (supprime données sensibles)
*/
private function sanitizeEventForOutput(array $event): array
{
// Supprimer l'IP complète, garder seulement les 2 premiers octets
if (isset($event['ip'])) {
$parts = explode('.', $event['ip']);
if (count($parts) === 4) {
$event['ip'] = $parts[0] . '.' . $parts[1] . '.x.x';
}
}
// Supprimer le user_agent complet
unset($event['user_agent']);
// Supprimer les données chiffrées si présentes
unset($event['encrypted_name']);
unset($event['encrypted_email']);
return $event;
}
}

View File

@@ -58,7 +58,7 @@ class ExportService {
$filepath = $exportDir . '/' . $filename; $filepath = $exportDir . '/' . $filename;
// Créer le spreadsheet // Créer le spreadsheet
$spreadsheet = new PhpOffice\PhpSpreadsheet\Spreadsheet(); $spreadsheet = new Spreadsheet();
// Insérer les données // Insérer les données
$this->createPassagesSheet($spreadsheet, $operationId, $userId); $this->createPassagesSheet($spreadsheet, $operationId, $userId);
@@ -283,11 +283,11 @@ class ExportService {
$dateEve = $passage['passed_at'] ? date('d/m/Y', strtotime($passage['passed_at'])) : ''; $dateEve = $passage['passed_at'] ? date('d/m/Y', strtotime($passage['passed_at'])) : '';
$heureEve = $passage['passed_at'] ? date('H:i', strtotime($passage['passed_at'])) : ''; $heureEve = $passage['passed_at'] ? date('H:i', strtotime($passage['passed_at'])) : '';
// Déchiffrer les données // Déchiffrer les données (avec vérification null)
$donateur = ApiService::decryptData($passage['encrypted_name']); $donateur = !empty($passage['encrypted_name']) ? ApiService::decryptData($passage['encrypted_name']) : '';
$email = !empty($passage['encrypted_email']) ? ApiService::decryptSearchableData($passage['encrypted_email']) : ''; $email = !empty($passage['encrypted_email']) ? ApiService::decryptSearchableData($passage['encrypted_email']) : '';
$phone = !empty($passage['encrypted_phone']) ? ApiService::decryptData($passage['encrypted_phone']) : ''; $phone = !empty($passage['encrypted_phone']) ? ApiService::decryptData($passage['encrypted_phone']) : '';
$userName = ApiService::decryptData($passage['user_name']); $userName = !empty($passage['user_name']) ? ApiService::decryptData($passage['user_name']) : '';
// Type de passage // Type de passage
$typeLabels = [ $typeLabels = [
@@ -382,7 +382,7 @@ class ExportService {
foreach ($users as $user) { foreach ($users as $user) {
$rowData = [ $rowData = [
$user['id'], $user['id'],
ApiService::decryptData($user['encrypted_name']), !empty($user['encrypted_name']) ? ApiService::decryptData($user['encrypted_name']) : '',
$user['first_name'], $user['first_name'],
!empty($user['encrypted_email']) ? ApiService::decryptSearchableData($user['encrypted_email']) : '', !empty($user['encrypted_email']) ? ApiService::decryptSearchableData($user['encrypted_email']) : '',
!empty($user['encrypted_phone']) ? ApiService::decryptData($user['encrypted_phone']) : '', !empty($user['encrypted_phone']) ? ApiService::decryptData($user['encrypted_phone']) : '',
@@ -480,7 +480,7 @@ class ExportService {
$row = 2; $row = 2;
foreach ($userSectors as $us) { foreach ($userSectors as $us) {
$userName = ApiService::decryptData($us['user_name']); $userName = !empty($us['user_name']) ? ApiService::decryptData($us['user_name']) : '';
$fullUserName = $us['first_name'] ? $us['first_name'] . ' ' . $userName : $userName; $fullUserName = $us['first_name'] ? $us['first_name'] . ' ' . $userName : $userName;
$rowData = [ $rowData = [
@@ -690,11 +690,11 @@ class ExportService {
$dateEve = $p["passed_at"] ? date("d/m/Y", strtotime($p["passed_at"])) : ""; $dateEve = $p["passed_at"] ? date("d/m/Y", strtotime($p["passed_at"])) : "";
$heureEve = $p["passed_at"] ? date("H:i", strtotime($p["passed_at"])) : ""; $heureEve = $p["passed_at"] ? date("H:i", strtotime($p["passed_at"])) : "";
// Déchiffrer les données // Déchiffrer les données (avec vérification null)
$donateur = ApiService::decryptData($p["encrypted_name"]); $donateur = !empty($p["encrypted_name"]) ? ApiService::decryptData($p["encrypted_name"]) : "";
$email = !empty($p["encrypted_email"]) ? ApiService::decryptSearchableData($p["encrypted_email"]) : ""; $email = !empty($p["encrypted_email"]) ? ApiService::decryptSearchableData($p["encrypted_email"]) : "";
$phone = !empty($p["encrypted_phone"]) ? ApiService::decryptData($p["encrypted_phone"]) : ""; $phone = !empty($p["encrypted_phone"]) ? ApiService::decryptData($p["encrypted_phone"]) : "";
$userName = ApiService::decryptData($p["user_name"]); $userName = !empty($p["user_name"]) ? ApiService::decryptData($p["user_name"]) : "";
// Nettoyer les données (comme dans l'ancienne version) // Nettoyer les données (comme dans l'ancienne version)
$nom = str_replace("/", "-", $userName); $nom = str_replace("/", "-", $userName);

View File

@@ -8,6 +8,12 @@ use AppConfig;
use ClientDetector; use ClientDetector;
class LogService { class LogService {
/** @var int Permissions du dossier */
private const DIR_PERMISSIONS = 0750;
/** @var int Permissions des fichiers */
private const FILE_PERMISSIONS = 0640;
public static function log(string $message, array $metadata = []): void { public static function log(string $message, array $metadata = []): void {
// Obtenir les informations client via ClientDetector // Obtenir les informations client via ClientDetector
$clientInfo = ClientDetector::getClientInfo(); $clientInfo = ClientDetector::getClientInfo();
@@ -67,12 +73,10 @@ class LogService {
// Créer le dossier logs s'il n'existe pas // Créer le dossier logs s'il n'existe pas
if (!is_dir($logDir)) { if (!is_dir($logDir)) {
if (!mkdir($logDir, 0777, true)) { if (!mkdir($logDir, self::DIR_PERMISSIONS, true)) {
error_log("Impossible de créer le dossier de logs: {$logDir}"); error_log("Impossible de créer le dossier de logs: {$logDir}");
return; // Sortir de la fonction si on ne peut pas créer le dossier return; // Sortir de la fonction si on ne peut pas créer le dossier
} }
// S'assurer que les permissions sont correctes
chmod($logDir, 0777);
} }
// Vérifier si le dossier est accessible en écriture // Vérifier si le dossier est accessible en écriture
@@ -139,26 +143,29 @@ class LogService {
$message $message
]) . "\n"; ]) . "\n";
// Écrire dans le fichier avec gestion d'erreur // Écrire dans le fichier avec gestion d'erreur et verrouillage
if (file_put_contents($filename, $logLine, FILE_APPEND) === false) { if (file_put_contents($filename, $logLine, FILE_APPEND | LOCK_EX) === false) {
error_log("Impossible d'écrire dans le fichier de logs: {$filename}"); error_log("Impossible d'écrire dans le fichier de logs: {$filename}");
} else {
// Appliquer les permissions au fichier
@chmod($filename, self::FILE_PERMISSIONS);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
error_log("Erreur lors de l'écriture des logs: " . $e->getMessage()); error_log("Erreur lors de l'écriture des logs: " . $e->getMessage());
} }
} }
public function info(string $message, array $metadata = []): void { public static function info(string $message, array $metadata = []): void {
$metadata['level'] = 'info'; $metadata['level'] = 'info';
self::log($message, $metadata); self::log($message, $metadata);
} }
public function warning(string $message, array $metadata = []): void { public static function warning(string $message, array $metadata = []): void {
$metadata['level'] = 'warning'; $metadata['level'] = 'warning';
self::log($message, $metadata); self::log($message, $metadata);
} }
public function error(string $message, array $metadata = []): void { public static function error(string $message, array $metadata = []): void {
$metadata['level'] = 'error'; $metadata['level'] = 'error';
self::log($message, $metadata); self::log($message, $metadata);
} }

View File

@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Database;
use PDO;
/**
* Service global pour la gestion des secteurs
*
* Fournit des fonctions réutilisables pour :
* - Géocoder une adresse via api-adresse.data.gouv.fr
* - Trouver un secteur à partir de coordonnées GPS
* - Trouver un secteur à partir d'une adresse
*/
class SectorService
{
private PDO $db;
public function __construct()
{
$this->db = Database::getInstance();
}
/**
* Géocode une adresse via api-adresse.data.gouv.fr
*
* @param string $num Numéro de rue
* @param string $bis Complément (bis, ter, etc.)
* @param string $rue Nom de la rue
* @param string $ville Nom de la ville
* @param string $cp Code postal (pour vérifier le département)
* @return array|null [lat, lng] ou null si non trouvé ou score trop faible
*/
public function geocodeAddress(string $num, string $bis, string $rue, string $ville, string $cp): ?array
{
try {
// Construire l'URL de l'API
$query = trim($num . $bis) . ' ' . $rue . ' ' . $ville;
$url = 'https://api-adresse.data.gouv.fr/search/?q=' . urlencode($query);
LogService::info('[SectorService] Géocodage adresse', [
'url' => $url,
'adresse' => $query
]);
// Appel à l'API
$ch = curl_init();
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$json = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || empty($json)) {
LogService::warning('[SectorService] Erreur API géocodage', [
'http_code' => $httpCode,
'adresse' => $query
]);
return null;
}
$data = json_decode($json);
if (empty($data->features)) {
LogService::info('[SectorService] Aucun résultat de géocodage', [
'adresse' => $query
]);
return null;
}
$score = $data->features[0]->properties->score ?? 0;
// Vérifier le score (> 0.7 = 70% de confiance)
if (floatval($score) <= 0.7) {
LogService::info('[SectorService] Score géocodage trop faible', [
'score' => $score,
'adresse' => $query
]);
return null;
}
// Vérifier le département
$cpTrouve = $data->features[0]->properties->postcode ?? '';
$deptTrouve = substr($cpTrouve, 0, 2);
$cpAmicale = $cp;
if (strlen($cpAmicale) == 4) {
$cpAmicale = '0' . $cpAmicale;
}
$deptAmicale = substr($cpAmicale, 0, 2);
if ($deptTrouve !== $deptAmicale) {
LogService::warning('[SectorService] Département différent', [
'dept_trouve' => $deptTrouve,
'dept_attendu' => $deptAmicale,
'adresse' => $query
]);
return null;
}
// Extraire les coordonnées [lng, lat] -> [lat, lng]
$coordinates = $data->features[0]->geometry->coordinates;
$lat = (float)$coordinates[1];
$lng = (float)$coordinates[0];
LogService::info('[SectorService] Géocodage réussi', [
'lat' => $lat,
'lng' => $lng,
'score' => $score,
'adresse' => $query
]);
return ['lat' => $lat, 'lng' => $lng];
} catch (\Exception $e) {
LogService::error('[SectorService] Erreur géocodage', [
'error' => $e->getMessage(),
'adresse' => "$num$bis $rue $ville"
]);
return null;
}
}
/**
* Trouve le secteur contenant une position GPS pour une opération donnée
*
* @param int $operationId ID de l'opération
* @param float $lat Latitude
* @param float $lng Longitude
* @return int|null ID du secteur trouvé ou null
*/
public function findSectorByGps(int $operationId, float $lat, float $lng): ?int
{
try {
// Récupérer tous les secteurs de l'opération avec leur polygone
$query = "SELECT id, sector FROM ope_sectors
WHERE fk_operation = :operation_id
AND chk_active = 1
AND sector IS NOT NULL
AND sector != ''";
$stmt = $this->db->prepare($query);
$stmt->execute(['operation_id' => $operationId]);
$sectors = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($sectors)) {
LogService::info('[SectorService] Aucun secteur trouvé pour l\'opération', [
'operation_id' => $operationId
]);
return null;
}
// Tester chaque secteur
foreach ($sectors as $sector) {
$polygon = $this->parseSectorPolygon($sector['sector']);
if (empty($polygon)) {
continue;
}
if ($this->isPointInsidePolygon($lat, $lng, $polygon)) {
LogService::info('[SectorService] Secteur trouvé par GPS', [
'sector_id' => $sector['id'],
'operation_id' => $operationId,
'lat' => $lat,
'lng' => $lng
]);
return (int)$sector['id'];
}
}
LogService::info('[SectorService] Aucun secteur ne contient ce point GPS', [
'operation_id' => $operationId,
'lat' => $lat,
'lng' => $lng,
'nb_sectors_tested' => count($sectors)
]);
return null;
} catch (\Exception $e) {
LogService::error('[SectorService] Erreur findSectorByGps', [
'error' => $e->getMessage(),
'operation_id' => $operationId,
'lat' => $lat,
'lng' => $lng
]);
return null;
}
}
/**
* Trouve le secteur pour une adresse (géocodage + recherche GPS)
*
* @param int $operationId ID de l'opération
* @param string $num Numéro de rue
* @param string $bis Complément
* @param string $rue Nom de la rue
* @param string $ville Nom de la ville
* @param string $cp Code postal
* @return array|null ['sector_id' => int, 'gps_lat' => float, 'gps_lng' => float] ou null
*/
public function findSectorByAddress(int $operationId, string $num, string $bis, string $rue, string $ville, string $cp): ?array
{
// Étape 1 : Géocoder l'adresse
$coords = $this->geocodeAddress($num, $bis, $rue, $ville, $cp);
if (!$coords) {
return null;
}
// Étape 2 : Chercher le secteur avec les coordonnées obtenues
$sectorId = $this->findSectorByGps($operationId, $coords['lat'], $coords['lng']);
if (!$sectorId) {
// Retourner quand même les coordonnées GPS trouvées (utiles pour mettre à jour le passage)
return [
'sector_id' => null,
'gps_lat' => $coords['lat'],
'gps_lng' => $coords['lng']
];
}
return [
'sector_id' => $sectorId,
'gps_lat' => $coords['lat'],
'gps_lng' => $coords['lng']
];
}
/**
* Parse le format de polygone stocké en base (lat/lng#lat/lng#...)
*
* @param string $sectorString Format "lat/lng#lat/lng#..."
* @return array Array de ['lat' => float, 'lng' => float]
*/
private function parseSectorPolygon(string $sectorString): array
{
$polygon = [];
$points = explode('#', rtrim($sectorString, '#'));
foreach ($points as $point) {
if (!empty($point) && strpos($point, '/') !== false) {
list($lat, $lng) = explode('/', $point);
$polygon[] = [
'lat' => (float)$lat,
'lng' => (float)$lng
];
}
}
return $polygon;
}
/**
* Vérifie si un point est à l'intérieur d'un polygone
* Utilise l'algorithme de ray casting
*
* @param float $lat Latitude du point
* @param float $lng Longitude du point
* @param array $polygon Array de ['lat' => float, 'lng' => float]
* @return bool
*/
private function isPointInsidePolygon(float $lat, float $lng, array $polygon): bool
{
$x = $lat;
$y = $lng;
$inside = false;
$count = count($polygon);
for ($i = 0, $j = $count - 1; $i < $count; $j = $i++) {
$xi = $polygon[$i]['lat'];
$yi = $polygon[$i]['lng'];
$xj = $polygon[$j]['lat'];
$yj = $polygon[$j]['lng'];
$intersect = (($yi > $y) != ($yj > $y))
&& ($x < ($xj - $xi) * ($y - $yi) / ($yj - $yi) + $xi);
if ($intersect) {
$inside = !$inside;
}
}
return $inside;
}
}

View File

@@ -465,6 +465,7 @@ class StripeService {
$entiteId = $params['fk_entite'] ?? 0; $entiteId = $params['fk_entite'] ?? 0;
$userId = $params['fk_user'] ?? 0; $userId = $params['fk_user'] ?? 0;
$metadata = $params['metadata'] ?? []; $metadata = $params['metadata'] ?? [];
$paymentMethodTypes = $params['payment_method_types'] ?? ['card_present'];
if ($amount < 100) { if ($amount < 100) {
throw new Exception("Le montant minimum est de 1€"); throw new Exception("Le montant minimum est de 1€");
@@ -481,117 +482,51 @@ class StripeService {
throw new Exception("Compte Stripe non trouvé"); throw new Exception("Compte Stripe non trouvé");
} }
// Pas de commission plateforme - 100% pour l'amicale // Déterminer le mode : Tap to Pay (card_present) ou Payment Link (card)
$isTapToPay = in_array('card_present', $paymentMethodTypes);
// Créer le PaymentIntent sans commission // Configuration du PaymentIntent selon le mode
$paymentIntent = $this->stripe->paymentIntents->create([ $paymentIntentData = [
'amount' => $amount, 'amount' => $amount,
'currency' => 'eur', 'currency' => 'eur',
'payment_method_types' => ['card_present'], 'payment_method_types' => $paymentMethodTypes,
'capture_method' => 'automatic', 'capture_method' => 'automatic',
// Pas d'application_fee_amount - tout va à l'amicale
'transfer_data' => [
'destination' => $account['stripe_account_id'],
],
'metadata' => array_merge($metadata, [ 'metadata' => array_merge($metadata, [
'entite_id' => $entiteId, 'entite_id' => $entiteId,
'user_id' => $userId, 'user_id' => $userId,
'calendrier_annee' => date('Y'), 'calendrier_annee' => date('Y'),
]), ]),
]); ];
// Sauvegarder en base // Options Stripe (avec ou sans stripe_account)
$stmt = $this->db->prepare( $stripeOptions = [];
"INSERT INTO stripe_payment_intents
(stripe_payment_intent_id, fk_entite, fk_user, amount, currency, status, application_fee, metadata, created_at) if ($isTapToPay) {
VALUES (:pi_id, :fk_entite, :fk_user, :amount, :currency, :status, :app_fee, :metadata, NOW())" // TAP TO PAY : Paiement direct sur le compte connecté
// Le PaymentIntent est créé sur le compte de l'amicale
$stripeOptions['stripe_account'] = $account['stripe_account_id'];
} else {
// PAYMENT LINK / WEB : Paiement via la plateforme avec transfert
// Le PaymentIntent est créé sur la plateforme et transféré
$paymentIntentData['transfer_data'] = [
'destination' => $account['stripe_account_id'],
];
}
// Créer le PaymentIntent
$paymentIntent = $this->stripe->paymentIntents->create(
$paymentIntentData,
$stripeOptions
); );
$stmt->execute([
'pi_id' => $paymentIntent->id, // Note : Le payment_intent_id est sauvegardé dans ope_pass.stripe_payment_id par le controller
'fk_entite' => $entiteId,
'fk_user' => $userId,
'amount' => $amount,
'currency' => 'eur',
'status' => $paymentIntent->status,
'app_fee' => 0, // Pas de commission
'metadata' => json_encode($metadata)
]);
return [ return [
'success' => true, 'success' => true,
'client_secret' => $paymentIntent->client_secret, 'client_secret' => $paymentIntent->client_secret,
'payment_intent_id' => $paymentIntent->id, 'payment_intent_id' => $paymentIntent->id,
'amount' => $amount, 'amount' => $amount,
'application_fee' => 0 // Pas de commission 'mode' => $isTapToPay ? 'tap_to_pay' : 'payment_link'
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Erreur: ' . $e->getMessage()
];
}
}
/**
* Vérifier la compatibilité Tap to Pay d'un appareil Android
*/
public function checkAndroidTapToPayCompatibility(string $manufacturer, string $model): array {
try {
$stmt = $this->db->prepare(
"SELECT * FROM stripe_android_certified_devices
WHERE manufacturer = :manufacturer
AND model = :model
AND tap_to_pay_certified = 1
AND country = 'FR'"
);
$stmt->execute([
'manufacturer' => $manufacturer,
'model' => $model
]);
$device = $stmt->fetch(PDO::FETCH_ASSOC);
if ($device) {
return [
'success' => true,
'tap_to_pay_supported' => true,
'message' => 'Tap to Pay disponible sur cet appareil',
'min_android_version' => $device['min_android_version']
];
}
return [
'success' => true,
'tap_to_pay_supported' => false,
'message' => 'Appareil non certifié pour Tap to Pay en France',
'alternative' => 'Utilisez un iPhone XS ou plus récent avec iOS 16.4+'
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Erreur: ' . $e->getMessage()
];
}
}
/**
* Récupérer les appareils Android certifiés
*/
public function getCertifiedAndroidDevices(): array {
try {
$stmt = $this->db->prepare(
"SELECT manufacturer, model, model_identifier, min_android_version
FROM stripe_android_certified_devices
WHERE tap_to_pay_certified = 1 AND country = 'FR'
ORDER BY manufacturer, model"
);
$stmt->execute();
return [
'success' => true,
'devices' => $stmt->fetchAll(PDO::FETCH_ASSOC)
]; ];
} catch (Exception $e) { } catch (Exception $e) {
@@ -747,6 +682,47 @@ class StripeService {
} }
} }
/**
* Annuler un PaymentIntent Stripe
*
* @param string $paymentIntentId L'ID du PaymentIntent à annuler
* @return array ['success' => bool, 'status' => string|null, 'message' => string|null]
*/
public function cancelPaymentIntent(string $paymentIntentId): array {
try {
// Annuler le PaymentIntent via l'API Stripe
$paymentIntent = $this->stripe->paymentIntents->cancel($paymentIntentId);
LogService::log('PaymentIntent annulé', [
'payment_intent_id' => $paymentIntentId,
'status' => $paymentIntent->status
]);
return [
'success' => true,
'status' => $paymentIntent->status,
'payment_intent_id' => $paymentIntentId
];
} catch (ApiErrorException $e) {
LogService::log('Erreur annulation PaymentIntent Stripe', [
'level' => 'error',
'payment_intent_id' => $paymentIntentId,
'error' => $e->getMessage()
]);
return [
'success' => false,
'message' => 'Erreur Stripe: ' . $e->getMessage()
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Erreur: ' . $e->getMessage()
];
}
}
/** /**
* Obtenir le mode actuel (test ou live) * Obtenir le mode actuel (test ou live)
*/ */

View File

@@ -9,7 +9,7 @@
}, },
{ {
"name": "_macros", "name": "_macros",
"rootUri": "file:///opt/flutter/bin/cache/dart-sdk/pkg/_macros", "rootUri": "file:///home/pierre/.local/flutter/bin/cache/dart-sdk/pkg/_macros",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
@@ -99,7 +99,7 @@
}, },
{ {
"name": "built_value", "name": "built_value",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/built_value-8.12.0", "rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/built_value-8.12.3",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.0" "languageVersion": "3.0"
}, },
@@ -169,6 +169,12 @@
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
{
"name": "cookie_jar",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/cookie_jar-4.0.8",
"packageUri": "lib/",
"languageVersion": "2.15"
},
{ {
"name": "cross_file", "name": "cross_file",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/cross_file-0.3.4+2", "rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/cross_file-0.3.4+2",
@@ -241,6 +247,12 @@
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "2.14" "languageVersion": "2.14"
}, },
{
"name": "dio_cookie_manager",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/dio_cookie_manager-3.3.0",
"packageUri": "lib/",
"languageVersion": "2.18"
},
{ {
"name": "dio_web_adapter", "name": "dio_web_adapter",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/dio_web_adapter-2.1.1", "rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/dio_web_adapter-2.1.1",
@@ -297,10 +309,16 @@
}, },
{ {
"name": "flutter", "name": "flutter",
"rootUri": "file:///opt/flutter/packages/flutter", "rootUri": "file:///home/pierre/.local/flutter/packages/flutter",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.3" "languageVersion": "3.3"
}, },
{
"name": "flutter_compass",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/flutter_compass-0.8.1",
"packageUri": "lib/",
"languageVersion": "2.12"
},
{ {
"name": "flutter_launcher_icons", "name": "flutter_launcher_icons",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/flutter_launcher_icons-0.14.4", "rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/flutter_launcher_icons-0.14.4",
@@ -339,7 +357,7 @@
}, },
{ {
"name": "flutter_localizations", "name": "flutter_localizations",
"rootUri": "file:///opt/flutter/packages/flutter_localizations", "rootUri": "file:///home/pierre/.local/flutter/packages/flutter_localizations",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.2" "languageVersion": "3.2"
}, },
@@ -375,13 +393,13 @@
}, },
{ {
"name": "flutter_test", "name": "flutter_test",
"rootUri": "file:///opt/flutter/packages/flutter_test", "rootUri": "file:///home/pierre/.local/flutter/packages/flutter_test",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.3" "languageVersion": "3.3"
}, },
{ {
"name": "flutter_web_plugins", "name": "flutter_web_plugins",
"rootUri": "file:///opt/flutter/packages/flutter_web_plugins", "rootUri": "file:///home/pierre/.local/flutter/packages/flutter_web_plugins",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.2" "languageVersion": "3.2"
}, },
@@ -483,7 +501,7 @@
}, },
{ {
"name": "http", "name": "http",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/http-1.5.0", "rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/http-1.6.0",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.4" "languageVersion": "3.4"
}, },
@@ -501,7 +519,7 @@
}, },
{ {
"name": "image", "name": "image",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/image-4.5.4", "rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/image-4.7.2",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.0" "languageVersion": "3.0"
}, },
@@ -873,7 +891,7 @@
}, },
{ {
"name": "sky_engine", "name": "sky_engine",
"rootUri": "file:///opt/flutter/bin/cache/pkg/sky_engine", "rootUri": "file:///home/pierre/.local/flutter/bin/cache/pkg/sky_engine",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.2" "languageVersion": "3.2"
}, },
@@ -1089,9 +1107,9 @@
}, },
{ {
"name": "watcher", "name": "watcher",
"rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/watcher-1.1.4", "rootUri": "file:///home/pierre/.pub-cache/hosted/pub.dev/watcher-1.2.1",
"packageUri": "lib/", "packageUri": "lib/",
"languageVersion": "3.1" "languageVersion": "3.4"
}, },
{ {
"name": "web", "name": "web",
@@ -1154,10 +1172,10 @@
"languageVersion": "3.0" "languageVersion": "3.0"
} }
], ],
"generated": "2025-11-09T17:40:58.673758Z", "generated": "2026-01-19T15:01:26.574661Z",
"generator": "pub", "generator": "pub",
"generatorVersion": "3.5.4", "generatorVersion": "3.5.4",
"flutterRoot": "file:///opt/flutter", "flutterRoot": "file:///home/pierre/.local/flutter",
"flutterVersion": "3.24.5", "flutterVersion": "3.24.5",
"pubCache": "file:///home/pierre/.pub-cache" "pubCache": "file:///home/pierre/.pub-cache"
} }

File diff suppressed because one or more lines are too long

View File

@@ -1,208 +1,143 @@
# 🍎 Guide de Build iOS - GEOSECTOR # 🍎 Guide de Build iOS - GEOSECTOR
**Date de création** : 21/10/2025 **Dernière mise à jour** : 16/11/2025
**Version actuelle** : 3.4.2 (Build 342) **Version système** : Workflow automatisé depuis Debian
--- ---
## 📋 **Prérequis** ## 📋 Prérequis
### Sur le Mac mini ### Mac mini (192.168.1.34)
-macOS installé -Xcode + Command Line Tools
-Xcode installé avec Command Line Tools -Flutter 3.24.5 LTS
-Flutter installé (3.24.5 LTS recommandé) -CocoaPods installé
- ✅ CocoaPods installé (`sudo gem install cocoapods`) - ✅ Certificats Apple (Team: **6WT84NWCTC**)
- ✅ Certificats Apple configurés (Team ID: 6WT84NWCTC)
### Sur Debian ### PC Debian (développement)
- ✅ Accès SSH au Mac mini (192.168.1.34) - ✅ Accès SSH au Mac mini
-rsync installé -Fichier `../VERSION` à jour
--- ---
## 🚀 **Procédure complète** ## 🚀 Build iOS - Workflow complet
### **Étape 1 : Transfert depuis Debian vers Mac mini** ### **Commande unique depuis Debian**
```bash ```bash
# Sur votre machine Debian
cd /home/pierre/dev/geosector/app cd /home/pierre/dev/geosector/app
./ios.sh
# Lancer le transfert
./transfer-to-mac.sh
``` ```
**Ce que fait le script** : **Ce que fait le script** :
1. Détecte automatiquement la version (ex: 342) 1. ✅ Lit `../VERSION` (ex: 3.5.3)
2. Crée le dossier `app_342` sur le Mac mini 2. ✅ Met à jour `pubspec.yaml` (3.5.3+353)
3. Transfert tous les fichiers nécessaires (lib, ios, pubspec.yaml, etc.) 3. ✅ Teste connexion Mac mini
4. Exclut les dossiers inutiles (build, .dart_tool, Pods, etc.) 4. ✅ Transfert rsync → `/Users/pierre/dev/geosector/app_353/`
5. 🔀 **Choix A** : Lance build SSH automatique
**Durée** : 2-5 minutes (selon la connexion réseau) 6. 🔀 **Choix B** : Instructions manuelles
**Note** : Vous devrez saisir le mot de passe du Mac mini
--- ---
### **Étape 2 : Connexion au Mac mini** ### **Option A : Build automatique (recommandé)**
Sélectionner **A** dans le menu :
- SSH automatique vers Mac mini
- Lance `ios-build-mac.sh`
- Ouvre Xcode pour l'archive
### **Option B : Build manuel**
```bash ```bash
# Depuis Debian
ssh pierre@192.168.1.34 ssh pierre@192.168.1.34
cd /Users/pierre/dev/geosector/app_353
# Aller dans le dossier transféré
cd /Users/pierre/dev/geosector/app_342
```
---
### **Étape 3 : Lancer le build iOS**
```bash
# Sur le Mac mini
./ios-build-mac.sh ./ios-build-mac.sh
``` ```
**Ce que fait le script** : ---
1. ✅ Nettoie le projet (`flutter clean`)
2. ✅ Récupère les dépendances (`flutter pub get`)
3. ✅ Installe les pods (`pod install`)
4. ✅ Compile en release (`flutter build ios --release`)
5. ✅ Ouvre Xcode pour l'archive (signature manuelle plus fiable)
**Durée de préparation** : 5-10 minutes ## 📦 Archive et Upload (Xcode)
**Résultat** : Xcode s'ouvre, prêt pour Product > Archive **Xcode s'ouvre automatiquement** après le build ✅
1. ⏳ Attendre chargement Xcode
2. ✅ Vérifier **Signing & Capabilities**
- Team : `6WT84NWCTC`
- "Automatically manage signing" : ✅
3. 🧹 **Product > Clean Build Folder** (⌘⇧K)
4. 📦 **Product > Archive** (⏳ 5-10 min)
5. 📤 **Organizer****Distribute App**
6. ☁️ **App Store Connect****Upload**
7.**Upload** (⏳ 2-5 min)
--- ---
### **Étape 4 : Créer l'archive et upload vers App Store Connect** ## 📱 TestFlight (App Store Connect)
**Xcode est ouvert automatiquement** https://appstoreconnect.apple.com
Dans Xcode : 1. **Apps** > **GeoSector** > **TestFlight**
1. ⏳ Attendre le chargement (quelques secondes) 2. ⏳ Attendre traitement (5-15 min)
2. ✅ Vérifier **Signing & Capabilities** : Team = 6WT84NWCTC, "Automatically manage signing" coché 3. Build **353 (3.5.3)** apparaît
3. 🧹 **Product > Clean Build Folder** (Cmd+Shift+K) 4. **Conformité export** :
4. 📦 **Product > Archive** - Utilise chiffrement ? → **Oui**
5. ⏳ Attendre l'archive (5-10 minutes) - Algorithmes exempts ? → **Aucun des algorithmes mentionnés**
6. 📤 **Organizer** s'ouvre → Clic **Distribute App** 5. **Testeurs internes** → Ajouter ton Apple ID
7. ☁️ Choisir **App Store Connect** 6. 📧 Invitation TestFlight envoyée
8.**Upload** → Automatique
9. 🚀 **Next** jusqu'à validation finale
**⚠️ Ne PAS utiliser xcodebuild en ligne de commande** : erreurs de signature (errSecInternalComponent). Xcode GUI obligatoire.
--- ---
## 📁 **Structure des dossiers sur Mac mini** ## ✅ Checklist rapide
``` - [ ] Mettre à jour `../VERSION` (ex: 3.5.4)
/Users/pierre/dev/geosector/ - [ ] Lancer `./ios.sh` depuis Debian
├── app_342/ # Version 3.4.2 (Build 342) - [ ] Archive créée dans Xcode
│ ├── ios/ - [ ] Upload vers App Store Connect
│ ├── lib/ - [ ] Conformité export renseignée
│ ├── pubspec.yaml - [ ] Testeur interne ajouté
│ ├── ios-build-mac.sh # Script de build - [ ] App installée via TestFlight
│ └── build/
│ └── Runner.xcarchive # Archive générée
├── app_341/ # Version précédente (si existe)
└── app_343/ # Version future
```
**Avantage** : Garder plusieurs versions côte à côte pour tests/rollback
--- ---
## 🔧 **Résolution de problèmes** ## 🔧 Résolution problèmes
### **Erreur : "Flutter not found"** ### Erreur SSH "Too many authentication failures"
**Corrigé** : Le script force l'authentification par mot de passe
```bash ### Erreur de signature Xcode
# Vérifier que Flutter est dans le PATH ```
echo $PATH | grep flutter Signing & Capabilities > Team = 6WT84NWCTC
"Automatically manage signing" ✅
# Ajouter Flutter au PATH (dans ~/.zshrc ou ~/.bash_profile)
export PATH="$PATH:/opt/flutter/bin"
source ~/.zshrc
``` ```
### **Erreur : "xcodebuild not found"** ### Pod install échoue
```bash ```bash
# Installer Xcode Command Line Tools
xcode-select --install
```
### **Erreur lors de pod install**
```bash
# Sur le Mac mini
cd ios cd ios
rm -rf Pods Podfile.lock rm -rf Pods Podfile.lock
pod install --repo-update pod install --repo-update
cd ..
```
### **Erreur de signature**
1. Ouvrir Xcode : `open ios/Runner.xcworkspace`
2. Sélectionner le target "Runner"
3. Onglet "Signing & Capabilities"
4. Vérifier Team ID : `6WT84NWCTC`
5. Cocher "Automatically manage signing"
### **Archive créée mais vide**
Vérifier que la compilation iOS a réussi :
```bash
flutter build ios --release --no-codesign --verbose
``` ```
--- ---
## 📊 **Checklist de validation** ## 🎯 Workflow version complète
- [ ] Version/Build incrémenté dans `pubspec.yaml` ```mermaid
- [ ] Compilation iOS réussie Debian (dev) → Mac mini (build) → App Store Connect → TestFlight → iPhone
- [ ] Archive validée dans Xcode Organizer │ │ │ │ │
- [ ] Build uploadé vers App Store Connect ↓ ↓ ↓ ↓ ↓
- [ ] **TestFlight** : Ajouter build au groupe "Testeurs externes" ios.sh build iOS Upload Traitement Install
- [ ] Renseigner "Infos sur l'exportation de conformité" : + Archive (5-15 min)
- **App utilise chiffrement ?** → Oui
- **Algorithmes exempts listés ?** → **Aucun des algorithmes mentionnés ci-dessus**
- (App utilise HTTPS standard iOS uniquement)
- [ ] Soumettre build pour révision TestFlight
- [ ] *(Optionnel)* Captures/Release notes pour production App Store
---
## 🎯 **Workflow complet**
```bash
# 1. Debian → Transfert
cd /home/pierre/dev/geosector/app
./transfer-to-mac.sh
# 2. Mac mini → Build + Archive
ssh pierre@192.168.1.34
cd /Users/pierre/dev/geosector/app_342
./ios-build-mac.sh
# Xcode s'ouvre → Product > Clean + Archive
# 3. Upload → TestFlight
# Organizer > Distribute App > App Store Connect > Upload
# App Store Connect > TestFlight > Conformité export
``` ```
**Temps total** : 20-30 minutes (build + upload + traitement Apple)
--- ---
## 📞 **Support** ## 📞 Liens utiles
- **Documentation Apple** : https://developer.apple.com
- **App Store Connect** : https://appstoreconnect.apple.com - **App Store Connect** : https://appstoreconnect.apple.com
- **TestFlight** : App dans l'App Store
- **Flutter iOS** : https://docs.flutter.dev/deployment/ios - **Flutter iOS** : https://docs.flutter.dev/deployment/ios
--- ---
**Prêt pour la production !** 🚀 **Prêt pour TestFlight !** 🚀

View File

@@ -57,42 +57,181 @@ if ! command -v flutter &> /dev/null; then
exit 1 exit 1
fi fi
# Récupérer la version depuis pubspec.yaml # Étape 0 : Synchroniser la version depuis ../VERSION
VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | sed 's/+/-/') print_message "Étape 0/5 : Synchronisation de la version..."
if [ -z "$VERSION" ]; then echo
print_error "Impossible de récupérer la version depuis pubspec.yaml"
VERSION_FILE="../VERSION"
if [ ! -f "$VERSION_FILE" ]; then
print_error "Fichier VERSION introuvable : $VERSION_FILE"
exit 1 exit 1
fi fi
# Extraire le version code # Lire la version depuis le fichier (enlever espaces/retours à la ligne)
VERSION_CODE=$(echo $VERSION | cut -d'-' -f2) VERSION_NUMBER=$(cat "$VERSION_FILE" | tr -d '\n\r ' | tr -d '[:space:]')
if [ -z "$VERSION_NUMBER" ]; then
print_error "Le fichier VERSION est vide"
exit 1
fi
print_message "Version lue depuis $VERSION_FILE : $VERSION_NUMBER"
# Calculer le versionCode (supprimer les points)
VERSION_CODE=$(echo $VERSION_NUMBER | tr -d '.')
if [ -z "$VERSION_CODE" ]; then if [ -z "$VERSION_CODE" ]; then
print_error "Impossible d'extraire le version code" print_error "Impossible de calculer le versionCode"
exit 1 exit 1
fi fi
print_message "Version détectée : $VERSION" print_message "Version code calculé : $VERSION_CODE"
# Mettre à jour pubspec.yaml
print_message "Mise à jour de pubspec.yaml..."
sed -i.bak "s/^version:.*/version: $VERSION_NUMBER+$VERSION_CODE/" pubspec.yaml
# Vérifier que la mise à jour a réussi
UPDATED_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //')
if [ "$UPDATED_VERSION" != "$VERSION_NUMBER+$VERSION_CODE" ]; then
print_error "Échec de la mise à jour de pubspec.yaml"
print_error "Attendu : $VERSION_NUMBER+$VERSION_CODE"
print_error "Obtenu : $UPDATED_VERSION"
exit 1
fi
print_success "pubspec.yaml mis à jour : version: $VERSION_NUMBER+$VERSION_CODE"
print_message "build.gradle.kts se synchronisera automatiquement via Flutter Gradle Plugin"
echo
# Récupérer la version finale pour l'affichage
VERSION="$VERSION_NUMBER-$VERSION_CODE"
print_message "Version finale : $VERSION"
print_message "Version code : $VERSION_CODE" print_message "Version code : $VERSION_CODE"
echo echo
# Vérifier la présence du keystore # Demander le mode Debug ou Release
if [ ! -f "android/app/geosector2025.jks" ]; then print_message "========================================="
print_error "Fichier keystore introuvable : android/app/geosector2025.jks" print_message " MODE DE BUILD"
exit 1 print_message "========================================="
echo
print_message "Choisissez le mode de build :"
echo
print_message " ${YELLOW}[D]${NC} Debug"
print_message " ✓ Installation rapide via ADB"
print_message " ✓ Hot reload possible"
print_message " ✓ Logs complets"
print_message " ⚠ Tap to Pay simulé uniquement"
print_message " ⚠ Performance non optimisée"
echo
print_message " ${GREEN}[R]${NC} Release (recommandé)"
print_message " ✓ APK/AAB optimisé"
print_message " ✓ Tap to Pay réel en production"
print_message " ✓ Performance maximale"
echo
read -p "Votre choix (D/R) [défaut: R] : " -n 1 -r BUILD_TYPE
echo
echo
# Définir le flag de build et le suffixe pour les noms de fichiers
BUILD_MODE_FLAG="--release"
MODE_SUFFIX="release"
SKIP_R8_CHOICE=false
if [[ $BUILD_TYPE =~ ^[Dd]$ ]]; then
BUILD_MODE_FLAG="--debug"
MODE_SUFFIX="debug"
SKIP_R8_CHOICE=true
print_success "Mode Debug sélectionné"
echo
print_warning "Attention : Tap to Pay ne fonctionnera qu'en mode simulé"
echo
# En mode debug, pas de choix R8 ni de vérification keystore
USE_R8=false
COPY_DEBUG_FILES=false
else
print_success "Mode Release sélectionné"
echo
fi fi
# Vérifier la présence du fichier key.properties # Demander le mode R8 SEULEMENT si Release
if [ ! -f "android/key.properties" ]; then if [ "$SKIP_R8_CHOICE" = false ]; then
print_message "========================================="
print_message " OPTIMISATION RELEASE"
print_message "========================================="
echo
print_message "Choisissez le niveau d'optimisation :"
echo
print_message " ${GREEN}[A]${NC} Production - R8/ProGuard activé"
print_message " ✓ Taille réduite (~30-40%)"
print_message " ✓ Code obscurci (sécurité)"
print_message " ✓ Génère mapping.txt pour débogage"
print_message " ✓ Génère symboles natifs"
echo
print_message " ${YELLOW}[B]${NC} Test interne - Sans R8/ProGuard (défaut)"
print_message " ✓ Build plus rapide"
print_message " ✓ Pas d'obscurcissement (débogage facile)"
print_message " ⚠ Taille plus importante"
print_message " ⚠ Avertissements Google Play Console"
echo
read -p "Votre choix (A/B) [défaut: B] : " -n 1 -r BUILD_MODE
echo
echo
# Définir les variables selon le choix
USE_R8=false
COPY_DEBUG_FILES=false
if [[ $BUILD_MODE =~ ^[Aa]$ ]]; then
USE_R8=true
COPY_DEBUG_FILES=true
print_success "Mode Production sélectionné - R8/ProGuard activé"
else
print_success "Mode Test interne sélectionné - R8/ProGuard désactivé"
fi
echo
fi
# Vérifier la présence du keystore SEULEMENT si Release
if [ "$SKIP_R8_CHOICE" = false ]; then
if [ ! -f "android/app/geosector2025.jks" ]; then
print_error "Fichier keystore introuvable : android/app/geosector2025.jks"
exit 1
fi
# Vérifier la présence du fichier key.properties
if [ ! -f "android/key.properties" ]; then
print_error "Fichier key.properties introuvable" print_error "Fichier key.properties introuvable"
print_error "Ce fichier est nécessaire pour signer l'application" print_error "Ce fichier est nécessaire pour signer l'application"
exit 1 exit 1
fi
print_success "Configuration de signature vérifiée"
echo
fi fi
print_success "Configuration de signature vérifiée" # Activer R8 si demandé (modification temporaire du build.gradle.kts)
echo GRADLE_FILE="android/app/build.gradle.kts"
GRADLE_BACKUP="android/app/build.gradle.kts.backup"
if [ "$USE_R8" = true ]; then
print_message "Activation de R8/ProGuard dans build.gradle.kts..."
# Créer une sauvegarde
cp "$GRADLE_FILE" "$GRADLE_BACKUP"
# Activer minifyEnabled et shrinkResources
sed -i.tmp 's/isMinifyEnabled = false/isMinifyEnabled = true/' "$GRADLE_FILE"
sed -i.tmp 's/isShrinkResources = false/isShrinkResources = true/' "$GRADLE_FILE"
# Nettoyer les fichiers temporaires de sed
rm -f "${GRADLE_FILE}.tmp"
print_success "R8/ProGuard activé temporairement"
echo
fi
# Étape 1 : Nettoyer le projet # Étape 1 : Nettoyer le projet
print_message "Étape 1/4 : Nettoyage du projet..." print_message "Étape 1/5 : Nettoyage du projet..."
flutter clean flutter clean
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
print_success "Projet nettoyé" print_success "Projet nettoyé"
@@ -103,7 +242,7 @@ fi
echo echo
# Étape 2 : Récupérer les dépendances # Étape 2 : Récupérer les dépendances
print_message "Étape 2/4 : Récupération des dépendances..." print_message "Étape 2/5 : Récupération des dépendances..."
flutter pub get flutter pub get
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
print_success "Dépendances récupérées" print_success "Dépendances récupérées"
@@ -113,8 +252,23 @@ else
fi fi
echo echo
# Étape 2.5 : Patcher nfc_manager pour AGP 8+
print_message "Étape 2.5/5 : Patch nfc_manager pour Android Gradle Plugin 8+..."
NFC_PATCH_SCRIPT="./fastlane/scripts/commun/fix-nfc-manager.sh"
if [ -f "$NFC_PATCH_SCRIPT" ]; then
bash "$NFC_PATCH_SCRIPT"
if [ $? -eq 0 ]; then
print_success "Patch nfc_manager appliqué"
else
print_warning "Le patch nfc_manager a échoué (peut être déjà appliqué)"
fi
else
print_warning "Script de patch nfc_manager introuvable : $NFC_PATCH_SCRIPT"
fi
echo
# Étape 3 : Analyser le code (optionnel mais recommandé) # Étape 3 : Analyser le code (optionnel mais recommandé)
print_message "Étape 3/4 : Analyse du code Dart..." print_message "Étape 3/5 : Analyse du code Dart..."
flutter analyze --no-fatal-infos --no-fatal-warnings || { flutter analyze --no-fatal-infos --no-fatal-warnings || {
print_warning "Des avertissements ont été détectés dans le code" print_warning "Des avertissements ont été détectés dans le code"
read -p "Voulez-vous continuer malgré les avertissements ? (y/n) " -n 1 -r read -p "Voulez-vous continuer malgré les avertissements ? (y/n) " -n 1 -r
@@ -128,9 +282,9 @@ print_success "Analyse du code terminée"
echo echo
# Étape 4 : Générer le bundle # Étape 4 : Générer le bundle
print_message "Étape 4/4 : Génération du bundle Android..." print_message "Étape 4/5 : Génération du bundle Android..."
print_message "Cette opération peut prendre plusieurs minutes..." print_message "Cette opération peut prendre plusieurs minutes..."
flutter build appbundle --release flutter build appbundle $BUILD_MODE_FLAG
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
print_success "Bundle généré avec succès" print_success "Bundle généré avec succès"
else else
@@ -139,15 +293,23 @@ else
fi fi
echo echo
# Restaurer le build.gradle.kts original si modifié
if [ "$USE_R8" = true ] && [ -f "$GRADLE_BACKUP" ]; then
print_message "Restauration du build.gradle.kts original..."
mv "$GRADLE_BACKUP" "$GRADLE_FILE"
print_success "Fichier restauré"
echo
fi
# Vérifier que le bundle a été créé # Vérifier que le bundle a été créé
BUNDLE_PATH="build/app/outputs/bundle/release/app-release.aab" BUNDLE_PATH="build/app/outputs/bundle/$MODE_SUFFIX/app-$MODE_SUFFIX.aab"
if [ ! -f "$BUNDLE_PATH" ]; then if [ ! -f "$BUNDLE_PATH" ]; then
print_error "Bundle introuvable à l'emplacement attendu : $BUNDLE_PATH" print_error "Bundle introuvable à l'emplacement attendu : $BUNDLE_PATH"
exit 1 exit 1
fi fi
# Copier le bundle à la racine avec le nouveau nom # Copier le bundle à la racine avec le nouveau nom
FINAL_NAME="geosector-$VERSION_CODE.aab" FINAL_NAME="geosector-$VERSION_CODE-$MODE_SUFFIX.aab"
print_message "Copie du bundle vers : $FINAL_NAME" print_message "Copie du bundle vers : $FINAL_NAME"
cp "$BUNDLE_PATH" "$FINAL_NAME" cp "$BUNDLE_PATH" "$FINAL_NAME"
@@ -162,6 +324,47 @@ else
exit 1 exit 1
fi fi
# Copier les fichiers de débogage si Option A sélectionnée
if [ "$COPY_DEBUG_FILES" = true ]; then
echo
print_message "Copie des fichiers de débogage pour Google Play Console..."
# Créer un dossier de release
RELEASE_DIR="release-$VERSION_CODE"
mkdir -p "$RELEASE_DIR"
# Copier le bundle
cp "$FINAL_NAME" "$RELEASE_DIR/"
# Copier le fichier mapping.txt (R8/ProGuard)
MAPPING_FILE="build/app/outputs/mapping/release/mapping.txt"
if [ -f "$MAPPING_FILE" ]; then
cp "$MAPPING_FILE" "$RELEASE_DIR/mapping.txt"
print_success "Fichier mapping.txt copié"
else
print_warning "Fichier mapping.txt introuvable (peut être normal)"
fi
# Copier les symboles natifs
SYMBOLS_ZIP="build/app/intermediates/merged_native_libs/release/out/lib"
if [ -d "$SYMBOLS_ZIP" ]; then
# Créer une archive des symboles
cd build/app/intermediates/merged_native_libs/release/out
zip -r "../../../../../../$RELEASE_DIR/native-symbols.zip" lib/
cd - > /dev/null
print_success "Symboles natifs archivés"
else
print_warning "Symboles natifs introuvables (peut être normal)"
fi
print_success "Fichiers de débogage copiés dans : $RELEASE_DIR/"
echo
print_message "Pour uploader sur Google Play Console :"
print_message "1. Bundle : $RELEASE_DIR/$FINAL_NAME"
print_message "2. Mapping : $RELEASE_DIR/mapping.txt"
print_message "3. Symboles : $RELEASE_DIR/native-symbols.zip"
fi
echo echo
print_message "=========================================" print_message "========================================="
print_success " GÉNÉRATION TERMINÉE AVEC SUCCÈS !" print_success " GÉNÉRATION TERMINÉE AVEC SUCCÈS !"
@@ -170,11 +373,37 @@ echo
print_message "Bundle généré : ${GREEN}$FINAL_NAME${NC}" print_message "Bundle généré : ${GREEN}$FINAL_NAME${NC}"
print_message "Version : $VERSION" print_message "Version : $VERSION"
print_message "Chemin : $(pwd)/$FINAL_NAME" print_message "Chemin : $(pwd)/$FINAL_NAME"
echo
print_message "Prochaines étapes :" if [ "$BUILD_MODE_FLAG" = "--debug" ]; then
print_message "1. Tester le bundle sur un appareil Android" echo
print_message "2. Uploader sur Google Play Console" print_message "Mode : ${YELLOW}Debug${NC}"
print_message "3. Soumettre pour review" print_message "⚠ Tap to Pay simulé uniquement"
print_message "✓ Logs complets disponibles"
echo
print_message "Prochaines étapes :"
print_message "1. Installer l'APK sur l'appareil (proposé ci-dessous)"
print_message "2. Tester l'application avec adb logcat"
print_message "3. Pour Tap to Pay réel, relancer en mode Release"
elif [ "$USE_R8" = true ]; then
echo
print_message "Mode : ${GREEN}Release - Production (R8/ProGuard activé)${NC}"
print_message "Dossier release : ${GREEN}$RELEASE_DIR/${NC}"
echo
print_message "Prochaines étapes :"
print_message "1. Tester le bundle sur un appareil Android"
print_message "2. Uploader le bundle sur Google Play Console"
print_message "3. Uploader mapping.txt et native-symbols.zip"
print_message "4. Soumettre pour review"
else
echo
print_message "Mode : ${GREEN}Release${NC} - ${YELLOW}Test interne (R8/ProGuard désactivé)${NC}"
print_warning "Avertissements attendus sur Google Play Console"
echo
print_message "Prochaines étapes :"
print_message "1. Tester le bundle sur un appareil Android"
print_message "2. Uploader sur Google Play Console (test interne)"
print_message "3. Pour production, relancer avec Option A"
fi
echo echo
# Optionnel : Générer aussi l'APK # Optionnel : Générer aussi l'APK
@@ -182,18 +411,50 @@ read -p "Voulez-vous aussi générer l'APK pour des tests ? (y/n) " -n 1 -r
echo echo
if [[ $REPLY =~ ^[Yy]$ ]]; then if [[ $REPLY =~ ^[Yy]$ ]]; then
print_message "Génération de l'APK..." print_message "Génération de l'APK..."
flutter build apk --release flutter build apk $BUILD_MODE_FLAG
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
APK_PATH="build/app/outputs/flutter-apk/app-release.apk" APK_PATH="build/app/outputs/flutter-apk/app-$MODE_SUFFIX.apk"
if [ -f "$APK_PATH" ]; then if [ -f "$APK_PATH" ]; then
APK_NAME="geosector-$VERSION_CODE.apk" APK_NAME="geosector-$VERSION_CODE-$MODE_SUFFIX.apk"
cp "$APK_PATH" "$APK_NAME" cp "$APK_PATH" "$APK_NAME"
print_success "APK généré : $APK_NAME" print_success "APK généré : $APK_NAME"
# Afficher la taille de l'APK # Afficher la taille de l'APK
APK_SIZE=$(du -h "$APK_NAME" | cut -f1) APK_SIZE=$(du -h "$APK_NAME" | cut -f1)
print_message "Taille de l'APK : $APK_SIZE" print_message "Taille de l'APK : $APK_SIZE"
# Si mode Debug, proposer installation automatique
if [ "$BUILD_MODE_FLAG" = "--debug" ]; then
echo
read -p "Installer l'APK debug sur l'appareil connecté ? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
print_message "Désinstallation de l'ancienne version..."
adb uninstall fr.geosector.app3 2>/dev/null || print_warning "Aucune version précédente trouvée"
print_message "Installation sur l'appareil..."
adb install "$APK_NAME"
if [ $? -eq 0 ]; then
print_success "APK installé avec succès"
# Proposer de lancer l'app
read -p "Lancer l'application ? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
adb shell am start -n fr.geosector.app3/.MainActivity
if [ $? -eq 0 ]; then
print_success "Application lancée"
else
print_warning "Impossible de lancer l'application"
fi
fi
else
print_error "Échec de l'installation"
print_message "Vérifiez qu'un appareil est bien connecté : adb devices"
fi
fi
fi
fi fi
else else
print_warning "Échec de la génération de l'APK (le bundle a été créé avec succès)" print_warning "Échec de la génération de l'APK (le bundle a été créé avec succès)"

View File

@@ -55,9 +55,13 @@ android {
buildTypes { buildTypes {
release { release {
// Optimisations sans ProGuard pour éviter les problèmes // Optimisations R8/ProGuard avec règles personnalisées
isMinifyEnabled = false isMinifyEnabled = true
isShrinkResources = false isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// Configuration de signature // Configuration de signature
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {

View File

@@ -0,0 +1,92 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
// Charger les propriétés de signature
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "fr.geosector.app3"
compileSdk = 35 // Requis par plusieurs plugins (flutter_local_notifications, stripe, etc.)
ndkVersion = "27.0.12077973"
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = "21"
}
defaultConfig {
// Application ID for Google Play Store
applicationId = "fr.geosector.app3"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
// Minimum SDK 28 requis pour Stripe Tap to Pay
minSdk = 28
targetSdk = 35 // API 35 requise par Google Play (Oct 2024+)
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
if (keystorePropertiesFile.exists()) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes {
release {
// Optimisations sans ProGuard pour éviter les problèmes
isMinifyEnabled = false
isShrinkResources = false
// Configuration de signature
if (keystorePropertiesFile.exists()) {
signingConfig = signingConfigs.getByName("release")
} else {
signingConfig = signingConfigs.getByName("debug")
}
}
debug {
// Mode debug pour le développement
isDebuggable = true
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
}
}
// Résolution des conflits de fichiers dupliqués (Stripe + BouncyCastle)
packaging {
resources {
pickFirst("org/bouncycastle/**")
}
}
}
flutter {
source = "../.."
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}

57
app/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,57 @@
# Règles ProGuard/R8 pour GEOSECTOR
# =====================================
## Règles générées automatiquement par R8 (classes manquantes)
## Ces classes Java ne sont pas disponibles sur Android mais ne sont pas utilisées
-dontwarn java.beans.ConstructorProperties
-dontwarn java.beans.Transient
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn org.slf4j.impl.StaticMDCBinder
## Règles pour Google Play Core (composants différés - non utilisés)
-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication
-dontwarn com.google.android.play.core.splitinstall.SplitInstallException
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManager
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManagerFactory
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest$Builder
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest
-dontwarn com.google.android.play.core.splitinstall.SplitInstallSessionState
-dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener
-dontwarn com.google.android.play.core.tasks.OnFailureListener
-dontwarn com.google.android.play.core.tasks.OnSuccessListener
-dontwarn com.google.android.play.core.tasks.Task
## Règles pour Stripe SDK
-keep class com.stripe.** { *; }
-keepclassmembers class com.stripe.** { *; }
## Règles pour Jackson (utilisé par Stripe)
-keep class com.fasterxml.jackson.** { *; }
-keepclassmembers class com.fasterxml.jackson.** { *; }
-dontwarn com.fasterxml.jackson.databind.**
## Règles pour les modèles de données (Hive)
-keep class fr.geosector.app3.** { *; }
-keepclassmembers class fr.geosector.app3.** { *; }
## Règles pour les réflexions Flutter
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.util.** { *; }
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
## Règles pour les annotations
-keepattributes *Annotation*
-keepattributes Signature
-keepattributes Exceptions
-keepattributes InnerClasses
-keepattributes EnclosingMethod
## Optimisation
-optimizationpasses 5
-dontusemixedcaseclassnames
-dontskipnonpubliclibraryclasses
-dontpreverify
-verbose

View File

@@ -41,7 +41,7 @@ FINAL_OWNER="nginx"
FINAL_GROUP="nginx" FINAL_GROUP="nginx"
# Configuration de sauvegarde # Configuration de sauvegarde
BACKUP_DIR="/data/backup/geosector" BACKUP_DIR="/home/pierre/samba/back/geosector/app/"
# Couleurs pour les messages # Couleurs pour les messages
GREEN='\033[0;32m' GREEN='\033[0;32m'
@@ -254,9 +254,13 @@ EOF
echo_info "Fixing web assets structure..." echo_info "Fixing web assets structure..."
./copy-web-images.sh || echo_error "Failed to fix web assets" ./copy-web-images.sh || echo_error "Failed to fix web assets"
# Créer l'archive depuis le build # Créer l'archive depuis le build (avec exclusions pour réduire la taille)
echo_info "Creating archive from build..." echo_info "Creating archive from build..."
tar -czf "${TEMP_ARCHIVE}" -C ${FLUTTER_BUILD_DIR} . || echo_error "Failed to create archive" tar -czf "${TEMP_ARCHIVE}" -C ${FLUTTER_BUILD_DIR} \
--exclude='*.symbols' \
--exclude='*.kra' \
--exclude='.DS_Store' \
. || echo_error "Failed to create archive"
create_local_backup "${TEMP_ARCHIVE}" "dev" create_local_backup "${TEMP_ARCHIVE}" "dev"

498
app/deploy-ios-full-auto.sh Executable file
View File

@@ -0,0 +1,498 @@
#!/bin/bash
# Script de déploiement iOS automatisé pour GEOSECTOR
# Version: 1.0
# Date: 2025-12-05
# Auteur: Pierre (avec l'aide de Claude)
#
# Usage:
# ./deploy-ios-full-auto.sh # Utilise ../VERSION
# ./deploy-ios-full-auto.sh 3.6.0 # Version spécifique
# ./deploy-ios-full-auto.sh 3.6.0 --skip-build # Skip Flutter build si déjà fait
set -euo pipefail
# =====================================
# Configuration
# =====================================
# Couleurs pour les messages
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Configuration Mac mini
MAC_MINI_HOST="minipi4" # Nom défini dans ~/.ssh/config
MAC_BASE_DIR="/Users/pierre/dev/geosector"
# Timestamp pour logs et archives
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
LOG_FILE="./logs/deploy-ios-${TIMESTAMP}.log"
mkdir -p ./logs
# Variables globales pour le rapport
STEP_START_TIME=0
TOTAL_START_TIME=$(date +%s)
ERRORS_COUNT=0
WARNINGS_COUNT=0
# =====================================
# Fonctions utilitaires
# =====================================
log() {
echo -e "$1" | tee -a "${LOG_FILE}"
}
log_step() {
STEP_START_TIME=$(date +%s)
log "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
log "${CYAN}$1${NC}"
log "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
}
log_substep() {
log "${MAGENTA}$1${NC}"
}
log_info() {
log "${BLUE} ${NC}$1"
}
log_success() {
local elapsed=$(($(date +%s) - STEP_START_TIME))
log "${GREEN}${NC} $1 ${CYAN}(${elapsed}s)${NC}"
}
log_warning() {
((WARNINGS_COUNT++))
log "${YELLOW}${NC}$1"
}
log_error() {
((ERRORS_COUNT++))
log "${RED}${NC} $1"
}
log_fatal() {
log_error "$1"
log "\n${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
log "${RED}DÉPLOIEMENT ÉCHOUÉ${NC}"
log "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
log_error "Consultez le log: ${LOG_FILE}"
exit 1
}
# Fonction pour exécuter une commande et capturer les erreurs
safe_exec() {
local cmd="$1"
local error_msg="$2"
if ! eval "$cmd" >> "${LOG_FILE}" 2>&1; then
log_fatal "$error_msg"
fi
}
# Fonction pour exécuter une commande SSH avec gestion d'erreurs
ssh_exec() {
local cmd="$1"
local error_msg="$2"
if ! ssh "$MAC_MINI_HOST" "$cmd" >> "${LOG_FILE}" 2>&1; then
log_fatal "$error_msg"
fi
}
# =====================================
# En-tête
# =====================================
clear
log "${BLUE}╔════════════════════════════════════════════════════════╗${NC}"
log "${BLUE}║ ║${NC}"
log "${BLUE}${GREEN}🍎 DÉPLOIEMENT iOS AUTOMATISÉ${BLUE}${NC}"
log "${BLUE}${CYAN}GEOSECTOR - Full Automation${BLUE}${NC}"
log "${BLUE}║ ║${NC}"
log "${BLUE}╚════════════════════════════════════════════════════════╝${NC}"
log ""
log_info "Démarrage: $(date '+%Y-%m-%d %H:%M:%S')"
log_info "Log file: ${LOG_FILE}"
log ""
# =====================================
# Étape 1 : Gestion de la version
# =====================================
log_step "ÉTAPE 1/8 : Gestion de la version"
# Déterminer la version à utiliser
if [ "${1:-}" != "" ] && [[ ! "${1}" =~ ^-- ]]; then
VERSION="$1"
log_info "Version fournie en argument: ${VERSION}"
else
# Lire depuis ../VERSION
if [ ! -f ../VERSION ]; then
log_fatal "Fichier ../VERSION introuvable et aucune version fournie"
fi
VERSION=$(cat ../VERSION | tr -d '\n\r ' | tr -d '[:space:]')
log_info "Version lue depuis ../VERSION: ${VERSION}"
fi
# Vérifier le format de version
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
log_fatal "Format de version invalide: ${VERSION} (attendu: x.x.x)"
fi
# Calculer le build number
BUILD_NUMBER=$(echo $VERSION | tr -d '.')
FULL_VERSION="${VERSION}+${BUILD_NUMBER}"
log_success "Version configurée"
log_info " Version name: ${GREEN}${VERSION}${NC}"
log_info " Build number: ${GREEN}${BUILD_NUMBER}${NC}"
log_info " Full version: ${GREEN}${FULL_VERSION}${NC}"
# =====================================
# Étape 2 : Mise à jour pubspec.yaml
# =====================================
log_step "ÉTAPE 2/8 : Mise à jour pubspec.yaml"
# Backup du pubspec.yaml
cp pubspec.yaml pubspec.yaml.backup
# Mise à jour de la version
sed -i "s/^version: .*/version: $FULL_VERSION/" pubspec.yaml
# Vérifier la mise à jour
UPDATED_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | tr -d ' ')
if [ "$UPDATED_VERSION" != "$FULL_VERSION" ]; then
log_fatal "Échec de la mise à jour de pubspec.yaml (attendu: $FULL_VERSION, obtenu: $UPDATED_VERSION)"
fi
log_success "pubspec.yaml mis à jour"
# =====================================
# Étape 3 : Préparation du projet
# =====================================
SKIP_BUILD=false
if [[ "${2:-}" == "--skip-build" ]]; then
SKIP_BUILD=true
log_warning "Mode --skip-build activé, Flutter build sera ignoré"
fi
if [ "$SKIP_BUILD" = false ]; then
log_step "ÉTAPE 3/8 : Préparation du projet Flutter"
log_substep "Configuration du cache local"
export PUB_CACHE="$PWD/.pub-cache-local"
export GRADLE_USER_HOME="$PWD/.gradle-local"
mkdir -p "$PUB_CACHE" "$GRADLE_USER_HOME"
log_info " Cache Pub: $PUB_CACHE"
log_info " Cache Gradle: $GRADLE_USER_HOME"
log_substep "Nettoyage du projet"
safe_exec "flutter clean" "Échec du nettoyage Flutter"
log_substep "Récupération des dépendances"
safe_exec "flutter pub get" "Échec de flutter pub get"
log_substep "Application du patch nfc_manager"
safe_exec "./fastlane/scripts/commun/fix-nfc-manager.sh" "Échec du patch nfc_manager"
log_substep "Application du patch permission_handler (si nécessaire)"
if [ -f "./fastlane/scripts/commun/fix-permission-handler.sh" ]; then
safe_exec "./fastlane/scripts/commun/fix-permission-handler.sh" "Échec du patch permission_handler"
fi
log_substep "Génération des fichiers Hive"
safe_exec "dart run build_runner build --delete-conflicting-outputs" "Échec de la génération de code"
log_success "Projet préparé (dépendances + patchs + génération de code)"
log_info " ⚠️ Build iOS sera fait sur le Mac mini via Fastlane"
else
log_step "ÉTAPE 3/8 : Préparation du projet (BUILD SKIPPED)"
log_substep "Configuration du cache local uniquement"
export PUB_CACHE="$PWD/.pub-cache-local"
export GRADLE_USER_HOME="$PWD/.gradle-local"
if [ ! -d "$PUB_CACHE" ]; then
log_warning "Cache local introuvable, le build pourrait échouer sur le Mac mini"
fi
log_success "Cache configuré (build Flutter ignoré)"
fi
# =====================================
# Étape 4 : Vérification de la connexion Mac mini
# =====================================
log_step "ÉTAPE 4/8 : Connexion au Mac mini"
log_substep "Test de connexion SSH à ${MAC_MINI_HOST}"
if ! ssh "$MAC_MINI_HOST" "echo 'Connection OK'" >> "${LOG_FILE}" 2>&1; then
log_fatal "Impossible de se connecter au Mac mini (${MAC_MINI_HOST})"
fi
log_success "Connexion SSH établie"
# Vérifier l'environnement Mac
log_substep "Vérification de l'environnement Mac"
MAC_INFO=$(ssh "$MAC_MINI_HOST" "sw_vers -productVersion && xcodebuild -version | head -1 && flutter --version | head -1" 2>/dev/null || echo "N/A")
log_info "$(echo "$MAC_INFO" | head -1 | xargs -I {} echo " macOS: {}")"
log_info "$(echo "$MAC_INFO" | sed -n '2p' | xargs -I {} echo " Xcode: {}")"
log_info "$(echo "$MAC_INFO" | sed -n '3p' | xargs -I {} echo " Flutter: {}")"
# =====================================
# Étape 5 : Transfert vers Mac mini
# =====================================
log_step "ÉTAPE 5/8 : Transfert du projet vers Mac mini"
DEST_DIR="${MAC_BASE_DIR}/app_${BUILD_NUMBER}"
log_substep "Création du dossier de destination: ${DEST_DIR}"
ssh_exec "mkdir -p ${DEST_DIR}" "Impossible de créer le dossier ${DEST_DIR} sur le Mac mini"
log_substep "Transfert rsync (peut prendre 2-5 minutes)"
TRANSFER_START=$(date +%s)
rsync -avz --progress \
--exclude='build/' \
--exclude='.dart_tool/' \
--exclude='ios/Pods/' \
--exclude='ios/.symlinks/' \
--exclude='macos/Pods/' \
--exclude='linux/flutter/ephemeral/' \
--exclude='windows/flutter/ephemeral/' \
--exclude='android/build/' \
--exclude='*.aab' \
--exclude='*.apk' \
--exclude='logs/' \
--exclude='*.log' \
./ "${MAC_MINI_HOST}:${DEST_DIR}/" >> "${LOG_FILE}" 2>&1 || log_fatal "Échec du transfert rsync"
TRANSFER_TIME=$(($(date +%s) - TRANSFER_START))
log_success "Transfert terminé"
log_info " Destination: ${DEST_DIR}"
log_info " Durée: ${TRANSFER_TIME}s"
# =====================================
# Étape 6 : Build et Archive avec Fastlane
# =====================================
log_step "ÉTAPE 6/8 : Build et Archive iOS avec Fastlane"
log_info "Cette étape peut prendre 15-25 minutes"
log_info "Fastlane va :"
log_info " 1. Nettoyer les artefacts"
log_info " 2. Installer les CocoaPods"
log_info " 3. Analyser le code"
log_info " 4. Build Flutter iOS"
log_info " 5. Archive Xcode (gym)"
log_info " 6. Export IPA"
log_info ""
log_substep "Lancement de: cd ${DEST_DIR} && fastlane ios build"
FASTLANE_START=$(date +%s)
# Créer un fichier temporaire pour capturer la sortie Fastlane
FASTLANE_LOG="/tmp/fastlane-ios-${TIMESTAMP}.log"
# Exécuter Fastlane en temps réel avec affichage des étapes importantes
ssh -t "$MAC_MINI_HOST" "cd ${DEST_DIR} && /opt/homebrew/bin/fastlane ios build" 2>&1 | tee -a "${LOG_FILE}" | tee "${FASTLANE_LOG}" | while IFS= read -r line; do
# Afficher les lignes importantes
if echo "$line" | grep -qE "(🧹|📦|🔧|🔍|🏗️|✓|✗|Error|error:|ERROR|Build succeeded|Build failed)"; then
echo -e "${CYAN} ${line}${NC}"
fi
done
# Vérifier le code de retour de Fastlane
FASTLANE_EXIT_CODE=${PIPESTATUS[0]}
FASTLANE_TIME=$(($(date +%s) - FASTLANE_START))
if [ $FASTLANE_EXIT_CODE -ne 0 ]; then
log_error "Fastlane a échoué (code: ${FASTLANE_EXIT_CODE})"
log_error "Analyse des erreurs..."
# Extraire les erreurs du log Fastlane
if [ -f "${FASTLANE_LOG}" ]; then
log ""
log "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
log "${RED}ERREURS DÉTECTÉES :${NC}"
log "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
grep -i "error:\|Error:\|ERROR:\|❌\|✗" "${FASTLANE_LOG}" | head -20 | while IFS= read -r error_line; do
log "${RED} ${error_line}${NC}"
done
log "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
fi
log_fatal "Build iOS échoué via Fastlane. Consultez ${FASTLANE_LOG} pour plus de détails."
fi
log_success "Build et Archive iOS réussis"
log_info " Durée totale Fastlane: ${FASTLANE_TIME}s ($((FASTLANE_TIME/60))m $((FASTLANE_TIME%60))s)"
# Vérifier que l'IPA existe
log_substep "Vérification de l'IPA généré"
IPA_EXISTS=$(ssh "$MAC_MINI_HOST" "test -f ${DEST_DIR}/build/ios/ipa/Runner.ipa && echo 'YES' || echo 'NO'")
if [ "$IPA_EXISTS" != "YES" ]; then
log_fatal "IPA non trouvé dans ${DEST_DIR}/build/ios/ipa/Runner.ipa"
fi
IPA_SIZE=$(ssh "$MAC_MINI_HOST" "du -h ${DEST_DIR}/build/ios/ipa/Runner.ipa | cut -f1")
log_info " IPA trouvé: ${GREEN}${IPA_SIZE}${NC}"
# =====================================
# Étape 7 : Upload vers TestFlight (optionnel)
# =====================================
log_step "ÉTAPE 7/8 : Upload vers TestFlight"
log ""
log_info "${YELLOW}Voulez-vous uploader l'IPA vers TestFlight maintenant ?${NC}"
log_info " [Y] Oui - Upload automatique via fastlane ios upload"
log_info " [N] Non - Je ferai l'upload manuellement plus tard"
log ""
read -p "$(echo -e ${CYAN}Votre choix [Y/n]: ${NC})" -n 1 -r UPLOAD_CHOICE
echo
log ""
if [[ $UPLOAD_CHOICE =~ ^[Yy]$ ]] || [ -z "$UPLOAD_CHOICE" ]; then
log_substep "Lancement de: fastlane ios upload"
log_info "Upload vers TestFlight (peut prendre 5-10 minutes)"
UPLOAD_START=$(date +%s)
ssh -t "$MAC_MINI_HOST" "cd ${DEST_DIR} && /opt/homebrew/bin/fastlane ios upload" 2>&1 | tee -a "${LOG_FILE}"
UPLOAD_EXIT_CODE=${PIPESTATUS[0]}
UPLOAD_TIME=$(($(date +%s) - UPLOAD_START))
if [ $UPLOAD_EXIT_CODE -ne 0 ]; then
log_error "Upload TestFlight échoué (code: ${UPLOAD_EXIT_CODE})"
log_warning "L'IPA est disponible sur le Mac mini, vous pouvez réessayer manuellement"
log_info " Commande: ssh $MAC_USER@$MAC_MINI_IP \"cd ${DEST_DIR} && fastlane ios upload\""
else
log_success "Upload TestFlight réussi"
log_info " Durée: ${UPLOAD_TIME}s"
log_info " URL: ${CYAN}https://appstoreconnect.apple.com${NC}"
fi
else
log_info "Upload ignoré. Pour uploader manuellement plus tard :"
log_info " ${CYAN}ssh $MAC_MINI_HOST \"cd ${DEST_DIR} && /opt/homebrew/bin/fastlane ios upload\"${NC}"
fi
# =====================================
# Étape 8 : Nettoyage et archivage
# =====================================
log_step "ÉTAPE 8/8 : Nettoyage et archivage"
log_substep "Voulez-vous archiver le dossier de build ?"
log_info " [Y] Oui - Créer une archive ${DEST_DIR}.tar.gz"
log_info " [N] Non - Garder le dossier tel quel (défaut)"
log ""
read -p "$(echo -e ${CYAN}Votre choix [y/N]: ${NC})" -n 1 -r ARCHIVE_CHOICE
echo
log ""
if [[ $ARCHIVE_CHOICE =~ ^[Yy]$ ]]; then
log_substep "Création de l'archive..."
ssh_exec "cd ${MAC_BASE_DIR} && tar -czf app_${BUILD_NUMBER}.tar.gz app_${BUILD_NUMBER}" \
"Échec de la création de l'archive"
ARCHIVE_SIZE=$(ssh "$MAC_MINI_HOST" "du -h ${MAC_BASE_DIR}/app_${BUILD_NUMBER}.tar.gz | cut -f1")
log_success "Archive créée"
log_info " Archive: ${MAC_BASE_DIR}/app_${BUILD_NUMBER}.tar.gz (${ARCHIVE_SIZE})"
log_substep "Suppression du dossier de build"
ssh_exec "rm -rf ${DEST_DIR}" "Échec de la suppression du dossier"
log_success "Dossier de build supprimé"
else
log_info "Dossier conservé: ${DEST_DIR}"
fi
# Restaurer le pubspec.yaml original (optionnel)
log_substep "Restauration de pubspec.yaml local"
mv pubspec.yaml.backup pubspec.yaml
log_info " pubspec.yaml local restauré à son état initial"
# =====================================
# Rapport final
# =====================================
TOTAL_TIME=$(($(date +%s) - TOTAL_START_TIME))
TOTAL_MINUTES=$((TOTAL_TIME / 60))
TOTAL_SECONDS=$((TOTAL_TIME % 60))
log ""
log "${GREEN}╔════════════════════════════════════════════════════════╗${NC}"
log "${GREEN}║ ║${NC}"
log "${GREEN}║ ✓ DÉPLOIEMENT iOS TERMINÉ AVEC SUCCÈS ║${NC}"
log "${GREEN}║ ║${NC}"
log "${GREEN}╚════════════════════════════════════════════════════════╝${NC}"
log ""
log "${CYAN}📊 RAPPORT DE DÉPLOIEMENT${NC}"
log "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
log ""
log " ${BLUE}Version déployée:${NC} ${GREEN}${VERSION} (Build ${BUILD_NUMBER})${NC}"
log " ${BLUE}Destination:${NC} ${DEST_DIR}"
log " ${BLUE}IPA généré:${NC} ${GREEN}${IPA_SIZE}${NC}"
log " ${BLUE}Durée totale:${NC} ${GREEN}${TOTAL_MINUTES}m ${TOTAL_SECONDS}s${NC}"
log ""
if [ $WARNINGS_COUNT -gt 0 ]; then
log " ${YELLOW}⚠ Avertissements:${NC} ${WARNINGS_COUNT}"
fi
if [ $ERRORS_COUNT -gt 0 ]; then
log " ${RED}✗ Erreurs:${NC} ${ERRORS_COUNT}"
fi
log ""
log "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
log ""
log "${BLUE}📱 PROCHAINES ÉTAPES${NC}"
log "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
log ""
if [[ $UPLOAD_CHOICE =~ ^[Yy]$ ]] || [ -z "$UPLOAD_CHOICE" ]; then
if [ $UPLOAD_EXIT_CODE -eq 0 ]; then
log " 1. ${GREEN}${NC} IPA uploadé sur TestFlight"
log " 2. Accéder à App Store Connect:"
log " ${CYAN}https://appstoreconnect.apple.com${NC}"
log " 3. Attendre le traitement Apple (5-15 min)"
log " 4. Configurer la conformité export si demandée"
log " 5. Ajouter des testeurs internes"
log " 6. Installer via TestFlight sur iPhone"
else
log " 1. ${YELLOW}${NC} Upload TestFlight a échoué"
log " 2. Réessayer manuellement:"
log " ${CYAN}ssh $MAC_USER@$MAC_MINI_IP \"cd ${DEST_DIR} && fastlane ios upload\"${NC}"
fi
else
log " 1. L'IPA est prêt sur le Mac mini"
log " 2. Pour uploader vers TestFlight:"
log " ${CYAN}ssh $MAC_USER@$MAC_MINI_IP \"cd ${DEST_DIR} && fastlane ios upload\"${NC}"
log " 3. Ou distribuer l'IPA manuellement via Xcode"
fi
log ""
log "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
log ""
log " ${BLUE}Log complet:${NC} ${LOG_FILE}"
log " ${BLUE}Fin:${NC} $(date '+%Y-%m-%d %H:%M:%S')"
log ""
# Nettoyer le log Fastlane temporaire
rm -f "${FASTLANE_LOG}"
exit 0

View File

@@ -278,55 +278,102 @@ Log::info('Stripe account activated', [
## 📱 FLOW TAP TO PAY (Application Flutter) ## 📱 FLOW TAP TO PAY (Application Flutter)
### 🎯 Architecture technique
Le flow Tap to Pay repose sur trois composants principaux :
1. **DeviceInfoService** - Vérification de compatibilité hardware (Android SDK ≥ 28, NFC disponible)
2. **StripeTapToPayService** - Gestion du SDK Stripe Terminal et des paiements
3. **Backend API** - Endpoints PHP pour les tokens de connexion et PaymentIntents
### 🔄 Diagramme de séquence complet ### 🔄 Diagramme de séquence complet
``` ```
┌─────────────┐ ┌─────────────┐ ┌──────────┐ ┌─────────┐ ┌─────────────┐ ┌─────────────────┐ ┌──────────┐ ┌─────────┐ ┌──────────────┐
│ App Flutter │ │ API PHP │ │ Stripe │ │ Carte │ App Flutter │ │ DeviceInfo │ │ API │ │ Stripe │ │ SDK Terminal
└──────┬──────┘ └──────┬──────┘ └────┬─────┘ └────┬────┘ │ │ Service │ │ PHP │ │ │ │ │
│ │ │ │ └──────┬──────┘ └────────┬────────┘ └────┬─────┘ └────┬─────┘ └──────┬───────┘
[1] │ Validation form │ │ │ │ │ │
│ + montant CB [1] │ Login utilisateur │
│ │ ────────────────────>│
[2] │ POST/PUT passage │ │ │ │ │ │
│──────────────────>│ │ │ [2] │ │ checkStripeCertification()
│ │ │ • Android SDK ≥ 28
[3] │<──────────────────│ │ │ │ │ • NFC disponible
Passage ID: 456 │ │
[3] │<────────────────────│
[4] │ POST create-intent│ │ │ ✅ Compatible │ │ │
──────────────────>│ (avec passage_id: 456) │ │ │
│ │ [4] │ Validation form
[5] │ │ Create PaymentIntent │ + montant CB │ │ │
│─────────────────>│ │ │ │
│ │ [5] │ POST/PUT passage
[6] │ │<─────────────────│ │────────────────────────────────────────>│ │
│ pi_xxx + secret │ │ │ │
[6] │<────────────────────────────────────────
[7] │<──────────────────│ │ │ Passage ID: 456 │ │ │
PaymentIntent ID │ │
│ │ [7] │ initialize()
[8] │ SDK Terminal Init │ │ │────────────────────────────────────────────────────────────────────────────>
"Approchez carte" │ │
│ │ │ [8] │ │ │ Terminal.initTerminal()
[9] │<──────────────────────────────────────────────────────│ │ │ │ │ │ (fetchToken callback)
NFC : Lecture carte sans contact │ │ │
[9] │ │ POST /terminal/connection-token
[10] │ Process Payment │ │ │ │────────────────────────────────────────>│
───────────────────────────────────>│ {amicale_id, stripe_account, location_id} │
│ │ │ │
[11] │<───────────────────────────────────│ [10] │ │ │ CreateConnectionToken
Payment Success │───────────────>│
│ │ │ │
[12] │ POST confirm [11] │ │<───────────────│
──────────────────>│ │ │ │ {secret: "..."}│
│ │ │ │
[13] │ PUT passage/456 [12] │<────────────────────────────────────────
──────────────────>│ (ajout stripe_payment_id) Connection Token │ │ │
│ │ │ │
[14] │<──────────────────│ │ [13] │────────────────────────────────────────────────────────────────────────────>
Passage updated │ │ │ Token delivered to SDK │ │ ✅ SDK Ready │
│ │ │ │
[14] │ createPaymentIntent() │ │ │
│────────────────────────────────────────>│ │ │
│ {amount, passage_id, amicale_id} │ │ │
│ │ │ │ │
[15] │ │ │ Create PaymentIntent │
│ │ │───────────────>│ │
│ │ │ │ │
[16] │ │ │<───────────────│ │
│ │ │ pi_xxx + secret│ │
│ │ │ │ │
[17] │<────────────────────────────────────────│ │ │
│ PaymentIntent ID │ │ │ │
│ │ │ │ │
[18] │ collectPayment() │ │ │ │
│────────────────────────────────────────────────────────────────────────────>│
│ │ │ │ │
[19] │ │ │ │ discoverReaders()
│ │ │ │ + connectReader()
│ │ │ │ │
[20] │ │ │ │ collectPaymentMethod()
│ 💳 "Présentez la carte" │ │ ⏳ Attente NFC │
│ │ │ │ │
[21] │<──────────────────────────────────────────────────────────── 📱 TAP CARTE │
│ │ │ │ │
[22] │ confirmPayment() │ │ │ │
│────────────────────────────────────────────────────────────────────────────>│
│ │ │ │ │
[23] │ │ │ │ confirmPaymentIntent()
│ │ │ │ │
[24] │ │ │ │ ✅ Succeeded │
│<────────────────────────────────────────────────────────────────────────────│
│ Payment Success │ │ │ │
│ │ │ │ │
[25] │ PUT passage/456 │ │ │ │
│────────────────────────────────────────>│ │ │
│ {stripe_payment_id: "pi_xxx"} │ │ │
│ │ │ │ │
[26] │<────────────────────────────────────────│ │ │
│ ✅ Passage updated │ │ │ │
``` ```
### 🎮 Gestion du Terminal de Paiement ### 🎮 Gestion du Terminal de Paiement
@@ -378,6 +425,59 @@ Le terminal de paiement reste affiché jusqu'à la réponse définitive de Strip
- **Persistence** : Le terminal reste ouvert jusqu'à réponse définitive de Stripe - **Persistence** : Le terminal reste ouvert jusqu'à réponse définitive de Stripe
- **Gestion d'erreur** : Possibilité de réessayer sans perdre le contexte - **Gestion d'erreur** : Possibilité de réessayer sans perdre le contexte
### 🔑 Connection Token - Flow détaillé
Le Connection Token est crucial pour le SDK Stripe Terminal. Il est récupéré via un callback au moment de l'initialisation.
**Code côté App (stripe_tap_to_pay_service.dart:87-89) :**
```dart
await Terminal.initTerminal(
fetchToken: _fetchConnectionToken, // Callback appelé automatiquement
);
```
**Callback de récupération (lignes 137-161) :**
```dart
Future<String> _fetchConnectionToken() async {
debugPrint('🔑 Récupération du token de connexion Stripe...');
final response = await ApiService.instance.post(
'/stripe/terminal/connection-token',
data: {
'amicale_id': CurrentAmicaleService.instance.amicaleId,
'stripe_account': _stripeAccountId,
'location_id': _locationId,
}
);
final token = response.data['secret'];
if (token == null || token.isEmpty) {
throw Exception('Token de connexion invalide');
}
debugPrint('✅ Token de connexion récupéré');
return token;
}
```
**Backend PHP :**
```php
// POST /stripe/terminal/connection-token
$token = \Stripe\Terminal\ConnectionToken::create([], [
'stripe_account' => $amicale->stripe_id,
]);
return response()->json([
'secret' => $token->secret,
]);
```
**Points importants :**
- ✅ Le token est **temporaire** (valide quelques minutes)
- ✅ Un nouveau token est créé à **chaque initialisation** du SDK
- ✅ Le token est spécifique au **compte Stripe Connect** de l'amicale
- ✅ Utilisé pour **authentifier** le Terminal SDK auprès de Stripe
### 📋 Détail des étapes ### 📋 Détail des étapes
#### Étape 1 : VALIDATION DU FORMULAIRE #### Étape 1 : VALIDATION DU FORMULAIRE
@@ -1085,28 +1185,168 @@ POST/PUT /api/passages
## 🔄 GESTION DES ERREURS ## 🔄 GESTION DES ERREURS
### 📱 Erreurs Tap to Pay ### 📱 Erreurs Tap to Pay - Messages utilisateur clairs
| Code erreur | Description | Action utilisateur | L'application détecte automatiquement le type d'erreur et affiche un message adapté :
|-------------|-------------|-------------------|
| `device_not_compatible` | iPhone non compatible | Afficher message explicatif |
| `nfc_disabled` | NFC désactivé | Demander activation dans réglages |
| `card_declined` | Carte refusée | Essayer autre carte |
| `insufficient_funds` | Solde insuffisant | Essayer autre carte |
| `network_error` | Erreur réseau | Réessayer ou mode offline |
| `timeout` | Timeout lecture carte | Rapprocher carte et réessayer |
### 🔄 Flow de retry #### Gestion intelligente des erreurs (passage_form_dialog.dart)
```dart
catch (e) {
final errorMsg = e.toString().toLowerCase();
String userMessage;
bool shouldCancelPayment = true;
if (errorMsg.contains('canceled') || errorMsg.contains('cancelled')) {
// Annulation volontaire
userMessage = 'Paiement annulé';
} else if (errorMsg.contains('cardreadtimedout') || errorMsg.contains('timed out')) {
// Timeout NFC avec conseils
userMessage = 'Lecture de la carte impossible.\n\n'
'Conseils :\n'
'• Maintenez la carte contre le dos du téléphone\n'
'• Ne bougez pas jusqu\'à confirmation\n'
'• Retirez la coque si nécessaire\n'
'• Essayez différentes positions sur le téléphone';
} else if (errorMsg.contains('already') && errorMsg.contains('payment')) {
// PaymentIntent existe déjà
userMessage = 'Un paiement est déjà en cours pour ce passage.\n'
'Veuillez réessayer dans quelques instants.';
shouldCancelPayment = false;
}
// Annulation automatique du PaymentIntent pour permettre nouvelle tentative
if (shouldCancelPayment && _paymentIntentId != null) {
await StripeTapToPayService.instance.cancelPayment(_paymentIntentId!);
}
}
```
#### Table des erreurs et actions
| Type erreur | Message utilisateur | Action automatique |
|-------------|--------------------|--------------------|
| `canceled` / `cancelled` | "Paiement annulé" | Annulation PaymentIntent ✅ |
| `cardReadTimedOut` | Message avec 4 conseils NFC | Annulation PaymentIntent ✅ |
| `already payment` | "Paiement déjà en cours" | Pas d'annulation ⏳ |
| `device_not_compatible` | "Appareil non compatible" | Annulation PaymentIntent ✅ |
| `nfc_disabled` | "NFC désactivé" | Annulation PaymentIntent ✅ |
| Autre erreur | Message technique complet | Annulation PaymentIntent ✅ |
### ⚠️ Contraintes NFC - Tap to Pay vs Google Pay
**Différence fondamentale :**
- **Google Pay (émission)** : Le téléphone *émet* un signal NFC puissant → fonctionne avec coque
- **Tap to Pay (réception)** : Le téléphone *lit* le signal de la carte → très sensible aux interférences
#### Coques problématiques
-**Kevlar / Carbone** : Fibres conductrices perturbent la réception NFC
-**Métal** : Bloque complètement les ondes NFC
-**Coque épaisse** : Réduit la portée effective
-**TPU / Silicone** : Compatible
#### Bonnes pratiques pour réussite NFC
**Position optimale :**
```
┌─────────────────┐
│ 📱 Téléphone │
│ │
│ [Capteur NFC]│ ← Généralement vers le haut du dos
│ │
│ │
│ 💳 Carte ici │ ← Maintenir immobile 3-5 secondes
└─────────────────┘
```
**Checklist utilisateur :**
1. ✅ Retirer la coque si échec
2. ✅ Carte à plat contre le dos du téléphone
3. ✅ Ne pas bouger pendant toute la lecture
4. ✅ Essayer différentes positions (haut/milieu du téléphone)
5. ✅ Carte sans contact activée (logo sans contact visible)
### 🔄 Flow de retry automatique
``` ```
1. Erreur détectée 1. Erreur détectée → Analyse du type
2. Message utilisateur explicite 2. Annulation automatique PaymentIntent (si applicable)
3. Option "Réessayer" proposée 3. Message clair avec conseils contextuels
4. Conservation du montant et contexte 4. Bouton "Réessayer" disponible
5. Nouveau PaymentIntent si nécessaire 5. Nouveau PaymentIntent créé automatiquement
6. Maximum 3 tentatives 6. Conservation du contexte (montant, passage)
``` ```
**Avantages :**
- ✅ Pas de blocage "PaymentIntent déjà existant"
- ✅ Nombre illimité de tentatives
- ✅ Contexte préservé (pas besoin de tout ressaisir)
- ✅ Messages orientés solution plutôt qu'erreur technique
### 🏗️ Environnement et Build Release
#### Détection automatique de l'environnement
L'application détecte l'environnement via l'URL de l'API (plus fiable que `kDebugMode`) :
```dart
// stripe_tap_to_pay_service.dart (lignes 236-252)
Future<bool> _ensureReaderConnected() async {
// Détection via URL API
final apiUrl = ApiService.instance.baseUrl;
final isProduction = apiUrl.contains('app3.geosector.fr');
final isSimulated = !isProduction;
final config = TapToPayDiscoveryConfiguration(
isSimulated: isSimulated,
);
debugPrint('🔧 Environnement: ${isProduction ? "PRODUCTION (réel)" : "DEV/REC (simulé)"}');
debugPrint('🔧 isSimulated: $isSimulated');
}
```
**Mapping environnement :**
| URL API | Environnement | Reader Stripe | Cartes acceptées |
|---------|---------------|---------------|------------------|
| `dapp.geosector.fr` | DEV | Simulé | Cartes test uniquement |
| `rapp.geosector.fr` | REC | Simulé | Cartes test uniquement |
| `app3.geosector.fr` | PROD | Réel | Cartes réelles uniquement |
#### ⚠️ Restriction Stripe - Build Release obligatoire en PROD
**Erreur si app debuggable en PROD :**
```
Debuggable applications are not supported when using the production
version of the Tap to Pay reader. Please use a simulated version of
the reader by setting TapToPayDiscoveryConfiguration.isSimulated to true.
```
**Solution - Build release :**
```bash
# Build APK optimisé pour production
flutter build apk --release
# Installation sur device
adb install build/app/outputs/flutter-apk/app-release.apk
```
**Différences debug vs release :**
| Aspect | Debug (`flutter run`) | Release (`flutter build`) |
|--------|-----------------------|--------------------|
| **Optimisation** | ❌ Code non optimisé | ✅ R8/ProGuard activé |
| **Taille APK** | ~200 MB | ~30-50 MB |
| **Performance** | Lente (dev mode) | Rapide (optimisée) |
| **Tap to Pay PROD** | ❌ Refusé par Stripe | ✅ Accepté |
| **Débogage** | ✅ Hot reload, logs | ❌ Pas de hot reload |
**Pourquoi Stripe refuse les apps debug :**
- **Sécurité renforcée** : Les apps debuggables peuvent être inspectées
- **Conformité PCI-DSS** : Exigences de sécurité pour paiements réels
- **Protection production** : Éviter utilisation accidentelle de readers réels en dev
--- ---
## 📊 MONITORING ET LOGS ## 📊 MONITORING ET LOGS

216
app/docs/connexions-api.md Normal file
View File

@@ -0,0 +1,216 @@
API Event Stats - Guide Flutter
Authentification
Toutes les routes nécessitent un Bearer Token (session) et un rôle Admin (2) ou Super-admin (1).
headers: {
'Authorization': 'Bearer $sessionToken',
'Content-Type': 'application/json',
}
---
1. Résumé du jour
GET /api/events/stats/summary?date=2025-12-22
| Param | Type | Requis | Description |
| ----- | ------ | ------ | ------------------------------------- |
| date | string | Non | Date YYYY-MM-DD (défaut: aujourd'hui) |
Réponse (~1 KB) :
{
"status": "success",
"data": {
"date": "2025-12-22",
"stats": {
"auth": { "success": 45, "failed": 3, "logout": 12 },
"passages": { "created": 128, "updated": 5, "deleted": 2, "amount": 2450.00 },
"users": { "created": 2, "updated": 5, "deleted": 0 },
"sectors": { "created": 1, "updated": 3, "deleted": 0 },
"stripe": { "created": 15, "success": 12, "failed": 1, "cancelled": 2, "amount": 890.00 }
},
"totals": { "events": 245, "unique_users": 18 }
}
}
---
2. Stats journalières (graphiques)
GET /api/events/stats/daily?from=2025-12-01&to=2025-12-22&events=passage_created,login_success
| Param | Type | Requis | Description |
| ------ | ------ | ------ | ------------------------------- |
| from | string | Oui | Date début YYYY-MM-DD |
| to | string | Oui | Date fin YYYY-MM-DD |
| events | string | Non | Types filtrés (comma-separated) |
Limite : 90 jours max
Réponse (~5 KB) :
{
"status": "success",
"data": {
"from": "2025-12-01",
"to": "2025-12-22",
"days": [
{
"date": "2025-12-01",
"events": {
"passage_created": { "count": 45, "sum_amount": 850.00, "unique_users": 8 },
"login_success": { "count": 12, "sum_amount": 0, "unique_users": 12 }
},
"totals": { "count": 57, "sum_amount": 850.00 }
},
...
]
}
}
---
3. Stats hebdomadaires
GET /api/events/stats/weekly?from=2025-10-01&to=2025-12-22
| Param | Type | Requis | Description |
| ------ | ------ | ------ | ------------- |
| from | string | Oui | Date début |
| to | string | Oui | Date fin |
| events | string | Non | Types filtrés |
Limite : 365 jours max
Réponse (~2 KB) :
{
"status": "success",
"data": {
"from": "2025-10-01",
"to": "2025-12-22",
"weeks": [
{
"week_start": "2025-12-16",
"week_number": 51,
"year": 2025,
"events": {
"passage_created": { "count": 320, "sum_amount": 5200.00, "unique_users": 15 }
},
"totals": { "count": 450, "sum_amount": 5200.00 }
},
...
]
}
}
---
4. Stats mensuelles
GET /api/events/stats/monthly?year=2025&events=passage_created,stripe_payment_success
| Param | Type | Requis | Description |
| ------ | ------ | ------ | ------------------------------ |
| year | int | Non | Année (défaut: année courante) |
| events | string | Non | Types filtrés |
Réponse (~1 KB) :
{
"status": "success",
"data": {
"year": 2025,
"months": [
{
"month": "2025-01",
"year": 2025,
"month_number": 1,
"events": {
"passage_created": { "count": 1250, "sum_amount": 18500.00, "unique_users": 25 }
},
"totals": { "count": 1800, "sum_amount": 18500.00 }
},
...
]
}
}
---
5. Détail des événements (drill-down)
GET /api/events/stats/details?date=2025-12-22&event=login_failed&limit=50&offset=0
| Param | Type | Requis | Description |
| ------ | ------ | ------ | ------------------------------------ |
| date | string | Oui | Date YYYY-MM-DD |
| event | string | Non | Filtrer par type |
| limit | int | Non | Max résultats (défaut: 50, max: 100) |
| offset | int | Non | Pagination (défaut: 0) |
Réponse (~10 KB) :
{
"status": "success",
"data": {
"date": "2025-12-22",
"events": [
{
"timestamp": "2025-12-22T08:15:32Z",
"event": "login_failed",
"username": "jean.dupont",
"reason": "invalid_password",
"attempt": 2,
"ip": "192.168.x.x",
"platform": "ios",
"app_version": "3.5.2"
},
...
],
"pagination": {
"total": 3,
"limit": 50,
"offset": 0,
"has_more": false
}
}
}
---
6. Types d'événements disponibles
GET /api/events/stats/types
Réponse :
{
"status": "success",
"data": {
"auth": ["login_success", "login_failed", "logout"],
"passages": ["passage_created", "passage_updated", "passage_deleted"],
"sectors": ["sector_created", "sector_updated", "sector_deleted"],
"users": ["user_created", "user_updated", "user_deleted"],
"entities": ["entity_created", "entity_updated", "entity_deleted"],
"operations": ["operation_created", "operation_updated", "operation_deleted"],
"stripe": ["stripe_payment_created", "stripe_payment_success", "stripe_payment_failed", "stripe_payment_cancelled", "stripe_terminal_error"]
}
}
---
Codes d'erreur
| Code | Signification |
| ---- | -------------------------------------------- |
| 200 | Succès |
| 400 | Paramètre invalide (date, plage trop grande) |
| 403 | Accès refusé (rôle insuffisant) |
| 500 | Erreur serveur |
---
Super-admin uniquement
Le super-admin peut ajouter ?entity_id=X pour filtrer par entité :
GET /api/events/stats/summary?date=2025-12-22&entity_id=5
Sans ce paramètre, il voit les stats globales de toutes les entités.

View File

@@ -12,6 +12,9 @@
default_platform(:android) default_platform(:android)
# Configure PATH pour Homebrew (M1/M2/M4 Mac)
ENV['PATH'] = "/opt/homebrew/bin:#{ENV['PATH']}"
# ============================================================================= # =============================================================================
# ANDROID # ANDROID
# ============================================================================= # =============================================================================

View File

@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/lib" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/.idea" />
<excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/.pub" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/connectivity_plus/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/connectivity_plus/.pub" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/connectivity_plus/build" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/connectivity_plus/example/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/connectivity_plus/example/.pub" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/connectivity_plus/example/build" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/path_provider_foundation/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/path_provider_foundation/.pub" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/path_provider_foundation/build" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/path_provider_foundation/example/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/path_provider_foundation/example/.pub" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/path_provider_foundation/example/build" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/url_launcher_ios/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/url_launcher_ios/.pub" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/url_launcher_ios/build" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/url_launcher_ios/example/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/url_launcher_ios/example/.pub" />
<excludeFolder url="file://$MODULE_DIR$/ios/.symlinks/plugins/url_launcher_ios/example/build" />
</content>
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Dart SDK" level="project" />
<orderEntry type="library" name="Flutter Plugins" level="project" />
<orderEntry type="library" name="Dart Packages" level="project" />
</component>
</module>

View File

@@ -33,9 +33,21 @@ fi
# Récupérer la version depuis pubspec.yaml # Récupérer la version depuis pubspec.yaml
VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | tr -d ' ') VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | tr -d ' ')
VERSION_NUMBER=$(echo $VERSION | cut -d'+' -f1)
VERSION_CODE=$(echo $VERSION | cut -d'+' -f2)
echo -e "${YELLOW}📦 Version détectée :${NC} $VERSION" echo -e "${YELLOW}📦 Version détectée :${NC} $VERSION"
echo -e "${YELLOW} Version name :${NC} $VERSION_NUMBER"
echo -e "${YELLOW} Build number :${NC} $VERSION_CODE"
echo "" echo ""
# Vérifier que la version est bien synchronisée depuis transfer-to-mac.sh
if [ -z "$VERSION_CODE" ]; then
echo -e "${RED}⚠️ Avertissement: Version code introuvable${NC}"
echo -e "${YELLOW}Assurez-vous d'avoir utilisé transfer-to-mac.sh pour synchroniser la version${NC}"
echo ""
fi
# Étape 1 : Clean # Étape 1 : Clean
echo -e "${YELLOW}🧹 Étape 1/5 : Nettoyage du projet...${NC}" echo -e "${YELLOW}🧹 Étape 1/5 : Nettoyage du projet...${NC}"
flutter clean flutter clean
@@ -50,6 +62,12 @@ echo ""
# Étape 3 : Pod install # Étape 3 : Pod install
echo -e "${YELLOW}🔧 Étape 3/5 : Installation des CocoaPods...${NC}" echo -e "${YELLOW}🔧 Étape 3/5 : Installation des CocoaPods...${NC}"
# Configurer l'environnement Ruby Homebrew
export PATH="/opt/homebrew/opt/ruby/bin:/opt/homebrew/bin:$PATH"
export GEM_HOME="/opt/homebrew/lib/ruby/gems/3.4.0"
echo -e "${BLUE} Environnement Ruby Homebrew configuré${NC}"
cd ios cd ios
rm -rf Pods Podfile.lock rm -rf Pods Podfile.lock
pod install --repo-update pod install --repo-update
@@ -57,10 +75,29 @@ cd ..
echo -e "${GREEN}✓ CocoaPods installés${NC}" echo -e "${GREEN}✓ CocoaPods installés${NC}"
echo "" echo ""
# Étape 4 : Build iOS Release # Étape 4 : Build iOS
echo -e "${YELLOW}🏗️ Étape 4/5 : Compilation iOS en mode release...${NC}" echo -e "${YELLOW}🏗️ Étape 4/5 : Choix du mode de compilation...${NC}"
flutter build ios --release echo ""
echo -e "${GREEN}✓ Compilation terminée${NC}" echo -e "${BLUE}Quel mode de compilation souhaitez-vous utiliser ?${NC}"
echo -e " ${GREEN}[D]${NC} Debug - Pour tester Stripe Tap to Pay (défaut)"
echo -e " ${YELLOW}[R]${NC} Release - Pour distribution App Store"
echo ""
read -p "Votre choix (D/R) [défaut: D] : " -n 1 -r BUILD_MODE
echo ""
echo ""
# Définir le mode de build
if [[ $BUILD_MODE =~ ^[Rr]$ ]]; then
BUILD_FLAG="--release"
BUILD_MODE_NAME="Release"
else
BUILD_FLAG="--debug"
BUILD_MODE_NAME="Debug"
fi
echo -e "${YELLOW}🏗️ Compilation iOS en mode ${BUILD_MODE_NAME}...${NC}"
flutter build ios $BUILD_FLAG
echo -e "${GREEN}✓ Compilation terminée (mode ${BUILD_MODE_NAME})${NC}"
echo "" echo ""
# Étape 5 : Ouvrir Xcode # Étape 5 : Ouvrir Xcode

249
app/ios.sh Executable file
View File

@@ -0,0 +1,249 @@
#!/bin/bash
# Script de génération iOS pour GEOSECTOR
# Usage: ./ios.sh
set -e # Arrêter le script en cas d'erreur
# Couleurs pour les messages
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration Mac mini
MAC_MINI_IP="192.168.1.34"
MAC_USER="pierre"
# Fonction pour afficher les messages
print_message() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
# Fonction pour gérer les erreurs
handle_error() {
print_error "Une erreur est survenue lors de l'exécution de la commande"
print_error "Ligne $1"
exit 1
}
# Trap pour capturer les erreurs
trap 'handle_error $LINENO' ERR
# Vérifier que nous sommes dans le bon dossier
if [ ! -f "pubspec.yaml" ]; then
print_error "Ce script doit être exécuté depuis le dossier racine de l'application Flutter"
print_error "Fichier pubspec.yaml introuvable"
exit 1
fi
print_message "========================================="
print_message " GEOSECTOR - Génération iOS"
print_message "========================================="
echo
# Vérifier que Flutter est installé
if ! command -v flutter &> /dev/null; then
print_error "Flutter n'est pas installé ou n'est pas dans le PATH"
exit 1
fi
# Étape 1 : Synchroniser la version depuis ../VERSION
print_message "Étape 1/4 : Synchronisation de la version..."
echo
VERSION_FILE="../VERSION"
if [ ! -f "$VERSION_FILE" ]; then
print_error "Fichier VERSION introuvable : $VERSION_FILE"
exit 1
fi
# Lire la version depuis le fichier (enlever espaces/retours à la ligne)
VERSION_NUMBER=$(cat "$VERSION_FILE" | tr -d '\n\r ' | tr -d '[:space:]')
if [ -z "$VERSION_NUMBER" ]; then
print_error "Le fichier VERSION est vide"
exit 1
fi
print_message "Version lue depuis $VERSION_FILE : $VERSION_NUMBER"
# Calculer le versionCode (supprimer les points)
VERSION_CODE=$(echo $VERSION_NUMBER | tr -d '.')
if [ -z "$VERSION_CODE" ]; then
print_error "Impossible de calculer le versionCode"
exit 1
fi
print_message "Version code calculé : $VERSION_CODE"
# Mettre à jour pubspec.yaml
print_message "Mise à jour de pubspec.yaml..."
sed -i.bak "s/^version:.*/version: $VERSION_NUMBER+$VERSION_CODE/" pubspec.yaml
# Vérifier que la mise à jour a réussi
UPDATED_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //')
if [ "$UPDATED_VERSION" != "$VERSION_NUMBER+$VERSION_CODE" ]; then
print_error "Échec de la mise à jour de pubspec.yaml"
print_error "Attendu : $VERSION_NUMBER+$VERSION_CODE"
print_error "Obtenu : $UPDATED_VERSION"
exit 1
fi
print_success "pubspec.yaml mis à jour : version: $VERSION_NUMBER+$VERSION_CODE"
echo
# Récupérer la version finale pour l'affichage
VERSION="$VERSION_NUMBER-$VERSION_CODE"
print_message "Version finale : $VERSION"
print_message "Version code : $VERSION_CODE"
echo
# Étape 2 : Vérifier la connexion au Mac mini
print_message "Étape 2/4 : Vérification de la connexion au Mac mini..."
echo
print_message "Test de connexion à $MAC_USER@$MAC_MINI_IP..."
if ssh -o ConnectTimeout=5 -o BatchMode=yes "$MAC_USER@$MAC_MINI_IP" exit 2>/dev/null; then
print_success "Connexion SSH au Mac mini établie"
else
print_warning "Impossible de se connecter au Mac mini en mode non-interactif"
print_message "Le transfert demandera votre mot de passe"
fi
echo
# Étape 3 : Nettoyer le projet
print_message "Étape 3/4 : Nettoyage du projet local..."
echo
print_message "Nettoyage en cours..."
flutter clean
print_success "Projet nettoyé"
echo
# Étape 4 : Transfert vers Mac mini
print_message "Étape 4/4 : Transfert vers Mac mini..."
echo
# Construire le chemin de destination avec numéro de version
DESTINATION_DIR="app_$VERSION_CODE"
DESTINATION="$MAC_USER@$MAC_MINI_IP:/Users/pierre/dev/geosector/$DESTINATION_DIR"
print_message "Configuration du transfert :"
print_message " Source : $(pwd)"
print_message " Destination : $DESTINATION"
echo
# Supprimer le dossier de destination s'il existe déjà
print_message "Suppression du dossier existant sur le Mac mini (si présent)..."
ssh -o IdentitiesOnly=yes -o PubkeyAuthentication=no -o PreferredAuthentications=password \
"$MAC_USER@$MAC_MINI_IP" "rm -rf /Users/pierre/dev/geosector/$DESTINATION_DIR" 2>/dev/null || true
print_success "Dossier nettoyé"
echo
print_warning "Note: Vous allez devoir saisir le mot de passe du Mac mini"
print_message "rsync va créer le dossier de destination automatiquement"
echo
# Transfert réel (rsync créera le dossier automatiquement)
# Options SSH pour éviter "too many authentication failures"
rsync -avz --progress \
-e "ssh -o IdentitiesOnly=yes -o PubkeyAuthentication=no -o PreferredAuthentications=password" \
--rsync-path="mkdir -p /Users/pierre/dev/geosector/$DESTINATION_DIR && rsync" \
--exclude='build/' \
--exclude='.dart_tool/' \
--exclude='ios/Pods/' \
--exclude='ios/.symlinks/' \
--exclude='macos/Pods/' \
--exclude='linux/flutter/ephemeral/' \
--exclude='windows/flutter/ephemeral/' \
--exclude='.pub-cache/' \
--exclude='android/build/' \
--exclude='*.aab' \
--exclude='*.apk' \
./ "$DESTINATION/"
if [ $? -eq 0 ]; then
echo
print_success "Transfert terminé avec succès !"
echo
else
print_error "Erreur lors du transfert"
exit 1
fi
# Afficher le résumé
echo
print_message "========================================="
print_success " TRANSFERT TERMINÉ AVEC SUCCÈS !"
print_message "========================================="
echo
print_message "Version : ${GREEN}$VERSION${NC}"
print_message "Dossier sur le Mac : ${GREEN}/Users/pierre/dev/geosector/$DESTINATION_DIR${NC}"
echo
# Proposer de lancer le build automatiquement
print_message "Voulez-vous lancer le build iOS maintenant ?"
echo
print_message " ${GREEN}[A]${NC} Se connecter en SSH et lancer le build automatiquement"
print_message " ${YELLOW}[B]${NC} Afficher les instructions manuelles (défaut)"
echo
read -p "Votre choix (A/B) [défaut: B] : " -n 1 -r BUILD_CHOICE
echo
echo
if [[ $BUILD_CHOICE =~ ^[Aa]$ ]]; then
print_message "Connexion au Mac mini et lancement du build..."
echo
# Se connecter en SSH et lancer le build
# Options SSH pour éviter "too many authentication failures"
ssh -t -o IdentitiesOnly=yes -o PubkeyAuthentication=no -o PreferredAuthentications=password \
"$MAC_USER@$MAC_MINI_IP" "cd /Users/pierre/dev/geosector/$DESTINATION_DIR && ./ios-build-mac.sh"
echo
print_success "Build terminé sur le Mac mini"
echo
print_message "Prochaines étapes sur le Mac mini :"
print_message "1. Xcode est maintenant ouvert"
print_message "2. Vérifier Signing & Capabilities (Team: 6WT84NWCTC)"
print_message "3. Product > Archive"
print_message "4. Organizer > Distribute App > App Store Connect"
else
print_message "========================================="
print_message " INSTRUCTIONS MANUELLES"
print_message "========================================="
echo
print_message "Sur votre Mac mini, exécutez les commandes suivantes :"
echo
echo -e "${YELLOW}# Se connecter au Mac mini${NC}"
echo "ssh $MAC_USER@$MAC_MINI_IP"
echo
echo -e "${YELLOW}# Aller dans le dossier du projet${NC}"
echo "cd /Users/pierre/dev/geosector/$DESTINATION_DIR"
echo
echo -e "${YELLOW}# Lancer le build iOS${NC}"
echo "./ios-build-mac.sh"
echo
print_message "========================================="
echo
print_message "Ou copiez-collez cette commande complète :"
echo
echo -e "${GREEN}ssh -t $MAC_USER@$MAC_MINI_IP \"cd /Users/pierre/dev/geosector/$DESTINATION_DIR && ./ios-build-mac.sh\"${NC}"
fi
echo
print_success "Script terminé !"

Binary file not shown.

View File

@@ -488,7 +488,8 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
@@ -504,7 +505,7 @@
PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3; PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 App Store"; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 Development";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
@@ -680,7 +681,8 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
@@ -696,7 +698,7 @@
PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3; PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 App Store"; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 Development";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -710,7 +712,8 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = ""; DEVELOPMENT_TEAM = "";
@@ -726,7 +729,7 @@
PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3; PRODUCT_BUNDLE_IDENTIFIER = fr.geosector.app3;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 App Store"; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "GeoSector v3 Development";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.proximity-reader.payment.acceptance</key>
<true/>
</dict>
</plist>

View File

@@ -1,8 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:geosector_app/core/theme/app_theme.dart'; import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/services/theme_service.dart'; import 'package:geosector_app/core/services/theme_service.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
// Import conditionnel pour le web
import 'package:universal_html/html.dart' as html;
import 'package:geosector_app/core/services/current_user_service.dart'; import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/repositories/user_repository.dart'; import 'package:geosector_app/core/repositories/user_repository.dart';
@@ -24,6 +27,7 @@ import 'package:geosector_app/presentation/pages/messages_page.dart';
import 'package:geosector_app/presentation/pages/amicale_page.dart'; import 'package:geosector_app/presentation/pages/amicale_page.dart';
import 'package:geosector_app/presentation/pages/operations_page.dart'; import 'package:geosector_app/presentation/pages/operations_page.dart';
import 'package:geosector_app/presentation/pages/field_mode_page.dart'; import 'package:geosector_app/presentation/pages/field_mode_page.dart';
import 'package:geosector_app/presentation/pages/connexions_page.dart';
// Instances globales des repositories (plus besoin d'injecter ApiService) // Instances globales des repositories (plus besoin d'injecter ApiService)
final operationRepository = OperationRepository(); final operationRepository = OperationRepository();
@@ -44,10 +48,80 @@ class GeosectorApp extends StatefulWidget {
} }
class _GeosectorAppState extends State<GeosectorApp> with WidgetsBindingObserver { class _GeosectorAppState extends State<GeosectorApp> with WidgetsBindingObserver {
// Clé globale pour accéder au contexte de l'app (pour les dialogues)
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
// Sur Web, intercepter F5 / Ctrl+R pour proposer un refresh des données
if (kIsWeb) {
_setupF5Interceptor();
}
}
/// Configure l'interception de F5/Ctrl+R sur Web
void _setupF5Interceptor() {
html.window.onKeyDown.listen((event) {
// Détecter F5 ou Ctrl+R
final isF5 = event.key == 'F5';
final isCtrlR = (event.ctrlKey || event.metaKey) && event.key?.toLowerCase() == 'r';
if (isF5 || isCtrlR) {
event.preventDefault();
debugPrint('🔄 F5/Ctrl+R intercepté - Affichage du dialogue de refresh');
_showRefreshDialog();
}
});
debugPrint('🌐 Intercepteur F5/Ctrl+R configuré pour Web');
}
/// Affiche le dialogue de confirmation de refresh
void _showRefreshDialog() {
final context = navigatorKey.currentContext;
if (context == null) {
debugPrint('⚠️ Impossible d\'afficher le dialogue - contexte non disponible');
return;
}
showDialog(
context: context,
barrierDismissible: true,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Row(
children: [
Icon(Icons.refresh, color: Colors.blue),
SizedBox(width: 12),
Text('Recharger les données ?'),
],
),
content: const Text(
'Voulez-vous actualiser vos données depuis le serveur ?\n\n'
'Vos modifications non synchronisées seront conservées.',
),
actions: [
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
debugPrint('❌ Refresh annulé par l\'utilisateur');
},
child: const Text('Non'),
),
ElevatedButton(
onPressed: () {
Navigator.of(dialogContext).pop();
debugPrint('✅ Refresh demandé par l\'utilisateur');
// TODO: Implémenter le refresh des données via API
},
child: const Text('Oui, recharger'),
),
],
);
},
);
} }
@override @override
@@ -158,6 +232,7 @@ class _GeosectorAppState extends State<GeosectorApp> with WidgetsBindingObserver
/// Création du routeur avec configuration pour URLs propres /// Création du routeur avec configuration pour URLs propres
GoRouter _createRouter() { GoRouter _createRouter() {
return GoRouter( return GoRouter(
navigatorKey: navigatorKey,
initialLocation: '/', initialLocation: '/',
routes: [ routes: [
GoRoute( GoRoute(
@@ -322,6 +397,15 @@ class _GeosectorAppState extends State<GeosectorApp> with WidgetsBindingObserver
return const OperationsPage(); return const OperationsPage();
}, },
), ),
// Sous-route pour connexions (role 2+ uniquement)
GoRoute(
path: 'connexions',
name: 'admin-connexions',
builder: (context, state) {
debugPrint('GoRoute: Affichage de ConnexionsPage (unifiée)');
return const ConnexionsPage();
},
),
], ],
), ),
], ],

View File

@@ -26,7 +26,7 @@ class RoomAdapter extends TypeAdapter<Room> {
unreadCount: fields[6] as int, unreadCount: fields[6] as int,
recentMessages: (fields[7] as List?) recentMessages: (fields[7] as List?)
?.map((dynamic e) => (e as Map).cast<String, dynamic>()) ?.map((dynamic e) => (e as Map).cast<String, dynamic>())
?.toList(), .toList(),
updatedAt: fields[8] as DateTime?, updatedAt: fields[8] as DateTime?,
createdBy: fields[9] as int?, createdBy: fields[9] as int?,
isSynced: fields[10] as bool, isSynced: fields[10] as bool,

View File

@@ -28,9 +28,8 @@ class ChatService {
Timer? _syncTimer; Timer? _syncTimer;
DateTime? _lastSyncTimestamp; DateTime? _lastSyncTimestamp;
DateTime? _lastFullSync; static const Duration _syncInterval = Duration(seconds: 30); // Sync toutes les 30 secondes
static const Duration _syncInterval = Duration(seconds: 15); // Changé à 15 secondes comme suggéré par l'API static const Duration _initialSyncDelay = Duration(seconds: 10); // Délai avant première sync
static const Duration _fullSyncInterval = Duration(minutes: 5);
/// Initialisation avec gestion des rôles et configuration YAML /// Initialisation avec gestion des rôles et configuration YAML
static Future<void> init({ static Future<void> init({
@@ -76,9 +75,12 @@ class ChatService {
// Charger le dernier timestamp de sync depuis Hive // Charger le dernier timestamp de sync depuis Hive
await _instance!._loadSyncTimestamp(); await _instance!._loadSyncTimestamp();
// Faire la sync initiale complète au login // Faire la sync initiale complète au login avec délai de 10 secondes
debugPrint('⏳ Sync initiale chat programmée dans 10 secondes...');
Future.delayed(_initialSyncDelay, () async {
await _instance!.getRooms(forceFullSync: true); await _instance!.getRooms(forceFullSync: true);
debugPrint('✅ Sync initiale complète effectuée au login'); debugPrint('✅ Sync initiale complète effectuée au login');
});
// Démarrer la synchronisation incrémentale périodique // Démarrer la synchronisation incrémentale périodique
_instance!._startSync(); _instance!._startSync();
@@ -136,6 +138,13 @@ class ChatService {
/// Obtenir les rooms avec synchronisation incrémentale /// Obtenir les rooms avec synchronisation incrémentale
Future<List<Room>> getRooms({bool forceFullSync = false}) async { Future<List<Room>> getRooms({bool forceFullSync = false}) async {
// DÉSACTIVATION TEMPORAIRE - Retour direct du cache sans appeler l'API
debugPrint('🚫 API /chat/rooms désactivée - utilisation du cache uniquement');
return _roomsBox.values.toList()
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
/* Code original commenté pour désactiver les appels API
// Vérifier la connectivité // Vérifier la connectivité
if (!connectivityService.isConnected) { if (!connectivityService.isConnected) {
debugPrint('📵 Pas de connexion réseau - utilisation du cache'); debugPrint('📵 Pas de connexion réseau - utilisation du cache');
@@ -156,15 +165,17 @@ class ChatService {
if (needsFullSync || _lastSyncTimestamp == null) { if (needsFullSync || _lastSyncTimestamp == null) {
// Synchronisation complète // Synchronisation complète
debugPrint('🔄 Synchronisation complète des rooms...'); debugPrint('🔄 Synchronisation complète des rooms...');
response = await _dio.get('/chat/rooms'); // response = await _dio.get('/chat/rooms'); // COMMENTÉ - Désactivation GET /chat/rooms
return; // Retour anticipé pour éviter l'appel API
_lastFullSync = now; _lastFullSync = now;
} else { } else {
// Synchronisation incrémentale // Synchronisation incrémentale
final isoTimestamp = _lastSyncTimestamp!.toUtc().toIso8601String(); final isoTimestamp = _lastSyncTimestamp!.toUtc().toIso8601String();
// debugPrint('🔄 Synchronisation incrémentale depuis $isoTimestamp'); // debugPrint('🔄 Synchronisation incrémentale depuis $isoTimestamp');
response = await _dio.get('/chat/rooms', queryParameters: { // response = await _dio.get('/chat/rooms', queryParameters: { // COMMENTÉ - Désactivation GET /chat/rooms
'updated_after': isoTimestamp, // 'updated_after': isoTimestamp,
}); // });
return; // Retour anticipé pour éviter l'appel API
} }
// Extraire le timestamp de synchronisation fourni par l'API // Extraire le timestamp de synchronisation fourni par l'API
@@ -348,6 +359,7 @@ class ChatService {
..sort((a, b) => (b.lastMessageAt ?? b.createdAt) ..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt)); .compareTo(a.lastMessageAt ?? a.createdAt));
} }
*/// Fin du code commenté
} }
/// Créer une room avec vérification des permissions /// Créer une room avec vérification des permissions
@@ -754,7 +766,7 @@ class ChatService {
}); });
// Pas de sync immédiate ici car déjà faite dans init() // Pas de sync immédiate ici car déjà faite dans init()
debugPrint('⏰ Timer de sync incrémentale démarré (toutes les 15 secondes)'); debugPrint('⏰ Timer de sync incrémentale démarré (toutes les 30 secondes)');
} }
/// Mettre en pause les synchronisations (app en arrière-plan) /// Mettre en pause les synchronisations (app en arrière-plan)

View File

@@ -146,9 +146,9 @@ class AppKeys {
1: { 1: {
'titres': 'Effectués', 'titres': 'Effectués',
'titre': 'Effectué', 'titre': 'Effectué',
'couleur1': 0xFF00E09D, // Vert (Figma) 'couleur1': 0xFF008000, // Vert foncé
'couleur2': 0xFF00E09D, // Vert (Figma) 'couleur2': 0xFF008000, // Vert foncé
'couleur3': 0xFF00E09D, // Vert (Figma) 'couleur3': 0xFF008000, // Vert foncé
'icon_data': Icons.task_alt, 'icon_data': Icons.task_alt,
}, },
2: { 2: {
@@ -170,9 +170,9 @@ class AppKeys {
4: { 4: {
'titres': 'Dons', 'titres': 'Dons',
'titre': 'Don', 'titre': 'Don',
'couleur1': 0xFF395AA7, // Bleu (Figma) 'couleur1': 0xFF00BCD4, // Cyan
'couleur2': 0xFF395AA7, // Bleu (Figma) 'couleur2': 0xFF00BCD4, // Cyan
'couleur3': 0xFF395AA7, // Bleu (Figma) 'couleur3': 0xFF00BCD4, // Cyan
'icon_data': Icons.volunteer_activism, 'icon_data': Icons.volunteer_activism,
}, },
5: { 5: {

View File

@@ -0,0 +1,594 @@
// Modèles pour les statistiques d'événements (connexions, passages, etc.)
//
// Ces modèles ne sont PAS stockés dans Hive car les données sont récupérées
// à la demande depuis l'API et ne nécessitent pas de persistance locale.
/// Statistiques d'authentification
class AuthStats {
final int success;
final int failed;
final int logout;
const AuthStats({
required this.success,
required this.failed,
required this.logout,
});
factory AuthStats.fromJson(Map<String, dynamic> json) {
return AuthStats(
success: _parseInt(json['success']),
failed: _parseInt(json['failed']),
logout: _parseInt(json['logout']),
);
}
int get total => success + failed + logout;
}
/// Statistiques de passages
class PassageStats {
final int created;
final int updated;
final int deleted;
final double amount;
const PassageStats({
required this.created,
required this.updated,
required this.deleted,
required this.amount,
});
factory PassageStats.fromJson(Map<String, dynamic> json) {
return PassageStats(
created: _parseInt(json['created']),
updated: _parseInt(json['updated']),
deleted: _parseInt(json['deleted']),
amount: _parseDouble(json['amount']),
);
}
int get total => created + updated + deleted;
}
/// Statistiques utilisateurs
class UserStats {
final int created;
final int updated;
final int deleted;
const UserStats({
required this.created,
required this.updated,
required this.deleted,
});
factory UserStats.fromJson(Map<String, dynamic> json) {
return UserStats(
created: _parseInt(json['created']),
updated: _parseInt(json['updated']),
deleted: _parseInt(json['deleted']),
);
}
int get total => created + updated + deleted;
}
/// Statistiques secteurs
class SectorStats {
final int created;
final int updated;
final int deleted;
const SectorStats({
required this.created,
required this.updated,
required this.deleted,
});
factory SectorStats.fromJson(Map<String, dynamic> json) {
return SectorStats(
created: _parseInt(json['created']),
updated: _parseInt(json['updated']),
deleted: _parseInt(json['deleted']),
);
}
int get total => created + updated + deleted;
}
/// Statistiques Stripe
class StripeStats {
final int created;
final int success;
final int failed;
final int cancelled;
final double amount;
const StripeStats({
required this.created,
required this.success,
required this.failed,
required this.cancelled,
required this.amount,
});
factory StripeStats.fromJson(Map<String, dynamic> json) {
return StripeStats(
created: _parseInt(json['created']),
success: _parseInt(json['success']),
failed: _parseInt(json['failed']),
cancelled: _parseInt(json['cancelled']),
amount: _parseDouble(json['amount']),
);
}
int get total => created + success + failed + cancelled;
}
/// Statistiques globales d'une journée
class DayStats {
final AuthStats auth;
final PassageStats passages;
final UserStats users;
final SectorStats sectors;
final StripeStats stripe;
const DayStats({
required this.auth,
required this.passages,
required this.users,
required this.sectors,
required this.stripe,
});
factory DayStats.fromJson(Map<String, dynamic> json) {
return DayStats(
auth: AuthStats.fromJson(json['auth'] ?? {}),
passages: PassageStats.fromJson(json['passages'] ?? {}),
users: UserStats.fromJson(json['users'] ?? {}),
sectors: SectorStats.fromJson(json['sectors'] ?? {}),
stripe: StripeStats.fromJson(json['stripe'] ?? {}),
);
}
}
/// Totaux d'une journée
class DayTotals {
final int events;
final int uniqueUsers;
const DayTotals({
required this.events,
required this.uniqueUsers,
});
factory DayTotals.fromJson(Map<String, dynamic> json) {
return DayTotals(
events: _parseInt(json['events']),
uniqueUsers: _parseInt(json['unique_users']),
);
}
}
/// Résumé complet d'une journée (réponse de /stats/summary)
class EventSummary {
final DateTime date;
final DayStats stats;
final DayTotals totals;
const EventSummary({
required this.date,
required this.stats,
required this.totals,
});
factory EventSummary.fromJson(Map<String, dynamic> json) {
return EventSummary(
date: DateTime.parse(json['date']),
stats: DayStats.fromJson(json['stats'] ?? {}),
totals: DayTotals.fromJson(json['totals'] ?? {}),
);
}
}
/// Statistiques d'un type d'événement pour une période
class EventTypeStats {
final int count;
final double sumAmount;
final int uniqueUsers;
const EventTypeStats({
required this.count,
required this.sumAmount,
required this.uniqueUsers,
});
factory EventTypeStats.fromJson(Map<String, dynamic> json) {
return EventTypeStats(
count: _parseInt(json['count']),
sumAmount: _parseDouble(json['sum_amount']),
uniqueUsers: _parseInt(json['unique_users']),
);
}
}
/// Données d'une journée dans les stats quotidiennes
class DailyStatsEntry {
final DateTime date;
final Map<String, EventTypeStats> events;
final int totalCount;
final double totalAmount;
const DailyStatsEntry({
required this.date,
required this.events,
required this.totalCount,
required this.totalAmount,
});
factory DailyStatsEntry.fromJson(Map<String, dynamic> json) {
final eventsJson = json['events'] as Map<String, dynamic>? ?? {};
final events = <String, EventTypeStats>{};
for (final entry in eventsJson.entries) {
events[entry.key] = EventTypeStats.fromJson(entry.value);
}
final totals = json['totals'] as Map<String, dynamic>? ?? {};
return DailyStatsEntry(
date: DateTime.parse(json['date']),
events: events,
totalCount: _parseInt(totals['count']),
totalAmount: _parseDouble(totals['sum_amount']),
);
}
}
/// Réponse des stats quotidiennes (/stats/daily)
class DailyStats {
final DateTime from;
final DateTime to;
final List<DailyStatsEntry> days;
const DailyStats({
required this.from,
required this.to,
required this.days,
});
factory DailyStats.fromJson(Map<String, dynamic> json) {
final daysJson = json['days'] as List<dynamic>? ?? [];
return DailyStats(
from: DateTime.parse(json['from']),
to: DateTime.parse(json['to']),
days: daysJson.map((d) => DailyStatsEntry.fromJson(d)).toList(),
);
}
}
/// Données d'une semaine dans les stats hebdomadaires
class WeeklyStatsEntry {
final DateTime weekStart;
final int weekNumber;
final int year;
final Map<String, EventTypeStats> events;
final int totalCount;
final double totalAmount;
const WeeklyStatsEntry({
required this.weekStart,
required this.weekNumber,
required this.year,
required this.events,
required this.totalCount,
required this.totalAmount,
});
factory WeeklyStatsEntry.fromJson(Map<String, dynamic> json) {
final eventsJson = json['events'] as Map<String, dynamic>? ?? {};
final events = <String, EventTypeStats>{};
for (final entry in eventsJson.entries) {
events[entry.key] = EventTypeStats.fromJson(entry.value);
}
final totals = json['totals'] as Map<String, dynamic>? ?? {};
return WeeklyStatsEntry(
weekStart: DateTime.parse(json['week_start']),
weekNumber: _parseInt(json['week_number']),
year: _parseInt(json['year']),
events: events,
totalCount: _parseInt(totals['count']),
totalAmount: _parseDouble(totals['sum_amount']),
);
}
}
/// Réponse des stats hebdomadaires (/stats/weekly)
class WeeklyStats {
final DateTime from;
final DateTime to;
final List<WeeklyStatsEntry> weeks;
const WeeklyStats({
required this.from,
required this.to,
required this.weeks,
});
factory WeeklyStats.fromJson(Map<String, dynamic> json) {
final weeksJson = json['weeks'] as List<dynamic>? ?? [];
return WeeklyStats(
from: DateTime.parse(json['from']),
to: DateTime.parse(json['to']),
weeks: weeksJson.map((w) => WeeklyStatsEntry.fromJson(w)).toList(),
);
}
}
/// Données d'un mois dans les stats mensuelles
class MonthlyStatsEntry {
final String month; // Format: "2025-01"
final int year;
final int monthNumber;
final Map<String, EventTypeStats> events;
final int totalCount;
final double totalAmount;
const MonthlyStatsEntry({
required this.month,
required this.year,
required this.monthNumber,
required this.events,
required this.totalCount,
required this.totalAmount,
});
factory MonthlyStatsEntry.fromJson(Map<String, dynamic> json) {
final eventsJson = json['events'] as Map<String, dynamic>? ?? {};
final events = <String, EventTypeStats>{};
for (final entry in eventsJson.entries) {
events[entry.key] = EventTypeStats.fromJson(entry.value);
}
final totals = json['totals'] as Map<String, dynamic>? ?? {};
return MonthlyStatsEntry(
month: json['month'] ?? '',
year: _parseInt(json['year']),
monthNumber: _parseInt(json['month_number']),
events: events,
totalCount: _parseInt(totals['count']),
totalAmount: _parseDouble(totals['sum_amount']),
);
}
}
/// Réponse des stats mensuelles (/stats/monthly)
class MonthlyStats {
final int year;
final List<MonthlyStatsEntry> months;
const MonthlyStats({
required this.year,
required this.months,
});
factory MonthlyStats.fromJson(Map<String, dynamic> json) {
final monthsJson = json['months'] as List<dynamic>? ?? [];
return MonthlyStats(
year: _parseInt(json['year']),
months: monthsJson.map((m) => MonthlyStatsEntry.fromJson(m)).toList(),
);
}
}
/// Détail d'un événement individuel
class EventDetail {
final DateTime timestamp;
final String event;
final String? username;
final String? reason;
final int? attempt;
final String? ip;
final String? platform;
final String? appVersion;
final Map<String, dynamic>? extra;
const EventDetail({
required this.timestamp,
required this.event,
this.username,
this.reason,
this.attempt,
this.ip,
this.platform,
this.appVersion,
this.extra,
});
factory EventDetail.fromJson(Map<String, dynamic> json) {
return EventDetail(
timestamp: DateTime.parse(json['timestamp']),
event: json['event'] ?? '',
username: json['username'],
reason: json['reason'],
attempt: json['attempt'] != null ? _parseInt(json['attempt']) : null,
ip: json['ip'],
platform: json['platform'],
appVersion: json['app_version'],
extra: json,
);
}
}
/// Pagination pour les détails
class EventPagination {
final int total;
final int limit;
final int offset;
final bool hasMore;
const EventPagination({
required this.total,
required this.limit,
required this.offset,
required this.hasMore,
});
factory EventPagination.fromJson(Map<String, dynamic> json) {
return EventPagination(
total: _parseInt(json['total']),
limit: _parseInt(json['limit']),
offset: _parseInt(json['offset']),
hasMore: json['has_more'] == true,
);
}
}
/// Réponse des détails d'événements (/stats/details)
class EventDetails {
final DateTime date;
final List<EventDetail> events;
final EventPagination pagination;
const EventDetails({
required this.date,
required this.events,
required this.pagination,
});
factory EventDetails.fromJson(Map<String, dynamic> json) {
final eventsJson = json['events'] as List<dynamic>? ?? [];
return EventDetails(
date: DateTime.parse(json['date']),
events: eventsJson.map((e) => EventDetail.fromJson(e)).toList(),
pagination: EventPagination.fromJson(json['pagination'] ?? {}),
);
}
}
/// Types d'événements disponibles
class EventTypes {
final List<String> auth;
final List<String> passages;
final List<String> sectors;
final List<String> users;
final List<String> entities;
final List<String> operations;
final List<String> stripe;
const EventTypes({
required this.auth,
required this.passages,
required this.sectors,
required this.users,
required this.entities,
required this.operations,
required this.stripe,
});
factory EventTypes.fromJson(Map<String, dynamic> json) {
return EventTypes(
auth: _parseStringList(json['auth']),
passages: _parseStringList(json['passages']),
sectors: _parseStringList(json['sectors']),
users: _parseStringList(json['users']),
entities: _parseStringList(json['entities']),
operations: _parseStringList(json['operations']),
stripe: _parseStringList(json['stripe']),
);
}
/// Obtient tous les types d'événements à plat
List<String> get allTypes => [
...auth,
...passages,
...sectors,
...users,
...entities,
...operations,
...stripe,
];
/// Obtient le libellé français d'un type d'événement
static String getLabel(String eventType) {
switch (eventType) {
// Auth
case 'login_success': return 'Connexion réussie';
case 'login_failed': return 'Connexion échouée';
case 'logout': return 'Déconnexion';
// Passages
case 'passage_created': return 'Passage créé';
case 'passage_updated': return 'Passage modifié';
case 'passage_deleted': return 'Passage supprimé';
// Sectors
case 'sector_created': return 'Secteur créé';
case 'sector_updated': return 'Secteur modifié';
case 'sector_deleted': return 'Secteur supprimé';
// Users
case 'user_created': return 'Utilisateur créé';
case 'user_updated': return 'Utilisateur modifié';
case 'user_deleted': return 'Utilisateur supprimé';
// Entities
case 'entity_created': return 'Entité créée';
case 'entity_updated': return 'Entité modifiée';
case 'entity_deleted': return 'Entité supprimée';
// Operations
case 'operation_created': return 'Opération créée';
case 'operation_updated': return 'Opération modifiée';
case 'operation_deleted': return 'Opération supprimée';
// Stripe
case 'stripe_payment_created': return 'Paiement créé';
case 'stripe_payment_success': return 'Paiement réussi';
case 'stripe_payment_failed': return 'Paiement échoué';
case 'stripe_payment_cancelled': return 'Paiement annulé';
case 'stripe_terminal_error': return 'Erreur terminal';
default: return eventType;
}
}
/// Obtient la catégorie d'un type d'événement
static String getCategory(String eventType) {
if (eventType.startsWith('login') || eventType == 'logout') return 'auth';
if (eventType.startsWith('passage')) return 'passages';
if (eventType.startsWith('sector')) return 'sectors';
if (eventType.startsWith('user')) return 'users';
if (eventType.startsWith('entity')) return 'entities';
if (eventType.startsWith('operation')) return 'operations';
if (eventType.startsWith('stripe')) return 'stripe';
return 'other';
}
}
// Helpers pour parser les types depuis JSON (gère int/string)
int _parseInt(dynamic value) {
if (value == null) return 0;
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? 0;
if (value is double) return value.toInt();
return 0;
}
double _parseDouble(dynamic value) {
if (value == null) return 0.0;
if (value is double) return value;
if (value is int) return value.toDouble();
if (value is String) return double.tryParse(value) ?? 0.0;
return 0.0;
}
List<String> _parseStringList(dynamic value) {
if (value == null) return [];
if (value is List) return value.map((e) => e.toString()).toList();
return [];
}

View File

@@ -247,10 +247,10 @@ class OperationRepository extends ChangeNotifier {
debugPrint('✅ Opérations traitées'); debugPrint('✅ Opérations traitées');
} }
// Traiter les secteurs (groupe secteurs) via DataLoadingService // Traiter les secteurs (groupe sectors) via DataLoadingService
if (responseData['secteurs'] != null) { if (responseData['sectors'] != null) {
await DataLoadingService.instance await DataLoadingService.instance
.processSectorsFromApi(responseData['secteurs']); .processSectorsFromApi(responseData['sectors']);
debugPrint('✅ Secteurs traités'); debugPrint('✅ Secteurs traités');
} }
@@ -521,10 +521,10 @@ class OperationRepository extends ChangeNotifier {
debugPrint('✅ Opérations traitées'); debugPrint('✅ Opérations traitées');
} }
// Traiter les secteurs (groupe secteurs) via DataLoadingService // Traiter les secteurs (groupe sectors) via DataLoadingService
if (responseData['secteurs'] != null) { if (responseData['sectors'] != null) {
await DataLoadingService.instance await DataLoadingService.instance
.processSectorsFromApi(responseData['secteurs']); .processSectorsFromApi(responseData['sectors']);
debugPrint('✅ Secteurs traités'); debugPrint('✅ Secteurs traités');
} }

View File

@@ -246,8 +246,9 @@ class PassageRepository extends ChangeNotifier {
// Mode online : traitement normal // Mode online : traitement normal
if (response.statusCode == 201 || response.statusCode == 200) { if (response.statusCode == 201 || response.statusCode == 200) {
// Récupérer l'ID du nouveau passage depuis la réponse // Récupérer l'ID du nouveau passage depuis la réponse (passage_id ou id)
final passageId = response.data['id'] is String ? int.parse(response.data['id']) : response.data['id'] as int; final rawId = response.data['passage_id'] ?? response.data['id'];
final passageId = rawId is String ? int.parse(rawId) : rawId as int;
// Créer le passage localement avec l'ID retourné par l'API // Créer le passage localement avec l'ID retourné par l'API
final newPassage = passage.copyWith( final newPassage = passage.copyWith(
@@ -431,10 +432,10 @@ class PassageRepository extends ChangeNotifier {
return true; return true;
} }
return false; throw Exception('Mise à jour refusée par le serveur');
} catch (e) { } catch (e) {
debugPrint('Erreur lors de la mise à jour du passage: $e'); debugPrint('Erreur lors de la mise à jour du passage: $e');
return false; rethrow; // Propager l'exception originale avec son message
} finally { } finally {
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();

View File

@@ -2,6 +2,8 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:geosector_app/core/data/models/user_model.dart'; import 'package:geosector_app/core/data/models/user_model.dart';
@@ -65,17 +67,50 @@ class ApiService {
headers['X-App-Identifier'] = _appIdentifier; headers['X-App-Identifier'] = _appIdentifier;
_dio.options.headers.addAll(headers); _dio.options.headers.addAll(headers);
// Gestionnaire de cookies pour les sessions PHP
// IMPORTANT: Désactivé sur Web car les navigateurs bloquent la manipulation des cookies via XHR
// Sur Web, on utilise uniquement le header Authorization avec Bearer token
if (!kIsWeb) {
final cookieJar = CookieJar();
_dio.interceptors.add(CookieManager(cookieJar));
debugPrint('🍪 [API] Gestionnaire de cookies activé (mobile)');
} else {
debugPrint('🌐 [API] Mode Web - pas de CookieManager (Bearer token uniquement)');
}
_dio.interceptors.add(InterceptorsWrapper( _dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) { onRequest: (options, handler) {
debugPrint('🌐 [API] Requête: ${options.method} ${options.path}');
debugPrint('🔑 [API] _sessionId présent: ${_sessionId != null}');
debugPrint('🔑 [API] Headers: ${options.headers}');
if (_sessionId != null) { if (_sessionId != null) {
debugPrint('🔑 [API] Token: Bearer ${_sessionId!.substring(0, 20)}...');
options.headers[AppKeys.sessionHeader] = 'Bearer $_sessionId'; options.headers[AppKeys.sessionHeader] = 'Bearer $_sessionId';
} else {
debugPrint('⚠️ [API] PAS DE TOKEN - Requête non authentifiée');
} }
handler.next(options); handler.next(options);
}, },
onError: (DioException error, handler) { onError: (DioException error, handler) {
if (error.response?.statusCode == 401) { if (error.response?.statusCode == 401) {
final path = error.requestOptions.path;
debugPrint('❌ [API] Erreur 401 sur: $path');
// Ne pas reset le token pour les requêtes non critiques
final nonCriticalPaths = [
'/users/device-info',
'/chat/rooms',
];
final isNonCritical = nonCriticalPaths.any((p) => path.contains(p));
if (isNonCritical) {
debugPrint('⚠️ [API] Requête non critique - Token conservé');
} else {
debugPrint('❌ [API] Requête critique - Token invalidé');
_sessionId = null; _sessionId = null;
} }
}
handler.next(error); handler.next(error);
}, },
)); ));
@@ -1066,16 +1101,22 @@ class ApiService {
if (data.containsKey('session_id')) { if (data.containsKey('session_id')) {
final sessionId = data['session_id']; final sessionId = data['session_id'];
if (sessionId != null) { if (sessionId != null) {
debugPrint('🔐 [LOGIN] Token reçu du serveur: $sessionId');
debugPrint('🔐 [LOGIN] Longueur token: ${sessionId.toString().length}');
setSessionId(sessionId); setSessionId(sessionId);
debugPrint('🔐 [LOGIN] Token stocké dans _sessionId');
// Collecter et envoyer les informations du device après login réussi // Collecter et envoyer les informations du device après login réussi
debugPrint('📱 Collecte des informations device après login...'); // Délai de 1 seconde pour laisser la session PHP se stabiliser
debugPrint('📱 Collecte des informations device après login (délai 1s)...');
Future.delayed(const Duration(seconds: 1), () {
DeviceInfoService.instance.collectAndSendDeviceInfo().then((_) { DeviceInfoService.instance.collectAndSendDeviceInfo().then((_) {
debugPrint('✅ Informations device collectées et envoyées'); debugPrint('✅ Informations device collectées et envoyées');
}).catchError((error) { }).catchError((error) {
debugPrint('⚠️ Erreur lors de l\'envoi des infos device: $error'); debugPrint('⚠️ Erreur lors de l\'envoi des infos device: $error');
// Ne pas bloquer le login si l'envoi des infos device échoue // Ne pas bloquer le login si l'envoi des infos device échoue
}); });
});
} }
} }

View File

@@ -1,6 +1,6 @@
// ⚠️ AUTO-GENERATED FILE - DO NOT EDIT MANUALLY // ⚠️ AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
// This file is automatically generated by deploy-app.sh script // This file is automatically generated by deploy-app.sh script
// Last update: 2025-11-09 12:39:26 // Last update: 2026-01-19 15:35:06
// Source: ../VERSION file // Source: ../VERSION file
// //
// GEOSECTOR App Version Service // GEOSECTOR App Version Service
@@ -8,10 +8,10 @@
class AppInfoService { class AppInfoService {
// Version number (format: x.x.x) // Version number (format: x.x.x)
static const String version = '3.5.2'; static const String version = '3.6.3';
// Build number (version without dots: xxx) // Build number (version without dots: xxx)
static const String buildNumber = '352'; static const String buildNumber = '363';
// Full version string (format: vx.x.x+xxx) // Full version string (format: vx.x.x+xxx)
static String get fullVersion => 'v$version+$buildNumber'; static String get fullVersion => 'v$version+$buildNumber';

View File

@@ -140,18 +140,43 @@ class CurrentUserService extends ChangeNotifier {
Future<void> loadFromHive() async { Future<void> loadFromHive() async {
try { try {
final box = Hive.box<UserModel>(AppKeys.userBoxName); // Nouvelle Box // 1. Récupérer l'ID utilisateur depuis settings
final user = box.get('current_user'); if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
debugPrint('⚠️ Box settings non ouverte, impossible de charger l\'utilisateur');
_currentUser = null;
notifyListeners();
return;
}
final settingsBox = Hive.box(AppKeys.settingsBoxName);
final userId = settingsBox.get('current_user_id');
if (userId == null) {
debugPrint(' Aucun current_user_id trouvé dans settings');
_currentUser = null;
notifyListeners();
return;
}
debugPrint('🔍 Recherche utilisateur avec ID: $userId');
// 2. Récupérer l'utilisateur avec le bon ID
final box = Hive.box<UserModel>(AppKeys.userBoxName);
final user = box.get(userId);
if (user?.hasValidSession == true) { if (user?.hasValidSession == true) {
_currentUser = user; _currentUser = user;
debugPrint('📥 Utilisateur chargé depuis Hive: ${user?.email}'); debugPrint('📥 Utilisateur chargé depuis Hive: ${user?.email} (ID: $userId)');
// Charger le mode d'affichage sauvegardé lors de la connexion // Charger le mode d'affichage sauvegardé lors de la connexion
await _loadDisplayMode(); await _loadDisplayMode();
} else { } else {
_currentUser = null; _currentUser = null;
debugPrint(' Aucun utilisateur valide trouvé dans Hive'); if (user == null) {
debugPrint(' Utilisateur ID $userId non trouvé dans la box');
} else {
debugPrint(' Session expirée pour l\'utilisateur ${user.email}');
}
} }
notifyListeners(); notifyListeners();

View File

@@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:battery_plus/battery_plus.dart'; import 'package:battery_plus/battery_plus.dart';
@@ -211,18 +212,18 @@ class DeviceInfoService {
} }
bool _checkIosTapToPaySupport(String machine, String systemVersion) { bool _checkIosTapToPaySupport(String machine, String systemVersion) {
// iPhone XS et plus récents (liste des identifiants) // Vérifier le modèle : iPhone11,x ou supérieur (iPhone XS+)
final supportedDevices = [ // Extraire le numéro majeur de l'identifiant (ex: "iPhone17,3" -> 17)
'iPhone11,', // XS, XS Max bool deviceSupported = false;
'iPhone12,', // 11, 11 Pro, 11 Pro Max
'iPhone13,', // 12 series
'iPhone14,', // 13 series
'iPhone15,', // 14 series
'iPhone16,', // 15 series
];
// Vérifier le modèle if (machine.startsWith('iPhone')) {
bool deviceSupported = supportedDevices.any((prefix) => machine.startsWith(prefix)); final match = RegExp(r'iPhone(\d+),').firstMatch(machine);
if (match != null) {
final majorVersion = int.tryParse(match.group(1) ?? '0') ?? 0;
// iPhone XS = iPhone11,x donc >= 11 pour supporter Tap to Pay
deviceSupported = majorVersion >= 11;
}
}
// Vérifier la version iOS (16.4+ selon la documentation officielle Stripe) // Vérifier la version iOS (16.4+ selon la documentation officielle Stripe)
final versionParts = systemVersion.split('.'); final versionParts = systemVersion.split('.');
@@ -334,10 +335,10 @@ class DeviceInfoService {
return deviceInfo; return deviceInfo;
} }
/// Vérifie la certification Stripe Tap to Pay via l'API /// Vérifie la certification Stripe Tap to Pay via le SDK Stripe Terminal
Future<bool> checkStripeCertification() async { Future<bool> checkStripeCertification() async {
try { try {
// Sur Web, toujours non certifié // Sur Web, toujours non supporté
if (kIsWeb) { if (kIsWeb) {
debugPrint('📱 Web platform - Tap to Pay non supporté'); debugPrint('📱 Web platform - Tap to Pay non supporté');
return false; return false;
@@ -354,33 +355,35 @@ class DeviceInfoService {
return isSupported; return isSupported;
} }
// Android : vérification via l'API Stripe // Android : vérification des pré-requis hardware de base
// Note: Le vrai check de compatibilité avec découverte de readers se fera
// dans StripeTapToPayService lors du premier paiement
if (Platform.isAndroid) { if (Platform.isAndroid) {
final androidInfo = await _deviceInfo.androidInfo; final androidInfo = await _deviceInfo.androidInfo;
debugPrint('📱 Vérification Tap to Pay pour ${androidInfo.manufacturer} ${androidInfo.model}');
debugPrint('📱 Vérification certification Stripe pour ${androidInfo.manufacturer} ${androidInfo.model}'); // Vérifications préalables de base
if (androidInfo.version.sdkInt < 28) {
try { debugPrint('❌ Android SDK trop ancien: ${androidInfo.version.sdkInt} (minimum 28)');
final response = await ApiService.instance.post( return false;
'/stripe/devices/check-tap-to-pay',
data: {
'platform': 'android',
'manufacturer': androidInfo.manufacturer,
'model': androidInfo.model,
},
);
final tapToPaySupported = response.data['tap_to_pay_supported'] == true;
final message = response.data['message'] ?? '';
debugPrint('📱 Résultat certification Stripe: $tapToPaySupported - $message');
return tapToPaySupported;
} catch (e) {
debugPrint('❌ Erreur lors de la vérification Stripe: $e');
// En cas d'erreur API, on se base sur la vérification locale
return androidInfo.version.sdkInt >= 28;
} }
// Vérifier la disponibilité du NFC
try {
final nfcAvailable = await NfcManager.instance.isAvailable();
if (!nfcAvailable) {
debugPrint('❌ NFC non disponible sur cet appareil');
return false;
}
debugPrint('✅ NFC disponible');
} catch (e) {
debugPrint('⚠️ Impossible de vérifier NFC: $e');
// On continue quand même, ce n'est pas bloquant à ce stade
}
// Pré-requis de base OK
debugPrint('✅ Pré-requis Android OK (SDK ${androidInfo.version.sdkInt}, NFC disponible)');
return true;
} }
return false; return false;
@@ -390,22 +393,89 @@ class DeviceInfoService {
} }
} }
/// Vérifie si le device peut utiliser Tap to Pay /// Vérifie si le device peut utiliser Tap to Pay
bool canUseTapToPay() { bool canUseTapToPay() {
final deviceInfo = getStoredDeviceInfo(); final deviceInfo = getStoredDeviceInfo();
// Vérifications requises // checkStripeCertification() fait déjà toutes les vérifications (modèle, iOS version, NFC Android)
final nfcCapable = deviceInfo['device_nfc_capable'] == true;
// Utiliser la certification Stripe si disponible, sinon l'ancienne vérification
final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay']; final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay'];
final batteryLevel = deviceInfo['battery_level'] as int?; final batteryLevel = deviceInfo['battery_level'] as int?;
// Batterie minimum 10% pour les paiements // Batterie minimum 10% pour les paiements
final sufficientBattery = batteryLevel != null && batteryLevel >= 10; final sufficientBattery = batteryLevel != null && batteryLevel >= 10;
return nfcCapable && stripeCertified == true && sufficientBattery; return stripeCertified == true && sufficientBattery;
} }
/// Stream pour surveiller les changements de batterie /// Retourne la raison pour laquelle Tap to Pay n'est pas disponible
Stream<BatteryState> get batteryStateStream => _battery.onBatteryStateChanged; /// Retourne null si Tap to Pay est disponible
String? getTapToPayUnavailableReason() {
// Sur Web, Tap to Pay n'est jamais disponible
if (kIsWeb) {
return 'Tap to Pay non disponible sur Web';
}
final deviceInfo = getStoredDeviceInfo();
// Vérifier la batterie
final batteryLevel = deviceInfo['battery_level'] as int?;
if (batteryLevel == null) {
return 'Niveau de batterie inconnu';
}
if (batteryLevel < 10) {
return 'Batterie insuffisante ($batteryLevel%) - Min. 10% requis';
}
// Vérifier la certification Stripe (inclut déjà modèle, iOS version, NFC Android)
final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay'];
if (stripeCertified != true) {
return 'Appareil non certifié pour Tap to Pay';
}
// Tout est OK
return null;
}
/// Version asynchrone avec vérification NFC en temps réel (Android uniquement)
Future<String?> getTapToPayUnavailableReasonAsync() async {
// Sur Web, Tap to Pay n'est jamais disponible
if (kIsWeb) {
return 'Tap to Pay non disponible sur Web';
}
final deviceInfo = getStoredDeviceInfo();
final platform = deviceInfo['platform'];
// Vérifier la batterie
final batteryLevel = deviceInfo['battery_level'] as int?;
if (batteryLevel == null) {
return 'Niveau de batterie inconnu';
}
if (batteryLevel < 10) {
return 'Batterie insuffisante ($batteryLevel%) - Min. 10% requis';
}
// Sur Android, vérifier le NFC EN TEMPS RÉEL (peut être désactivé dans les paramètres)
if (platform == 'Android') {
try {
final nfcAvailable = await NfcManager.instance.isAvailable();
if (!nfcAvailable) {
return 'NFC désactivé - Activez-le dans les paramètres Android';
}
} catch (e) {
debugPrint('⚠️ Impossible de vérifier le statut NFC: $e');
return 'Impossible de vérifier le NFC';
}
}
// Vérifier la certification Stripe (inclut déjà modèle, iOS version)
final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay'];
if (stripeCertified != true) {
return 'Appareil non certifié pour Tap to Pay';
}
// Tout est OK
return null;
}
} }

View File

@@ -0,0 +1,312 @@
import 'package:flutter/foundation.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/data/models/event_stats_model.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:intl/intl.dart';
/// Service pour récupérer les statistiques d'événements depuis l'API.
///
/// Ce service est un singleton qui gère les appels API vers les endpoints
/// /api/events/stats/*. Il est accessible uniquement aux admins (rôle >= 2).
class EventStatsService {
static EventStatsService? _instance;
EventStatsService._internal();
static EventStatsService get instance {
_instance ??= EventStatsService._internal();
return _instance!;
}
final _dateFormat = DateFormat('yyyy-MM-dd');
/// Récupère le résumé des stats pour une date donnée.
///
/// GET /api/events/stats/summary?date=YYYY-MM-DD&entity_id=X
///
/// [date] : Date à récupérer (défaut: aujourd'hui)
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
Future<EventSummary> getSummary({
DateTime? date,
int? entityId,
}) async {
try {
final queryParams = <String, dynamic>{};
if (date != null) {
queryParams['date'] = _dateFormat.format(date);
}
if (entityId != null) {
queryParams['entity_id'] = entityId.toString();
}
debugPrint('📊 [EventStats] Récupération résumé: $queryParams');
final response = await ApiService.instance.get(
'/events/stats/summary',
queryParameters: queryParams.isNotEmpty ? queryParams : null,
);
if (response.data['status'] == 'success') {
return EventSummary.fromJson(response.data['data']);
} else {
throw ApiException(
response.data['message'] ?? 'Erreur lors de la récupération du résumé',
);
}
} catch (e) {
debugPrint('❌ [EventStats] Erreur getSummary: $e');
if (e is ApiException) rethrow;
throw ApiException('Erreur lors de la récupération des statistiques', originalError: e);
}
}
/// Récupère les stats quotidiennes pour une période.
///
/// GET /api/events/stats/daily?from=YYYY-MM-DD&to=YYYY-MM-DD&events=type1,type2
///
/// [from] : Date de début (obligatoire)
/// [to] : Date de fin (obligatoire)
/// [events] : Types d'événements à filtrer (optionnel)
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
///
/// Limite : 90 jours maximum
Future<DailyStats> getDailyStats({
required DateTime from,
required DateTime to,
List<String>? events,
int? entityId,
}) async {
try {
// Vérifier la limite de 90 jours
final daysDiff = to.difference(from).inDays;
if (daysDiff > 90) {
throw const ApiException('La période ne peut pas dépasser 90 jours');
}
final queryParams = <String, dynamic>{
'from': _dateFormat.format(from),
'to': _dateFormat.format(to),
};
if (events != null && events.isNotEmpty) {
queryParams['events'] = events.join(',');
}
if (entityId != null) {
queryParams['entity_id'] = entityId.toString();
}
debugPrint('📊 [EventStats] Récupération stats quotidiennes: $queryParams');
final response = await ApiService.instance.get(
'/events/stats/daily',
queryParameters: queryParams,
);
if (response.data['status'] == 'success') {
return DailyStats.fromJson(response.data['data']);
} else {
throw ApiException(
response.data['message'] ?? 'Erreur lors de la récupération des stats quotidiennes',
);
}
} catch (e) {
debugPrint('❌ [EventStats] Erreur getDailyStats: $e');
if (e is ApiException) rethrow;
throw ApiException('Erreur lors de la récupération des statistiques quotidiennes', originalError: e);
}
}
/// Récupère les stats hebdomadaires pour une période.
///
/// GET /api/events/stats/weekly?from=YYYY-MM-DD&to=YYYY-MM-DD&events=type1,type2
///
/// [from] : Date de début (obligatoire)
/// [to] : Date de fin (obligatoire)
/// [events] : Types d'événements à filtrer (optionnel)
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
///
/// Limite : 365 jours maximum
Future<WeeklyStats> getWeeklyStats({
required DateTime from,
required DateTime to,
List<String>? events,
int? entityId,
}) async {
try {
// Vérifier la limite de 365 jours
final daysDiff = to.difference(from).inDays;
if (daysDiff > 365) {
throw const ApiException('La période ne peut pas dépasser 365 jours');
}
final queryParams = <String, dynamic>{
'from': _dateFormat.format(from),
'to': _dateFormat.format(to),
};
if (events != null && events.isNotEmpty) {
queryParams['events'] = events.join(',');
}
if (entityId != null) {
queryParams['entity_id'] = entityId.toString();
}
debugPrint('📊 [EventStats] Récupération stats hebdomadaires: $queryParams');
final response = await ApiService.instance.get(
'/events/stats/weekly',
queryParameters: queryParams,
);
if (response.data['status'] == 'success') {
return WeeklyStats.fromJson(response.data['data']);
} else {
throw ApiException(
response.data['message'] ?? 'Erreur lors de la récupération des stats hebdomadaires',
);
}
} catch (e) {
debugPrint('❌ [EventStats] Erreur getWeeklyStats: $e');
if (e is ApiException) rethrow;
throw ApiException('Erreur lors de la récupération des statistiques hebdomadaires', originalError: e);
}
}
/// Récupère les stats mensuelles pour une année.
///
/// GET /api/events/stats/monthly?year=YYYY&events=type1,type2
///
/// [year] : Année (défaut: année courante)
/// [events] : Types d'événements à filtrer (optionnel)
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
Future<MonthlyStats> getMonthlyStats({
int? year,
List<String>? events,
int? entityId,
}) async {
try {
final queryParams = <String, dynamic>{};
if (year != null) {
queryParams['year'] = year.toString();
}
if (events != null && events.isNotEmpty) {
queryParams['events'] = events.join(',');
}
if (entityId != null) {
queryParams['entity_id'] = entityId.toString();
}
debugPrint('📊 [EventStats] Récupération stats mensuelles: $queryParams');
final response = await ApiService.instance.get(
'/events/stats/monthly',
queryParameters: queryParams.isNotEmpty ? queryParams : null,
);
if (response.data['status'] == 'success') {
return MonthlyStats.fromJson(response.data['data']);
} else {
throw ApiException(
response.data['message'] ?? 'Erreur lors de la récupération des stats mensuelles',
);
}
} catch (e) {
debugPrint('❌ [EventStats] Erreur getMonthlyStats: $e');
if (e is ApiException) rethrow;
throw ApiException('Erreur lors de la récupération des statistiques mensuelles', originalError: e);
}
}
/// Récupère les détails des événements pour une date.
///
/// GET /api/events/stats/details?date=YYYY-MM-DD&event=type&limit=50&offset=0
///
/// [date] : Date à récupérer (obligatoire)
/// [event] : Type d'événement à filtrer (optionnel)
/// [limit] : Nombre de résultats max (défaut: 50, max: 100)
/// [offset] : Pagination (défaut: 0)
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
Future<EventDetails> getDetails({
required DateTime date,
String? event,
int? limit,
int? offset,
int? entityId,
}) async {
try {
final queryParams = <String, dynamic>{
'date': _dateFormat.format(date),
};
if (event != null && event.isNotEmpty) {
queryParams['event'] = event;
}
if (limit != null) {
queryParams['limit'] = limit.toString();
}
if (offset != null) {
queryParams['offset'] = offset.toString();
}
if (entityId != null) {
queryParams['entity_id'] = entityId.toString();
}
debugPrint('📊 [EventStats] Récupération détails: $queryParams');
final response = await ApiService.instance.get(
'/events/stats/details',
queryParameters: queryParams,
);
if (response.data['status'] == 'success') {
return EventDetails.fromJson(response.data['data']);
} else {
throw ApiException(
response.data['message'] ?? 'Erreur lors de la récupération des détails',
);
}
} catch (e) {
debugPrint('❌ [EventStats] Erreur getDetails: $e');
if (e is ApiException) rethrow;
throw ApiException('Erreur lors de la récupération des détails', originalError: e);
}
}
/// Récupère les types d'événements disponibles.
///
/// GET /api/events/stats/types
Future<EventTypes> getEventTypes() async {
try {
debugPrint('📊 [EventStats] Récupération types d\'événements');
final response = await ApiService.instance.get('/events/stats/types');
if (response.data['status'] == 'success') {
return EventTypes.fromJson(response.data['data']);
} else {
throw ApiException(
response.data['message'] ?? 'Erreur lors de la récupération des types',
);
}
} catch (e) {
debugPrint('❌ [EventStats] Erreur getEventTypes: $e');
if (e is ApiException) rethrow;
throw ApiException('Erreur lors de la récupération des types d\'événements', originalError: e);
}
}
/// Réinitialise le singleton (pour les tests)
static void reset() {
_instance = null;
}
}

View File

@@ -50,6 +50,57 @@ class HiveService {
bool _isInitialized = false; bool _isInitialized = false;
bool get isInitialized => _isInitialized; bool get isInitialized => _isInitialized;
// === INITIALISATION LÉGÈRE POUR F5 (préserve les données) ===
/// Initialisation légère de Hive SANS destruction des données
/// Utilisée pour le F5 sur Web afin de vérifier si une session existe
/// Retourne true si l'initialisation a réussi et qu'une session utilisateur existe
Future<bool> initializeWithoutReset() async {
try {
debugPrint('🔧 Initialisation légère de Hive (préservation des données)...');
// 1. Initialisation de base de Hive (idempotent)
await Hive.initFlutter();
debugPrint('✅ Hive.initFlutter() terminé');
// 2. Enregistrement des adaptateurs (idempotent)
_registerAdapters();
// 3. Ouvrir les boxes SANS les détruire
await _createAllBoxes();
// 4. Vérifier si une session utilisateur existe
bool hasSession = false;
try {
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
final userId = settingsBox.get('current_user_id');
if (userId != null) {
// Vérifier que l'utilisateur existe dans la box user
if (Hive.isBoxOpen(AppKeys.userBoxName)) {
final userBox = Hive.box<UserModel>(AppKeys.userBoxName);
final user = userBox.get(userId);
if (user != null && user.hasValidSession) {
hasSession = true;
debugPrint('✅ Session utilisateur trouvée pour ID: $userId');
}
}
}
}
} catch (e) {
debugPrint('⚠️ Erreur vérification session: $e');
}
_isInitialized = true;
debugPrint('✅ Initialisation légère terminée, session existante: $hasSession');
return hasSession;
} catch (e) {
debugPrint('❌ Erreur lors de l\'initialisation légère: $e');
return false;
}
}
// === INITIALISATION COMPLÈTE (appelée par main.dart) === // === INITIALISATION COMPLÈTE (appelée par main.dart) ===
/// Initialisation complète de Hive avec réinitialisation totale /// Initialisation complète de Hive avec réinitialisation totale

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:mek_stripe_terminal/mek_stripe_terminal.dart';
import 'api_service.dart'; import 'api_service.dart';
import 'device_info_service.dart'; import 'device_info_service.dart';
@@ -13,6 +14,7 @@ class StripeTapToPayService {
StripeTapToPayService._internal(); StripeTapToPayService._internal();
bool _isInitialized = false; bool _isInitialized = false;
static bool _terminalInitialized = false; // Flag STATIC pour savoir si Terminal.initTerminal a été appelé (global à l'app)
String? _stripeAccountId; String? _stripeAccountId;
String? _locationId; String? _locationId;
bool _deviceCompatible = false; bool _deviceCompatible = false;
@@ -78,6 +80,36 @@ class StripeTapToPayService {
return false; return false;
} }
// 4. Initialiser le SDK Stripe Terminal (une seule fois par session app)
if (!_terminalInitialized) {
try {
debugPrint('🔧 Initialisation du SDK Stripe Terminal...');
await Terminal.initTerminal(
fetchToken: _fetchConnectionToken,
);
_terminalInitialized = true;
debugPrint('✅ SDK Stripe Terminal initialisé');
} catch (e) {
final errorMsg = e.toString().toLowerCase();
debugPrint('🔍 Exception capturée lors de l\'initialisation: $e');
debugPrint('🔍 Type d\'exception: ${e.runtimeType}');
// Vérifier plusieurs variantes du message "already initialized"
if (errorMsg.contains('already initialized') ||
errorMsg.contains('already been initialized') ||
errorMsg.contains('sdkfailure')) {
debugPrint(' SDK Stripe Terminal déjà initialisé (détecté via exception)');
_terminalInitialized = true;
// Ne PAS rethrow - continuer normalement car c'est un état valide
} else {
debugPrint('❌ Erreur inattendue lors de l\'initialisation du SDK');
rethrow; // Autre erreur, on la propage
}
}
} else {
debugPrint(' SDK Stripe Terminal déjà initialisé, réutilisation');
}
_isInitialized = true; _isInitialized = true;
debugPrint('✅ Tap to Pay initialisé avec succès'); debugPrint('✅ Tap to Pay initialisé avec succès');
@@ -101,6 +133,34 @@ class StripeTapToPayService {
} }
} }
/// Callback pour récupérer un token de connexion depuis l'API
Future<String> _fetchConnectionToken() async {
try {
debugPrint('🔑 Récupération du token de connexion Stripe...');
final response = await ApiService.instance.post(
'/stripe/terminal/connection-token',
data: {
'amicale_id': CurrentAmicaleService.instance.amicaleId,
'stripe_account': _stripeAccountId,
'location_id': _locationId,
}
);
final token = response.data['secret'];
if (token == null || token.isEmpty) {
throw Exception('Token de connexion invalide');
}
debugPrint('✅ Token de connexion récupéré');
return token;
} catch (e) {
debugPrint('❌ Erreur récupération token: $e');
throw Exception('Impossible de récupérer le token de connexion');
}
}
/// Crée un PaymentIntent pour un paiement Tap to Pay /// Crée un PaymentIntent pour un paiement Tap to Pay
Future<PaymentIntentResult?> createPaymentIntent({ Future<PaymentIntentResult?> createPaymentIntent({
required int amountInCents, required int amountInCents,
@@ -124,9 +184,7 @@ class StripeTapToPayService {
// Extraire passage_id des metadata si présent // Extraire passage_id des metadata si présent
final passageId = metadata?['passage_id'] ?? '0'; final passageId = metadata?['passage_id'] ?? '0';
final response = await ApiService.instance.post( final requestData = {
'/stripe/payments/create-intent',
data: {
'amount': amountInCents, 'amount': amountInCents,
'currency': 'eur', 'currency': 'eur',
'description': description ?? 'Calendrier pompiers', 'description': description ?? 'Calendrier pompiers',
@@ -138,7 +196,13 @@ class StripeTapToPayService {
'stripe_account': _stripeAccountId, 'stripe_account': _stripeAccountId,
'location_id': _locationId, 'location_id': _locationId,
'metadata': metadata, 'metadata': metadata,
}, };
debugPrint('🔵 Données envoyées create-intent: $requestData');
final response = await ApiService.instance.post(
'/stripe/payments/create-intent',
data: requestData,
); );
final result = PaymentIntentResult( final result = PaymentIntentResult(
@@ -169,11 +233,110 @@ class StripeTapToPayService {
} }
} }
/// Simule le processus de collecte de paiement /// Découvre et connecte le reader Tap to Pay local
/// (Dans la version finale, cela appellera le SDK natif) Future<bool> _ensureReaderConnected() async {
try {
debugPrint('🔍 Découverte du reader Tap to Pay...');
// Configuration pour découvrir le reader local (Tap to Pay)
// Détection de l'environnement via l'URL de l'API (plus fiable que kDebugMode)
final apiUrl = ApiService.instance.baseUrl;
final isProduction = apiUrl.contains('app3.geosector.fr');
final isSimulated = !isProduction; // Simulé uniquement si pas en PROD
final config = TapToPayDiscoveryConfiguration(
isSimulated: isSimulated,
);
debugPrint('🔧 Environnement: ${isProduction ? "PRODUCTION (réel)" : "DEV/REC (simulé)"}');
debugPrint('🔧 isSimulated: $isSimulated');
// Découvrir les readers avec un Completer pour gérer le stream correctement
final completer = Completer<Reader?>();
StreamSubscription<List<Reader>>? subscription;
subscription = Terminal.instance.discoverReaders(config).listen(
(readers) {
debugPrint('📡 Stream readers reçu: ${readers.length} reader(s)');
if (readers.isNotEmpty && !completer.isCompleted) {
debugPrint('📱 ${readers.length} reader(s) trouvé(s): ${readers.map((r) => r.label).join(", ")}');
completer.complete(readers.first);
subscription?.cancel();
}
},
onError: (error) {
debugPrint('❌ Erreur lors de la découverte: $error');
if (!completer.isCompleted) {
completer.complete(null);
}
subscription?.cancel();
},
onDone: () {
debugPrint('🏁 Stream découverte terminé');
if (!completer.isCompleted) {
debugPrint('⚠️ Découverte terminée sans reader trouvé');
completer.complete(null);
}
},
);
debugPrint('⏳ Attente du résultat de la découverte...');
// Attendre le résultat avec timeout
final reader = await completer.future.timeout(
const Duration(seconds: 15),
onTimeout: () {
debugPrint('⏱️ Timeout lors de la découverte du reader');
subscription?.cancel();
return null;
},
);
if (reader == null) {
debugPrint('❌ Aucun reader Tap to Pay trouvé');
return false;
}
debugPrint('📱 Reader trouvé: ${reader.label}');
// Se connecter au reader
debugPrint('🔌 Connexion au reader...');
final connectionConfig = TapToPayConnectionConfiguration(
locationId: _locationId ?? '',
readerDelegate: null, // Pas de delegate pour l'instant
);
await Terminal.instance.connectReader(
reader,
configuration: connectionConfig,
);
debugPrint('✅ Connecté au reader Tap to Pay');
return true;
} catch (e) {
debugPrint('❌ Erreur connexion reader: $e');
return false;
}
}
/// Collecte le paiement avec le SDK Stripe Terminal
Future<bool> collectPayment(PaymentIntentResult paymentIntent) async { Future<bool> collectPayment(PaymentIntentResult paymentIntent) async {
try { try {
debugPrint('💳 Collecte du paiement...'); debugPrint('💳 Collecte du paiement avec SDK...');
_paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.processing,
message: 'Préparation du terminal...',
paymentIntentId: paymentIntent.paymentIntentId,
));
// 1. S'assurer qu'un reader est connecté
debugPrint('🔌 Vérification connexion reader...');
final readerConnected = await _ensureReaderConnected();
if (!readerConnected) {
throw Exception('Impossible de se connecter au reader Tap to Pay');
}
_paymentStatusController.add(TapToPayStatus( _paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.processing, type: TapToPayStatusType.processing,
@@ -181,11 +344,22 @@ class StripeTapToPayService {
paymentIntentId: paymentIntent.paymentIntentId, paymentIntentId: paymentIntent.paymentIntentId,
)); ));
// TODO: Ici, intégrer le vrai SDK Stripe Terminal // 2. Récupérer le PaymentIntent depuis le SDK avec le clientSecret
// Pour l'instant, on simule une attente debugPrint('💳 Récupération du PaymentIntent...');
await Future.delayed(const Duration(seconds: 2)); final stripePaymentIntent = await Terminal.instance.retrievePaymentIntent(
paymentIntent.clientSecret,
);
debugPrint('✅ Paiement collecté'); // 3. Utiliser le SDK Stripe Terminal pour collecter le paiement
debugPrint('💳 En attente du paiement sans contact...');
final collectedPaymentIntent = await Terminal.instance.collectPaymentMethod(
stripePaymentIntent,
);
// Sauvegarder le PaymentIntent collecté pour l'étape de confirmation
paymentIntent._collectedPaymentIntent = collectedPaymentIntent;
debugPrint('✅ Paiement collecté via SDK');
_paymentStatusController.add(TapToPayStatus( _paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.confirming, type: TapToPayStatusType.confirming,
@@ -208,24 +382,25 @@ class StripeTapToPayService {
} }
} }
/// Confirme le paiement auprès du serveur /// Confirme le paiement via le SDK Stripe Terminal
Future<bool> confirmPayment(PaymentIntentResult paymentIntent) async { Future<bool> confirmPayment(PaymentIntentResult paymentIntent) async {
try { try {
debugPrint('✅ Confirmation du paiement...'); debugPrint('✅ Confirmation du paiement via SDK...');
// Notifier le serveur du succès // Vérifier que le paiement a été collecté
await ApiService.instance.post( if (paymentIntent._collectedPaymentIntent == null) {
'/stripe/payments/confirm', throw Exception('Le paiement doit d\'abord être collecté');
data: { }
'payment_intent_id': paymentIntent.paymentIntentId,
'amount': paymentIntent.amount, // Utiliser le SDK Stripe Terminal pour confirmer le paiement
'status': 'succeeded', final confirmedPaymentIntent = await Terminal.instance.confirmPaymentIntent(
'amicale_id': CurrentAmicaleService.instance.amicaleId, paymentIntent._collectedPaymentIntent!,
'member_id': CurrentUserService.instance.userId,
},
); );
debugPrint('🎉 Paiement confirmé avec succès'); // Vérifier le statut final
if (confirmedPaymentIntent.status == PaymentIntentStatus.succeeded) {
debugPrint('🎉 Paiement confirmé avec succès via SDK');
debugPrint(' Payment Intent: ${paymentIntent.paymentIntentId}');
_paymentStatusController.add(TapToPayStatus( _paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.success, type: TapToPayStatusType.success,
@@ -235,6 +410,9 @@ class StripeTapToPayService {
)); ));
return true; return true;
} else {
throw Exception('Paiement non confirmé: ${confirmedPaymentIntent.status}');
}
} catch (e) { } catch (e) {
debugPrint('❌ Erreur confirmation paiement: $e'); debugPrint('❌ Erreur confirmation paiement: $e');
@@ -304,6 +482,9 @@ class PaymentIntentResult {
final String clientSecret; final String clientSecret;
final int amount; final int amount;
// PaymentIntent collecté (utilisé entre collectPayment et confirmPayment)
PaymentIntent? _collectedPaymentIntent;
PaymentIntentResult({ PaymentIntentResult({
required this.paymentIntentId, required this.paymentIntentId,
required this.clientSecret, required this.clientSecret,

View File

@@ -31,6 +31,7 @@ class ApiException implements Exception {
if (response?.data != null) { if (response?.data != null) {
try { try {
final data = response!.data as Map<String, dynamic>; final data = response!.data as Map<String, dynamic>;
debugPrint('🔍 API Error Response: $data');
// Message spécifique de l'API // Message spécifique de l'API
if (data.containsKey('message')) { if (data.containsKey('message')) {
@@ -42,12 +43,21 @@ class ApiException implements Exception {
errorCode = data['error_code'] as String; errorCode = data['error_code'] as String;
} }
// Détails supplémentaires // Détails supplémentaires - peut être une Map ou une List
if (data.containsKey('errors')) { if (data.containsKey('errors')) {
details = data['errors'] as Map<String, dynamic>?; final errorsData = data['errors'];
if (errorsData is Map<String, dynamic>) {
// Format: {field: [errors]}
details = errorsData;
} else if (errorsData is List) {
// Format: [error1, error2, ...]
details = {'errors': errorsData};
}
debugPrint('🔍 Validation Errors: $details');
} }
} catch (e) { } catch (e) {
// Si on ne peut pas parser la réponse, utiliser le message par défaut // Si on ne peut pas parser la réponse, utiliser le message par défaut
debugPrint('⚠️ Impossible de parser la réponse d\'erreur: $e');
} }
} }
@@ -130,7 +140,43 @@ class ApiException implements Exception {
String toString() => message; String toString() => message;
/// Obtenir un message d'erreur formaté pour l'affichage /// Obtenir un message d'erreur formaté pour l'affichage
String get displayMessage => message; String get displayMessage {
debugPrint('🔍 [displayMessage] statusCode: $statusCode');
debugPrint('🔍 [displayMessage] isValidationError: $isValidationError');
debugPrint('🔍 [displayMessage] details: $details');
debugPrint('🔍 [displayMessage] details != null: ${details != null}');
debugPrint('🔍 [displayMessage] details!.isNotEmpty: ${details != null ? details!.isNotEmpty : "null"}');
// Si c'est une erreur de validation avec des détails, formater le message
if (isValidationError && details != null && details!.isNotEmpty) {
debugPrint('✅ [displayMessage] Formatage des erreurs de validation');
final buffer = StringBuffer(message);
buffer.write('\n');
details!.forEach((field, errors) {
if (errors is List) {
// Si le champ est 'errors', c'est une liste simple d'erreurs
if (field == 'errors') {
for (final error in errors) {
buffer.write('$error\n');
}
} else {
// Sinon c'est un champ avec une liste d'erreurs
for (final error in errors) {
buffer.write('$field: $error\n');
}
}
} else {
buffer.write('$field: $errors\n');
}
});
return buffer.toString().trim();
}
debugPrint('⚠️ [displayMessage] Retour du message simple');
return message;
}
/// Vérifier si c'est une erreur de validation /// Vérifier si c'est une erreur de validation
bool get isValidationError => statusCode == 422 || statusCode == 400; bool get isValidationError => statusCode == 422 || statusCode == 400;

File diff suppressed because it is too large Load Diff

View File

@@ -807,7 +807,7 @@ class _RegisterPageState extends State<RegisterPage> {
const SizedBox( const SizedBox(
height: 16), height: 16),
Text( Text(
'Vous allez recevoir un email contenant :', 'Vous allez recevoir 2 emails contenant :',
style: theme style: theme
.textTheme .textTheme
.bodyMedium, .bodyMedium,
@@ -852,7 +852,7 @@ class _RegisterPageState extends State<RegisterPage> {
width: 4), width: 4),
const Expanded( const Expanded(
child: Text( child: Text(
'Un lien pour définir votre mot de passe'), 'Votre mot de passe de connexion'),
), ),
], ],
), ),

View File

@@ -313,13 +313,29 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
// Appeler le nouvel endpoint API pour restaurer la session // Appeler le nouvel endpoint API pour restaurer la session
// IMPORTANT: Utiliser getWithoutQueue() pour ne JAMAIS mettre cette requête en file d'attente // IMPORTANT: Utiliser getWithoutQueue() pour ne JAMAIS mettre cette requête en file d'attente
// Les refresh de session sont liés à une session spécifique et ne doivent pas être rejoués // Les refresh de session sont liés à une session spécifique et ne doivent pas être rejoués
debugPrint('🔄 Appel API: user/session avec sessionId: ${sessionId.substring(0, 10)}...');
final response = await ApiService.instance.getWithoutQueue( final response = await ApiService.instance.getWithoutQueue(
'/api/user/session', 'user/session',
queryParameters: {'mode': displayMode}, queryParameters: {'mode': displayMode},
); );
// Gestion des codes de retour HTTP // Gestion des codes de retour HTTP
final statusCode = response.statusCode ?? 0; final statusCode = response.statusCode ?? 0;
// Vérifier que la réponse est bien du JSON et pas du HTML
if (response.data is String) {
final dataStr = response.data as String;
if (dataStr.contains('<!DOCTYPE') || dataStr.contains('<html')) {
debugPrint('❌ ERREUR: L\'API a retourné du HTML au lieu de JSON !');
debugPrint('❌ StatusCode: $statusCode');
debugPrint('❌ URL de base: ${ApiService.instance.baseUrl}');
debugPrint('❌ Début de la réponse: ${dataStr.substring(0, 100)}...');
await CurrentUserService.instance.clearUser();
return false;
}
}
final data = response.data as Map<String, dynamic>?; final data = response.data as Map<String, dynamic>?;
switch (statusCode) { switch (statusCode) {
@@ -599,12 +615,59 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
}); });
} }
// Étape 2: Initialisation Hive - 15 à 60% (étape la plus longue) // === GESTION F5 WEB : Vérifier session AVANT de détruire les données ===
// Sur Web, on essaie d'abord de récupérer une session existante
if (kIsWeb) {
debugPrint('🌐 Web détecté - tentative de récupération de session existante...');
if (mounted) {
setState(() {
_statusMessage = "Vérification de session...";
_progress = 0.20;
});
}
// Initialisation légère qui préserve les données
final hasExistingSession = await HiveService.instance.initializeWithoutReset();
if (hasExistingSession) {
debugPrint('✅ Session existante détectée, tentative de restauration...');
if (mounted) {
setState(() {
_statusMessage = "Restauration de la session...";
_progress = 0.40;
});
}
// Tenter la restauration via l'API
final sessionRestored = await _handleSessionRefreshIfNeeded();
if (sessionRestored) {
debugPrint('✅ Session restaurée via F5 - fin de l\'initialisation');
return;
}
// Si la restauration API échoue, on continue vers le login
debugPrint('⚠️ Restauration API échouée, passage au login normal');
} else {
debugPrint(' Pas de session existante, initialisation normale');
}
}
// === INITIALISATION NORMALE (si pas de session F5 ou pas Web) ===
// Étape 2: Initialisation Hive complète - 15 à 60%
if (mounted) {
setState(() {
_statusMessage = "Configuration du stockage...";
_progress = 0.30;
});
}
await HiveService.instance.initializeAndResetHive(); await HiveService.instance.initializeAndResetHive();
if (mounted) { if (mounted) {
setState(() { setState(() {
_statusMessage = "Configuration du stockage..."; _statusMessage = "Préparation des données...";
_progress = 0.45; _progress = 0.45;
}); });
} }
@@ -613,7 +676,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
if (mounted) { if (mounted) {
setState(() { setState(() {
_statusMessage = "Préparation des données..."; _statusMessage = "Ouverture des bases...";
_progress = 0.60; _progress = 0.60;
}); });
} }
@@ -621,19 +684,9 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
// Étape 3: Ouverture des Box - 60 à 80% // Étape 3: Ouverture des Box - 60 à 80%
await HiveService.instance.ensureBoxesAreOpen(); await HiveService.instance.ensureBoxesAreOpen();
// NOUVEAU : Vérifier et nettoyer si nouvelle version (Web uniquement) // Vérifier et nettoyer si nouvelle version (Web uniquement)
// Maintenant que les boxes sont ouvertes, on peut vérifier la version dans Hive
await _checkVersionAndCleanIfNeeded(); await _checkVersionAndCleanIfNeeded();
// NOUVEAU : Détecter et gérer le F5 (refresh de page web avec session existante)
final sessionRestored = await _handleSessionRefreshIfNeeded();
if (sessionRestored) {
// Session restaurée avec succès, on arrête ici
// L'utilisateur a été redirigé vers son interface
debugPrint('✅ Session restaurée via F5 - fin de l\'initialisation');
return;
}
// Gérer la box pending_requests séparément pour préserver les données // Gérer la box pending_requests séparément pour préserver les données
try { try {
debugPrint('📦 Gestion de la box pending_requests...'); debugPrint('📦 Gestion de la box pending_requests...');

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
import 'package:geosector_app/presentation/admin/admin_connexions_page.dart';
import 'package:geosector_app/app.dart';
/// Page des connexions et événements utilisant AppScaffold.
/// Accessible uniquement aux administrateurs (rôle >= 2).
///
/// - Admin Amicale (rôle 2) : voit les connexions de son amicale uniquement
/// - Super Admin (rôle >= 3) : voit les connexions de toutes les amicales
class ConnexionsPage extends StatelessWidget {
const ConnexionsPage({super.key});
@override
Widget build(BuildContext context) {
// Vérifier le rôle pour l'accès
final currentUser = userRepository.getCurrentUser();
final userRole = currentUser?.role ?? 1;
// Vérifier que l'utilisateur a le rôle 2 minimum (admin amicale)
if (userRole < 2) {
// Rediriger vers le dashboard user
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushReplacementNamed('/user/dashboard');
});
return const SizedBox.shrink();
}
return AppScaffold(
key: const ValueKey('connexions_scaffold_admin'),
selectedIndex: 6, // Connexions est l'index 6
pageTitle: 'Connexions',
body: AdminConnexionsPage(
userRepository: userRepository,
amicaleRepository: amicaleRepository,
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,12 @@ class _HomeContentState extends State<HomeContent> {
final isDesktop = screenWidth > 800; final isDesktop = screenWidth > 800;
// Retourner seulement le contenu (sans scaffold) // Retourner seulement le contenu (sans scaffold)
return SingleChildScrollView( return Column(
children: [
// Widget BtnPassages collé en haut/gauche/droite
const BtnPassages(),
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS, horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
vertical: AppTheme.spacingL, vertical: AppTheme.spacingL,
@@ -48,9 +53,6 @@ class _HomeContentState extends State<HomeContent> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Widget BtnPassages
const BtnPassages(),
const SizedBox(height: AppTheme.spacingL),
// LIGNE 1 : Graphiques de répartition (type de passage et mode de paiement) // LIGNE 1 : Graphiques de répartition (type de passage et mode de paiement)
isDesktop isDesktop
@@ -174,6 +176,9 @@ class _HomeContentState extends State<HomeContent> {
], ],
], ],
), ),
),
),
],
); );
} }

View File

@@ -123,6 +123,9 @@ class _MapPageContentState extends State<MapPageContent> {
// État pour bloquer la sauvegarde du zoom lors du centrage sur secteur // État pour bloquer la sauvegarde du zoom lors du centrage sur secteur
bool _isCenteringOnSector = false; bool _isCenteringOnSector = false;
// Source des tuiles de la carte (IGN Plan ou IGN Ortho)
TileSource _tileSource = TileSource.ignPlan;
// Comptages des secteurs (calculés uniquement lors de création/modification de secteurs) // Comptages des secteurs (calculés uniquement lors de création/modification de secteurs)
Map<int, int> _sectorPassageCount = {}; Map<int, int> _sectorPassageCount = {};
Map<int, int> _sectorMemberCount = {}; Map<int, int> _sectorMemberCount = {};
@@ -215,6 +218,16 @@ class _MapPageContentState extends State<MapPageContent> {
_settingsBox.put('mapZoom', 15.0); _settingsBox.put('mapZoom', 15.0);
debugPrint('🔍 MapPage: Aucun zoom sauvegardé, utilisation du défaut = 15.0'); debugPrint('🔍 MapPage: Aucun zoom sauvegardé, utilisation du défaut = 15.0');
} }
// Charger la source des tuiles (IGN Plan par défaut)
final savedTileSource = _settingsBox.get('mapTileSource');
if (savedTileSource != null) {
_tileSource = TileSource.values.firstWhere(
(t) => t.name == savedTileSource,
orElse: () => TileSource.ignPlan,
);
debugPrint('🗺️ MapPage: Source tuiles chargée = $_tileSource');
}
} }
// Méthode pour gérer les changements de sélection de secteur // Méthode pour gérer les changements de sélection de secteur
@@ -4151,8 +4164,8 @@ class _MapPageContentState extends State<MapPageContent> {
initialZoom: _currentZoom, initialZoom: _currentZoom,
mapController: _mapController, mapController: _mapController,
disableDrag: _isDraggingPoint, disableDrag: _isDraggingPoint,
// Utiliser OpenStreetMap temporairement sur mobile si Mapbox échoue // Utiliser les tuiles IGN (Plan ou Ortho selon le choix utilisateur)
useOpenStreetMap: !kIsWeb, // true sur mobile, false sur web tileSource: _tileSource,
labelMarkers: _buildSectorLabels(), labelMarkers: _buildSectorLabels(),
markers: [ markers: [
..._buildMarkers(), ..._buildMarkers(),
@@ -4199,14 +4212,38 @@ class _MapPageContentState extends State<MapPageContent> {
), ),
)), )),
// Boutons d'action en haut à droite (Web uniquement et admin seulement) // Bouton switch IGN Plan / Ortho en haut à droite (visible pour tous)
if (kIsWeb && canEditSectors)
Positioned( Positioned(
right: 16, right: 16,
top: 16, top: 16,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
// Bouton switch IGN Plan / Ortho
// L'icône montre l'action (vers quoi on bascule), pas l'état actuel
_buildActionButton(
icon: _tileSource == TileSource.ignPlan
? Icons.satellite_alt // En mode plan → afficher satellite pour basculer
: Icons.map_outlined, // En mode ortho → afficher plan pour basculer
tooltip: _tileSource == TileSource.ignPlan
? 'Passer en vue satellite'
: 'Passer en vue plan',
color: Colors.white,
iconColor: Colors.blueGrey[700],
onPressed: () {
setState(() {
_tileSource = _tileSource == TileSource.ignPlan
? TileSource.ignOrtho
: TileSource.ignPlan;
_settingsBox.put('mapTileSource', _tileSource.name);
debugPrint('🗺️ MapPage: Source tuiles changée = $_tileSource');
});
},
),
// Espacement avant les boutons admin
if (kIsWeb && canEditSectors) const SizedBox(height: 16),
// Boutons admin (création, modification, suppression de secteurs)
if (kIsWeb && canEditSectors) ...[
// Bouton Créer // Bouton Créer
_buildActionButton( _buildActionButton(
icon: Icons.pentagon_outlined, icon: Icons.pentagon_outlined,
@@ -4246,6 +4283,7 @@ class _MapPageContentState extends State<MapPageContent> {
: null, : null,
), ),
], ],
],
), ),
), ),

View File

@@ -8,13 +8,14 @@ import 'package:latlong2/latlong.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter_compass/flutter_compass.dart';
import 'package:geosector_app/core/constants/app_keys.dart'; import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/data/models/passage_model.dart'; import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart'; import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart'; import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart'; import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
import 'package:geosector_app/presentation/widgets/grouped_passages_dialog.dart'; import 'package:geosector_app/presentation/widgets/grouped_passages_dialog.dart';
import 'package:geosector_app/presentation/widgets/mapbox_map.dart' show TileSource;
import 'package:geosector_app/app.dart'; import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/utils/api_exception.dart'; import 'package:geosector_app/core/utils/api_exception.dart';
@@ -59,10 +60,20 @@ class _UserFieldModePageState extends State<UserFieldModePage>
// Listener pour les changements de la box passages // Listener pour les changements de la box passages
Box<PassageModel>? _passagesBox; Box<PassageModel>? _passagesBox;
// Source des tuiles de la carte (IGN Plan ou IGN Ortho)
TileSource _tileSource = TileSource.ignPlan;
Box? _settingsBox;
// Mode boussole (Android/iOS uniquement)
bool _compassModeEnabled = false;
StreamSubscription<CompassEvent>? _compassSubscription;
double _currentHeading = 0.0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_initializeAnimations(); _initializeAnimations();
_loadTileSourceSetting();
// Écouter les changements de la Hive box passages pour rafraîchir la carte // Écouter les changements de la Hive box passages pour rafraîchir la carte
_passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName); _passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
@@ -85,6 +96,26 @@ class _UserFieldModePageState extends State<UserFieldModePage>
} }
} }
// Charger le paramètre de source des tuiles depuis Hive
Future<void> _loadTileSourceSetting() async {
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
} else {
_settingsBox = Hive.box(AppKeys.settingsBoxName);
}
final savedTileSource = _settingsBox?.get('mapTileSource');
if (savedTileSource != null && mounted) {
setState(() {
_tileSource = TileSource.values.firstWhere(
(t) => t.name == savedTileSource,
orElse: () => TileSource.ignPlan,
);
});
debugPrint('FieldMode: Source tuiles chargée = $_tileSource');
}
}
void _initializeWebMode() async { void _initializeWebMode() async {
// Essayer d'obtenir la position réelle depuis le navigateur // Essayer d'obtenir la position réelle depuis le navigateur
try { try {
@@ -539,6 +570,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
void dispose() { void dispose() {
_positionStreamSubscription?.cancel(); _positionStreamSubscription?.cancel();
_qualityUpdateTimer?.cancel(); _qualityUpdateTimer?.cancel();
_compassSubscription?.cancel();
_gpsBlinkController.dispose(); _gpsBlinkController.dispose();
_networkBlinkController.dispose(); _networkBlinkController.dispose();
_searchController.dispose(); _searchController.dispose();
@@ -546,6 +578,35 @@ class _UserFieldModePageState extends State<UserFieldModePage>
super.dispose(); super.dispose();
} }
// Activer/désactiver le mode boussole (Android/iOS uniquement)
void _toggleCompassMode() {
if (kIsWeb) return; // Pas de boussole sur web
setState(() {
_compassModeEnabled = !_compassModeEnabled;
});
if (_compassModeEnabled) {
// Activer l'écoute de la boussole
_compassSubscription = FlutterCompass.events?.listen((CompassEvent event) {
if (event.heading != null && mounted) {
setState(() {
_currentHeading = event.heading!;
});
// Faire pivoter la carte selon la direction
_mapController.rotate(-_currentHeading);
}
});
debugPrint('FieldMode: Mode boussole activé');
} else {
// Désactiver l'écoute et remettre la carte vers le nord
_compassSubscription?.cancel();
_compassSubscription = null;
_mapController.rotate(0);
debugPrint('FieldMode: Mode boussole désactivé');
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -823,10 +884,6 @@ class _UserFieldModePageState extends State<UserFieldModePage>
); );
} }
final apiService = ApiService.instance;
final mapboxApiKey =
AppKeys.getMapboxApiKey(apiService.getCurrentEnvironment());
return Stack( return Stack(
children: [ children: [
FlutterMap( FlutterMap(
@@ -837,21 +894,36 @@ class _UserFieldModePageState extends State<UserFieldModePage>
initialZoom: 17, initialZoom: 17,
maxZoom: 19, maxZoom: 19,
minZoom: 10, minZoom: 10,
interactionOptions: const InteractionOptions( interactionOptions: InteractionOptions(
enableMultiFingerGestureRace: true, enableMultiFingerGestureRace: true,
flags: InteractiveFlag.all & ~InteractiveFlag.rotate, // Permettre la rotation uniquement si le mode boussole est activé
flags: _compassModeEnabled
? InteractiveFlag.all
: InteractiveFlag.all & ~InteractiveFlag.rotate,
), ),
), ),
children: [ children: [
TileLayer( TileLayer(
// Utiliser l'API v4 de Mapbox sur mobile ou OpenStreetMap en fallback // Utiliser les tuiles IGN (Plan ou Ortho selon le choix utilisateur)
urlTemplate: kIsWeb urlTemplate: _tileSource == TileSource.ignOrtho
? 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/{z}/{x}/{y}?access_token=$mapboxApiKey' ? 'https://data.geopf.fr/wmts?'
: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', // OpenStreetMap temporairement sur mobile 'REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0'
'&TILEMATRIXSET=PM'
'&LAYER=ORTHOIMAGERY.ORTHOPHOTOS'
'&STYLE=normal'
'&FORMAT=image/jpeg'
'&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}'
: 'https://data.geopf.fr/wmts?'
'REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0'
'&TILEMATRIXSET=PM'
'&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2'
'&STYLE=normal'
'&FORMAT=image/png'
'&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}',
userAgentPackageName: 'app3.geosector.fr', userAgentPackageName: 'app3.geosector.fr',
additionalOptions: const { maxNativeZoom: 19,
'attribution': '© OpenStreetMap contributors', maxZoom: 20,
}, minZoom: 7,
), ),
// Markers des passages // Markers des passages
MarkerLayer( MarkerLayer(
@@ -900,6 +972,56 @@ class _UserFieldModePageState extends State<UserFieldModePage>
child: const Icon(Icons.my_location), child: const Icon(Icons.my_location),
), ),
), ),
// Boutons haut droite (IGN + Boussole)
Positioned(
top: 16,
right: 16,
child: Column(
children: [
// Bouton switch IGN Plan / Ortho
FloatingActionButton.small(
heroTag: 'tileSource',
backgroundColor: Colors.white,
foregroundColor: Colors.green[700],
tooltip: _tileSource == TileSource.ignPlan
? 'Passer en vue satellite'
: 'Passer en vue plan',
onPressed: () {
setState(() {
_tileSource = _tileSource == TileSource.ignPlan
? TileSource.ignOrtho
: TileSource.ignPlan;
});
// Sauvegarder le choix
_settingsBox?.put('mapTileSource', _tileSource.name);
debugPrint('FieldMode: Source tuiles = $_tileSource');
},
// L'icône montre l'action (vers quoi on bascule), pas l'état actuel
child: Icon(
_tileSource == TileSource.ignPlan
? Icons.satellite_alt // En mode plan → afficher satellite pour basculer
: Icons.map_outlined, // En mode ortho → afficher plan pour basculer
),
),
// Bouton mode boussole (uniquement sur mobile)
if (!kIsWeb) ...[
const SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'compass',
backgroundColor: _compassModeEnabled ? Colors.green[700] : Colors.white,
foregroundColor: _compassModeEnabled ? Colors.white : Colors.green[700],
tooltip: _compassModeEnabled
? 'Désactiver le mode boussole'
: 'Activer le mode boussole',
onPressed: _toggleCompassMode,
child: Icon(
_compassModeEnabled ? Icons.explore : Icons.explore_outlined,
),
),
],
],
),
),
], ],
); );
} }

View File

@@ -305,6 +305,11 @@ class NavigationHelper {
selectedIcon: Icon(Icons.calendar_today), selectedIcon: Icon(Icons.calendar_today),
label: 'Opérations', label: 'Opérations',
), ),
const NavigationDestination(
icon: Icon(Icons.analytics_outlined),
selectedIcon: Icon(Icons.analytics),
label: 'Connexions',
),
]); ]);
} }
@@ -341,6 +346,9 @@ class NavigationHelper {
case 5: case 5:
context.go('/admin/operations'); context.go('/admin/operations');
break; break;
case 6:
context.go('/admin/connexions');
break;
default: default:
context.go('/admin'); context.go('/admin');
} }
@@ -380,6 +388,7 @@ class NavigationHelper {
if (cleanRoute.contains('/admin/messages')) return 3; if (cleanRoute.contains('/admin/messages')) return 3;
if (cleanRoute.contains('/admin/amicale')) return 4; if (cleanRoute.contains('/admin/amicale')) return 4;
if (cleanRoute.contains('/admin/operations')) return 5; if (cleanRoute.contains('/admin/operations')) return 5;
if (cleanRoute.contains('/admin/connexions')) return 6;
return 0; // Dashboard par défaut return 0; // Dashboard par défaut
} else { } else {
if (cleanRoute.contains('/user/history')) return 1; if (cleanRoute.contains('/user/history')) return 1;
@@ -400,6 +409,7 @@ class NavigationHelper {
case 3: return 'messages'; case 3: return 'messages';
case 4: return 'amicale'; case 4: return 'amicale';
case 5: return 'operations'; case 5: return 'operations';
case 6: return 'connexions';
default: return 'dashboard'; default: return 'dashboard';
} }
} else { } else {

View File

@@ -40,7 +40,7 @@ class BtnPassages extends StatelessWidget {
final shouldShowLotType = _shouldShowLotType(); final shouldShowLotType = _shouldShowLotType();
return SizedBox( return SizedBox(
height: 80, height: 92, // 80 + 12 pour le triangle indicateur
width: double.infinity, width: double.infinity,
child: ValueListenableBuilder<Box<PassageModel>>( child: ValueListenableBuilder<Box<PassageModel>>(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(), valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
@@ -121,6 +121,7 @@ class BtnPassages extends StatelessWidget {
/// Colonne TOTAL (cliquable, affiche tous les passages) /// Colonne TOTAL (cliquable, affiche tous les passages)
Widget _buildTotalColumn(BuildContext context, int total) { Widget _buildTotalColumn(BuildContext context, int total) {
final bool isSelected = selectedTypeId == null; final bool isSelected = selectedTypeId == null;
final Color bgColor = Colors.grey[200]!;
return InkWell( return InkWell(
onTap: () async { onTap: () async {
@@ -147,13 +148,16 @@ class BtnPassages extends StatelessWidget {
} }
} }
}, },
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Container( child: Container(
height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[200], color: bgColor,
border: Border.all( border: Border.all(
color: Colors.grey[400]!, color: Colors.grey[400]!,
width: isSelected ? 5 : 1, width: 1,
), ),
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppTheme.borderRadiusMedium), topLeft: Radius.circular(AppTheme.borderRadiusMedium),
@@ -197,6 +201,19 @@ class BtnPassages extends StatelessWidget {
], ],
), ),
), ),
),
// Triangle indicateur de sélection
if (isSelected)
Center(
child: CustomPaint(
size: const Size(20, 12),
painter: _TrianglePainter(color: bgColor),
),
)
else
const SizedBox(height: 12),
],
),
); );
} }
@@ -236,13 +253,16 @@ class BtnPassages extends StatelessWidget {
} }
} }
}, },
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Container( child: Container(
height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: couleur.withOpacity(0.1), color: couleur,
border: Border.all( border: Border.all(
color: couleur, color: couleur,
width: isSelected ? 5 : 1, width: 1,
), ),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
boxShadow: [ boxShadow: [
@@ -260,15 +280,15 @@ class BtnPassages extends StatelessWidget {
Icon( Icon(
iconData, iconData,
size: 20, size: 20,
color: couleur, color: Colors.white,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
count.toString(), count.toString(),
style: TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: couleur, color: Colors.white,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
@@ -276,9 +296,9 @@ class BtnPassages extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text( child: Text(
titre, titre,
style: TextStyle( style: const TextStyle(
fontSize: 10, fontSize: 10,
color: couleur, color: Colors.white,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
maxLines: 1, maxLines: 1,
@@ -288,10 +308,23 @@ class BtnPassages extends StatelessWidget {
], ],
), ),
), ),
),
// Triangle indicateur de sélection
if (isSelected)
Center(
child: CustomPaint(
size: const Size(20, 12),
painter: _TrianglePainter(color: couleur),
),
)
else
const SizedBox(height: 12),
],
),
); );
} }
/// Colonne NOUVEAU PASSAGE (bouton +, fond vert) /// Colonne NOUVEAU PASSAGE (bouton +, fond blanc)
Widget _buildAddColumn(BuildContext context) { Widget _buildAddColumn(BuildContext context) {
return InkWell( return InkWell(
onTap: () { onTap: () {
@@ -302,12 +335,15 @@ class BtnPassages extends StatelessWidget {
_showPassageFormDialog(context); _showPassageFormDialog(context);
} }
}, },
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Container( child: Container(
height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.buttonSuccessColor.withOpacity(0.1), color: Colors.white,
border: Border.all( border: Border.all(
color: AppTheme.buttonSuccessColor, color: Colors.grey[400]!,
width: 1, width: 1,
), ),
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
@@ -326,17 +362,17 @@ class BtnPassages extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Icon( const Icon(
Icons.add_circle_outline, Icons.add_circle_outline,
size: 24, size: 24,
color: AppTheme.buttonSuccessColor, color: Colors.black87,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'Nouveau', 'Nouveau',
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
color: AppTheme.buttonSuccessColor, color: Colors.grey[700],
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
@@ -344,6 +380,11 @@ class BtnPassages extends StatelessWidget {
], ],
), ),
), ),
),
// Espace pour aligner avec les autres colonnes (pas de triangle sur ce bouton)
const SizedBox(height: 12),
],
),
); );
} }
@@ -377,3 +418,30 @@ class BtnPassages extends StatelessWidget {
); );
} }
} }
/// CustomPainter pour dessiner un triangle pointant vers le bas
class _TrianglePainter extends CustomPainter {
final Color color;
_TrianglePainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
final path = Path()
..moveTo(0, 0) // Coin supérieur gauche
..lineTo(size.width, 0) // Coin supérieur droit
..lineTo(size.width / 2, size.height) // Pointe en bas au centre
..close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant _TrianglePainter oldDelegate) {
return oldDelegate.color != color;
}
}

View File

@@ -8,6 +8,18 @@ import 'package:latlong2/latlong.dart';
import 'package:geosector_app/core/constants/app_keys.dart'; import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/api_service.dart'; // Import du service singleton import 'package:geosector_app/core/services/api_service.dart'; // Import du service singleton
/// Enum représentant les différentes sources de tuiles disponibles
enum TileSource {
/// Tuiles Mapbox (par défaut)
mapbox,
/// Tuiles OpenStreetMap
openStreetMap,
/// Tuiles IGN Plan (carte routière française)
ignPlan,
/// Tuiles IGN Ortho Photos (photos aériennes)
ignOrtho,
}
/// Widget de carte réutilisable utilisant Mapbox /// Widget de carte réutilisable utilisant Mapbox
/// ///
/// Ce widget encapsule un FlutterMap avec des tuiles Mapbox et fournit /// Ce widget encapsule un FlutterMap avec des tuiles Mapbox et fournit
@@ -48,8 +60,12 @@ class MapboxMap extends StatefulWidget {
final bool disableDrag; final bool disableDrag;
/// Utiliser OpenStreetMap au lieu de Mapbox (en cas de problème de token) /// Utiliser OpenStreetMap au lieu de Mapbox (en cas de problème de token)
@Deprecated('Utiliser tileSource à la place')
final bool useOpenStreetMap; final bool useOpenStreetMap;
/// Source des tuiles de la carte (Mapbox, OpenStreetMap, IGN Plan, IGN Ortho)
final TileSource tileSource;
const MapboxMap({ const MapboxMap({
super.key, super.key,
this.initialPosition = const LatLng(48.1173, -1.6778), // Rennes par défaut this.initialPosition = const LatLng(48.1173, -1.6778), // Rennes par défaut
@@ -64,6 +80,7 @@ class MapboxMap extends StatefulWidget {
this.mapStyle, this.mapStyle,
this.disableDrag = false, this.disableDrag = false,
this.useOpenStreetMap = false, this.useOpenStreetMap = false,
this.tileSource = TileSource.mapbox,
}); });
@override @override
@@ -125,7 +142,7 @@ class _MapboxMapState extends State<MapboxMap> {
_cacheInitialized = true; _cacheInitialized = true;
}); });
} }
debugPrint('MapboxMap: Cache initialisé avec succès pour ${widget.useOpenStreetMap ? "OpenStreetMap" : "Mapbox"}'); debugPrint('MapboxMap: Cache initialisé avec succès');
} catch (e) { } catch (e) {
debugPrint('MapboxMap: Erreur lors de l\'initialisation du cache: $e'); debugPrint('MapboxMap: Erreur lors de l\'initialisation du cache: $e');
// En cas d'erreur, on continue sans cache // En cas d'erreur, on continue sans cache
@@ -175,30 +192,76 @@ class _MapboxMapState extends State<MapboxMap> {
); );
} }
@override /// Retourne l'URL template pour la source de tuiles sélectionnée
Widget build(BuildContext context) { String _getTileUrlTemplate() {
String urlTemplate; // Rétrocompatibilité avec useOpenStreetMap
// ignore: deprecated_member_use_from_same_package
if (widget.useOpenStreetMap && widget.tileSource == TileSource.mapbox) {
return 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
}
if (widget.useOpenStreetMap) { switch (widget.tileSource) {
// Utiliser OpenStreetMap comme alternative case TileSource.openStreetMap:
urlTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; return 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
debugPrint('MapboxMap: Utilisation d\'OpenStreetMap');
} else { case TileSource.ignPlan:
// IGN Plan IGN v2 - Carte routière française
// Source: https://data.geopf.fr/wmts
return 'https://data.geopf.fr/wmts?'
'REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0'
'&TILEMATRIXSET=PM'
'&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2'
'&STYLE=normal'
'&FORMAT=image/png'
'&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}';
case TileSource.ignOrtho:
// IGN Ortho Photos - Photos aériennes
// Source: https://data.geopf.fr/wmts
return 'https://data.geopf.fr/wmts?'
'REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0'
'&TILEMATRIXSET=PM'
'&LAYER=ORTHOIMAGERY.ORTHOPHOTOS'
'&STYLE=normal'
'&FORMAT=image/jpeg'
'&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}';
case TileSource.mapbox:
default:
// Déterminer l'URL du template de tuiles Mapbox // Déterminer l'URL du template de tuiles Mapbox
// Utiliser l'environnement actuel pour obtenir la bonne clé API
final String environment = ApiService.instance.getCurrentEnvironment(); final String environment = ApiService.instance.getCurrentEnvironment();
final String mapboxToken = AppKeys.getMapboxApiKey(environment); final String mapboxToken = AppKeys.getMapboxApiKey(environment);
// Essayer différentes API Mapbox selon la plateforme
if (kIsWeb) { if (kIsWeb) {
// Sur web, on peut utiliser l'API styles return 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
urlTemplate = 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
} else { } else {
// Sur mobile, utiliser l'API v4 qui fonctionne mieux avec les tokens standards return 'https://api.tiles.mapbox.com/v4/mapbox.streets/{z}/{x}/{y}@2x.png?access_token=$mapboxToken';
// Format: mapbox.streets pour les rues, mapbox.satellite pour satellite
urlTemplate = 'https://api.tiles.mapbox.com/v4/mapbox.streets/{z}/{x}/{y}@2x.png?access_token=$mapboxToken';
} }
} }
}
/// Retourne le nom de la source de tuiles pour le debug
String _getTileSourceName() {
// ignore: deprecated_member_use_from_same_package
if (widget.useOpenStreetMap && widget.tileSource == TileSource.mapbox) {
return 'OpenStreetMap (legacy)';
}
switch (widget.tileSource) {
case TileSource.mapbox:
return 'Mapbox';
case TileSource.openStreetMap:
return 'OpenStreetMap';
case TileSource.ignPlan:
return 'IGN Plan';
case TileSource.ignOrtho:
return 'IGN Ortho Photos';
}
}
@override
Widget build(BuildContext context) {
final urlTemplate = _getTileUrlTemplate();
debugPrint('MapboxMap: Utilisation de ${_getTileSourceName()}');
// Afficher un indicateur pendant l'initialisation du cache // Afficher un indicateur pendant l'initialisation du cache
if (!_cacheInitialized) { if (!_cacheInitialized) {

View File

@@ -124,10 +124,13 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
), ),
), ),
// Corps avec le tableau // Corps avec le tableau - écoute membres ET passages pour rafraîchissement auto
ValueListenableBuilder<Box<MembreModel>>( ValueListenableBuilder<Box<MembreModel>>(
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(), valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
builder: (context, membresBox, child) { builder: (context, membresBox, child) {
return ValueListenableBuilder<Box<PassageModel>>(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, passagesBox, child) {
final membres = membresBox.values.toList(); final membres = membresBox.values.toList();
// Récupérer l'opération courante // Récupérer l'opération courante
@@ -186,6 +189,8 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
), ),
); );
}, },
);
},
), ),
], ],
), ),

View File

@@ -79,6 +79,16 @@ class MembreRowWidget extends StatelessWidget {
), ),
), ),
// Tournée (sectName) - masqué en mobile
if (!isMobile)
Expanded(
flex: 2,
child: Text(
membre.sectName ?? '',
style: theme.textTheme.bodyMedium,
),
),
// Email - masqué en mobile // Email - masqué en mobile
if (!isMobile) if (!isMobile)
Expanded( Expanded(

View File

@@ -113,6 +113,19 @@ class MembreTableWidget extends StatelessWidget {
), ),
), ),
// Tournée (sectName) - masqué en mobile
if (!isMobile)
Expanded(
flex: 2,
child: Text(
'Tournée',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
// Email - masqué en mobile // Email - masqué en mobile
if (!isMobile) if (!isMobile)
Expanded( Expanded(

View File

@@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:geosector_app/core/theme/app_theme.dart'; import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart'; import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart'; import 'package:geosector_app/core/repositories/passage_repository.dart';
@@ -15,6 +15,7 @@ import 'package:geosector_app/core/services/stripe_connect_service.dart';
import 'package:geosector_app/core/services/api_service.dart'; import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/utils/api_exception.dart'; import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/constants/app_keys.dart'; import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/location_service.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart'; import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
import 'package:geosector_app/presentation/widgets/form_section.dart'; import 'package:geosector_app/presentation/widgets/form_section.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart'; import 'package:geosector_app/presentation/widgets/result_dialog.dart';
@@ -88,13 +89,17 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Helpers de validation // Helpers de validation
String? _validateNumero(String? value) { String? _validateNumero(String? value) {
debugPrint('🔍 [VALIDATOR] _validateNumero appelé avec: "$value"');
if (value == null || value.trim().isEmpty) { if (value == null || value.trim().isEmpty) {
debugPrint('❌ [VALIDATOR] Numéro vide -> retourne erreur');
return 'Le numéro est obligatoire'; return 'Le numéro est obligatoire';
} }
final numero = int.tryParse(value.trim()); final numero = int.tryParse(value.trim());
if (numero == null || numero <= 0) { if (numero == null || numero <= 0) {
debugPrint('❌ [VALIDATOR] Numéro invalide: $value -> retourne erreur');
return 'Numéro invalide'; return 'Numéro invalide';
} }
debugPrint('✅ [VALIDATOR] Numéro valide: $numero');
return null; return null;
} }
@@ -329,48 +334,102 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
} }
void _handleSubmit() async { void _handleSubmit() async {
if (_isSubmitting) return; debugPrint('🔵 [SUBMIT] Début _handleSubmit');
// ✅ Validation intégrée avec focus automatique sur erreur if (_isSubmitting) {
if (!_formKey.currentState!.validate()) { debugPrint('⚠️ [SUBMIT] Déjà en cours de soumission, abandon');
// Le focus est automatiquement mis sur le premier champ en erreur
// Les bordures rouges et messages d'erreur sont affichés automatiquement
return; return;
} }
// Toujours sauvegarder le passage en premier debugPrint('🔵 [SUBMIT] Vérification de l\'état du formulaire');
debugPrint('🔵 [SUBMIT] _formKey: $_formKey');
debugPrint('🔵 [SUBMIT] _formKey.currentState: ${_formKey.currentState}');
// Validation avec protection contre le null
if (_formKey.currentState == null) {
debugPrint('❌ [SUBMIT] ERREUR: _formKey.currentState est null !');
if (mounted) {
await ResultDialog.show(
context: context,
success: false,
message: "Erreur d'initialisation du formulaire",
);
}
return;
}
debugPrint('🔵 [SUBMIT] Validation du formulaire...');
final isValid = _formKey.currentState!.validate();
debugPrint('🔵 [SUBMIT] Résultat validation: $isValid');
if (!isValid) {
debugPrint('⚠️ [SUBMIT] Validation échouée, abandon');
// Afficher un dialog d'erreur clair à l'utilisateur
if (mounted) {
await ResultDialog.show(
context: context,
success: false,
message: 'Veuillez vérifier tous les champs marqués comme obligatoires',
);
}
return;
}
debugPrint('✅ [SUBMIT] Validation OK, appel _savePassage()');
await _savePassage(); await _savePassage();
debugPrint('🔵 [SUBMIT] Fin _handleSubmit');
} }
Future<void> _savePassage() async { Future<void> _savePassage() async {
if (_isSubmitting) return; debugPrint('🟢 [SAVE] Début _savePassage');
if (_isSubmitting) {
debugPrint('⚠️ [SAVE] Déjà en cours de soumission, abandon');
return;
}
debugPrint('🟢 [SAVE] Mise à jour état _isSubmitting = true');
setState(() { setState(() {
_isSubmitting = true; _isSubmitting = true;
}); });
// Afficher l'overlay de chargement // Afficher l'overlay de chargement
debugPrint('🟢 [SAVE] Affichage overlay de chargement');
final overlay = LoadingSpinOverlayUtils.show( final overlay = LoadingSpinOverlayUtils.show(
context: context, context: context,
message: 'Enregistrement en cours...', message: 'Enregistrement en cours...',
); );
try { try {
debugPrint('🟢 [SAVE] Récupération utilisateur actuel');
final currentUser = widget.userRepository.getCurrentUser(); final currentUser = widget.userRepository.getCurrentUser();
debugPrint('🟢 [SAVE] currentUser: ${currentUser?.id} - ${currentUser?.name}');
if (currentUser == null) { if (currentUser == null) {
debugPrint('❌ [SAVE] ERREUR: Utilisateur non connecté');
throw Exception("Utilisateur non connecté"); throw Exception("Utilisateur non connecté");
} }
debugPrint('🟢 [SAVE] Récupération opération active');
final currentOperation = widget.operationRepository.getCurrentOperation(); final currentOperation = widget.operationRepository.getCurrentOperation();
debugPrint('🟢 [SAVE] currentOperation: ${currentOperation?.id} - ${currentOperation?.name}');
if (currentOperation == null && widget.passage == null) { if (currentOperation == null && widget.passage == null) {
debugPrint('❌ [SAVE] ERREUR: Aucune opération active trouvée');
throw Exception("Aucune opération active trouvée"); throw Exception("Aucune opération active trouvée");
} }
// Déterminer les valeurs de montant et type de règlement selon le type de passage // Déterminer les valeurs de montant et type de règlement selon le type de passage
debugPrint('🟢 [SAVE] Calcul des valeurs finales');
debugPrint('🟢 [SAVE] _selectedPassageType: $_selectedPassageType');
final String finalMontant = final String finalMontant =
(_selectedPassageType == 1 || _selectedPassageType == 5) (_selectedPassageType == 1 || _selectedPassageType == 5)
? _montantController.text.trim().replaceAll(',', '.') ? _montantController.text.trim().replaceAll(',', '.')
: '0'; : '0';
debugPrint('🟢 [SAVE] finalMontant: $finalMontant');
// Déterminer le type de règlement final selon le type de passage // Déterminer le type de règlement final selon le type de passage
final int finalTypeReglement; final int finalTypeReglement;
if (_selectedPassageType == 1 || _selectedPassageType == 5) { if (_selectedPassageType == 1 || _selectedPassageType == 5) {
@@ -380,6 +439,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Pour tous les autres types, forcer "Non renseigné" // Pour tous les autres types, forcer "Non renseigné"
finalTypeReglement = 4; finalTypeReglement = 4;
} }
debugPrint('🟢 [SAVE] finalTypeReglement: $finalTypeReglement');
// Déterminer la valeur de nbPassages selon le type de passage // Déterminer la valeur de nbPassages selon le type de passage
final int finalNbPassages; final int finalNbPassages;
@@ -397,6 +457,31 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Nouveau passage : toujours 1 // Nouveau passage : toujours 1
finalNbPassages = 1; finalNbPassages = 1;
} }
debugPrint('🟢 [SAVE] finalNbPassages: $finalNbPassages');
// Récupérer les coordonnées GPS pour un nouveau passage
String finalGpsLat = '0.0';
String finalGpsLng = '0.0';
if (widget.passage == null) {
// Nouveau passage : tenter de récupérer la position GPS actuelle
debugPrint('🟢 [SAVE] Récupération de la position GPS...');
try {
final position = await LocationService.getCurrentPosition();
if (position != null) {
finalGpsLat = position.latitude.toString();
finalGpsLng = position.longitude.toString();
debugPrint('🟢 [SAVE] GPS récupéré: lat=$finalGpsLat, lng=$finalGpsLng');
} else {
debugPrint('🟢 [SAVE] GPS non disponible, utilisation de 0.0 (l\'API utilisera le géocodage)');
}
} catch (e) {
debugPrint('⚠️ [SAVE] Erreur récupération GPS: $e - l\'API utilisera le géocodage');
}
} else {
// Modification : conserver les coordonnées existantes
finalGpsLat = widget.passage!.gpsLat;
finalGpsLng = widget.passage!.gpsLng;
}
final passageData = widget.passage?.copyWith( final passageData = widget.passage?.copyWith(
fkType: _selectedPassageType!, fkType: _selectedPassageType!,
@@ -422,7 +507,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
PassageModel( PassageModel(
id: 0, // Nouveau passage id: 0, // Nouveau passage
fkOperation: currentOperation!.id, // Opération active fkOperation: currentOperation!.id, // Opération active
fkSector: 0, // Secteur par défaut fkSector: 0, // Secteur par défaut (sera déterminé par l'API)
fkUser: currentUser.id, // Utilisateur actuel fkUser: currentUser.id, // Utilisateur actuel
fkType: _selectedPassageType!, fkType: _selectedPassageType!,
fkAdresse: "0", // Adresse par défaut pour nouveau passage fkAdresse: "0", // Adresse par défaut pour nouveau passage
@@ -435,8 +520,8 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
fkHabitat: _fkHabitat, fkHabitat: _fkHabitat,
appt: _apptController.text.trim(), appt: _apptController.text.trim(),
niveau: _niveauController.text.trim(), niveau: _niveauController.text.trim(),
gpsLat: '0.0', // GPS par défaut gpsLat: finalGpsLat, // GPS de l'utilisateur ou 0.0 si non disponible
gpsLng: '0.0', // GPS par défaut gpsLng: finalGpsLng, // GPS de l'utilisateur ou 0.0 si non disponible
nomRecu: _nameController.text.trim(), nomRecu: _nameController.text.trim(),
remarque: _remarqueController.text.trim(), remarque: _remarqueController.text.trim(),
montant: finalMontant, montant: finalMontant,
@@ -453,32 +538,37 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
); );
// Sauvegarder le passage d'abord // Sauvegarder le passage d'abord
debugPrint('🟢 [SAVE] Préparation sauvegarde passage');
PassageModel? savedPassage; PassageModel? savedPassage;
if (widget.passage == null || widget.passage!.id == 0) { if (widget.passage == null || widget.passage!.id == 0) {
// Création d'un nouveau passage (passage null OU id=0) // Création d'un nouveau passage (passage null OU id=0)
debugPrint('🟢 [SAVE] Création d\'un nouveau passage');
savedPassage = await widget.passageRepository.createPassageWithReturn(passageData); savedPassage = await widget.passageRepository.createPassageWithReturn(passageData);
} else { debugPrint('🟢 [SAVE] Passage créé avec ID: ${savedPassage?.id}');
// Mise à jour d'un passage existant
final success = await widget.passageRepository.updatePassage(passageData);
if (success) {
savedPassage = passageData;
}
}
if (savedPassage == null) { if (savedPassage == null) {
throw Exception(widget.passage == null || widget.passage!.id == 0 debugPrint('❌ [SAVE] ERREUR: savedPassage est null après création');
? "Échec de la création du passage" throw Exception("Échec de la création du passage");
: "Échec de la mise à jour du passage"); }
} else {
// Mise à jour d'un passage existant
debugPrint('🟢 [SAVE] Mise à jour passage existant ID: ${widget.passage!.id}');
await widget.passageRepository.updatePassage(passageData);
debugPrint('🟢 [SAVE] Mise à jour réussie');
savedPassage = passageData;
} }
// Garantir le type non-nullable après la vérification // Garantir le type non-nullable après la vérification
final confirmedPassage = savedPassage; final confirmedPassage = savedPassage;
debugPrint('✅ [SAVE] Passage sauvegardé avec succès ID: ${confirmedPassage.id}');
// Mémoriser l'adresse pour la prochaine création de passage // Mémoriser l'adresse pour la prochaine création de passage
debugPrint('🟢 [SAVE] Mémorisation adresse');
await _saveLastPassageAddress(); await _saveLastPassageAddress();
// Propager la résidence aux autres passages de l'immeuble si nécessaire // Propager la résidence aux autres passages de l'immeuble si nécessaire
if (_fkHabitat == 2 && _residenceController.text.trim().isNotEmpty) { if (_fkHabitat == 2 && _residenceController.text.trim().isNotEmpty) {
debugPrint('🟢 [SAVE] Propagation résidence à l\'immeuble');
await _propagateResidenceToBuilding(confirmedPassage); await _propagateResidenceToBuilding(confirmedPassage);
} }
@@ -514,17 +604,30 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Lancer le flow Tap to Pay // Lancer le flow Tap to Pay
final paymentSuccess = await _attemptTapToPayWithPassage(confirmedPassage, montant); final paymentSuccess = await _attemptTapToPayWithPassage(confirmedPassage, montant);
if (!paymentSuccess) { if (paymentSuccess) {
debugPrint('⚠️ Paiement Tap to Pay échoué pour le passage ${confirmedPassage.id}'); // Fermer le formulaire en cas de succès
}
},
);
// Fermer le formulaire après le choix de paiement
if (mounted) { if (mounted) {
Navigator.of(context, rootNavigator: false).pop(); Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call(); widget.onSuccess?.call();
} }
} else {
debugPrint('⚠️ Paiement Tap to Pay échoué - formulaire reste ouvert');
// Ne pas fermer le formulaire en cas d'échec
// L'utilisateur peut réessayer ou annuler
}
},
onQRCodeCompleted: () {
// Pour QR Code: fermer le formulaire après l'affichage du QR
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
},
);
// NOTE: Le formulaire n'est plus fermé systématiquement ici
// Il est fermé dans onQRCodeCompleted pour QR Code
// ou dans onTapToPaySelected en cas de succès Tap to Pay
} }
} else { } else {
// Stripe non activé pour cette amicale // Stripe non activé pour cette amicale
@@ -563,30 +666,44 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
} }
} }
} }
} catch (e) { } catch (e, stackTrace) {
// Masquer le loading // Masquer le loading
debugPrint('❌ [SAVE] ERREUR CAPTURÉE');
debugPrint('❌ [SAVE] Type erreur: ${e.runtimeType}');
debugPrint('❌ [SAVE] Message erreur: $e');
debugPrint('❌ [SAVE] Stack trace:\n$stackTrace');
LoadingSpinOverlayUtils.hideSpecific(overlay); LoadingSpinOverlayUtils.hideSpecific(overlay);
// Afficher l'erreur // Afficher l'erreur
final errorMessage = ApiException.fromError(e).message;
debugPrint('❌ [SAVE] Message d\'erreur formaté: $errorMessage');
if (mounted) { if (mounted) {
await ResultDialog.show( await ResultDialog.show(
context: context, context: context,
success: false, success: false,
message: ApiException.fromError(e).message, message: errorMessage,
); );
} }
} finally { } finally {
debugPrint('🟢 [SAVE] Bloc finally - Nettoyage');
if (mounted) { if (mounted) {
setState(() { setState(() {
_isSubmitting = false; _isSubmitting = false;
}); });
debugPrint('🟢 [SAVE] _isSubmitting = false');
} }
debugPrint('🟢 [SAVE] Fin _savePassage');
} }
} }
/// Mémoriser l'adresse du passage pour la prochaine création /// Mémoriser l'adresse du passage pour la prochaine création
Future<void> _saveLastPassageAddress() async { Future<void> _saveLastPassageAddress() async {
try { try {
debugPrint('🟡 [ADDRESS] Début mémorisation adresse');
debugPrint('🟡 [ADDRESS] _settingsBox.isOpen: ${_settingsBox.isOpen}');
await _settingsBox.put('lastPassageNumero', _numeroController.text.trim()); await _settingsBox.put('lastPassageNumero', _numeroController.text.trim());
await _settingsBox.put('lastPassageRueBis', _rueBisController.text.trim()); await _settingsBox.put('lastPassageRueBis', _rueBisController.text.trim());
await _settingsBox.put('lastPassageRue', _rueController.text.trim()); await _settingsBox.put('lastPassageRue', _rueController.text.trim());
@@ -596,20 +713,28 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
await _settingsBox.put('lastPassageAppt', _apptController.text.trim()); await _settingsBox.put('lastPassageAppt', _apptController.text.trim());
await _settingsBox.put('lastPassageNiveau', _niveauController.text.trim()); await _settingsBox.put('lastPassageNiveau', _niveauController.text.trim());
debugPrint('✅ Adresse mémorisée pour la prochaine création de passage'); debugPrint(' [ADDRESS] Adresse mémorisée avec succès');
} catch (e) { } catch (e, stackTrace) {
debugPrint('⚠️ Erreur lors de la mémorisation de l\'adresse: $e'); debugPrint('❌ [ADDRESS] Erreur lors de la mémorisation: $e');
debugPrint('❌ [ADDRESS] Stack trace:\n$stackTrace');
} }
} }
/// Propager la résidence aux autres passages de l'immeuble (fkType=2, même adresse, résidence vide) /// Propager la résidence aux autres passages de l'immeuble (fkType=2, même adresse, résidence vide)
Future<void> _propagateResidenceToBuilding(PassageModel savedPassage) async { Future<void> _propagateResidenceToBuilding(PassageModel savedPassage) async {
try { try {
debugPrint('🟡 [PROPAGATE] Début propagation résidence');
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName); final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
debugPrint('🟡 [PROPAGATE] passagesBox.isOpen: ${passagesBox.isOpen}');
debugPrint('🟡 [PROPAGATE] passagesBox.length: ${passagesBox.length}');
final residence = _residenceController.text.trim(); final residence = _residenceController.text.trim();
debugPrint('🟡 [PROPAGATE] résidence: "$residence"');
// Clé d'adresse du passage sauvegardé // Clé d'adresse du passage sauvegardé
final addressKey = '${savedPassage.numero}|${savedPassage.rueBis}|${savedPassage.rue}|${savedPassage.ville}'; final addressKey = '${savedPassage.numero}|${savedPassage.rueBis}|${savedPassage.rue}|${savedPassage.ville}';
debugPrint('🟡 [PROPAGATE] addressKey: "$addressKey"');
int updatedCount = 0; int updatedCount = 0;
@@ -625,6 +750,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
passageAddressKey == addressKey && // Même adresse passageAddressKey == addressKey && // Même adresse
passage.residence.trim().isEmpty) { // Résidence vide passage.residence.trim().isEmpty) { // Résidence vide
debugPrint('🟡 [PROPAGATE] Mise à jour passage ID: ${passage.id}');
// Mettre à jour la résidence dans Hive // Mettre à jour la résidence dans Hive
final updatedPassage = passage.copyWith(residence: residence); final updatedPassage = passage.copyWith(residence: residence);
await passagesBox.put(passage.key, updatedPassage); await passagesBox.put(passage.key, updatedPassage);
@@ -634,10 +760,13 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
} }
if (updatedCount > 0) { if (updatedCount > 0) {
debugPrint('✅ Résidence propagée à $updatedCount passage(s) de l\'immeuble'); debugPrint(' [PROPAGATE] Résidence propagée à $updatedCount passage(s)');
} else {
debugPrint('✅ [PROPAGATE] Aucun passage à mettre à jour');
} }
} catch (e) { } catch (e, stackTrace) {
debugPrint('⚠️ Erreur lors de la propagation de la résidence: $e'); debugPrint('❌ [PROPAGATE] Erreur lors de la propagation: $e');
debugPrint('❌ [PROPAGATE] Stack trace:\n$stackTrace');
} }
} }
@@ -1819,9 +1948,46 @@ class _TapToPayFlowDialogState extends State<_TapToPayFlowDialog> {
} }
} catch (e) { } catch (e) {
// Analyser le type d'erreur pour afficher un message clair
final errorMsg = e.toString().toLowerCase();
String userMessage;
bool shouldCancelPayment = true;
if (errorMsg.contains('canceled') || errorMsg.contains('cancelled')) {
// Annulation volontaire par l'utilisateur
userMessage = 'Paiement annulé';
} else if (errorMsg.contains('cardreadtimedout') || errorMsg.contains('timed out')) {
// Timeout de lecture NFC
userMessage = 'Lecture de la carte impossible.\n\n'
'Conseils :\n'
'• Maintenez la carte contre le dos du téléphone\n'
'• Ne bougez pas jusqu\'à confirmation\n'
'• Retirez la coque si nécessaire\n'
'• Essayez différentes positions sur le téléphone';
} else if (errorMsg.contains('already') && errorMsg.contains('payment')) {
// PaymentIntent existe déjà
userMessage = 'Un paiement est déjà en cours pour ce passage.\n'
'Veuillez réessayer dans quelques instants.';
shouldCancelPayment = false; // Déjà en cours, pas besoin d'annuler
} else {
// Autre erreur technique
userMessage = 'Erreur lors du paiement.\n\n$e';
}
// Annuler le PaymentIntent si créé pour permettre une nouvelle tentative
if (shouldCancelPayment && _paymentIntentId != null) {
StripeTapToPayService.instance.cancelPayment(_paymentIntentId!).catchError((cancelError) {
debugPrint('⚠️ Erreur annulation PaymentIntent: $cancelError');
});
}
setState(() { setState(() {
_currentState = 'error'; _currentState = 'error';
_errorMessage = e.toString(); _errorMessage = userMessage;
}); });
} }
} }

View File

@@ -8,13 +8,14 @@ import 'package:geosector_app/presentation/widgets/qr_code_payment_dialog.dart';
/// Dialog de sélection de la méthode de paiement CB /// Dialog de sélection de la méthode de paiement CB
/// Affiche les options QR Code et/ou Tap to Pay selon la compatibilité /// Affiche les options QR Code et/ou Tap to Pay selon la compatibilité
class PaymentMethodSelectionDialog extends StatelessWidget { class PaymentMethodSelectionDialog extends StatefulWidget {
final PassageModel passage; final PassageModel passage;
final double amount; final double amount;
final String habitantName; final String habitantName;
final StripeConnectService stripeConnectService; final StripeConnectService stripeConnectService;
final PassageRepository? passageRepository; final PassageRepository? passageRepository;
final VoidCallback? onTapToPaySelected; final VoidCallback? onTapToPaySelected;
final VoidCallback? onQRCodeCompleted;
const PaymentMethodSelectionDialog({ const PaymentMethodSelectionDialog({
super.key, super.key,
@@ -24,12 +25,61 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
required this.stripeConnectService, required this.stripeConnectService,
this.passageRepository, this.passageRepository,
this.onTapToPaySelected, this.onTapToPaySelected,
this.onQRCodeCompleted,
}); });
@override
State<PaymentMethodSelectionDialog> createState() => _PaymentMethodSelectionDialogState();
/// Afficher le dialog de sélection de méthode de paiement
static Future<void> show({
required BuildContext context,
required PassageModel passage,
required double amount,
required String habitantName,
required StripeConnectService stripeConnectService,
PassageRepository? passageRepository,
VoidCallback? onTapToPaySelected,
VoidCallback? onQRCodeCompleted,
}) {
return showDialog(
context: context,
barrierDismissible: false, // Ne peut pas fermer en cliquant à côté
builder: (context) => PaymentMethodSelectionDialog(
passage: passage,
amount: amount,
habitantName: habitantName,
stripeConnectService: stripeConnectService,
passageRepository: passageRepository,
onTapToPaySelected: onTapToPaySelected,
onQRCodeCompleted: onQRCodeCompleted,
),
);
}
}
class _PaymentMethodSelectionDialogState extends State<PaymentMethodSelectionDialog> {
String? _tapToPayUnavailableReason;
bool _isCheckingNFC = true;
@override
void initState() {
super.initState();
_checkTapToPayAvailability();
}
Future<void> _checkTapToPayAvailability() async {
final reason = await DeviceInfoService.instance.getTapToPayUnavailableReasonAsync();
setState(() {
_tapToPayUnavailableReason = reason;
_isCheckingNFC = false;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final canUseTapToPay = DeviceInfoService.instance.canUseTapToPay(); final canUseTapToPay = !_isCheckingNFC && _tapToPayUnavailableReason == null;
final amountEuros = amount.toStringAsFixed(2); final amountEuros = widget.amount.toStringAsFixed(2);
return Dialog( return Dialog(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -42,9 +92,6 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// En-tête // En-tête
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text( const Text(
'Règlement CB', 'Règlement CB',
style: TextStyle( style: TextStyle(
@@ -52,12 +99,6 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 24), const SizedBox(height: 24),
// Informations du paiement // Informations du paiement
@@ -76,7 +117,7 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
habitantName, widget.habitantName,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -128,23 +169,28 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
description: 'Le client scanne le code avec son téléphone', description: 'Le client scanne le code avec son téléphone',
onPressed: () => _handleQRCodePayment(context), onPressed: () => _handleQRCodePayment(context),
color: Colors.blue, color: Colors.blue,
isEnabled: true,
), ),
if (canUseTapToPay) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
// Bouton Tap to Pay
// Bouton Tap to Pay (toujours affiché, désactivé si non disponible)
_buildPaymentButton( _buildPaymentButton(
context: context, context: context,
icon: Icons.contactless, icon: Icons.contactless,
label: 'Tap to Pay', label: _isCheckingNFC ? 'Tap to Pay (vérification...)' : 'Tap to Pay',
description: 'Paiement sans contact sur cet appareil', description: canUseTapToPay
onPressed: () { ? 'Paiement sans contact sur cet appareil'
: _tapToPayUnavailableReason ?? 'Vérification en cours...',
onPressed: canUseTapToPay
? () {
Navigator.of(context).pop(); Navigator.of(context).pop();
onTapToPaySelected?.call(); widget.onTapToPaySelected?.call();
}, }
: null,
color: Colors.green, color: Colors.green,
isEnabled: canUseTapToPay,
), ),
],
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -179,18 +225,24 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
required IconData icon, required IconData icon,
required String label, required String label,
required String description, required String description,
required VoidCallback onPressed, required VoidCallback? onPressed,
required Color color, required Color color,
required bool isEnabled,
}) { }) {
// Couleurs selon l'état activé/désactivé
final effectiveColor = isEnabled ? color : Colors.grey;
final backgroundColor = isEnabled ? color.withOpacity(0.1) : Colors.grey.shade100;
final borderColor = isEnabled ? color.withOpacity(0.3) : Colors.grey.shade300;
return InkWell( return InkWell(
onTap: onPressed, onTap: isEnabled ? onPressed : null,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Container( child: Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withOpacity(0.1), color: backgroundColor,
border: Border.all(color: color.withOpacity(0.3), width: 2), border: Border.all(color: borderColor, width: 2),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
@@ -198,36 +250,69 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withOpacity(0.2), color: isEnabled ? color.withOpacity(0.2) : Colors.grey.shade200,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Icon(icon, color: color, size: 32), child: Icon(
icon,
color: effectiveColor,
size: 32,
),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
children: [
Expanded(
child: Text(
label, label,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: color, color: effectiveColor,
), ),
), ),
),
if (!isEnabled)
Icon(
Icons.lock_outline,
color: Colors.grey.shade600,
size: 20,
),
],
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isEnabled) ...[
Icon(
Icons.warning_amber_rounded,
color: Colors.orange.shade700,
size: 16,
),
const SizedBox(width: 4),
],
Expanded(
child: Text(
description, description,
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
color: Colors.grey.shade700, color: isEnabled ? Colors.grey.shade700 : Colors.orange.shade700,
fontWeight: isEnabled ? FontWeight.normal : FontWeight.w500,
),
), ),
), ),
], ],
), ),
],
), ),
Icon(Icons.arrow_forward_ios, color: color, size: 20), ),
if (isEnabled)
Icon(Icons.arrow_forward_ios, color: effectiveColor, size: 20),
], ],
), ),
), ),
@@ -238,6 +323,7 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
Future<void> _handleQRCodePayment(BuildContext context) async { Future<void> _handleQRCodePayment(BuildContext context) async {
// Sauvegarder le navigator avant de fermer les dialogs // Sauvegarder le navigator avant de fermer les dialogs
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
bool loaderDisplayed = false;
try { try {
// Afficher un loader // Afficher un loader
@@ -248,19 +334,20 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
), ),
); );
loaderDisplayed = true;
// Créer le Payment Link // Créer le Payment Link
final amountInCents = (amount * 100).round(); final amountInCents = (widget.amount * 100).round();
debugPrint('🔵 Création Payment Link : ${amountInCents} cents, passage ${passage.id}'); debugPrint('🔵 Création Payment Link : ${amountInCents} cents, passage ${widget.passage.id}');
final paymentLink = await stripeConnectService.createPaymentLink( final paymentLink = await widget.stripeConnectService.createPaymentLink(
amountInCents: amountInCents, amountInCents: amountInCents,
passageId: passage.id, passageId: widget.passage.id,
description: 'Calendrier pompiers - ${habitantName}', description: 'Calendrier pompiers - ${widget.habitantName}',
metadata: { metadata: {
'passage_id': passage.id.toString(), 'passage_id': widget.passage.id.toString(),
'habitant_name': habitantName, 'habitant_name': widget.habitantName,
'adresse': '${passage.numero} ${passage.rue}, ${passage.ville}', 'adresse': '${widget.passage.numero} ${widget.passage.rue}, ${widget.passage.ville}',
}, },
); );
@@ -270,22 +357,18 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
debugPrint(' ID: ${paymentLink.paymentLinkId}'); debugPrint(' ID: ${paymentLink.paymentLinkId}');
} }
// Fermer le loader
navigator.pop();
debugPrint('🔵 Loader fermé');
if (paymentLink == null) { if (paymentLink == null) {
throw Exception('Impossible de créer le lien de paiement'); throw Exception('Impossible de créer le lien de paiement');
} }
// Sauvegarder l'URL du Payment Link dans le passage // Sauvegarder l'URL du Payment Link dans le passage
if (passageRepository != null) { if (widget.passageRepository != null) {
try { try {
debugPrint('🔵 Sauvegarde de l\'URL du Payment Link dans le passage...'); debugPrint('🔵 Sauvegarde de l\'URL du Payment Link dans le passage...');
final updatedPassage = passage.copyWith( final updatedPassage = widget.passage.copyWith(
stripePaymentLinkUrl: paymentLink.url, stripePaymentLinkUrl: paymentLink.url,
); );
await passageRepository!.updatePassage(updatedPassage); await widget.passageRepository!.updatePassage(updatedPassage);
debugPrint('✅ URL du Payment Link sauvegardée'); debugPrint('✅ URL du Payment Link sauvegardée');
} catch (e) { } catch (e) {
debugPrint('⚠️ Erreur lors de la sauvegarde de l\'URL: $e'); debugPrint('⚠️ Erreur lors de la sauvegarde de l\'URL: $e');
@@ -293,7 +376,12 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
} }
} }
// Fermer le dialog de sélection // Fermer le loader
navigator.pop();
loaderDisplayed = false;
debugPrint('🔵 Loader fermé');
// Fermer le dialog de sélection (seulement en cas de succès)
navigator.pop(); navigator.pop();
debugPrint('🔵 Dialog de sélection fermé'); debugPrint('🔵 Dialog de sélection fermé');
@@ -311,43 +399,25 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
); );
debugPrint('🔵 Dialog QR Code affiché'); debugPrint('🔵 Dialog QR Code affiché');
// Notifier que le QR Code est complété
widget.onQRCodeCompleted?.call();
debugPrint('✅ Callback onQRCodeCompleted appelé');
} catch (e, stack) { } catch (e, stack) {
debugPrint('❌ Erreur dans _handleQRCodePayment: $e'); debugPrint('❌ Erreur dans _handleQRCodePayment: $e');
debugPrint(' Stack: $stack'); debugPrint(' Stack: $stack');
// Fermer le loader si encore ouvert // Fermer le loader si encore ouvert
if (loaderDisplayed) {
try { try {
navigator.pop(); navigator.pop();
} catch (_) {} } catch (_) {}
}
// Afficher l'erreur // Afficher l'erreur (le dialogue de sélection reste ouvert)
if (context.mounted) { if (context.mounted) {
ApiException.showError(context, e); ApiException.showError(context, e);
} }
} }
} }
/// Afficher le dialog de sélection de méthode de paiement
static Future<void> show({
required BuildContext context,
required PassageModel passage,
required double amount,
required String habitantName,
required StripeConnectService stripeConnectService,
PassageRepository? passageRepository,
VoidCallback? onTapToPaySelected,
}) {
return showDialog(
context: context,
barrierDismissible: true,
builder: (context) => PaymentMethodSelectionDialog(
passage: passage,
amount: amount,
habitantName: habitantName,
stripeConnectService: stripeConnectService,
passageRepository: passageRepository,
onTapToPaySelected: onTapToPaySelected,
),
);
}
} }

View File

@@ -818,7 +818,7 @@ class _UserFormState extends State<UserForm> {
), ),
helperText: widget.user?.id != 0 helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel" ? "Laissez vide pour conserver le mot de passe actuel"
: "8 à 64 caractères. Phrases de passe recommandées (ex: Mon chat Félix a 3 ans!)", : "8 à 64 caractères, alphanumériques et caractères spéciaux",
helperMaxLines: 3, helperMaxLines: 3,
validator: _validatePassword, validator: _validatePassword,
), ),
@@ -897,7 +897,7 @@ class _UserFormState extends State<UserForm> {
), ),
helperText: widget.user?.id != 0 helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel" ? "Laissez vide pour conserver le mot de passe actuel"
: "8 à 64 caractères. Phrases de passe recommandées (ex: Mon chat Félix a 3 ans!)", : "8 à 64 caractères, alphanumériques et caractères spéciaux",
helperMaxLines: 3, helperMaxLines: 3,
validator: _validatePassword, validator: _validatePassword,
), ),

View File

@@ -240,7 +240,7 @@ class _UserFormDialogState extends State<UserFormDialog> {
user: widget.user, user: widget.user,
readOnly: widget.readOnly, readOnly: widget.readOnly,
allowUsernameEdit: widget.allowUsernameEdit, allowUsernameEdit: widget.allowUsernameEdit,
allowSectNameEdit: widget.allowUsernameEdit, allowSectNameEdit: widget.isAdmin, // Toujours éditable pour un admin
amicale: widget.amicale, // Passer l'amicale amicale: widget.amicale, // Passer l'amicale
isAdmin: widget.isAdmin, // Passer isAdmin isAdmin: widget.isAdmin, // Passer isAdmin
onSubmit: null, // Pas besoin de callback onSubmit: null, // Pas besoin de callback

View File

@@ -1 +1 @@
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/battery_plus-6.0.3/ /home/pierre/.pub-cache/hosted/pub.dev/battery_plus-6.0.3/

View File

@@ -1 +1 @@
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/connectivity_plus-6.0.5/ /home/pierre/.pub-cache/hosted/pub.dev/connectivity_plus-6.0.5/

View File

@@ -1 +1 @@
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/device_info_plus-11.3.0/ /home/pierre/.pub-cache/hosted/pub.dev/device_info_plus-11.3.0/

View File

@@ -1 +1 @@
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/file_selector_linux-0.9.3+2/ /home/pierre/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.3+2/

View File

@@ -1 +1 @@
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/flutter_local_notifications_linux-6.0.0/ /home/pierre/.pub-cache/hosted/pub.dev/flutter_local_notifications_linux-6.0.0/

View File

@@ -1 +1 @@
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/image_picker_linux-0.2.1+2/ /home/pierre/.pub-cache/hosted/pub.dev/image_picker_linux-0.2.1+2/

View File

@@ -1 +1 @@
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/path_provider_linux-2.2.1/ /home/pierre/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/

View File

@@ -1 +1 @@
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/url_launcher_linux-3.2.1/ /home/pierre/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.1/

View File

@@ -1,10 +1,10 @@
// This is a generated file; do not edit or check into version control. // This is a generated file; do not edit or check into version control.
FLUTTER_ROOT=/opt/flutter FLUTTER_ROOT=/home/pierre/.local/flutter
FLUTTER_APPLICATION_PATH=/home/pierre/dev/geosector/app FLUTTER_APPLICATION_PATH=/home/pierre/dev/geosector/app
COCOAPODS_PARALLEL_CODE_SIGN=true COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_BUILD_DIR=build FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=3.5.2 FLUTTER_BUILD_NAME=3.6.3
FLUTTER_BUILD_NUMBER=352 FLUTTER_BUILD_NUMBER=363
DART_OBFUSCATION=false DART_OBFUSCATION=false
TRACK_WIDGET_CREATION=true TRACK_WIDGET_CREATION=true
TREE_SHAKE_ICONS=false TREE_SHAKE_ICONS=false

View File

@@ -1,11 +1,11 @@
#!/bin/sh #!/bin/sh
# This is a generated file; do not edit or check into version control. # This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=/opt/flutter" export "FLUTTER_ROOT=/home/pierre/.local/flutter"
export "FLUTTER_APPLICATION_PATH=/home/pierre/dev/geosector/app" export "FLUTTER_APPLICATION_PATH=/home/pierre/dev/geosector/app"
export "COCOAPODS_PARALLEL_CODE_SIGN=true" export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_BUILD_DIR=build" export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=3.5.2" export "FLUTTER_BUILD_NAME=3.6.3"
export "FLUTTER_BUILD_NUMBER=352" export "FLUTTER_BUILD_NUMBER=363"
export "DART_OBFUSCATION=false" export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true" export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false" export "TREE_SHAKE_ICONS=false"

View File

@@ -130,10 +130,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: built_value name: built_value
sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.12.0" version: "8.12.3"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@@ -222,6 +222,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
cookie_jar:
dependency: "direct main"
description:
name: cookie_jar
sha256: a6ac027d3ed6ed756bfce8f3ff60cb479e266f3b0fdabd6242b804b6765e52de
url: "https://pub.dev"
source: hosted
version: "4.0.8"
cross_file: cross_file:
dependency: transitive dependency: transitive
description: description:
@@ -318,6 +326,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.2" version: "3.2.2"
dio_cookie_manager:
dependency: "direct main"
description:
name: dio_cookie_manager
sha256: d39c16abcc711c871b7b29bd51c6b5f3059ef39503916c6a9df7e22c4fc595e0
url: "https://pub.dev"
source: hosted
version: "3.3.0"
dio_web_adapter: dio_web_adapter:
dependency: transitive dependency: transitive
description: description:
@@ -395,6 +411,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_compass:
dependency: "direct main"
description:
name: flutter_compass
sha256: "1b4d7e6c95a675ec8482b5c9c9ccf1ebf0ced3dbec59dce28ad609da953de850"
url: "https://pub.dev"
source: hosted
version: "0.8.1"
flutter_launcher_icons: flutter_launcher_icons:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -630,10 +654,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: http name: http
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.0" version: "1.6.0"
http_multi_server: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@@ -654,10 +678,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.4" version: "4.7.2"
image_picker: image_picker:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1435,10 +1459,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: watcher name: watcher
sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.4" version: "1.2.1"
web: web:
dependency: transitive dependency: transitive
description: description:

View File

@@ -1,7 +1,7 @@
name: geosector_app name: geosector_app
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers' description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
publish_to: 'none' publish_to: 'none'
version: 3.5.2+352 version: 3.6.3+363
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@@ -22,6 +22,8 @@ dependencies:
# API & Réseau # API & Réseau
dio: ^5.3.3 dio: ^5.3.3
cookie_jar: ^4.0.8 # Gestion des cookies pour sessions PHP
dio_cookie_manager: ^3.1.1 # Interceptor cookies pour Dio
connectivity_plus: 6.0.5 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle) connectivity_plus: 6.0.5 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
retry: ^3.1.2 retry: ^3.1.2
@@ -46,7 +48,7 @@ dependencies:
geolocator: ^13.0.3 # ⬇️ Downgrade depuis 14.0.2 (Flutter 3.24.5 LTS) geolocator: ^13.0.3 # ⬇️ Downgrade depuis 14.0.2 (Flutter 3.24.5 LTS)
geolocator_android: 4.6.1 # ✅ Force version sans toARGB32() geolocator_android: 4.6.1 # ✅ Force version sans toARGB32()
universal_html: ^2.2.4 # Pour accéder à la localisation du navigateur (detection env) universal_html: ^2.2.4 # Pour accéder à la localisation du navigateur (detection env)
# sensors_plus: ^3.1.0 # ❌ SUPPRIMÉ - Mode boussole retiré (feature optionnelle peu utilisée) (13/10/2025) flutter_compass: ^0.8.1 # Mode boussole pour le mode terrain (Android/iOS uniquement)
# Chat et notifications # Chat et notifications
# mqtt5_client: ^4.11.0 # mqtt5_client: ^4.11.0

113
app/pubspec.yaml.backup Executable file
View File

@@ -0,0 +1,113 @@
name: geosector_app
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
publish_to: 'none'
version: 3.5.9+359
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
cupertino_icons: ^1.0.6
# Navigation
go_router: ^15.1.2 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 16.0.0)
# État et gestion des données
hive: ^2.2.3
hive_flutter: ^1.1.0
# API & Réseau
dio: ^5.3.3
cookie_jar: ^4.0.8 # Gestion des cookies pour sessions PHP
dio_cookie_manager: ^3.1.1 # Interceptor cookies pour Dio
connectivity_plus: 6.0.5 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
retry: ^3.1.2
# UI et animations
google_fonts: ^6.1.0
flutter_svg: ^2.0.9
# package_info_plus: ^4.2.0 # ❌ SUPPRIMÉ - Remplacé par AppInfoService auto-généré (13/10/2025)
# Utilitaires
intl: 0.19.0 # Version requise par flutter_localizations Flutter 3.24.5 LTS
uuid: ^4.2.1
syncfusion_flutter_charts: 27.2.5 # ⬇️ Compatible Flutter 3.24.5 LTS (sweet spot)
# shared_preferences: ^2.3.3 # Remplacé par Hive pour cohérence architecturale
# Cartes et géolocalisation
url_launcher: ^6.1.14 # ⬇️ Version stable (depuis 6.3.1)
flutter_map: ^7.0.2 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 8.2.2)
flutter_map_cache: ^1.5.1 # Compatible Flutter 3.24.5 LTS (Dart 3.5.4)
dio_cache_interceptor_hive_store: ^3.2.1 # ✅ Compatible flutter_map_cache 1.5.1
path_provider: ^2.1.2 # Requis pour le cache
latlong2: ^0.9.1
geolocator: ^13.0.3 # ⬇️ Downgrade depuis 14.0.2 (Flutter 3.24.5 LTS)
geolocator_android: 4.6.1 # ✅ Force version sans toARGB32()
universal_html: ^2.2.4 # Pour accéder à la localisation du navigateur (detection env)
# sensors_plus: ^3.1.0 # ❌ SUPPRIMÉ - Mode boussole retiré (feature optionnelle peu utilisée) (13/10/2025)
# Chat et notifications
# mqtt5_client: ^4.11.0
flutter_local_notifications: ^19.0.1
# Upload d'images
image_picker: ^0.8.9 # ⬇️ Avant SwiftPM (depuis 1.1.2)
# Configuration YAML
yaml: ^3.1.2
# Stripe Terminal et détection device (V2)
device_info_plus: ^11.3.0 # ✅ Compatible Flutter 3.24.5 LTS (depuis 12.1.0)
battery_plus: 6.0.3 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
# network_info_plus: ^4.1.0 # ❌ SUPPRIMÉ - Remplacé par NetworkInterface natif Dart (13/10/2025)
nfc_manager: 3.3.0 # ✅ Version stable - Nécessite patch AndroidManifest (fix-nfc-manager.sh)
mek_stripe_terminal: ^4.6.0
flutter_stripe: ^11.5.0 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 12.0.0)
permission_handler: ^12.0.1
qr_flutter: ^4.1.0 # Génération de QR codes pour paiements Stripe
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 6.0.0)
hive_generator: ^2.0.1
build_runner: ^2.4.6
flutter_launcher_icons: ^0.14.4
flutter_launcher_icons:
android: true
ios: true
image_path: 'assets/images/icons/icon-1024.png'
min_sdk_android: 21
adaptive_icon_background: '#FFFFFF'
adaptive_icon_foreground: 'assets/images/icons/icon-1024.png'
remove_alpha_ios: true
web:
generate: true
image_path: 'assets/images/icons/icon-1024.png'
background_color: '#FFFFFF'
theme_color: '#4B77BE'
windows:
generate: true
image_path: 'assets/images/icons/icon-1024.png'
icon_size: 48
flutter:
uses-material-design: true
assets:
- assets/images/
- assets/icons/
- assets/animations/
- lib/chat/chat_config.yaml
fonts:
- family: Inter
fonts:
- asset: assets/fonts/InterVariable.ttf
- asset: assets/fonts/InterVariable-Italic.ttf
style: italic

113
app/pubspec.yaml.bak Executable file
View File

@@ -0,0 +1,113 @@
name: geosector_app
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
publish_to: 'none'
version: 3.6.3+363
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
cupertino_icons: ^1.0.6
# Navigation
go_router: ^15.1.2 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 16.0.0)
# État et gestion des données
hive: ^2.2.3
hive_flutter: ^1.1.0
# API & Réseau
dio: ^5.3.3
cookie_jar: ^4.0.8 # Gestion des cookies pour sessions PHP
dio_cookie_manager: ^3.1.1 # Interceptor cookies pour Dio
connectivity_plus: 6.0.5 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
retry: ^3.1.2
# UI et animations
google_fonts: ^6.1.0
flutter_svg: ^2.0.9
# package_info_plus: ^4.2.0 # ❌ SUPPRIMÉ - Remplacé par AppInfoService auto-généré (13/10/2025)
# Utilitaires
intl: 0.19.0 # Version requise par flutter_localizations Flutter 3.24.5 LTS
uuid: ^4.2.1
syncfusion_flutter_charts: 27.2.5 # ⬇️ Compatible Flutter 3.24.5 LTS (sweet spot)
# shared_preferences: ^2.3.3 # Remplacé par Hive pour cohérence architecturale
# Cartes et géolocalisation
url_launcher: ^6.1.14 # ⬇️ Version stable (depuis 6.3.1)
flutter_map: ^7.0.2 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 8.2.2)
flutter_map_cache: ^1.5.1 # Compatible Flutter 3.24.5 LTS (Dart 3.5.4)
dio_cache_interceptor_hive_store: ^3.2.1 # ✅ Compatible flutter_map_cache 1.5.1
path_provider: ^2.1.2 # Requis pour le cache
latlong2: ^0.9.1
geolocator: ^13.0.3 # ⬇️ Downgrade depuis 14.0.2 (Flutter 3.24.5 LTS)
geolocator_android: 4.6.1 # ✅ Force version sans toARGB32()
universal_html: ^2.2.4 # Pour accéder à la localisation du navigateur (detection env)
flutter_compass: ^0.8.1 # Mode boussole pour le mode terrain (Android/iOS uniquement)
# Chat et notifications
# mqtt5_client: ^4.11.0
flutter_local_notifications: ^19.0.1
# Upload d'images
image_picker: ^0.8.9 # ⬇️ Avant SwiftPM (depuis 1.1.2)
# Configuration YAML
yaml: ^3.1.2
# Stripe Terminal et détection device (V2)
device_info_plus: ^11.3.0 # ✅ Compatible Flutter 3.24.5 LTS (depuis 12.1.0)
battery_plus: 6.0.3 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
# network_info_plus: ^4.1.0 # ❌ SUPPRIMÉ - Remplacé par NetworkInterface natif Dart (13/10/2025)
nfc_manager: 3.3.0 # ✅ Version stable - Nécessite patch AndroidManifest (fix-nfc-manager.sh)
mek_stripe_terminal: ^4.6.0
flutter_stripe: ^11.5.0 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 12.0.0)
permission_handler: ^12.0.1
qr_flutter: ^4.1.0 # Génération de QR codes pour paiements Stripe
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0 # ⬇️ Compatible Flutter 3.24.5 LTS (depuis 6.0.0)
hive_generator: ^2.0.1
build_runner: ^2.4.6
flutter_launcher_icons: ^0.14.4
flutter_launcher_icons:
android: true
ios: true
image_path: 'assets/images/icons/icon-1024.png'
min_sdk_android: 21
adaptive_icon_background: '#FFFFFF'
adaptive_icon_foreground: 'assets/images/icons/icon-1024.png'
remove_alpha_ios: true
web:
generate: true
image_path: 'assets/images/icons/icon-1024.png'
background_color: '#FFFFFF'
theme_color: '#4B77BE'
windows:
generate: true
image_path: 'assets/images/icons/icon-1024.png'
icon_size: 48
flutter:
uses-material-design: true
assets:
- assets/images/
- assets/icons/
- assets/animations/
- lib/chat/chat_config.yaml
fonts:
- family: Inter
fonts:
- asset: assets/fonts/InterVariable.ttf
- asset: assets/fonts/InterVariable-Italic.ttf
style: italic

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Some files were not shown because too many files have changed in this diff Show More