Files
geo/app/lib/presentation/widgets/passage_form_dialog.dart
pierre 570a1fa1f0 feat: Version 3.3.4 - Nouvelle architecture pages, optimisations widgets Flutter et API
- 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>
2025-10-05 20:11:15 +02:00

1868 lines
68 KiB
Dart
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
);
}
}