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:hive_flutter/hive_flutter.dart'; import 'package:geosector_app/core/data/models/passage_model.dart'; import 'package:geosector_app/core/repositories/passage_repository.dart'; import 'package:geosector_app/core/repositories/user_repository.dart'; import 'package:geosector_app/core/repositories/operation_repository.dart'; import 'package:geosector_app/core/repositories/amicale_repository.dart'; import 'package:geosector_app/core/services/current_user_service.dart'; import 'package:geosector_app/core/services/current_amicale_service.dart'; import 'package:geosector_app/core/services/stripe_tap_to_pay_service.dart'; import 'package:geosector_app/core/services/device_info_service.dart'; import 'package:geosector_app/core/utils/api_exception.dart'; import 'package:geosector_app/core/constants/app_keys.dart'; import 'package:geosector_app/presentation/widgets/custom_text_field.dart'; import 'package:geosector_app/presentation/widgets/form_section.dart'; class PassageFormDialog extends StatefulWidget { final PassageModel? passage; final String title; final bool readOnly; final PassageRepository passageRepository; final UserRepository userRepository; final OperationRepository operationRepository; final AmicaleRepository amicaleRepository; final VoidCallback? onSuccess; const PassageFormDialog({ super.key, this.passage, required this.title, this.readOnly = false, required this.passageRepository, required this.userRepository, required this.operationRepository, required this.amicaleRepository, this.onSuccess, }); @override State createState() => _PassageFormDialogState(); } class _PassageFormDialogState extends State { final _formKey = GlobalKey(); bool _isSubmitting = false; // Sélection du type de passage int? _selectedPassageType; bool _showForm = false; // Controllers late final TextEditingController _numeroController; late final TextEditingController _rueBisController; late final TextEditingController _rueController; late final TextEditingController _villeController; late final TextEditingController _nameController; late final TextEditingController _emailController; late final TextEditingController _phoneController; late final TextEditingController _montantController; late final TextEditingController _apptController; late final TextEditingController _niveauController; late final TextEditingController _residenceController; late final TextEditingController _remarqueController; late final TextEditingController _dateController; late final TextEditingController _timeController; // Form values int _fkHabitat = 1; // Par défaut Maison int _fkTypeReglement = 4; // Par défaut Non renseigné DateTime _passedAt = DateTime.now(); // Date et heure de passage // Variable pour Tap to Pay String? _stripePaymentIntentId; // Boîte Hive pour mémoriser la dernière adresse late Box _settingsBox; // Helpers de validation String? _validateNumero(String? value) { if (value == null || value.trim().isEmpty) { return 'Le numéro est obligatoire'; } final numero = int.tryParse(value.trim()); if (numero == null || numero <= 0) { return 'Numéro invalide'; } return null; } String? _validateRue(String? value) { if (value == null || value.trim().isEmpty) { return 'La rue est obligatoire'; } if (value.trim().length < 3) { return 'La rue doit contenir au moins 3 caractères'; } return null; } String? _validateVille(String? value) { if (value == null || value.trim().isEmpty) { return 'La ville est obligatoire'; } return null; } String? _validateNomOccupant(String? value) { // Le nom est obligatoire uniquement si un email est renseigné final emailValue = _emailController.text.trim(); if (emailValue.isNotEmpty) { if (value == null || value.trim().isEmpty) { return 'Le nom est obligatoire si un email est renseigné'; } if (value.trim().length < 2) { return 'Le nom doit contenir au moins 2 caractères'; } } return null; } String? _validateEmail(String? value) { if (value == null || value.trim().isEmpty) { return null; // Email optionnel } const emailRegex = r'^[^@]+@[^@]+\.[^@]+$'; if (!RegExp(emailRegex).hasMatch(value.trim())) { return 'Format email invalide'; } return null; } String? _validateMontant(String? value) { if (_selectedPassageType == 1 || _selectedPassageType == 5) { if (value == null || value.trim().isEmpty) { return 'Le montant est obligatoire pour ce type'; } final montant = double.tryParse(value.replaceAll(',', '.')); if (montant == null) { return 'Montant invalide'; } if (montant <= 0) { return 'Le montant doit être supérieur à 0'; } } return null; } @override void initState() { super.initState(); try { debugPrint('=== DEBUT PassageFormDialog.initState ==='); // Accéder à la settingsBox (déjà ouverte dans l'app) _settingsBox = Hive.box(AppKeys.settingsBoxName); // Initialize controllers with passage data if available final passage = widget.passage; debugPrint('Passage reçu: ${passage != null}'); if (passage != null) { debugPrint('Passage ID: ${passage.id}'); debugPrint('Passage fkType: ${passage.fkType}'); debugPrint('Passage numero: ${passage.numero}'); debugPrint('Passage rueBis: ${passage.rueBis}'); debugPrint('Passage rue: ${passage.rue}'); debugPrint('Passage ville: ${passage.ville}'); debugPrint('Passage name: ${passage.name}'); debugPrint('Passage email: ${passage.email}'); debugPrint('Passage phone: ${passage.phone}'); debugPrint('Passage montant: ${passage.montant}'); debugPrint('Passage remarque: ${passage.remarque}'); debugPrint('Passage fkHabitat: ${passage.fkHabitat}'); debugPrint('Passage fkTypeReglement: ${passage.fkTypeReglement}'); } _selectedPassageType = passage?.fkType; _showForm = false; // Toujours commencer par la sélection de type _fkHabitat = passage?.fkHabitat ?? 1; _fkTypeReglement = passage?.fkTypeReglement ?? 4; debugPrint('Initialisation des controllers...'); // S'assurer que toutes les valeurs null deviennent des chaînes vides String numero = passage?.numero.toString() ?? ''; String rueBis = passage?.rueBis.toString() ?? ''; String rue = passage?.rue.toString() ?? ''; String ville = passage?.ville.toString() ?? ''; final String name = passage?.name.toString() ?? ''; final String email = passage?.email.toString() ?? ''; final String phone = passage?.phone.toString() ?? ''; // Pour le montant, afficher vide si c'est 0.00 final String montantRaw = passage?.montant.toString() ?? '0.00'; final String montant = (montantRaw == '0.00' || montantRaw == '0' || montantRaw == '0.0') ? '' : montantRaw; String appt = passage?.appt.toString() ?? ''; String niveau = passage?.niveau.toString() ?? ''; String residence = passage?.residence.toString() ?? ''; final String remarque = passage?.remarque.toString() ?? ''; // Si nouveau passage, charger les valeurs mémorisées de la dernière adresse if (passage == null) { debugPrint('Nouveau passage: chargement des valeurs mémorisées...'); numero = _settingsBox.get('lastPassageNumero', defaultValue: '') as String; rueBis = _settingsBox.get('lastPassageRueBis', defaultValue: '') as String; rue = _settingsBox.get('lastPassageRue', defaultValue: '') as String; ville = _settingsBox.get('lastPassageVille', defaultValue: '') as String; residence = _settingsBox.get('lastPassageResidence', defaultValue: '') as String; _fkHabitat = _settingsBox.get('lastPassageFkHabitat', defaultValue: 1) as int; appt = _settingsBox.get('lastPassageAppt', defaultValue: '') as String; niveau = _settingsBox.get('lastPassageNiveau', defaultValue: '') as String; debugPrint('Valeurs chargées: numero="$numero", rue="$rue", ville="$ville"'); } // Initialiser la date de passage _passedAt = passage?.passedAt ?? DateTime.now(); final String dateFormatted = '${_passedAt.day.toString().padLeft(2, '0')}/${_passedAt.month.toString().padLeft(2, '0')}/${_passedAt.year}'; final String timeFormatted = '${_passedAt.hour.toString().padLeft(2, '0')}:${_passedAt.minute.toString().padLeft(2, '0')}'; debugPrint('Valeurs pour controllers:'); debugPrint(' numero: "$numero"'); debugPrint(' rueBis: "$rueBis"'); debugPrint(' rue: "$rue"'); debugPrint(' ville: "$ville"'); debugPrint(' name: "$name"'); debugPrint(' email: "$email"'); debugPrint(' phone: "$phone"'); debugPrint(' montant: "$montant"'); debugPrint(' remarque: "$remarque"'); debugPrint(' passedAt: "$_passedAt"'); debugPrint(' dateFormatted: "$dateFormatted"'); debugPrint(' timeFormatted: "$timeFormatted"'); _numeroController = TextEditingController(text: numero); _rueBisController = TextEditingController(text: rueBis); _rueController = TextEditingController(text: rue); _villeController = TextEditingController(text: ville); _nameController = TextEditingController(text: name); _emailController = TextEditingController(text: email); _phoneController = TextEditingController(text: phone); _montantController = TextEditingController(text: montant); _apptController = TextEditingController(text: appt); _niveauController = TextEditingController(text: niveau); _residenceController = TextEditingController(text: residence); _remarqueController = TextEditingController(text: remarque); _dateController = TextEditingController(text: dateFormatted); _timeController = TextEditingController(text: timeFormatted); // Ajouter un listener sur le champ email pour mettre à jour la validation du nom _emailController.addListener(() { // Force la revalidation du formulaire quand l'email change if (mounted) { setState(() { // Cela va déclencher un rebuild et mettre à jour l'indicateur isRequired }); } }); debugPrint('=== FIN PassageFormDialog.initState ==='); } catch (e, stackTrace) { debugPrint('=== ERREUR PassageFormDialog.initState ==='); debugPrint('Erreur: $e'); debugPrint('StackTrace: $stackTrace'); rethrow; } } @override void dispose() { _numeroController.dispose(); _rueBisController.dispose(); _rueController.dispose(); _villeController.dispose(); _nameController.dispose(); _emailController.dispose(); _phoneController.dispose(); _montantController.dispose(); _apptController.dispose(); _niveauController.dispose(); _residenceController.dispose(); _remarqueController.dispose(); _dateController.dispose(); _timeController.dispose(); super.dispose(); } void _selectPassageType(int typeId) { setState(() { _selectedPassageType = typeId; _showForm = true; // Réinitialiser montant et type de règlement selon le type de passage if (typeId == 1 || typeId == 5) { // Pour "Effectué" (1) ou "Lot" (5), laisser le choix à l'utilisateur // Ne pas forcer un type de règlement spécifique } else { // Pour tous les autres types, réinitialiser _montantController.text = ''; _fkTypeReglement = 4; // Non renseigné } // Si c'est un nouveau passage et qu'on change de type, réinitialiser la date à maintenant if (widget.passage == null) { _passedAt = DateTime.now(); _dateController.text = '${_passedAt.day.toString().padLeft(2, '0')}/${_passedAt.month.toString().padLeft(2, '0')}/${_passedAt.year}'; _timeController.text = '${_passedAt.hour.toString().padLeft(2, '0')}:${_passedAt.minute.toString().padLeft(2, '0')}'; } }); } void _handleSubmit() async { if (_isSubmitting) return; // ✅ 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 return; } // Toujours sauvegarder le passage en premier await _savePassage(); } Future _savePassage() async { setState(() { _isSubmitting = true; }); try { final currentUser = widget.userRepository.getCurrentUser(); if (currentUser == null) { throw Exception("Utilisateur non connecté"); } final currentOperation = widget.operationRepository.getCurrentOperation(); if (currentOperation == null && widget.passage == null) { throw Exception("Aucune opération active trouvée"); } // Déterminer les valeurs de montant et type de règlement selon le type de passage final String finalMontant = (_selectedPassageType == 1 || _selectedPassageType == 5) ? _montantController.text.trim() : '0'; // Déterminer le type de règlement final selon le type de passage final int finalTypeReglement; if (_selectedPassageType == 1 || _selectedPassageType == 5) { // Pour les types 1 et 5, utiliser la valeur sélectionnée (qui a été validée) finalTypeReglement = _fkTypeReglement; } else { // Pour tous les autres types, forcer "Non renseigné" finalTypeReglement = 4; } // Déterminer la valeur de nbPassages selon le type de passage final int finalNbPassages; if (widget.passage != null) { // Modification d'un passage existant if (_selectedPassageType == 2) { // Type 2 (À finaliser) : toujours incrémenter finalNbPassages = widget.passage!.nbPassages + 1; } else { // Autres types : mettre à 1 si actuellement 0, sinon conserver final currentNbPassages = widget.passage!.nbPassages; finalNbPassages = currentNbPassages == 0 ? 1 : currentNbPassages; } } else { // Nouveau passage : toujours 1 finalNbPassages = 1; } final passageData = widget.passage?.copyWith( fkType: _selectedPassageType!, numero: _numeroController.text.trim(), rueBis: _rueBisController.text.trim(), rue: _rueController.text.trim(), ville: _villeController.text.trim(), name: _nameController.text.trim(), email: _emailController.text.trim(), phone: _phoneController.text.trim(), montant: finalMontant, fkHabitat: _fkHabitat, appt: _apptController.text.trim(), niveau: _niveauController.text.trim(), residence: _residenceController.text.trim(), remarque: _remarqueController.text.trim(), fkTypeReglement: finalTypeReglement, nbPassages: finalNbPassages, passedAt: _passedAt, stripePaymentId: _stripePaymentIntentId, lastSyncedAt: DateTime.now(), ) ?? PassageModel( id: 0, // Nouveau passage fkOperation: currentOperation!.id, // Opération active fkSector: 0, // Secteur par défaut fkUser: currentUser.id, // Utilisateur actuel fkType: _selectedPassageType!, fkAdresse: "0", // Adresse par défaut pour nouveau passage passedAt: _passedAt, numero: _numeroController.text.trim(), rue: _rueController.text.trim(), rueBis: _rueBisController.text.trim(), ville: _villeController.text.trim(), residence: _residenceController.text.trim(), fkHabitat: _fkHabitat, appt: _apptController.text.trim(), niveau: _niveauController.text.trim(), gpsLat: '0.0', // GPS par défaut gpsLng: '0.0', // GPS par défaut nomRecu: _nameController.text.trim(), remarque: _remarqueController.text.trim(), montant: finalMontant, fkTypeReglement: finalTypeReglement, emailErreur: '', nbPassages: finalNbPassages, name: _nameController.text.trim(), email: _emailController.text.trim(), phone: _phoneController.text.trim(), stripePaymentId: _stripePaymentIntentId, lastSyncedAt: DateTime.now(), isActive: true, isSynced: false, ); // Sauvegarder le passage d'abord PassageModel? savedPassage; if (widget.passage == null) { // Création d'un nouveau passage savedPassage = await widget.passageRepository.createPassageWithReturn(passageData); } 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 ? "Échec de la création du passage" : "Échec de la mise à jour du passage"); } // Mémoriser l'adresse pour la prochaine création de passage await _saveLastPassageAddress(); // Vérifier si paiement CB nécessaire APRÈS la sauvegarde if (finalTypeReglement == 3 && (_selectedPassageType == 1 || _selectedPassageType == 5)) { final montant = double.tryParse(finalMontant.replaceAll(',', '.')) ?? 0; if (montant > 0 && mounted) { // Vérifier si le device supporte Tap to Pay if (DeviceInfoService.instance.canUseTapToPay()) { // Lancer le flow Tap to Pay avec l'ID du passage sauvegardé final paymentSuccess = await _attemptTapToPayWithPassage(savedPassage, montant); if (!paymentSuccess) { // Si le paiement échoue, on pourrait marquer le passage comme "À finaliser" // ou le supprimer selon la logique métier debugPrint('⚠️ Paiement échoué pour le passage ${savedPassage.id}'); // Optionnel : mettre à jour le passage en type "À finaliser" (7) } } else { // Le device ne supporte pas Tap to Pay (Web ou device non compatible) if (mounted) { // Déterminer le message d'avertissement approprié String warningMessage; if (kIsWeb) { warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Le paiement sans contact n'est pas disponible sur navigateur web. Utilisez l'application mobile native pour cette fonctionnalité."; } else { // Vérifier pourquoi le device n'est pas compatible final deviceInfo = DeviceInfoService.instance.getStoredDeviceInfo(); final nfcCapable = deviceInfo['device_nfc_capable'] == true; final stripeCertified = deviceInfo['device_stripe_certified'] == true; final batteryLevel = deviceInfo['battery_level'] as int?; final platform = deviceInfo['platform']; if (!nfcCapable) { warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Votre appareil n'a pas de NFC activé ou disponible pour les paiements sans contact."; } else if (!stripeCertified) { if (platform == 'iOS') { warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Votre iPhone n'est pas compatible. Tap to Pay nécessite un iPhone XS ou plus récent avec iOS 16.4+."; } else { warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Votre appareil Android n'est pas certifié par Stripe pour les paiements sans contact en France."; } } else if (batteryLevel != null && batteryLevel < 10) { warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Batterie trop faible ($batteryLevel%). Minimum 10% requis pour les paiements sans contact."; } else { warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Votre appareil ne peut pas utiliser le paiement sans contact actuellement."; } } // Fermer le dialog et afficher le message de succès avec avertissement Future.delayed(const Duration(milliseconds: 200), () { if (mounted) { Navigator.of(context, rootNavigator: false).pop(); widget.onSuccess?.call(); Future.delayed(const Duration(milliseconds: 100), () { if (mounted) { // Afficher un SnackBar orange pour l'avertissement ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(warningMessage), backgroundColor: Colors.orange, duration: const Duration(seconds: 5), ), ); } }); } }); } } } } else { // Pas de paiement CB, fermer le dialog avec succès if (mounted) { Future.delayed(const Duration(milliseconds: 200), () { if (mounted) { Navigator.of(context, rootNavigator: false).pop(); widget.onSuccess?.call(); Future.delayed(const Duration(milliseconds: 100), () { if (mounted) { ApiException.showSuccess( context, widget.passage == null ? "Nouveau passage créé avec succès" : "Passage modifié avec succès", ); } }); } }); } } } catch (e) { if (mounted) { ApiException.showError(context, e); } } finally { if (mounted) { setState(() { _isSubmitting = false; }); } } } /// Mémoriser l'adresse du passage pour la prochaine création Future _saveLastPassageAddress() async { try { await _settingsBox.put('lastPassageNumero', _numeroController.text.trim()); await _settingsBox.put('lastPassageRueBis', _rueBisController.text.trim()); await _settingsBox.put('lastPassageRue', _rueController.text.trim()); await _settingsBox.put('lastPassageVille', _villeController.text.trim()); await _settingsBox.put('lastPassageResidence', _residenceController.text.trim()); await _settingsBox.put('lastPassageFkHabitat', _fkHabitat); 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'); } } Widget _buildPassageTypeSelection() { final theme = Theme.of(context); // Récupérer l'amicale de l'utilisateur pour vérifier chkLotActif final currentUser = CurrentUserService.instance.currentUser; bool showLotType = true; // Par défaut, on affiche le type Lot if (currentUser != null && currentUser.fkEntite != null) { final userAmicale = widget.amicaleRepository.getAmicaleById(currentUser.fkEntite!); if (userAmicale != null) { // Si chkLotActif = false (0), on ne doit pas afficher le type Lot (5) showLotType = userAmicale.chkLotActif; debugPrint('Amicale ${userAmicale.name}: chkLotActif = $showLotType'); } } // Filtrer les types de passages en fonction de chkLotActif final filteredTypes = Map>.from(AppKeys.typesPassages); if (!showLotType) { filteredTypes.remove(5); // Retirer le type "Lot" si chkLotActif = 0 debugPrint('Type Lot (5) masqué car chkLotActif = false'); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Type de passage', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.primary, ), ), const SizedBox(height: 16), GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, childAspectRatio: MediaQuery.of(context).size.width < 600 ? 1.8 : 2.5, crossAxisSpacing: 12, mainAxisSpacing: 12, ), itemCount: filteredTypes.length, itemBuilder: (context, index) { try { final typeId = filteredTypes.keys.elementAt(index); final typeData = filteredTypes[typeId]; if (typeData == null) { debugPrint('ERREUR: typeData null pour typeId: $typeId'); return const SizedBox(); } final isSelected = _selectedPassageType == typeId; return InkWell( onTap: widget.readOnly ? null : () => _selectPassageType(typeId), borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Color(typeData['couleur2'] as int? ?? 0xFF000000) .withValues(alpha: 0.15), border: Border.all( color: Color(typeData['couleur2'] as int? ?? 0xFF000000), width: isSelected ? 3 : 2, ), borderRadius: BorderRadius.circular(12), boxShadow: isSelected ? [ BoxShadow( color: Color(typeData['couleur2'] as int? ?? 0xFF000000) .withValues(alpha: 0.2), blurRadius: 8, offset: const Offset(0, 2), ) ] : null, ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( typeData['icon_data'] as IconData? ?? Icons.help, color: Color(typeData['couleur2'] as int? ?? 0xFF000000), size: 36, // Icône plus grande ), const SizedBox(height: 8), Text( typeData['titre'] as String? ?? 'Type inconnu', style: theme.textTheme.bodyMedium?.copyWith( fontWeight: isSelected ? FontWeight.bold : FontWeight.w600, color: isSelected ? Color( typeData['couleur2'] as int? ?? 0xFF000000) : theme.colorScheme.onSurface, ), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, maxLines: 2, ), ], ), ), ); } catch (e) { debugPrint('ERREUR dans itemBuilder pour index $index: $e'); return const SizedBox(); } }, ), ], ); } Widget _buildPassageForm() { try { debugPrint('=== DEBUT _buildPassageForm ==='); debugPrint('Building Form...'); return Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Section Date et Heure FormSection( title: 'Date et Heure de passage', icon: Icons.schedule, children: [ // Layout responsive : 1 ligne desktop, 2 lignes mobile _isMobile(context) ? Column( children: [ CustomTextField( controller: _dateController, label: "Date", isRequired: true, readOnly: true, showLabel: false, hintText: "DD/MM/YYYY", suffixIcon: const Icon(Icons.calendar_today), onTap: widget.readOnly ? null : _selectDate, ), const SizedBox(height: 16), CustomTextField( controller: _timeController, label: "Heure", isRequired: true, readOnly: true, showLabel: false, hintText: "HH:MM", suffixIcon: const Icon(Icons.access_time), onTap: widget.readOnly ? null : _selectTime, ), ], ) : Row( children: [ Expanded( child: CustomTextField( controller: _dateController, label: "Date", isRequired: true, readOnly: true, showLabel: false, hintText: "DD/MM/YYYY", suffixIcon: const Icon(Icons.calendar_today), onTap: widget.readOnly ? null : _selectDate, ), ), const SizedBox(width: 12), Expanded( child: CustomTextField( controller: _timeController, label: "Heure", isRequired: true, readOnly: true, showLabel: false, hintText: "HH:MM", suffixIcon: const Icon(Icons.access_time), onTap: widget.readOnly ? null : _selectTime, ), ), ], ), ], ), const SizedBox(height: 24), // Section Adresse FormSection( title: 'Adresse', icon: Icons.location_on, children: [ Row( children: [ Expanded( flex: 1, child: CustomTextField( controller: _numeroController, label: "Numéro", isRequired: true, showLabel: false, textAlign: TextAlign.right, keyboardType: TextInputType.number, readOnly: widget.readOnly, validator: _validateNumero, ), ), const SizedBox(width: 12), Expanded( flex: 1, child: CustomTextField( controller: _rueBisController, label: "Bis, Ter...", showLabel: false, readOnly: widget.readOnly, ), ), ], ), const SizedBox(height: 16), CustomTextField( controller: _rueController, label: "Rue", isRequired: true, showLabel: false, readOnly: widget.readOnly, validator: _validateRue, ), const SizedBox(height: 16), CustomTextField( controller: _villeController, label: "Ville", isRequired: true, showLabel: false, readOnly: widget.readOnly, validator: _validateVille, ), ], ), const SizedBox(height: 24), // Section Habitat FormSection( title: 'Habitat', icon: Icons.home, children: [ // Boutons radio pour le type d'habitat Row( children: [ Expanded( // TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+) child: RadioListTile( title: const Text('Maison'), value: 1, groupValue: _fkHabitat, // ignore: deprecated_member_use onChanged: widget.readOnly // ignore: deprecated_member_use ? null : (value) { setState(() { _fkHabitat = value!; }); }, contentPadding: EdgeInsets.zero, ), ), Expanded( child: RadioListTile( title: const Text('Appart'), value: 2, groupValue: _fkHabitat, // ignore: deprecated_member_use onChanged: widget.readOnly // ignore: deprecated_member_use ? null : (value) { setState(() { _fkHabitat = value!; }); }, contentPadding: EdgeInsets.zero, ), ), ], ), // Champs spécifiques aux appartements if (_fkHabitat == 2) ...[ const SizedBox(height: 16), Row( children: [ Expanded( flex: 1, child: TextField( controller: _niveauController, decoration: const InputDecoration( labelText: "Niveau", border: OutlineInputBorder(), ), maxLength: 5, readOnly: widget.readOnly, ), ), const SizedBox(width: 12), Expanded( flex: 1, child: TextField( controller: _apptController, decoration: const InputDecoration( labelText: "Appt", border: OutlineInputBorder(), ), maxLength: 5, readOnly: widget.readOnly, ), ), ], ), const SizedBox(height: 16), TextField( controller: _residenceController, decoration: const InputDecoration( labelText: "Résidence", border: OutlineInputBorder(), ), maxLength: 50, readOnly: widget.readOnly, ), ], ], ), const SizedBox(height: 24), // Section Occupant FormSection( title: 'Occupant', icon: Icons.person, children: [ CustomTextField( controller: _nameController, label: "Nom de l'occupant", isRequired: _emailController.text.trim().isNotEmpty, showLabel: false, readOnly: widget.readOnly, validator: _validateNomOccupant, ), const SizedBox(height: 16), // Layout responsive : 1 ligne desktop, 2 lignes mobile _isMobile(context) ? Column( children: [ CustomTextField( controller: _emailController, label: "Email", showLabel: false, keyboardType: TextInputType.emailAddress, readOnly: widget.readOnly, validator: _validateEmail, prefixIcon: Icons.email, ), const SizedBox(height: 16), CustomTextField( controller: _phoneController, label: "Téléphone", showLabel: false, keyboardType: TextInputType.phone, readOnly: widget.readOnly, prefixIcon: Icons.phone, ), ], ) : Row( children: [ Expanded( child: CustomTextField( controller: _emailController, label: "Email", showLabel: false, keyboardType: TextInputType.emailAddress, readOnly: widget.readOnly, validator: _validateEmail, prefixIcon: Icons.email, ), ), const SizedBox(width: 12), Expanded( child: CustomTextField( controller: _phoneController, label: "Téléphone", showLabel: false, keyboardType: TextInputType.phone, readOnly: widget.readOnly, prefixIcon: Icons.phone, ), ), ], ), ], ), const SizedBox(height: 24), // Section Règlement et Remarque FormSection( title: (_selectedPassageType == 1 || _selectedPassageType == 5) ? 'Règlement et Note' : 'Note', icon: Icons.note, children: [ // Afficher montant et type de règlement seulement pour fkType 1 (Effectué) ou 5 (Lot) if (_selectedPassageType == 1 || _selectedPassageType == 5) ...[ Row( children: [ Expanded( child: CustomTextField( controller: _montantController, label: "Montant", isRequired: true, showLabel: false, hintText: "0.00", textAlign: TextAlign.right, keyboardType: const TextInputType.numberWithOptions( decimal: true), readOnly: widget.readOnly, validator: _validateMontant, prefixIcon: Icons.euro, ), ), const SizedBox(width: 12), Expanded( child: DropdownButtonFormField( initialValue: _fkTypeReglement, decoration: const InputDecoration( labelText: "Type de règlement *", border: OutlineInputBorder(), ), items: AppKeys.typesReglements.entries.map((entry) { return DropdownMenuItem( value: entry.key, child: Row( children: [ Icon( entry.value['icon_data'] as IconData, color: Color(entry.value['couleur'] as int), size: 16, ), const SizedBox(width: 8), Text(entry.value['titre'] as String), ], ), ); }).toList(), onChanged: widget.readOnly ? null : (value) { setState(() { _fkTypeReglement = value!; }); }, validator: (_selectedPassageType == 1 || _selectedPassageType == 5) ? (value) { if (value == null || value < 1 || value > 3) { return 'Type de règlement requis'; } return null; } : null, ), ), ], ), const SizedBox(height: 16), ], CustomTextField( controller: _remarqueController, label: "Note", showLabel: false, hintText: "Commentaire sur le passage...", maxLines: 2, readOnly: widget.readOnly, ), ], ), ], ), ); } catch (e, stackTrace) { debugPrint('=== ERREUR _buildPassageForm ==='); debugPrint('Erreur: $e'); debugPrint('StackTrace: $stackTrace'); return Container( padding: const EdgeInsets.all(16), child: Column( children: [ const Icon(Icons.error, color: Colors.red), const SizedBox(height: 8), Text('Erreur dans le formulaire: $e'), ], ), ); } } Future _selectDate() async { final DateTime? picked = await showDatePicker( context: context, initialDate: _passedAt, firstDate: DateTime(2020), lastDate: DateTime(2030), ); if (picked != null) { setState(() { _passedAt = DateTime( picked.year, picked.month, picked.day, _passedAt.hour, _passedAt.minute, ); _dateController.text = '${_passedAt.day.toString().padLeft(2, '0')}/${_passedAt.month.toString().padLeft(2, '0')}/${_passedAt.year}'; }); } } Future _selectTime() async { final TimeOfDay? picked = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(_passedAt), ); if (picked != null) { setState(() { _passedAt = DateTime( _passedAt.year, _passedAt.month, _passedAt.day, picked.hour, picked.minute, ); _timeController.text = '${_passedAt.hour.toString().padLeft(2, '0')}:${_passedAt.minute.toString().padLeft(2, '0')}'; }); } } // Méthode pour détecter si on est sur mobile bool _isMobile(BuildContext context) { // Détecter si on est sur mobile natif ou web mobile (largeur < 600px) return Theme.of(context).platform == TargetPlatform.iOS || Theme.of(context).platform == TargetPlatform.android || (kIsWeb && MediaQuery.of(context).size.width < 600); } // Méthode pour construire l'en-tête du formulaire Widget _buildHeader() { final theme = Theme.of(context); return Container( decoration: BoxDecoration( color: _selectedPassageType != null && AppKeys.typesPassages.containsKey(_selectedPassageType) ? Color(AppKeys.typesPassages[_selectedPassageType]!['couleur2'] as int? ?? 0xFF000000) .withValues(alpha: 0.1) : null, borderRadius: BorderRadius.circular(8), ), padding: const EdgeInsets.all(12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Row( children: [ Icon( widget.passage == null ? Icons.add_circle : Icons.edit, color: _selectedPassageType != null && AppKeys.typesPassages .containsKey(_selectedPassageType) ? Color(AppKeys.typesPassages[_selectedPassageType]![ 'couleur2'] as int? ?? 0xFF000000) : theme.colorScheme.primary, ), const SizedBox(width: 8), Flexible( child: Text( widget.title, style: theme.textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, color: _selectedPassageType != null && AppKeys.typesPassages .containsKey(_selectedPassageType) ? Color(AppKeys.typesPassages[_selectedPassageType]![ 'couleur2'] as int? ?? 0xFF000000) : theme.colorScheme.primary, ), overflow: TextOverflow.ellipsis, ), ), if (_selectedPassageType != null && AppKeys.typesPassages .containsKey(_selectedPassageType)) ...[ const SizedBox(width: 12), Icon( AppKeys.typesPassages[_selectedPassageType]!['icon_data'] as IconData? ?? Icons.help, color: Color( AppKeys.typesPassages[_selectedPassageType]!['couleur2'] as int? ?? 0xFF000000), size: 20, ), const SizedBox(width: 4), Text( AppKeys.typesPassages[_selectedPassageType]!['titre'] as String? ?? 'Inconnu', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, color: Color(AppKeys.typesPassages[_selectedPassageType]![ 'couleur2'] as int? ?? 0xFF000000), ), ), ], ], ), ), IconButton( icon: const Icon(Icons.close), onPressed: _isSubmitting ? null : () { Navigator.of(context, rootNavigator: false).pop(); }, ), ], ), ); } // Méthode pour construire le contenu principal Widget _buildContent() { return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!_showForm) ...[ () { debugPrint('Building passage type selection...'); return _buildPassageTypeSelection(); }(), ] else ...[ () { debugPrint('Building passage form...'); return _buildPassageForm(); }(), ], ], ), ); } // Méthode pour construire les boutons du footer Widget _buildFooterButtons() { final theme = Theme.of(context); return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: _isSubmitting ? null : () { Navigator.of(context, rootNavigator: false).pop(); }, child: const Text('Annuler'), ), const SizedBox(width: 16), if (!widget.readOnly && _showForm && _selectedPassageType != null) ElevatedButton.icon( onPressed: _isSubmitting ? null : _handleSubmit, icon: _isSubmitting ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : Icon(widget.passage == null ? Icons.add : Icons.save), label: Text(_isSubmitting ? 'Enregistrement...' : (widget.passage == null ? 'Créer' : 'Enregistrer')), style: ElevatedButton.styleFrom( backgroundColor: theme.colorScheme.primary, foregroundColor: Colors.white, ), ), ], ); } // Méthode pour construire le contenu du Dialog Widget _buildDialogContent() { return Column( mainAxisSize: MainAxisSize.min, children: [ // Header _buildHeader(), const Divider(), // Contenu Expanded( child: _buildContent(), ), const SizedBox(height: 24), // Footer _buildFooterButtons(), ], ); } // Méthode pour construire l'AppBar mobile AppBar _buildMobileAppBar() { final theme = Theme.of(context); final typeColor = _selectedPassageType != null && AppKeys.typesPassages.containsKey(_selectedPassageType) ? Color( AppKeys.typesPassages[_selectedPassageType]!['couleur2'] as int? ?? 0xFF000000) : theme.colorScheme.primary; return AppBar( backgroundColor: typeColor.withValues(alpha: 0.1), elevation: 0, leading: IconButton( icon: Icon(Icons.close, color: typeColor), onPressed: _isSubmitting ? null : () { Navigator.of(context, rootNavigator: false).pop(); }, ), title: Row( children: [ Icon( widget.passage == null ? Icons.add_circle : Icons.edit, color: typeColor, size: 24, ), const SizedBox(width: 8), Expanded( child: Text( widget.title, style: TextStyle( color: typeColor, fontWeight: FontWeight.bold, fontSize: AppTheme.r(context, 18), ), overflow: TextOverflow.ellipsis, ), ), ], ), actions: _selectedPassageType != null && AppKeys.typesPassages.containsKey(_selectedPassageType) ? [ Padding( padding: const EdgeInsets.only(right: 8), child: Row( children: [ Icon( AppKeys.typesPassages[_selectedPassageType]!['icon_data'] as IconData? ?? Icons.help, color: typeColor, size: 20, ), const SizedBox(width: 4), Text( AppKeys.typesPassages[_selectedPassageType]!['titre'] as String? ?? 'Inconnu', style: TextStyle( fontWeight: FontWeight.w600, color: typeColor, fontSize: AppTheme.r(context, 14), ), ), ], ), ), ] : null, ); } /// Tente d'effectuer un paiement Tap to Pay avec un passage déjà sauvegardé Future _attemptTapToPayWithPassage(PassageModel passage, double montant) async { try { // Afficher le dialog de paiement avec l'ID réel du passage final result = await showDialog( context: context, barrierDismissible: false, builder: (context) => _TapToPayFlowDialog( amount: montant, passageId: passage.id, // ID réel du passage sauvegardé onSuccess: (paymentIntentId) { // Mettre à jour le passage avec le stripe_payment_id final updatedPassage = passage.copyWith( stripePaymentId: paymentIntentId, ); // Envoyer la mise à jour à l'API (sera fait de manière asynchrone) widget.passageRepository.updatePassage(updatedPassage).then((_) { debugPrint('✅ Passage mis à jour avec stripe_payment_id: $paymentIntentId'); }).catchError((error) { debugPrint('❌ Erreur mise à jour passage: $error'); }); setState(() { _stripePaymentIntentId = paymentIntentId; }); }, ), ); // Si paiement réussi, afficher le message de succès et fermer if (result == true && mounted) { Future.delayed(const Duration(milliseconds: 200), () { if (mounted) { Navigator.of(context, rootNavigator: false).pop(); widget.onSuccess?.call(); Future.delayed(const Duration(milliseconds: 100), () { if (mounted) { ApiException.showSuccess( context, "Paiement effectué avec succès", ); } }); } }); return true; } return false; } catch (e) { debugPrint('Erreur Tap to Pay: $e'); if (mounted) { ApiException.showError(context, e); } return false; } } @override Widget build(BuildContext context) { try { debugPrint('=== DEBUT PassageFormDialog.build ==='); final isMobile = _isMobile(context); debugPrint('Platform mobile détectée: $isMobile'); if (isMobile) { // Mode plein écran pour mobile return Scaffold( appBar: _buildMobileAppBar(), body: SafeArea( child: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ Expanded( child: _buildContent(), ), ], ), ), ), bottomNavigationBar: _showForm && _selectedPassageType != null ? SafeArea( child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), blurRadius: 4, offset: const Offset(0, -2), ), ], ), child: _buildFooterButtons(), ), ) : null, ); } else { // Mode Dialog pour desktop/tablette return Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), insetPadding: const EdgeInsets.all(24), child: Container( width: MediaQuery.of(context).size.width * 0.6, constraints: const BoxConstraints( maxWidth: 800, maxHeight: 900, ), padding: const EdgeInsets.all(24), child: _buildDialogContent(), ), ); } } catch (e, stackTrace) { debugPrint('=== ERREUR PassageFormDialog.build ==='); debugPrint('Erreur: $e'); debugPrint('StackTrace: $stackTrace'); // Retourner un widget d'erreur simple return Dialog( child: Container( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error, color: Colors.red, size: 48), const SizedBox(height: 16), Text('Erreur lors de l\'affichage du formulaire: $e'), const SizedBox(height: 16), ElevatedButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Fermer'), ), ], ), ), ); } } } /// Dialog pour gérer le flow de paiement Tap to Pay class _TapToPayFlowDialog extends StatefulWidget { final double amount; final int passageId; final void Function(String paymentIntentId)? onSuccess; const _TapToPayFlowDialog({ required this.amount, required this.passageId, this.onSuccess, }); @override State<_TapToPayFlowDialog> createState() => _TapToPayFlowDialogState(); } class _TapToPayFlowDialogState extends State<_TapToPayFlowDialog> { String _currentState = 'confirming'; String? _paymentIntentId; String? _errorMessage; StreamSubscription? _statusSubscription; @override void initState() { super.initState(); _listenToPaymentStatus(); } @override void dispose() { _statusSubscription?.cancel(); super.dispose(); } void _listenToPaymentStatus() { _statusSubscription = StripeTapToPayService.instance.paymentStatusStream.listen( (status) { if (!mounted) return; setState(() { switch (status.type) { case TapToPayStatusType.ready: _currentState = 'ready'; break; case TapToPayStatusType.awaitingTap: _currentState = 'awaiting_tap'; break; case TapToPayStatusType.processing: _currentState = 'processing'; break; case TapToPayStatusType.confirming: _currentState = 'confirming'; break; case TapToPayStatusType.success: _currentState = 'success'; _paymentIntentId = status.paymentIntentId; _handleSuccess(); break; case TapToPayStatusType.error: _currentState = 'error'; _errorMessage = status.message; break; case TapToPayStatusType.cancelled: Navigator.pop(context, false); break; } }); }, ); } void _handleSuccess() { if (_paymentIntentId != null) { widget.onSuccess?.call(_paymentIntentId!); // Attendre un peu pour montrer le succès Future.delayed(const Duration(seconds: 2), () { if (mounted) { Navigator.pop(context, true); } }); } } Future _startPayment() async { setState(() { _currentState = 'initializing'; _errorMessage = null; }); try { // Initialiser le service si nécessaire if (!StripeTapToPayService.instance.isInitialized) { final initialized = await StripeTapToPayService.instance.initialize(); if (!initialized) { throw Exception('Impossible d\'initialiser Tap to Pay'); } } // Vérifier que le service est prêt if (!StripeTapToPayService.instance.isReadyForPayments()) { throw Exception('L\'appareil n\'est pas prêt pour les paiements'); } // Créer le PaymentIntent avec l'ID du passage dans les metadata final paymentIntent = await StripeTapToPayService.instance.createPaymentIntent( amountInCents: (widget.amount * 100).round(), description: 'Calendrier pompiers${widget.passageId > 0 ? " - Passage #${widget.passageId}" : ""}', metadata: { 'type': 'tap_to_pay', 'passage_id': widget.passageId.toString(), 'amicale_id': CurrentAmicaleService.instance.amicaleId.toString(), 'member_id': CurrentUserService.instance.userId.toString(), }, ); if (paymentIntent == null) { throw Exception('Impossible de créer le paiement'); } _paymentIntentId = paymentIntent.paymentIntentId; // Collecter le paiement final collected = await StripeTapToPayService.instance.collectPayment(paymentIntent); if (!collected) { throw Exception('Échec de la collecte du paiement'); } // Confirmer le paiement final confirmed = await StripeTapToPayService.instance.confirmPayment(paymentIntent); if (!confirmed) { throw Exception('Échec de la confirmation du paiement'); } } catch (e) { setState(() { _currentState = 'error'; _errorMessage = e.toString(); }); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); Widget content; List actions = []; switch (_currentState) { case 'confirming': content = Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.contactless, size: 64, color: theme.colorScheme.primary), const SizedBox(height: 24), Text( 'Paiement par carte sans contact', style: theme.textTheme.headlineSmall, ), const SizedBox(height: 16), Text( 'Montant: ${widget.amount.toStringAsFixed(2)}€', style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.primary, ), ), const SizedBox(height: 24), const Text( 'Le client va payer par carte bancaire sans contact.\n' 'Son téléphone ou sa carte sera présenté(e) sur cet appareil.', textAlign: TextAlign.center, ), ], ); actions = [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Annuler'), ), ElevatedButton.icon( onPressed: _startPayment, icon: const Icon(Icons.payment), label: const Text('Lancer le paiement'), ), ]; break; case 'initializing': content = Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), const SizedBox(height: 24), Text( 'Initialisation du terminal...', style: theme.textTheme.titleMedium, ), ], ); break; case 'awaiting_tap': content = Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.tap_and_play, size: 80, color: theme.colorScheme.primary, ), const SizedBox(height: 24), Text( 'Présentez la carte', style: theme.textTheme.headlineSmall, ), const SizedBox(height: 16), const LinearProgressIndicator(), const SizedBox(height: 16), Text( 'Montant: ${widget.amount.toStringAsFixed(2)}€', style: theme.textTheme.titleMedium, ), ], ); actions = [ TextButton( onPressed: () { if (_paymentIntentId != null) { StripeTapToPayService.instance.cancelPayment(_paymentIntentId!); } Navigator.pop(context, false); }, child: const Text('Annuler'), ), ]; break; case 'processing': content = Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), const SizedBox(height: 24), Text( 'Traitement du paiement...', style: theme.textTheme.titleMedium, ), const SizedBox(height: 8), const Text('Ne pas retirer la carte'), ], ); break; case 'success': content = Column( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.check_circle, size: 80, color: Colors.green, ), const SizedBox(height: 24), Text( 'Paiement réussi !', style: theme.textTheme.headlineSmall?.copyWith( color: Colors.green, ), ), const SizedBox(height: 16), Text( '${widget.amount.toStringAsFixed(2)}€ payé par carte', style: theme.textTheme.titleMedium, ), ], ); break; case 'error': content = Column( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.error_outline, size: 80, color: Colors.red, ), const SizedBox(height: 24), Text( 'Échec du paiement', style: theme.textTheme.headlineSmall?.copyWith( color: Colors.red, ), ), const SizedBox(height: 16), Text( _errorMessage ?? 'Une erreur est survenue', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium, ), ], ); actions = [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Annuler'), ), ElevatedButton.icon( onPressed: _startPayment, icon: const Icon(Icons.refresh), label: const Text('Réessayer'), ), ]; break; default: content = const Center(child: CircularProgressIndicator()); } return AlertDialog( title: Row( children: [ Icon(Icons.contactless, color: theme.colorScheme.primary), const SizedBox(width: 8), const Text('Paiement sans contact'), ], ), content: Container( constraints: const BoxConstraints(maxWidth: 400), child: content, ), actions: actions.isEmpty ? null : actions, ); } }