- Mise à jour VERSION vers 3.3.4 - Optimisations et révisions architecture API (deploy-api.sh, scripts de migration) - Ajout documentation Stripe Tap to Pay complète - Migration vers polices Inter Variable pour Flutter - Optimisations build Android et nettoyage fichiers temporaires - Amélioration système de déploiement avec gestion backups - Ajout scripts CRON et migrations base de données 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1868 lines
68 KiB
Dart
Executable File
1868 lines
68 KiB
Dart
Executable File
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<PassageFormDialog> createState() => _PassageFormDialogState();
|
||
}
|
||
|
||
class _PassageFormDialogState extends State<PassageFormDialog> {
|
||
final _formKey = GlobalKey<FormState>();
|
||
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<void> _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<void> _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<int, Map<String, dynamic>>.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<int>(
|
||
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<int>(
|
||
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<int>(
|
||
initialValue: _fkTypeReglement,
|
||
decoration: const InputDecoration(
|
||
labelText: "Type de règlement *",
|
||
border: OutlineInputBorder(),
|
||
),
|
||
items: AppKeys.typesReglements.entries.map((entry) {
|
||
return DropdownMenuItem<int>(
|
||
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<void> _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<void> _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<bool> _attemptTapToPayWithPassage(PassageModel passage, double montant) async {
|
||
try {
|
||
// Afficher le dialog de paiement avec l'ID réel du passage
|
||
final result = await showDialog<bool>(
|
||
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<TapToPayStatus>? _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<void> _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<Widget> 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,
|
||
);
|
||
}
|
||
}
|