Compare commits
1 Commits
v3.6.2
...
5b6808db25
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b6808db25 |
@@ -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 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
|
||||
- Flutter/Dart: Follow Flutter lint rules in analysis_options.yaml
|
||||
- Naming: camelCase for variables/methods, PascalCase for classes/enums
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -179,6 +179,14 @@ if [ "$SOURCE_TYPE" = "local_code" ]; then
|
||||
--exclude='*.swp' \
|
||||
--exclude='*.swo' \
|
||||
--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"
|
||||
|
||||
echo_info "Archive created: ${ARCHIVE_PATH}"
|
||||
@@ -198,6 +206,16 @@ elif [ "$SOURCE_TYPE" = "remote_container" ]; then
|
||||
--exclude='uploads' \
|
||||
--exclude='sessions' \
|
||||
--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} .
|
||||
" || echo_error "Failed to create archive on remote"
|
||||
|
||||
|
||||
@@ -252,6 +252,21 @@ else
|
||||
fi
|
||||
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é)
|
||||
print_message "Étape 3/5 : Analyse du code Dart..."
|
||||
flutter analyze --no-fatal-infos --no-fatal-warnings || {
|
||||
@@ -415,8 +430,10 @@ if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
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 -r "$APK_NAME"
|
||||
adb install "$APK_NAME"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "APK installé avec succès"
|
||||
|
||||
@@ -254,9 +254,13 @@ EOF
|
||||
echo_info "Fixing web assets structure..."
|
||||
./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..."
|
||||
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"
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/services/theme_service.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/repositories/user_repository.dart';
|
||||
@@ -45,10 +48,80 @@ class GeosectorApp extends StatefulWidget {
|
||||
}
|
||||
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
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
|
||||
@@ -159,6 +232,7 @@ class _GeosectorAppState extends State<GeosectorApp> with WidgetsBindingObserver
|
||||
/// Création du routeur avec configuration pour URLs propres
|
||||
GoRouter _createRouter() {
|
||||
return GoRouter(
|
||||
navigatorKey: navigatorKey,
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
|
||||
@@ -26,7 +26,7 @@ class RoomAdapter extends TypeAdapter<Room> {
|
||||
unreadCount: fields[6] as int,
|
||||
recentMessages: (fields[7] as List?)
|
||||
?.map((dynamic e) => (e as Map).cast<String, dynamic>())
|
||||
?.toList(),
|
||||
.toList(),
|
||||
updatedAt: fields[8] as DateTime?,
|
||||
createdBy: fields[9] as int?,
|
||||
isSynced: fields[10] as bool,
|
||||
|
||||
@@ -28,10 +28,8 @@ class ChatService {
|
||||
|
||||
Timer? _syncTimer;
|
||||
DateTime? _lastSyncTimestamp;
|
||||
DateTime? _lastFullSync;
|
||||
static const Duration _syncInterval = Duration(seconds: 30); // Sync toutes les 30 secondes
|
||||
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
|
||||
static Future<void> init({
|
||||
|
||||
@@ -146,9 +146,9 @@ class AppKeys {
|
||||
1: {
|
||||
'titres': 'Effectués',
|
||||
'titre': 'Effectué',
|
||||
'couleur1': 0xFF00E09D, // Vert (Figma)
|
||||
'couleur2': 0xFF00E09D, // Vert (Figma)
|
||||
'couleur3': 0xFF00E09D, // Vert (Figma)
|
||||
'couleur1': 0xFF008000, // Vert foncé
|
||||
'couleur2': 0xFF008000, // Vert foncé
|
||||
'couleur3': 0xFF008000, // Vert foncé
|
||||
'icon_data': Icons.task_alt,
|
||||
},
|
||||
2: {
|
||||
@@ -170,9 +170,9 @@ class AppKeys {
|
||||
4: {
|
||||
'titres': 'Dons',
|
||||
'titre': 'Don',
|
||||
'couleur1': 0xFF395AA7, // Bleu (Figma)
|
||||
'couleur2': 0xFF395AA7, // Bleu (Figma)
|
||||
'couleur3': 0xFF395AA7, // Bleu (Figma)
|
||||
'couleur1': 0xFF00BCD4, // Cyan
|
||||
'couleur2': 0xFF00BCD4, // Cyan
|
||||
'couleur3': 0xFF00BCD4, // Cyan
|
||||
'icon_data': Icons.volunteer_activism,
|
||||
},
|
||||
5: {
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:cookie_jar/cookie_jar.dart';
|
||||
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
@@ -69,10 +68,15 @@ class ApiService {
|
||||
_dio.options.headers.addAll(headers);
|
||||
|
||||
// Gestionnaire de cookies pour les sessions PHP
|
||||
// Utilise CookieJar en mémoire (cookies maintenus pendant la durée de vie de l'app)
|
||||
// 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é');
|
||||
debugPrint('🍪 [API] Gestionnaire de cookies activé (mobile)');
|
||||
} else {
|
||||
debugPrint('🌐 [API] Mode Web - pas de CookieManager (Bearer token uniquement)');
|
||||
}
|
||||
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// ⚠️ AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
|
||||
// This file is automatically generated by deploy-app.sh script
|
||||
// Last update: 2026-01-16 13:37:45
|
||||
// Last update: 2026-01-19 15:35:06
|
||||
// Source: ../VERSION file
|
||||
//
|
||||
// GEOSECTOR App Version Service
|
||||
@@ -8,10 +8,10 @@
|
||||
|
||||
class AppInfoService {
|
||||
// Version number (format: x.x.x)
|
||||
static const String version = '3.6.2';
|
||||
static const String version = '3.6.3';
|
||||
|
||||
// Build number (version without dots: xxx)
|
||||
static const String buildNumber = '362';
|
||||
static const String buildNumber = '363';
|
||||
|
||||
// Full version string (format: vx.x.x+xxx)
|
||||
static String get fullVersion => 'v$version+$buildNumber';
|
||||
|
||||
@@ -140,18 +140,43 @@ class CurrentUserService extends ChangeNotifier {
|
||||
|
||||
Future<void> loadFromHive() async {
|
||||
try {
|
||||
final box = Hive.box<UserModel>(AppKeys.userBoxName); // Nouvelle Box
|
||||
final user = box.get('current_user');
|
||||
// 1. Récupérer l'ID utilisateur depuis settings
|
||||
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) {
|
||||
_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
|
||||
await _loadDisplayMode();
|
||||
} else {
|
||||
_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();
|
||||
|
||||
@@ -50,6 +50,57 @@ class HiveService {
|
||||
|
||||
bool _isInitialized = false;
|
||||
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 de Hive avec réinitialisation totale
|
||||
|
||||
@@ -313,13 +313,29 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
// Appeler le nouvel endpoint API pour restaurer la session
|
||||
// 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
|
||||
debugPrint('🔄 Appel API: user/session avec sessionId: ${sessionId.substring(0, 10)}...');
|
||||
|
||||
final response = await ApiService.instance.getWithoutQueue(
|
||||
'/api/user/session',
|
||||
'user/session',
|
||||
queryParameters: {'mode': displayMode},
|
||||
);
|
||||
|
||||
// Gestion des codes de retour HTTP
|
||||
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>?;
|
||||
|
||||
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();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Configuration du stockage...";
|
||||
_statusMessage = "Préparation des données...";
|
||||
_progress = 0.45;
|
||||
});
|
||||
}
|
||||
@@ -613,7 +676,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Préparation des données...";
|
||||
_statusMessage = "Ouverture des bases...";
|
||||
_progress = 0.60;
|
||||
});
|
||||
}
|
||||
@@ -621,19 +684,9 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
// Étape 3: Ouverture des Box - 60 à 80%
|
||||
await HiveService.instance.ensureBoxesAreOpen();
|
||||
|
||||
// NOUVEAU : Vérifier et nettoyer si nouvelle version (Web uniquement)
|
||||
// Maintenant que les boxes sont ouvertes, on peut vérifier la version dans Hive
|
||||
// Vérifier et nettoyer si nouvelle version (Web uniquement)
|
||||
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
|
||||
try {
|
||||
debugPrint('📦 Gestion de la box pending_requests...');
|
||||
|
||||
@@ -40,7 +40,12 @@ class _HomeContentState extends State<HomeContent> {
|
||||
final isDesktop = screenWidth > 800;
|
||||
|
||||
// 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(
|
||||
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
|
||||
vertical: AppTheme.spacingL,
|
||||
@@ -48,9 +53,6 @@ class _HomeContentState extends State<HomeContent> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Widget BtnPassages
|
||||
const BtnPassages(),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// LIGNE 1 : Graphiques de répartition (type de passage et mode de paiement)
|
||||
isDesktop
|
||||
@@ -174,6 +176,9 @@ class _HomeContentState extends State<HomeContent> {
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,9 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
// État pour bloquer la sauvegarde du zoom lors du centrage sur secteur
|
||||
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)
|
||||
Map<int, int> _sectorPassageCount = {};
|
||||
Map<int, int> _sectorMemberCount = {};
|
||||
@@ -215,6 +218,16 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
_settingsBox.put('mapZoom', 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
|
||||
@@ -4151,8 +4164,8 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
initialZoom: _currentZoom,
|
||||
mapController: _mapController,
|
||||
disableDrag: _isDraggingPoint,
|
||||
// Utiliser OpenStreetMap temporairement sur mobile si Mapbox échoue
|
||||
useOpenStreetMap: !kIsWeb, // true sur mobile, false sur web
|
||||
// Utiliser les tuiles IGN (Plan ou Ortho selon le choix utilisateur)
|
||||
tileSource: _tileSource,
|
||||
labelMarkers: _buildSectorLabels(),
|
||||
markers: [
|
||||
..._buildMarkers(),
|
||||
@@ -4199,14 +4212,38 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
),
|
||||
)),
|
||||
|
||||
// Boutons d'action en haut à droite (Web uniquement et admin seulement)
|
||||
if (kIsWeb && canEditSectors)
|
||||
// Bouton switch IGN Plan / Ortho en haut à droite (visible pour tous)
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 16,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
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
|
||||
_buildActionButton(
|
||||
icon: Icons.pentagon_outlined,
|
||||
@@ -4246,6 +4283,7 @@ class _MapPageContentState extends State<MapPageContent> {
|
||||
: null,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -8,13 +8,14 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.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/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/presentation/widgets/passage_form_dialog.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/mapbox_map.dart' show TileSource;
|
||||
import 'package:geosector_app/app.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
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAnimations();
|
||||
_loadTileSourceSetting();
|
||||
|
||||
// Écouter les changements de la Hive box passages pour rafraîchir la carte
|
||||
_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 {
|
||||
// Essayer d'obtenir la position réelle depuis le navigateur
|
||||
try {
|
||||
@@ -539,6 +570,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
void dispose() {
|
||||
_positionStreamSubscription?.cancel();
|
||||
_qualityUpdateTimer?.cancel();
|
||||
_compassSubscription?.cancel();
|
||||
_gpsBlinkController.dispose();
|
||||
_networkBlinkController.dispose();
|
||||
_searchController.dispose();
|
||||
@@ -546,6 +578,35 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -823,10 +884,6 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
);
|
||||
}
|
||||
|
||||
final apiService = ApiService.instance;
|
||||
final mapboxApiKey =
|
||||
AppKeys.getMapboxApiKey(apiService.getCurrentEnvironment());
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
@@ -837,21 +894,36 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
initialZoom: 17,
|
||||
maxZoom: 19,
|
||||
minZoom: 10,
|
||||
interactionOptions: const InteractionOptions(
|
||||
interactionOptions: InteractionOptions(
|
||||
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: [
|
||||
TileLayer(
|
||||
// Utiliser l'API v4 de Mapbox sur mobile ou OpenStreetMap en fallback
|
||||
urlTemplate: kIsWeb
|
||||
? 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/{z}/{x}/{y}?access_token=$mapboxApiKey'
|
||||
: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', // OpenStreetMap temporairement sur mobile
|
||||
// Utiliser les tuiles IGN (Plan ou Ortho selon le choix utilisateur)
|
||||
urlTemplate: _tileSource == TileSource.ignOrtho
|
||||
? '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}'
|
||||
: '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',
|
||||
additionalOptions: const {
|
||||
'attribution': '© OpenStreetMap contributors',
|
||||
},
|
||||
maxNativeZoom: 19,
|
||||
maxZoom: 20,
|
||||
minZoom: 7,
|
||||
),
|
||||
// Markers des passages
|
||||
MarkerLayer(
|
||||
@@ -900,6 +972,56 @@ class _UserFieldModePageState extends State<UserFieldModePage>
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class BtnPassages extends StatelessWidget {
|
||||
final shouldShowLotType = _shouldShowLotType();
|
||||
|
||||
return SizedBox(
|
||||
height: 80,
|
||||
height: 92, // 80 + 12 pour le triangle indicateur
|
||||
width: double.infinity,
|
||||
child: ValueListenableBuilder<Box<PassageModel>>(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
@@ -121,6 +121,7 @@ class BtnPassages extends StatelessWidget {
|
||||
/// Colonne TOTAL (cliquable, affiche tous les passages)
|
||||
Widget _buildTotalColumn(BuildContext context, int total) {
|
||||
final bool isSelected = selectedTypeId == null;
|
||||
final Color bgColor = Colors.grey[200]!;
|
||||
|
||||
return InkWell(
|
||||
onTap: () async {
|
||||
@@ -147,13 +148,16 @@ class BtnPassages extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
color: bgColor,
|
||||
border: Border.all(
|
||||
color: Colors.grey[400]!,
|
||||
width: isSelected ? 5 : 1,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
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(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: couleur.withOpacity(0.1),
|
||||
color: couleur,
|
||||
border: Border.all(
|
||||
color: couleur,
|
||||
width: isSelected ? 5 : 1,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [
|
||||
@@ -260,15 +280,15 @@ class BtnPassages extends StatelessWidget {
|
||||
Icon(
|
||||
iconData,
|
||||
size: 20,
|
||||
color: couleur,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
count.toString(),
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: couleur,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
@@ -276,9 +296,9 @@ class BtnPassages extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Text(
|
||||
titre,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: couleur,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
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) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
@@ -302,12 +335,15 @@ class BtnPassages extends StatelessWidget {
|
||||
_showPassageFormDialog(context);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.buttonSuccessColor.withOpacity(0.1),
|
||||
color: Colors.white,
|
||||
border: Border.all(
|
||||
color: AppTheme.buttonSuccessColor,
|
||||
color: Colors.grey[400]!,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
@@ -326,17 +362,17 @@ class BtnPassages extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.add_circle_outline,
|
||||
size: 24,
|
||||
color: AppTheme.buttonSuccessColor,
|
||||
color: Colors.black87,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Nouveau',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppTheme.buttonSuccessColor,
|
||||
color: Colors.grey[700],
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,18 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
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
|
||||
///
|
||||
/// Ce widget encapsule un FlutterMap avec des tuiles Mapbox et fournit
|
||||
@@ -48,8 +60,12 @@ class MapboxMap extends StatefulWidget {
|
||||
final bool disableDrag;
|
||||
|
||||
/// Utiliser OpenStreetMap au lieu de Mapbox (en cas de problème de token)
|
||||
@Deprecated('Utiliser tileSource à la place')
|
||||
final bool useOpenStreetMap;
|
||||
|
||||
/// Source des tuiles de la carte (Mapbox, OpenStreetMap, IGN Plan, IGN Ortho)
|
||||
final TileSource tileSource;
|
||||
|
||||
const MapboxMap({
|
||||
super.key,
|
||||
this.initialPosition = const LatLng(48.1173, -1.6778), // Rennes par défaut
|
||||
@@ -64,6 +80,7 @@ class MapboxMap extends StatefulWidget {
|
||||
this.mapStyle,
|
||||
this.disableDrag = false,
|
||||
this.useOpenStreetMap = false,
|
||||
this.tileSource = TileSource.mapbox,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -125,7 +142,7 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
_cacheInitialized = true;
|
||||
});
|
||||
}
|
||||
debugPrint('MapboxMap: Cache initialisé avec succès pour ${widget.useOpenStreetMap ? "OpenStreetMap" : "Mapbox"}');
|
||||
debugPrint('MapboxMap: Cache initialisé avec succès');
|
||||
} catch (e) {
|
||||
debugPrint('MapboxMap: Erreur lors de l\'initialisation du cache: $e');
|
||||
// En cas d'erreur, on continue sans cache
|
||||
@@ -175,30 +192,76 @@ class _MapboxMapState extends State<MapboxMap> {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String urlTemplate;
|
||||
/// Retourne l'URL template pour la source de tuiles sélectionnée
|
||||
String _getTileUrlTemplate() {
|
||||
// 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) {
|
||||
// Utiliser OpenStreetMap comme alternative
|
||||
urlTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
debugPrint('MapboxMap: Utilisation d\'OpenStreetMap');
|
||||
} else {
|
||||
switch (widget.tileSource) {
|
||||
case TileSource.openStreetMap:
|
||||
return 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
|
||||
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
|
||||
// Utiliser l'environnement actuel pour obtenir la bonne clé API
|
||||
final String environment = ApiService.instance.getCurrentEnvironment();
|
||||
final String mapboxToken = AppKeys.getMapboxApiKey(environment);
|
||||
|
||||
// Essayer différentes API Mapbox selon la plateforme
|
||||
if (kIsWeb) {
|
||||
// Sur web, on peut utiliser l'API styles
|
||||
urlTemplate = 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
|
||||
return 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
|
||||
} else {
|
||||
// Sur mobile, utiliser l'API v4 qui fonctionne mieux avec les tokens standards
|
||||
// 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';
|
||||
return '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
|
||||
if (!_cacheInitialized) {
|
||||
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -3,8 +3,8 @@ FLUTTER_ROOT=/home/pierre/.local/flutter
|
||||
FLUTTER_APPLICATION_PATH=/home/pierre/dev/geosector/app
|
||||
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||
FLUTTER_BUILD_DIR=build
|
||||
FLUTTER_BUILD_NAME=3.6.2
|
||||
FLUTTER_BUILD_NUMBER=362
|
||||
FLUTTER_BUILD_NAME=3.6.3
|
||||
FLUTTER_BUILD_NUMBER=363
|
||||
DART_OBFUSCATION=false
|
||||
TRACK_WIDGET_CREATION=true
|
||||
TREE_SHAKE_ICONS=false
|
||||
|
||||
@@ -4,8 +4,8 @@ export "FLUTTER_ROOT=/home/pierre/.local/flutter"
|
||||
export "FLUTTER_APPLICATION_PATH=/home/pierre/dev/geosector/app"
|
||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||
export "FLUTTER_BUILD_DIR=build"
|
||||
export "FLUTTER_BUILD_NAME=3.6.2"
|
||||
export "FLUTTER_BUILD_NUMBER=362"
|
||||
export "FLUTTER_BUILD_NAME=3.6.3"
|
||||
export "FLUTTER_BUILD_NUMBER=363"
|
||||
export "DART_OBFUSCATION=false"
|
||||
export "TRACK_WIDGET_CREATION=true"
|
||||
export "TREE_SHAKE_ICONS=false"
|
||||
|
||||
@@ -411,6 +411,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: geosector_app
|
||||
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
|
||||
publish_to: 'none'
|
||||
version: 3.6.2+362
|
||||
version: 3.6.3+363
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -48,7 +48,7 @@ dependencies:
|
||||
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)
|
||||
flutter_compass: ^0.8.1 # Mode boussole pour le mode terrain (Android/iOS uniquement)
|
||||
|
||||
# Chat et notifications
|
||||
# mqtt5_client: ^4.11.0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: geosector_app
|
||||
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
|
||||
publish_to: 'none'
|
||||
version: 3.5.9+359
|
||||
version: 3.6.3+363
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -48,7 +48,7 @@ dependencies:
|
||||
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)
|
||||
flutter_compass: ^0.8.1 # Mode boussole pour le mode terrain (Android/iOS uniquement)
|
||||
|
||||
# Chat et notifications
|
||||
# mqtt5_client: ^4.11.0
|
||||
|
||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 3.0 MiB |
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -1 +1 @@
|
||||
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/file_selector_windows-0.9.3+4/
|
||||
/home/pierre/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+4/
|
||||
@@ -1 +1 @@
|
||||
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/flutter_local_notifications_windows-1.0.3/
|
||||
/home/pierre/.pub-cache/hosted/pub.dev/flutter_local_notifications_windows-1.0.3/
|
||||
@@ -1 +1 @@
|
||||
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/geolocator_windows-0.2.5/
|
||||
/home/pierre/.pub-cache/hosted/pub.dev/geolocator_windows-0.2.5/
|
||||
@@ -1 +1 @@
|
||||
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/image_picker_windows-0.2.1+1/
|
||||
/home/pierre/.pub-cache/hosted/pub.dev/image_picker_windows-0.2.1+1/
|
||||
@@ -1 +1 @@
|
||||
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/path_provider_windows-2.3.0/
|
||||
/home/pierre/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/
|
||||
@@ -1 +1 @@
|
||||
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/permission_handler_windows-0.2.1/
|
||||
/home/pierre/.pub-cache/hosted/pub.dev/permission_handler_windows-0.2.1/
|
||||
@@ -1 +1 @@
|
||||
/home/pierre/dev/geosector/app/.pub-cache-local/hosted/pub.dev/url_launcher_windows-3.1.4/
|
||||
/home/pierre/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.4/
|
||||
@@ -1,214 +1,176 @@
|
||||
# Planning Geosector Q1 2026 - COMPLET
|
||||
|
||||
**Période** : 16/01/2026 - 16/03/2026 (60 jours)
|
||||
**Tâches** : 126 tâches actives
|
||||
**Période** : 16/01/2026 - 28/02/2026 (44 jours)
|
||||
**Tâches** : 73 tâches restantes (phases 3-6)
|
||||
**Priorités** : UI/UX et MAP en premier
|
||||
**Stack Techno** : Flutter et Hive pour Web et Mobiles / API REST Full PHP8.3
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1 : BUGS CRITIQUES
|
||||
### 16-18 janvier (3 jours) - 5 tâches
|
||||
|
||||
| Date | ID | Tâche | Catégorie |
|
||||
|------|-----|-------|-----------|
|
||||
| 16/01 | #17 | ✅ Création membre impossible | BUG |
|
||||
| 16/01 | #18 | ✅ Création opération impossible | BUG |
|
||||
| 17/01 | #19 | ✅ Export opération cassé | BUG |
|
||||
| 17/01 | #20 | Enregistrement des passages ne fonctionne pas | BUG |
|
||||
| 18/01 | #14 | Bug F5 - déconnexion lors du rafraîchissement | BUG |
|
||||
| Date | ID | Tâche | Catégorie | Statut |
|
||||
|-------|-----|--------------------------------------------------|-----------|-------------------|
|
||||
| 16/01 | `#17` | ✅ Création membre impossible | BUG | Livré et à tester v3.6.2 |
|
||||
| 16/01 | `#18` | ✅ Création opération impossible | BUG | Livré et à tester v3.6.2 |
|
||||
| 16/01 | `#19` | ✅ Export opération cassé | BUG | Livré et à tester v3.6.2 |
|
||||
| 17/01 | `#20` | ✅ Enregistrement des passages ne fonctionne pas | BUG | Livré et à tester v3.6.2 |
|
||||
| 17/01 | `#61` | ✅ Valider passage directement depuis carte | | Livré et à tester v3.6.2 |
|
||||
| 18/01 | `#216` | ✅ Vérifier géolocalisation nouveau passage | Passage | Livré et à tester v3.6.2 |
|
||||
| 18/01 | `#14` | ✅ Bug F5 - déconnexion lors du rafraîchissement | BUG | à livrer v3.6.3 |
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2 : STRIPE iOS + UX
|
||||
### 19-25 janvier (7 jours) - 14 tâches
|
||||
### 19-25 janvier (7 jours) - 10 tâches
|
||||
|
||||
**Tâche principale** : #13 Tests Stripe iOS (5 jours du 19 au 23)
|
||||
**Tâche principale** : `#13` Tests Stripe iOS (5 jours du 19 au 23)
|
||||
|
||||
| Date | Stripe iOS | En parallèle (UX) |
|
||||
|------|------------|-------------------|
|
||||
| 19/01 | #13 Jour 1 | #204 Design couleurs flashy |
|
||||
| 19/01 | | #205 Écrans utilisateurs simplifiés |
|
||||
| 20/01 | #13 Jour 2 | #113 Couleur repasses orange |
|
||||
| 20/01 | | #72 Épaisseur police lisibilité |
|
||||
| 21/01 | #13 Jour 3 | #71 Visibilité bouton "Envoyer message" |
|
||||
| 21/01 | | #59 Listing rues invisible (clavier) |
|
||||
| 22/01 | #13 Jour 4 | #46 Figer headers tableau Home |
|
||||
| 22/01 | | #42 Historique adresses cliquables |
|
||||
| 23/01 | #13 Jour 5 | #74 Simplifier DashboardLayout/AppScaffold |
|
||||
| 23/01 | | #110 Supprimer refresh session partiels |
|
||||
| 24/01 | Buffer | #28 Gestion reçus Flutter nouveaux champs |
|
||||
| 25/01 | Buffer | #50 Modifier secteur au clic |
|
||||
| 25/01 | | #41 Secteurs avec membres visible carte |
|
||||
| Date | Stripe iOS | En parallèle (UX) | Statut |
|
||||
|-------|------------|--------------------------------------------|--------|
|
||||
| 19/01 | `#13` Jour 1 | ✅ `#204` Design couleurs flashy | à livrer v3.6.3 |
|
||||
| 19/01 | | ✅ `#205` Écrans utilisateurs simplifiés | à livrer v3.6.3 |
|
||||
| 20/01 | `#13` Jour 2 | `#113` Couleur repasses orange | |
|
||||
| 20/01 | | `#72`Épaisseur police lisibilité | |
|
||||
| 21/01 | `#13` Jour 3 | `#71`Visibilité bouton "Envoyer message" | |
|
||||
| 21/01 | | `#59`Listing rues invisible (clavier) | |
|
||||
| 22/01 | `#13` Jour 4 | `#42`Historique adresses cliquables | |
|
||||
| 23/01 | `#13` Jour 5 | `#74`Simplifier DashboardLayout/AppScaffold | |
|
||||
| 24/01 | | `#28`Gestion reçus Flutter nouveaux champs | |
|
||||
| 25/01 | | `#50`Modifier secteur au clic | |
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3 : MAP / CARTE
|
||||
### 26 janvier - 9 février (15 jours) - 28 tâches
|
||||
### 26 janvier - 7 février (10 jours) - 25 tâches
|
||||
|
||||
| Date | ID | Tâche |
|
||||
|------|-----|-------|
|
||||
| 26/01 | #206 | Corriger géolocalisation par défaut Rennes |
|
||||
| 26/01 | #22 | S'assurer cache Mapbox en place |
|
||||
| 27/01 | #215 | Mode boussole + carte IGN/satellite zoom max |
|
||||
| 27/01 | #53 | Définir zoom maximal éviter sur-zoom |
|
||||
| 28/01 | #37 | Clic sur la carte pour créer un passage |
|
||||
| 28/01 | #61 | Valider passage directement depuis carte |
|
||||
| 29/01 | #51 | Déplacer markers double-clic |
|
||||
| 29/01 | #115 | Déplacement marker sans bouton Enregistrer |
|
||||
| 30/01 | #123 | Déplacer rapidement un pointeur |
|
||||
| 30/01 | #58 | Points carte devant textes (z-index) |
|
||||
| 31/01 | #55 | Optimiser précision GPS mode terrain |
|
||||
| 31/01 | #56 | Mode Web : se déplacer sur carte terrain |
|
||||
| 01/02 | #57 | Mode terrain smartphone : zoom auto |
|
||||
| 01/02 | #60 | Recherche rue hors proximité |
|
||||
| 02/02 | #209 | Filtres Particuliers / Entreprises |
|
||||
| 02/02 | #216 | Vérifier géolocalisation nouveau passage |
|
||||
| 03/02 | #217 | Chercher adresse hors secteur |
|
||||
| 03/02 | #49 | Secteur sans membre |
|
||||
| 04/02 | #25 | Membres affectés en 1er modif secteur |
|
||||
| 04/02 | #31 | Gestion ajout/suppression membre secteur |
|
||||
| 05/02 | #54 | Style carte type Snapchat |
|
||||
| 05/02 | #210 | Base SIREN géolocalisation entreprises |
|
||||
| 06/02 | #67 | Graphique règlements par secteur |
|
||||
| 06/02 | #104 | Tests multi-départements |
|
||||
| 07/02 | #89 | Page clients paiements en ligne |
|
||||
| 07/02 | #94 | Paiement en ligne formulaire passage |
|
||||
| 08/02 | #96 | Option "Paiement par carte" |
|
||||
| 08/02 | #99 | Paiement Stripe mode hors ligne |
|
||||
| 09/02 | Buffer MAP | - |
|
||||
| Date | ID | Tâche | Statut |
|
||||
|-------|----------|----------------------------------------------|--------|
|
||||
| 26/01 | `#206` | Corriger géolocalisation par défaut Rennes | |
|
||||
| 26/01 | `#22` | S'assurer cache Mapbox en place | |
|
||||
| 26/01 | `#215` | ✅ Mode boussole + carte IGN/satellite zoom max | à livrer v3.6.3 |
|
||||
| 27/01 | `#53` | ✅ Définir zoom maximal éviter sur-zoom | à livrer v3.6.3 |
|
||||
| 27/01 | `#37` | Clic sur la carte pour créer un passage | |
|
||||
| 28/01 | `#51` | Déplacer markers double-clic | |
|
||||
| 28/01 | `#115` | Déplacement marker sans bouton Enregistrer | |
|
||||
| 28/01 | `#123` | Déplacer rapidement un pointeur | |
|
||||
| 29/01 | `#58` | Points carte devant textes (z-index) | |
|
||||
| 29/01 | `#55` | Optimiser précision GPS mode terrain | |
|
||||
| 29/01 | `#56` | Se déplacer librement sur carte terrain | |
|
||||
| 30/01 | `#57` | Mode terrain smartphone : zoom auto | |
|
||||
| 30/01 | `#60` | Recherche rue hors proximité | |
|
||||
| 30/01 | `#209` | Filtres Particuliers / Entreprises | |
|
||||
| 31/01 | `#49` | Secteur possible sans membre | |
|
||||
| 01/02 | `#25` | Membres affectés en 1er modif secteur | |
|
||||
| 02/02 | `#210` | Base SIREN géolocalisation entreprises | |
|
||||
| 02/02 | `#67` | Graphique règlements par secteur | |
|
||||
| 03/02 | `#104` | Tests multi-départements | |
|
||||
| 03/02 | `#89` | Page clients paiements en ligne | |
|
||||
| 04/02 | `#99` | Paiement Stripe mode hors ligne | |
|
||||
|
||||
---
|
||||
|
||||
## PHASE 4 : STRIPE + PASSAGES
|
||||
### 10-21 février (12 jours) - 20 tâches
|
||||
### 8-14 février (6 jours) - 11 tâches
|
||||
|
||||
| Date | ID | Tâche | Cat |
|
||||
|------|-----|-------|-----|
|
||||
| 10/02 | #92 | 💳 Stripe (config générale) | STRIPE |
|
||||
| 10/02 | #93 | Double configuration Stripe | STRIPE |
|
||||
| 11/02 | #97 | Interface paiement sécurisée intégrée | STRIPE |
|
||||
| 11/02 | #98 | Génération auto reçu après paiement | STRIPE |
|
||||
| 13/02 | #207 | Dashboard clic card règlement filtrer | |
|
||||
| 13/02 | #208 | Type règlement Virement bancaire à ajouter | |
|
||||
| 14/02 | #62 | 📋 Gestion des passages | PASSAGE |
|
||||
| 14/02 | #16 | Modifier passage sur l'application | PASSAGE |
|
||||
| 15/02 | #40 | Suppression lot de passages | PASSAGE |
|
||||
| 15/02 | #63 | Corbeille passages admin | PASSAGE |
|
||||
| 16/02 | #64 | Supprimer passages sauvegardés | PASSAGE |
|
||||
| 16/02 | #66 | Récupérer passages supprimés | PASSAGE |
|
||||
| 17/02 | #65 | Désactiver envoi reçu temporaire | PASSAGE |
|
||||
| 17/02 | #118 | Prévenir habitants du passage | PASSAGE |
|
||||
| 18/02 | #119 | Historique montant année précédente | PASSAGE |
|
||||
| 19/02 | #81 | Ralentissement suppressions amicales | BUG |
|
||||
| 19/02 | #219 | Double authentification super-admin (fk_role=9) | ADMIN |
|
||||
| 20-21/02 | Buffer | - | - |
|
||||
| Date | ID | Tâche | Cat | Statut |
|
||||
|-------|------|-------------------------------------------------|---------|--------|
|
||||
| 08/02 | `#98` | Génération auto reçu après paiement | STRIPE | |
|
||||
| 08/02 | `#207` | Dashboard clic card règlement filtrer | | |
|
||||
| 09/02 | `#208` | Type règlement Virement bancaire à ajouter | | |
|
||||
| 09/02 | `#16` | Modifier passage sur l'application | PASSAGE | |
|
||||
| 10/02 | `#40` | Suppression lot de passages | PASSAGE | |
|
||||
| 10/02 | `#63` | Corbeille passages admin | PASSAGE | |
|
||||
| 11/02 | `#66` | Récupérer passages supprimés | PASSAGE | |
|
||||
| 11/02 | `#65` | Désactiver envoi reçu temporaire | PASSAGE | |
|
||||
| 12/02 | `#119` | Historique montant année précédente | PASSAGE | |
|
||||
| 13/02 | `#81` | Ralentissement suppressions amicales | BUG | |
|
||||
| 14/02 | `#219` | Double authentification super-admin (fk_role=9) | ADMIN | |
|
||||
|
||||
---
|
||||
|
||||
## PHASE 5 : ADMIN + MEMBRES
|
||||
### 22 février - 6 mars (13 jours) - 29 tâches
|
||||
### 15-22 février (7 jours) - 22 tâches
|
||||
|
||||
| Date | ID | Tâche | Cat |
|
||||
|------|-----|-------|-----|
|
||||
| 22/02 | #79 | 👑 Mode Super Admin | ADMIN |
|
||||
| 22/02 | #80 | FAQ gérée depuis Super-Admin | ADMIN |
|
||||
| 23/02 | #76 | Accès admin limité web uniquement | ADMIN |
|
||||
| 23/02 | #77 | Choisir rôle admin/membre connexion | ADMIN |
|
||||
| 24/02 | #78 | Admin peut se connecter utilisateur | ADMIN |
|
||||
| 24/02 | #82 | Optimiser purge données | ADMIN |
|
||||
| 25/02 | #83 | Filtres liste amicales | ADMIN |
|
||||
| 25/02 | #85 | Distinguer amicales actives | ADMIN |
|
||||
| 26/02 | #24 | Trier liste membres | ADMIN |
|
||||
| 26/02 | #29 | Filtres liste membres | ADMIN |
|
||||
| 27/02 | #33 | Communication membres <-> admin | ADMIN |
|
||||
| 27/02 | #70 | Revoir chat complet | ADMIN |
|
||||
| 28/02 | #108 | MQTT temps réel ⭐⭐⭐ | ADMIN |
|
||||
| 28/02 | #43 | Nb amicales partenariat ODP | ADMIN |
|
||||
| 01/03 | #211 | Modifier lots avec montants | ADMIN |
|
||||
| 01/03 | #218 | Tests montée charge Poissy | ADMIN |
|
||||
| 02/03 | #15 | Nouveau membre non synchronisé | MEMBRE |
|
||||
| 02/03 | #23 | Emails failed intégrer base | MEMBRE |
|
||||
| 03/03 | #26 | Figer membres combobox | MEMBRE |
|
||||
| 03/03 | #27 | Autocomplete combobox membres | MEMBRE |
|
||||
| 04/03 | #30 | Membres sélectionnés haut liste | MEMBRE |
|
||||
| 04/03 | #32 | Modifier identifiant utilisateur | MEMBRE |
|
||||
| 05/03 | #34 | Email non obligatoire | MEMBRE |
|
||||
| 05/03 | #36 | Textes aide fiches membres | MEMBRE |
|
||||
| 06/03 | #90 | 📧 Processus inscription | MEMBRE |
|
||||
| 06/03 | #91 | 2 emails séparés inscription | MEMBRE |
|
||||
| 06/03 | #117 | Prénoms accents majuscule | MEMBRE |
|
||||
| 06/03 | #122 | Modif rapide email renvoi reçu | MEMBRE |
|
||||
| Date | ID | Tâche | Cat | Statut |
|
||||
|-------|------|-------------------------------------|--------|--------|
|
||||
| 15/02 | `#80` | FAQ gérée depuis Super-Admin | ADMIN | |
|
||||
| 15/02 | `#76` | Accès admin limité web uniquement | ADMIN | |
|
||||
| 15/02 | `#82` | Optimiser purge données | ADMIN | |
|
||||
| 16/02 | `#83` | Filtres liste amicales | ADMIN | |
|
||||
| 16/02 | `#85` | Distinguer amicales actives | ADMIN | |
|
||||
| 16/02 | `#24` | Trier liste membres | ADMIN | |
|
||||
| 17/02 | `#29` | Filtres liste membres | ADMIN | |
|
||||
| 17/02 | `#70` | Revoir chat complet | ADMIN | |
|
||||
| 17/02 | `#108` | Temps réel chat et data ⭐⭐⭐ | ADMIN | |
|
||||
| 18/02 | `#211` | Modifier lots avec montants | ADMIN | |
|
||||
| 18/02 | `#218` | Tests montée charge Poissy | ADMIN | |
|
||||
| 18/02 | `#15` | Nouveau membre non synchronisé | MEMBRE | |
|
||||
| 19/02 | `#23` | Emails failed intégrer base | MEMBRE | |
|
||||
| 19/02 | `#26` | Figer membres combobox | MEMBRE | |
|
||||
| 19/02 | `#27` | Autocomplete combobox membres | MEMBRE | |
|
||||
| 20/02 | `#30` | Membres sélectionnés haut liste | MEMBRE | |
|
||||
| 20/02 | `#32` | Modifier identifiant utilisateur | MEMBRE | |
|
||||
| 20/02 | `#34` | Email non obligatoire | MEMBRE | |
|
||||
| 21/02 | `#36` | Textes aide fiches membres | MEMBRE | |
|
||||
| 21/02 | `#91` | 2 emails séparés inscription | MEMBRE | |
|
||||
| 22/02 | `#117` | Prénoms accents majuscule | MEMBRE | |
|
||||
| 22/02 | `#122` | Modif rapide email renvoi reçu | MEMBRE | |
|
||||
|
||||
---
|
||||
|
||||
## PHASE 6 : EXPORT + COM + DIVERS
|
||||
### 7-16 mars (10 jours) - 30 tâches
|
||||
### 23-28 février (5 jours) - 15 tâches
|
||||
|
||||
| Date | ID | Tâche | Cat |
|
||||
|------|-----|-------|-----|
|
||||
| 07/03 | #45 | Home filtres et graphes | EXPORT |
|
||||
| 07/03 | #47 | Home bouton export données | EXPORT |
|
||||
| 07/03 | #48 | Export par membre | EXPORT |
|
||||
| 08/03 | #68 | Comparatif année précédente | EXPORT |
|
||||
| 08/03 | #212 | Bergerac logs + export Excel | EXPORT |
|
||||
| 09/03 | #35 | Bouton alerte 3s messagerie | COM |
|
||||
| 09/03 | #109 | SMS impératif ⭐⭐⭐ | COM |
|
||||
| 10/03 | #69 | Bloquer création opération | OPER |
|
||||
| 10/03 | #86 | Suppression opé réactiver précédente | OPER |
|
||||
| 10/03 | #87 | 🏢 Gestion Clients | OPER |
|
||||
| 11/03 | #88 | Écran Clients créer/améliorer | OPER |
|
||||
| 11/03 | #116 | Remarque sous adresse | OPER |
|
||||
| 11/03 | #214 | Opérations afficher texte | OPER |
|
||||
| 12/03 | #102 | Compatibilité appareils test | TEST |
|
||||
| 12/03 | #103 | 🧪 Tests | TEST |
|
||||
| 12/03 | #213 | Lots montant nb calendriers Poissy | TEST |
|
||||
| 13/03 | #21 | Requêtes en attente dupliquées | AUTRE |
|
||||
| 13/03 | #38 | Parrainage | AUTRE |
|
||||
| 13/03 | #39 | Multilingue ? | AUTRE |
|
||||
| 14/03 | #44 | Envoi contrat | AUTRE |
|
||||
| 14/03 | #52 | Même adresse par niveau | AUTRE |
|
||||
| 14/03 | #73 | Reconnaissance biométrique | AUTRE |
|
||||
| 14/03 | #75 | Refactoriser responsabilités | AUTRE |
|
||||
| 15/03 | #84 | Mode démo présentations | AUTRE |
|
||||
| 15/03 | #105 | 🌍 Internationalization | AUTRE |
|
||||
| 15/03 | #106 | Devises Franc Suisse | AUTRE |
|
||||
| 15/03 | #107 | 📡 Fonctionnalités futures | AUTRE |
|
||||
| 16/03 | #111 | iwanttobealone | AUTRE |
|
||||
| 16/03 | #112 | db-backup site 256 | AUTRE |
|
||||
| 16/03 | #114 | Liste adresses mail d6soft/unikoffice | AUTRE |
|
||||
| 16/03 | #120 | Double auth faceId/touchId | AUTRE |
|
||||
| 16/03 | #121 | Recette (lien) | AUTRE |
|
||||
| Date | ID | Tâche | Cat | Statut |
|
||||
|-------|------|--------------------------------------|--------|--------|
|
||||
| 23/02 | `#45` | Home filtres et graphes | EXPORT | |
|
||||
| 23/02 | `#48` | Export par membre | EXPORT | |
|
||||
| 23/02 | `#68` | Comparatif année précédente | EXPORT | |
|
||||
| 24/02 | `#212` | Bergerac logs + export Excel | EXPORT | |
|
||||
| 24/02 | `#35` | Bouton alerte 3s messagerie | COM | |
|
||||
| 24/02 | `#109` | SMS impératif ⭐⭐⭐ | COM | |
|
||||
| 25/02 | `#69` | Bloquer création opération | OPER | |
|
||||
| 25/02 | `#86` | Suppression opé réactiver précédente | OPER | |
|
||||
| 25/02 | `#88` | Écran Clients créer/améliorer | OPER | |
|
||||
| 26/02 | `#116` | Remarque sous adresse | OPER | |
|
||||
| 26/02 | `#214` | Opérations afficher texte | OPER | |
|
||||
| 26/02 | `#213` | Lots montant nb calendriers Poissy | TEST | |
|
||||
| 27/02 | `#21` | Requêtes en attente dupliquées | AUTRE | |
|
||||
| 27/02 | `#73` | Reconnaissance biométrique : touchId | AUTRE | |
|
||||
| 28/02 | `#106` | Devises Franc Suisse | AUTRE | |
|
||||
|
||||
---
|
||||
|
||||
## RÉCAPITULATIF
|
||||
|
||||
| Phase | Période | Jours | Tâches | Focus |
|
||||
|-------|---------|-------|--------|-------|
|
||||
|-----------|--------------|-------|--------|---------------------|
|
||||
| 1 | 16-18/01 | 3 | 5 | Bugs critiques |
|
||||
| 2 | 19-25/01 | 7 | 14 | **Stripe iOS #13** + UX |
|
||||
| 3 | 26/01-09/02 | 15 | 28 | **MAP / Carte** |
|
||||
| 4 | 10-21/02 | 12 | 20 | Stripe + Passages |
|
||||
| 5 | 22/02-06/03 | 13 | 29 | Admin + Membres |
|
||||
| 6 | 07-16/03 | 10 | 30 | Export + Divers |
|
||||
| **TOTAL** | **60 jours** | | **126** | |
|
||||
| 2 | 19-25/01 | 7 | 10 | Stripe iOS + UX |
|
||||
| 3 | 26/01-07/02 | 10 | 25 | MAP / Carte |
|
||||
| 4 | 08-14/02 | 6 | 11 | Stripe + Passages |
|
||||
| 5 | 15-22/02 | 7 | 22 | Admin + Membres |
|
||||
| 6 | 23-28/02 | 5 | 15 | Export + Divers |
|
||||
| **TOTAL** | **44 jours** | | **88** | |
|
||||
|
||||
---
|
||||
|
||||
## Jalons clés
|
||||
|
||||
- **18/01** : Bugs critiques résolus
|
||||
- **23/01** : Tests Stripe iOS terminés
|
||||
- **09/02** : Carte/Map finalisée
|
||||
- **21/02** : Paiements + Passages OK
|
||||
- **06/03** : Admin + Membres OK
|
||||
- **16/03** : **Livraison Q1 complète**
|
||||
- **25/01** : Stripe iOS + UX terminés
|
||||
- **07/02** : Carte/Map finalisée
|
||||
- **14/02** : Paiements + Passages OK
|
||||
- **22/02** : Admin + Membres OK
|
||||
- **28/02** : **Livraison complète**
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Rythme : ~2 tâches/jour en moyenne
|
||||
- Weekends = buffer si besoin
|
||||
- Tâches ⭐⭐⭐ (#108 MQTT, #109 SMS) intégrées dans le planning
|
||||
- La tâche #13 (Stripe iOS) reste bloquante pour paiement mobile
|
||||
- Rythme : ~2-3 tâches/jour
|
||||
- Weekends inclus comme buffer si retard
|
||||
- Tâches ⭐⭐⭐ (`#108` temps réel, `#109` SMS) intégrées
|
||||
- Phase 3 (MAP) reste la plus chargée : 25 tâches en 10 jours
|
||||
|
||||
163
docs/planning-geosector-q1-2026.xls
Normal file
@@ -0,0 +1,163 @@
|
||||
<html xmlns:x="urn:schemas-microsoft-com:office:excel">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<!--[if gte mso 9]>
|
||||
<xml>
|
||||
<x:ExcelWorkbook>
|
||||
<x:ExcelWorksheets>
|
||||
<x:ExcelWorksheet>
|
||||
<x:Name>Planning Q1 2026</x:Name>
|
||||
</x:ExcelWorksheet>
|
||||
</x:ExcelWorksheets>
|
||||
</x:ExcelWorkbook>
|
||||
</xml>
|
||||
<![endif]-->
|
||||
<style>
|
||||
table { border-collapse: collapse; margin-bottom: 20px; }
|
||||
th, td { border: 1px solid #000; padding: 8px; text-align: left; }
|
||||
th { background-color: #4472C4; color: white; font-weight: bold; }
|
||||
.week-header { background-color: #2F5496; color: white; font-size: 14px; font-weight: bold; }
|
||||
.phase { background-color: #D6DCE4; font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h2>Planning Geosector Q1 2026</h2>
|
||||
|
||||
<!-- SEMAINE 1 : 16-18 janvier -->
|
||||
<table>
|
||||
<tr class="week-header"><td colspan="3">SEMAINE 1 : 16-18 janvier - Phase 1 (Bugs critiques)</td></tr>
|
||||
<tr><th>ID</th><th>Tâche</th><th>Statut</th></tr>
|
||||
<tr><td>#17</td><td>Création membre impossible</td><td>Livré et à tester</td></tr>
|
||||
<tr><td>#18</td><td>Création opération impossible</td><td>Livré et à tester</td></tr>
|
||||
<tr><td>#19</td><td>Export opération cassé</td><td>Livré et à tester</td></tr>
|
||||
<tr><td>#20</td><td>Enregistrement des passages ne fonctionne pas</td><td>Livré et à tester</td></tr>
|
||||
<tr><td>#14</td><td>Bug F5 - déconnexion lors du rafraîchissement</td><td></td></tr>
|
||||
</table>
|
||||
|
||||
<!-- SEMAINE 2 : 19-25 janvier -->
|
||||
<table>
|
||||
<tr class="week-header"><td colspan="3">SEMAINE 2 : 19-25 janvier - Phase 2 (Stripe iOS + UX)</td></tr>
|
||||
<tr><th>ID</th><th>Tâche</th><th>Statut</th></tr>
|
||||
<tr><td>#13</td><td>Tests Stripe iOS (5 jours)</td><td></td></tr>
|
||||
<tr><td>#204</td><td>Design couleurs flashy</td><td></td></tr>
|
||||
<tr><td>#205</td><td>Écrans utilisateurs simplifiés</td><td></td></tr>
|
||||
<tr><td>#113</td><td>Couleur repasses orange</td><td></td></tr>
|
||||
<tr><td>#72</td><td>Épaisseur police lisibilité</td><td></td></tr>
|
||||
<tr><td>#71</td><td>Visibilité bouton "Envoyer message"</td><td></td></tr>
|
||||
<tr><td>#59</td><td>Listing rues invisible (clavier)</td><td></td></tr>
|
||||
<tr><td>#42</td><td>Historique adresses cliquables</td><td></td></tr>
|
||||
<tr><td>#74</td><td>Simplifier DashboardLayout/AppScaffold</td><td></td></tr>
|
||||
<tr><td>#28</td><td>Gestion reçus Flutter nouveaux champs</td><td></td></tr>
|
||||
<tr><td>#50</td><td>Modifier secteur au clic</td><td></td></tr>
|
||||
</table>
|
||||
|
||||
<!-- SEMAINE 3 : 26 janvier - 1er février -->
|
||||
<table>
|
||||
<tr class="week-header"><td colspan="3">SEMAINE 3 : 26 janvier - 1er février - Phase 3 (MAP / Carte)</td></tr>
|
||||
<tr><th>ID</th><th>Tâche</th><th>Statut</th></tr>
|
||||
<tr><td>#206</td><td>Corriger géolocalisation par défaut Rennes</td><td></td></tr>
|
||||
<tr><td>#22</td><td>S'assurer cache Mapbox en place</td><td></td></tr>
|
||||
<tr><td>#215</td><td>Mode boussole + carte IGN/satellite zoom max</td><td></td></tr>
|
||||
<tr><td>#53</td><td>Définir zoom maximal éviter sur-zoom</td><td></td></tr>
|
||||
<tr><td>#37</td><td>Clic sur la carte pour créer un passage</td><td></td></tr>
|
||||
<tr><td>#61</td><td>Valider passage directement depuis carte</td><td></td></tr>
|
||||
<tr><td>#51</td><td>Déplacer markers double-clic</td><td></td></tr>
|
||||
<tr><td>#115</td><td>Déplacement marker sans bouton Enregistrer</td><td></td></tr>
|
||||
<tr><td>#123</td><td>Déplacer rapidement un pointeur</td><td></td></tr>
|
||||
<tr><td>#58</td><td>Points carte devant textes (z-index)</td><td></td></tr>
|
||||
<tr><td>#55</td><td>Optimiser précision GPS mode terrain</td><td></td></tr>
|
||||
<tr><td>#56</td><td>Se déplacer librement sur carte terrain</td><td></td></tr>
|
||||
<tr><td>#57</td><td>Mode terrain smartphone : zoom auto</td><td></td></tr>
|
||||
<tr><td>#60</td><td>Recherche rue hors proximité</td><td></td></tr>
|
||||
<tr><td>#209</td><td>Filtres Particuliers / Entreprises</td><td></td></tr>
|
||||
<tr><td>#216</td><td>Vérifier géolocalisation nouveau passage</td><td></td></tr>
|
||||
<tr><td>#217</td><td>Chercher adresse hors secteur</td><td></td></tr>
|
||||
<tr><td>#49</td><td>Secteur sans membre</td><td></td></tr>
|
||||
<tr><td>#25</td><td>Membres affectés en 1er modif secteur</td><td></td></tr>
|
||||
<tr><td>#31</td><td>Gestion ajout/suppression membre secteur</td><td></td></tr>
|
||||
</table>
|
||||
|
||||
<!-- SEMAINE 4 : 2-8 février -->
|
||||
<table>
|
||||
<tr class="week-header"><td colspan="3">SEMAINE 4 : 2-8 février - Phase 3 (fin) + Phase 4 (début)</td></tr>
|
||||
<tr><th>ID</th><th>Tâche</th><th>Statut</th></tr>
|
||||
<tr class="phase"><td colspan="3">Phase 3 - MAP (fin)</td></tr>
|
||||
<tr><td>#210</td><td>Base SIREN géolocalisation entreprises</td><td></td></tr>
|
||||
<tr><td>#67</td><td>Graphique règlements par secteur</td><td></td></tr>
|
||||
<tr><td>#104</td><td>Tests multi-départements</td><td></td></tr>
|
||||
<tr><td>#89</td><td>Page clients paiements en ligne</td><td></td></tr>
|
||||
<tr><td>#99</td><td>Paiement Stripe mode hors ligne</td><td></td></tr>
|
||||
<tr class="phase"><td colspan="3">Phase 4 - Stripe + Passages (début)</td></tr>
|
||||
<tr><td>#98</td><td>Génération auto reçu après paiement</td><td></td></tr>
|
||||
<tr><td>#207</td><td>Dashboard clic card règlement filtrer</td><td></td></tr>
|
||||
</table>
|
||||
|
||||
<!-- SEMAINE 5 : 9-15 février -->
|
||||
<table>
|
||||
<tr class="week-header"><td colspan="3">SEMAINE 5 : 9-15 février - Phase 4 (fin) + Phase 5 (début)</td></tr>
|
||||
<tr><th>ID</th><th>Tâche</th><th>Statut</th></tr>
|
||||
<tr class="phase"><td colspan="3">Phase 4 - Stripe + Passages (fin)</td></tr>
|
||||
<tr><td>#208</td><td>Type règlement Virement bancaire à ajouter</td><td></td></tr>
|
||||
<tr><td>#16</td><td>Modifier passage sur l'application</td><td></td></tr>
|
||||
<tr><td>#40</td><td>Suppression lot de passages</td><td></td></tr>
|
||||
<tr><td>#63</td><td>Corbeille passages admin</td><td></td></tr>
|
||||
<tr><td>#66</td><td>Récupérer passages supprimés</td><td></td></tr>
|
||||
<tr><td>#65</td><td>Désactiver envoi reçu temporaire</td><td></td></tr>
|
||||
<tr><td>#119</td><td>Historique montant année précédente</td><td></td></tr>
|
||||
<tr><td>#81</td><td>Ralentissement suppressions amicales</td><td></td></tr>
|
||||
<tr><td>#219</td><td>Double authentification super-admin (fk_role=9)</td><td></td></tr>
|
||||
<tr class="phase"><td colspan="3">Phase 5 - Admin + Membres (début)</td></tr>
|
||||
<tr><td>#80</td><td>FAQ gérée depuis Super-Admin</td><td></td></tr>
|
||||
<tr><td>#76</td><td>Accès admin limité web uniquement</td><td></td></tr>
|
||||
<tr><td>#82</td><td>Optimiser purge données</td><td></td></tr>
|
||||
</table>
|
||||
|
||||
<!-- SEMAINE 6 : 16-22 février -->
|
||||
<table>
|
||||
<tr class="week-header"><td colspan="3">SEMAINE 6 : 16-22 février - Phase 5 (Admin + Membres)</td></tr>
|
||||
<tr><th>ID</th><th>Tâche</th><th>Statut</th></tr>
|
||||
<tr><td>#83</td><td>Filtres liste amicales</td><td></td></tr>
|
||||
<tr><td>#85</td><td>Distinguer amicales actives</td><td></td></tr>
|
||||
<tr><td>#24</td><td>Trier liste membres</td><td></td></tr>
|
||||
<tr><td>#29</td><td>Filtres liste membres</td><td></td></tr>
|
||||
<tr><td>#70</td><td>Revoir chat complet</td><td></td></tr>
|
||||
<tr><td>#108</td><td>Temps réel chat et data ⭐⭐⭐</td><td></td></tr>
|
||||
<tr><td>#211</td><td>Modifier lots avec montants</td><td></td></tr>
|
||||
<tr><td>#218</td><td>Tests montée charge Poissy</td><td></td></tr>
|
||||
<tr><td>#15</td><td>Nouveau membre non synchronisé</td><td></td></tr>
|
||||
<tr><td>#23</td><td>Emails failed intégrer base</td><td></td></tr>
|
||||
<tr><td>#26</td><td>Figer membres combobox</td><td></td></tr>
|
||||
<tr><td>#27</td><td>Autocomplete combobox membres</td><td></td></tr>
|
||||
<tr><td>#30</td><td>Membres sélectionnés haut liste</td><td></td></tr>
|
||||
<tr><td>#32</td><td>Modifier identifiant utilisateur</td><td></td></tr>
|
||||
<tr><td>#34</td><td>Email non obligatoire</td><td></td></tr>
|
||||
<tr><td>#36</td><td>Textes aide fiches membres</td><td></td></tr>
|
||||
<tr><td>#91</td><td>2 emails séparés inscription</td><td></td></tr>
|
||||
<tr><td>#117</td><td>Prénoms accents majuscule</td><td></td></tr>
|
||||
<tr><td>#122</td><td>Modif rapide email renvoi reçu</td><td></td></tr>
|
||||
</table>
|
||||
|
||||
<!-- SEMAINE 7 : 23-28 février -->
|
||||
<table>
|
||||
<tr class="week-header"><td colspan="3">SEMAINE 7 : 23-28 février - Phase 6 (Export + Divers)</td></tr>
|
||||
<tr><th>ID</th><th>Tâche</th><th>Statut</th></tr>
|
||||
<tr><td>#45</td><td>Home filtres et graphes</td><td></td></tr>
|
||||
<tr><td>#48</td><td>Export par membre</td><td></td></tr>
|
||||
<tr><td>#68</td><td>Comparatif année précédente</td><td></td></tr>
|
||||
<tr><td>#212</td><td>Bergerac logs + export Excel</td><td></td></tr>
|
||||
<tr><td>#35</td><td>Bouton alerte 3s messagerie</td><td></td></tr>
|
||||
<tr><td>#109</td><td>SMS impératif ⭐⭐⭐</td><td></td></tr>
|
||||
<tr><td>#69</td><td>Bloquer création opération</td><td></td></tr>
|
||||
<tr><td>#86</td><td>Suppression opé réactiver précédente</td><td></td></tr>
|
||||
<tr><td>#88</td><td>Écran Clients créer/améliorer</td><td></td></tr>
|
||||
<tr><td>#116</td><td>Remarque sous adresse</td><td></td></tr>
|
||||
<tr><td>#214</td><td>Opérations afficher texte</td><td></td></tr>
|
||||
<tr><td>#213</td><td>Lots montant nb calendriers Poissy</td><td></td></tr>
|
||||
<tr><td>#21</td><td>Requêtes en attente dupliquées</td><td></td></tr>
|
||||
<tr><td>#73</td><td>Reconnaissance biométrique : touchId</td><td></td></tr>
|
||||
<tr><td>#106</td><td>Devises Franc Suisse</td><td></td></tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
||||