feat: Version 3.6.2 - Correctifs tâches #17-20

- #17: Amélioration gestion des secteurs et statistiques
- #18: Optimisation services API et logs
- #19: Corrections Flutter widgets et repositories
- #20: Fix création passage - détection automatique ope_users.id vs users.id

Suppression dossier web/ (migration vers app Flutter)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 14:11:15 +01:00
parent 7b78037175
commit 232940b1eb
196 changed files with 8483 additions and 7966 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

View File

@@ -41,7 +41,7 @@ FINAL_OWNER="nginx"
FINAL_GROUP="nginx"
# Configuration de sauvegarde
BACKUP_DIR="/data/backup/geosector"
BACKUP_DIR="/home/pierre/samba/back/geosector/app/"
# Couleurs pour les messages
GREEN='\033[0;32m'
@@ -549,4 +549,4 @@ echo_info "[$(date '+%H:%M:%S.%3N')] Fin du script"
echo_step "⏱️ TEMPS TOTAL D'EXÉCUTION: ${TOTAL_TIME} ms ($((TOTAL_TIME/1000)) secondes)"
# Journaliser le déploiement
echo "$(date '+%Y-%m-%d %H:%M:%S') - Flutter app deployed to ${ENV_NAME} (${DEST_CONTAINER}) - Total: ${TOTAL_TIME}ms" >> ~/.geo_deploy_history
echo "$(date '+%Y-%m-%d %H:%M:%S') - Flutter app deployed to ${ENV_NAME} (${DEST_CONTAINER}) - Total: ${TOTAL_TIME}ms" >> ~/.geo_deploy_history

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

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

View File

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

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

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

View File

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

View File

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

249
app/ios.sh Executable file
View File

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

Binary file not shown.

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ import 'package:geosector_app/presentation/pages/messages_page.dart';
import 'package:geosector_app/presentation/pages/amicale_page.dart';
import 'package:geosector_app/presentation/pages/operations_page.dart';
import 'package:geosector_app/presentation/pages/field_mode_page.dart';
import 'package:geosector_app/presentation/pages/connexions_page.dart';
// Instances globales des repositories (plus besoin d'injecter ApiService)
final operationRepository = OperationRepository();
@@ -322,6 +323,15 @@ class _GeosectorAppState extends State<GeosectorApp> with WidgetsBindingObserver
return const OperationsPage();
},
),
// Sous-route pour connexions (role 2+ uniquement)
GoRoute(
path: 'connexions',
name: 'admin-connexions',
builder: (context, state) {
debugPrint('GoRoute: Affichage de ConnexionsPage (unifiée)');
return const ConnexionsPage();
},
),
],
),
],

View File

