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

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

Suppression dossier web/ (migration vers app Flutter)

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

View File

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