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>
@@ -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 !** 🚀
|
||||
|
||||
324
app/android.sh
@@ -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)"
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
92
app/android/app/build.gradle.kts.backup
Executable 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
@@ -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
|
||||
BIN
app/assets/images/geosector-1024x500.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
app/assets/images/geosector-admin-amicale-1800x1800.png
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
app/assets/images/geosector-admin-tbord-1800x1800.png
Normal file
|
After Width: | Height: | Size: 305 KiB |
BIN
app/assets/images/geosector-user-carte-1800x1800.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
app/assets/images/geosector-user-histo-1800x1800.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
app/assets/images/geosector-user-login-1800x1800.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
app/assets/images/geosector-user-stripe-1800x1800.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
app/assets/images/geosector-user-tbord-1800x1800.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
@@ -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
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
@@ -12,6 +12,9 @@
|
||||
|
||||
default_platform(:android)
|
||||
|
||||
# Configure PATH pour Homebrew (M1/M2/M4 Mac)
|
||||
ENV['PATH'] = "/opt/homebrew/bin:#{ENV['PATH']}"
|
||||
|
||||
# =============================================================================
|
||||
# ANDROID
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
@@ -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é !"
|
||||
BIN
app/ios/GeoSector_v3_Development.mobileprovision
Normal 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";
|
||||
|
||||
8
app/ios/Runner/Runner.entitlements
Normal 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>
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
|
||||
594
app/lib/core/data/models/event_stats_model.dart
Normal 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 [];
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
312
app/lib/core/services/event_stats_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
1135
app/lib/presentation/admin/admin_connexions_page.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
39
app/lib/presentation/pages/connexions_page.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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/' \
|
||||
|
||||