@@ -29,7 +29,8 @@ class ChatService {
Timer? _syncTimer;
DateTime? _lastSyncTimestamp;
DateTime? _lastFullSync;
static const Duration _syncInterval = Duration(seconds: 15); // Changé à 15 secondes comme suggéré par l'API
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
@@ -76,10 +77,13 @@ class ChatService {
// Charger le dernier timestamp de sync depuis Hive
await _instance!._loadSyncTimestamp();
// Faire la sync initiale complète au login
await _instance!.getRooms(forceFullSync: true);
debugPrint('✅ Sync initiale complète effectuée au login');
// Faire la sync initiale complète au login avec délai de 10 secondes
debugPrint('⏳ Sync initiale chat programmée dans 10 secondes...');
Future.delayed(_initialSyncDelay, () async {
await _instance!.getRooms(forceFullSync: true);
debugPrint('✅ Sync initiale complète effectuée au login');
});
// Démarrer la synchronisation incrémentale périodique
_instance!._startSync();
}
@@ -136,6 +140,13 @@ class ChatService {
/// Obtenir les rooms avec synchronisation incrémentale
Future<List<Room>> getRooms({bool forceFullSync = false}) async {
// DÉSACTIVATION TEMPORAIRE - Retour direct du cache sans appeler l'API
debugPrint('🚫 API /chat/rooms désactivée - utilisation du cache uniquement');
return _roomsBox.values.toList()
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
/* Code original commenté pour désactiver les appels API
// Vérifier la connectivité
if (!connectivityService.isConnected) {
debugPrint('📵 Pas de connexion réseau - utilisation du cache');
@@ -143,30 +154,32 @@ class ChatService {
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
}
try {
// Déterminer si on fait une sync complète ou incrémentale
final now = DateTime.now();
final needsFullSync = forceFullSync ||
_lastFullSync == null ||
now.difference(_lastFullSync!).compareTo(_fullSyncInterval) > 0;
Response response;
if (needsFullSync || _lastSyncTimestamp == null) {
// Synchronisation complète
debugPrint('🔄 Synchronisation complète des rooms...');
response = await _dio.get('/chat/rooms');
// response = await _dio.get('/chat/rooms'); // COMMENTÉ - Désactivation GET /chat/rooms
return; // Retour anticipé pour éviter l'appel API
_lastFullSync = now;
} else {
// Synchronisation incrémentale
final isoTimestamp = _lastSyncTimestamp!.toUtc().toIso8601String();
// debugPrint('🔄 Synchronisation incrémentale depuis $isoTimestamp');
response = await _dio.get('/chat/rooms', queryParameters: {
'updated_after': isoTimestamp,
});
// response = await _dio.get('/chat/rooms', queryParameters: { // COMMENTÉ - Désactivation GET /chat/rooms
// 'updated_after': isoTimestamp,
// });
return; // Retour anticipé pour éviter l'appel API
}
// Extraire le timestamp de synchronisation fourni par l'API
if (response.data is Map && response.data['sync_timestamp'] != null) {
_lastSyncTimestamp = DateTime.parse(response.data['sync_timestamp']);
@@ -180,7 +193,7 @@ class ChatService {
// On utilise le timestamp actuel comme fallback mais ce n'est pas idéal
_lastSyncTimestamp = now;
}
// Vérifier s'il y a des changements (pour sync incrémentale)
if (!needsFullSync && response.data is Map && response.data['has_changes'] == false) {
// debugPrint('✅ Aucun changement depuis la dernière sync');
@@ -188,7 +201,7 @@ class ChatService {
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
}
// Gérer différents formats de réponse API
List<dynamic> roomsData;
if (response.data is Map) {
@@ -206,11 +219,11 @@ class ChatService {
} else {
roomsData = [];
}
// Parser les rooms
final rooms = <Room>[];
final deletedRoomIds = <String>[];
for (final json in roomsData) {
try {
// Vérifier si la room est marquée comme supprimée
@@ -218,21 +231,21 @@ class ChatService {
deletedRoomIds.add(json['id']);
continue;
}
final room = Room.fromJson(json);
rooms.add(room);
} catch (e) {
debugPrint('❌ Erreur parsing room: $e');
}
}
// Appliquer les modifications à Hive
if (needsFullSync) {
// Sync complète : remplacer tout mais préserver certaines données locales
final existingRooms = Map.fromEntries(
_roomsBox.values.map((r) => MapEntry(r.id, r))
);
await _roomsBox.clear();
for (final room in rooms) {
final existingRoom = existingRooms[room.id];
@@ -250,7 +263,7 @@ class ChatService {
createdBy: room.createdBy ?? existingRoom?.createdBy,
);
await _roomsBox.put(roomToSave.id, roomToSave);
// Traiter les messages récents de la room
if (room.recentMessages != null && room.recentMessages!.isNotEmpty) {
for (final msgData in room.recentMessages!) {
@@ -288,10 +301,10 @@ class ChatService {
// Préserver createdBy existant si la nouvelle room n'en a pas
createdBy: room.createdBy ?? existingRoom?.createdBy,
);
debugPrint('💾 Sauvegarde room ${roomToSave.title} (${roomToSave.id}): createdBy=${roomToSave.createdBy}');
await _roomsBox.put(roomToSave.id, roomToSave);
// Traiter les messages récents de la room
if (room.recentMessages != null && room.recentMessages!.isNotEmpty) {
for (final msgData in room.recentMessages!) {
@@ -314,22 +327,22 @@ class ChatService {
for (final roomId in deletedRoomIds) {
// Supprimer la room
await _roomsBox.delete(roomId);
// Supprimer tous les messages de cette room
final messagesToDelete = _messagesBox.values
.where((msg) => msg.roomId == roomId)
.map((msg) => msg.id)
.toList();
for (final msgId in messagesToDelete) {
await _messagesBox.delete(msgId);
}
debugPrint('🗑️ Room $roomId supprimée avec ${messagesToDelete.length} messages');
}
debugPrint('💾 Sync incrémentale: ${rooms.length} rooms mises à jour, ${deletedRoomIds.length} supprimées');
}
// Mettre à jour les stats globales
final allRooms = _roomsBox.values.toList();
final totalUnread = allRooms.fold<int>(0, (sum, room) => sum + room.unreadCount);
@@ -337,7 +350,7 @@ class ChatService {
totalRooms: allRooms.length,
unreadMessages: totalUnread,
);
return allRooms
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
@@ -348,6 +361,7 @@ class ChatService {
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
}
*/// Fin du code commenté
}
/// Créer une room avec vérification des permissions
@@ -754,7 +768,7 @@ class ChatService {
});
// Pas de sync immédiate ici car déjà faite dans init()
debugPrint('⏰ Timer de sync incrémentale démarré (toutes les 15 secondes)');
debugPrint('⏰ Timer de sync incrémentale démarré (toutes les 30 secondes)');
}
/// Mettre en pause les synchronisations (app en arrière-plan)

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,9 @@ import 'dart:async';
import 'dart:convert';
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';
@@ -65,16 +68,44 @@ class ApiService {
headers['X-App-Identifier'] = _appIdentifier;
_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)
final cookieJar = CookieJar();
_dio.interceptors.add(CookieManager(cookieJar));
debugPrint('🍪 [API] Gestionnaire de cookies activé');
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
debugPrint('🌐 [API] Requête: ${options.method} ${options.path}');
debugPrint('🔑 [API] _sessionId présent: ${_sessionId != null}');
debugPrint('🔑 [API] Headers: ${options.headers}');
if (_sessionId != null) {
debugPrint('🔑 [API] Token: Bearer ${_sessionId!.substring(0, 20)}...');
options.headers[AppKeys.sessionHeader] = 'Bearer $_sessionId';
} else {
debugPrint('⚠️ [API] PAS DE TOKEN - Requête non authentifiée');
}
handler.next(options);
},
onError: (DioException error, handler) {
if (error.response?.statusCode == 401) {
_sessionId = null;
final path = error.requestOptions.path;
debugPrint('❌ [API] Erreur 401 sur: $path');
// Ne pas reset le token pour les requêtes non critiques
final nonCriticalPaths = [
'/users/device-info',
'/chat/rooms',
];
final isNonCritical = nonCriticalPaths.any((p) => path.contains(p));
if (isNonCritical) {
debugPrint('⚠️ [API] Requête non critique - Token conservé');
} else {
debugPrint('❌ [API] Requête critique - Token invalidé');
_sessionId = null;
}
}
handler.next(error);
},
@@ -1066,15 +1097,21 @@ class ApiService {
if (data.containsKey('session_id')) {
final sessionId = data['session_id'];
if (sessionId != null) {
debugPrint('🔐 [LOGIN] Token reçu du serveur: $sessionId');
debugPrint('🔐 [LOGIN] Longueur token: ${sessionId.toString().length}');
setSessionId(sessionId);
debugPrint('🔐 [LOGIN] Token stocké dans _sessionId');
// Collecter et envoyer les informations du device après login réussi
debugPrint('📱 Collecte des informations device après login...');
DeviceInfoService.instance.collectAndSendDeviceInfo().then((_) {
debugPrint('✅ Informations device collectées et envoyées');
}).catchError((error) {
debugPrint('⚠️ Erreur lors de l\'envoi des infos device: $error');
// Ne pas bloquer le login si l'envoi des infos device échoue
// Délai de 1 seconde pour laisser la session PHP se stabiliser
debugPrint('📱 Collecte des informations device après login (délai 1s)...');
Future.delayed(const Duration(seconds: 1), () {
DeviceInfoService.instance.collectAndSendDeviceInfo().then((_) {
debugPrint('✅ Informations device collectées et envoyées');
}).catchError((error) {
debugPrint('⚠️ Erreur lors de l\'envoi des infos device: $error');
// Ne pas bloquer le login si l'envoi des infos device échoue
});
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -124,66 +124,71 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
),
),
// Corps avec le tableau
// Corps avec le tableau - écoute membres ET passages pour rafraîchissement auto
ValueListenableBuilder<Box<MembreModel>>(
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
builder: (context, membresBox, child) {
final membres = membresBox.values.toList();
return ValueListenableBuilder<Box<PassageModel>>(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, passagesBox, child) {
final membres = membresBox.values.toList();
// Récupérer l'opération courante
final currentOperation = _operationRepository.getCurrentOperation();
if (currentOperation == null) {
return const Center(
child: Padding(
padding: EdgeInsets.all(AppTheme.spacingL),
child: Text('Aucune opération en cours'),
),
);
}
// Trier les membres selon la colonne sélectionnée
_sortMembers(membres, currentOperation.id);
// Construire les lignes : TOTAL en première position + détails membres
final allRows = [
_buildTotalRow(membres, currentOperation.id, theme),
..._buildRows(membres, currentOperation.id, theme),
];
// Afficher le tableau complet sans scroll interne
return SizedBox(
width: double.infinity, // Prendre toute la largeur disponible
child: Theme(
data: Theme.of(context).copyWith(
dataTableTheme: DataTableThemeData(
headingRowColor: WidgetStateProperty.resolveWith<Color?>(
(Set<WidgetState> states) {
return theme.colorScheme.primary.withOpacity(0.08);
},
// Récupérer l'opération courante
final currentOperation = _operationRepository.getCurrentOperation();
if (currentOperation == null) {
return const Center(
child: Padding(
padding: EdgeInsets.all(AppTheme.spacingL),
child: Text('Aucune opération en cours'),
),
dataRowColor: WidgetStateProperty.resolveWith<Color?>(
(Set<WidgetState> states) {
if (states.contains(WidgetState.selected)) {
return theme.colorScheme.primary.withOpacity(0.08);
}
return null;
},
);
}
// Trier les membres selon la colonne sélectionnée
_sortMembers(membres, currentOperation.id);
// Construire les lignes : TOTAL en première position + détails membres
final allRows = [
_buildTotalRow(membres, currentOperation.id, theme),
..._buildRows(membres, currentOperation.id, theme),
];
// Afficher le tableau complet sans scroll interne
return SizedBox(
width: double.infinity, // Prendre toute la largeur disponible
child: Theme(
data: Theme.of(context).copyWith(
dataTableTheme: DataTableThemeData(
headingRowColor: WidgetStateProperty.resolveWith<Color?>(
(Set<WidgetState> states) {
return theme.colorScheme.primary.withOpacity(0.08);
},
),
dataRowColor: WidgetStateProperty.resolveWith<Color?>(
(Set<WidgetState> states) {
if (states.contains(WidgetState.selected)) {
return theme.colorScheme.primary.withOpacity(0.08);
}
return null;
},
),
),
),
child: DataTable(
columnSpacing: 4, // Espacement minimal entre colonnes
horizontalMargin: 4, // Marges horizontales minimales
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
dataRowMinHeight: 42,
dataRowMaxHeight: 42,
// Utiliser les flèches natives de DataTable
sortColumnIndex: _sortColumnIndex,
sortAscending: _sortAscending,
columns: _buildColumns(theme),
rows: allRows,
),
),
),
child: DataTable(
columnSpacing: 4, // Espacement minimal entre colonnes
horizontalMargin: 4, // Marges horizontales minimales
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
dataRowMinHeight: 42,
dataRowMaxHeight: 42,
// Utiliser les flèches natives de DataTable
sortColumnIndex: _sortColumnIndex,
sortAscending: _sortAscending,
columns: _buildColumns(theme),
rows: allRows,
),
),
);
},
);
},
),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
name: geosector_app
description: 'GEOSECTOR - Gestion de distribution des calendriers par secteurs géographiques pour les amicales de pompiers'
publish_to: 'none'
version: 3.5.2+352
version: 3.6.2+362
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -22,6 +22,8 @@ dependencies:
# API & Réseau
dio: ^5.3.3
cookie_jar: ^4.0.8 # Gestion des cookies pour sessions PHP
dio_cookie_manager: ^3.1.1 # Interceptor cookies pour Dio
connectivity_plus: 6.0.5 # ⬇️ Compatible Flutter 3.24.5 LTS (fix Gradle)
retry: ^3.1.2

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

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

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

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

View File

@@ -26,19 +26,52 @@ if [ ! -f "pubspec.yaml" ]; then
exit 1
fi
# Récupérer la version depuis pubspec.yaml
VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | tr -d ' ')
VERSION_CODE=$(echo $VERSION | cut -d'+' -f2)
# Synchroniser la version depuis ../VERSION
echo -e "${BLUE}📋 Synchronisation de la version depuis ../VERSION...${NC}"
echo ""
if [ -z "$VERSION_CODE" ]; then
echo -e "${RED}Impossible de récupérer le version code depuis pubspec.yaml${NC}"
VERSION_FILE="../VERSION"
if [ ! -f "$VERSION_FILE" ]; then
echo -e "${RED}Erreur: Fichier VERSION introuvable : $VERSION_FILE${NC}"
exit 1
fi
echo -e "${YELLOW}Version détectée :${NC} $VERSION"
echo -e "${YELLOW}Version code :${NC} $VERSION_CODE"
# Lire la version depuis le fichier (enlever espaces/retours à la ligne)
VERSION_NUMBER=$(cat "$VERSION_FILE" | tr -d '\n\r ' | tr -d '[:space:]')
if [ -z "$VERSION_NUMBER" ]; then
echo -e "${RED}Erreur: Le fichier VERSION est vide${NC}"
exit 1
fi
echo -e "${YELLOW}Version lue depuis $VERSION_FILE :${NC} $VERSION_NUMBER"
# Calculer le versionCode (supprimer les points)
VERSION_CODE=$(echo $VERSION_NUMBER | tr -d '.')
if [ -z "$VERSION_CODE" ]; then
echo -e "${RED}Erreur: Impossible de calculer le versionCode${NC}"
exit 1
fi
echo -e "${YELLOW}Version code calculé :${NC} $VERSION_CODE"
# Mettre à jour pubspec.yaml
echo -e "${BLUE}Mise à jour de pubspec.yaml...${NC}"
sed -i.bak "s/^version:.*/version: $VERSION_NUMBER+$VERSION_CODE/" pubspec.yaml
# Vérifier que la mise à jour a réussi
UPDATED_VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | tr -d ' ')
if [ "$UPDATED_VERSION" != "$VERSION_NUMBER+$VERSION_CODE" ]; then
echo -e "${RED}Erreur: Échec de la mise à jour de pubspec.yaml${NC}"
echo -e "${RED}Attendu : $VERSION_NUMBER+$VERSION_CODE${NC}"
echo -e "${RED}Obtenu : $UPDATED_VERSION${NC}"
exit 1
fi
echo -e "${GREEN}✓ pubspec.yaml mis à jour : version: $VERSION_NUMBER+$VERSION_CODE${NC}"
echo ""
VERSION="$VERSION_NUMBER+$VERSION_CODE"
# Construire le chemin de destination avec numéro de version
DESTINATION_DIR="app_$VERSION_CODE"
DESTINATION="$MAC_USER@$MAC_MINI_IP:/Users/pierre/dev/geosector/$DESTINATION_DIR"
@@ -56,6 +89,7 @@ echo -e "${BLUE}rsync va créer le dossier de destination automatiquement${NC}"
echo ""
rsync -avz --progress \
-e "ssh -o IdentitiesOnly=yes -o PubkeyAuthentication=no -o PreferredAuthentications=password" \
--rsync-path="mkdir -p /Users/pierre/dev/geosector/$DESTINATION_DIR && rsync" \
--exclude='build/' \
--exclude='.dart_tool/' \