Files
geo/app/lib/presentation/widgets/passage_form_dialog.dart
pierre 2f5946a184 feat: Version 3.5.2 - Configuration Stripe et gestion des immeubles
- Configuration complète Stripe pour les 3 environnements (DEV/REC/PROD)
  * DEV: Clés TEST Pierre (mode test)
  * REC: Clés TEST Client (mode test)
  * PROD: Clés LIVE Client (mode live)
- Ajout de la gestion des bases de données immeubles/bâtiments
  * Configuration buildings_database pour DEV/REC/PROD
  * Service BuildingService pour enrichissement des adresses
- Optimisations pages et améliorations ergonomie
- Mises à jour des dépendances Composer
- Nettoyage des fichiers obsolètes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 18:26:27 +01:00

2024 lines
74 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/stripe_connect_service.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
import 'package:geosector_app/presentation/widgets/form_section.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
import 'package:geosector_app/presentation/widgets/payment_method_selection_dialog.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;
// État d'expansion des sections
bool _isAddressSectionExpanded = true;
bool _isDateTimeSectionExpanded = false; // Toujours fermée par défaut
// 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;
// Section Adresse : ouverte si nouveau passage, fermée si modification
_isAddressSectionExpanded = passage == null;
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é
}
// Toujours mettre à jour la date et l'heure à maintenant lors de la sélection du type
_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 {
if (_isSubmitting) return;
setState(() {
_isSubmitting = true;
});
// Afficher l'overlay de chargement
final overlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Enregistrement en cours...',
);
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().replaceAll(',', '.')
: '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 || widget.passage!.id == 0) {
// Création d'un nouveau passage (passage null OU id=0)
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 || widget.passage!.id == 0
? "Échec de la création du passage"
: "Échec de la mise à jour du passage");
}
// Garantir le type non-nullable après la vérification
final confirmedPassage = savedPassage;
// Mémoriser l'adresse pour la prochaine création de passage
await _saveLastPassageAddress();
// Propager la résidence aux autres passages de l'immeuble si nécessaire
if (_fkHabitat == 2 && _residenceController.text.trim().isNotEmpty) {
await _propagateResidenceToBuilding(confirmedPassage);
}
// 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 l'amicale a Stripe activé
final amicale = CurrentAmicaleService.instance.currentAmicale;
final stripeEnabled = amicale?.chkStripe == true &&
amicale?.stripeId != null &&
amicale!.stripeId.isNotEmpty;
if (stripeEnabled) {
// Masquer le loading avant d'afficher le dialog de sélection
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Afficher le dialog de sélection de méthode de paiement
if (mounted) {
final habitantName = _nameController.text.trim();
await PaymentMethodSelectionDialog.show(
context: context,
passage: confirmedPassage,
amount: montant,
habitantName: habitantName.isNotEmpty ? habitantName : 'Client',
stripeConnectService: StripeConnectService(
apiService: ApiService.instance,
),
passageRepository: widget.passageRepository,
onTapToPaySelected: () async {
// Lancer le flow Tap to Pay
final paymentSuccess = await _attemptTapToPayWithPassage(confirmedPassage, montant);
if (!paymentSuccess) {
debugPrint('⚠️ Paiement Tap to Pay échoué pour le passage ${confirmedPassage.id}');
}
},
);
// Fermer le formulaire après le choix de paiement
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
}
} else {
// Stripe non activé pour cette amicale
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (mounted) {
await ResultDialog.show(
context: context,
success: true,
message: "Passage enregistré avec succès.\n\n Note : Les paiements par carte ne sont pas activés pour votre amicale. Contactez l'administrateur pour activer Stripe.",
);
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
}
}
}
} else {
// Pas de paiement CB, afficher le succès
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (mounted) {
await ResultDialog.show(
context: context,
success: true,
message: widget.passage == null || widget.passage!.id == 0
? "Nouveau passage créé avec succès"
: "Passage modifié avec succès",
);
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
}
}
} catch (e) {
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Afficher l'erreur
if (mounted) {
await ResultDialog.show(
context: context,
success: false,
message: ApiException.fromError(e).message,
);
}
} 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');
}
}
/// Propager la résidence aux autres passages de l'immeuble (fkType=2, même adresse, résidence vide)
Future<void> _propagateResidenceToBuilding(PassageModel savedPassage) async {
try {
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final residence = _residenceController.text.trim();
// Clé d'adresse du passage sauvegardé
final addressKey = '${savedPassage.numero}|${savedPassage.rueBis}|${savedPassage.rue}|${savedPassage.ville}';
int updatedCount = 0;
// Parcourir tous les passages
for (int i = 0; i < passagesBox.length; i++) {
final passage = passagesBox.getAt(i);
if (passage != null) {
// Vérifier les critères
final passageAddressKey = '${passage.numero}|${passage.rueBis}|${passage.rue}|${passage.ville}';
if (passage.id != savedPassage.id && // Pas le passage actuel
passage.fkHabitat == 2 && // Appartement
passageAddressKey == addressKey && // Même adresse
passage.residence.trim().isEmpty) { // Résidence vide
// Mettre à jour la résidence dans Hive
final updatedPassage = passage.copyWith(residence: residence);
await passagesBox.put(passage.key, updatedPassage);
updatedCount++;
}
}
}
if (updatedCount > 0) {
debugPrint('✅ Résidence propagée à $updatedCount passage(s) de l\'immeuble');
}
} catch (e) {
debugPrint('⚠️ Erreur lors de la propagation de la résidence: $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)
.withOpacity(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)
.withOpacity(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 (rétractable)
Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
key: ValueKey(_isDateTimeSectionExpanded),
initiallyExpanded: _isDateTimeSectionExpanded,
onExpansionChanged: (expanded) {
setState(() {
_isDateTimeSectionExpanded = expanded;
});
},
leading: Icon(
Icons.schedule,
color: Theme.of(context).colorScheme.primary,
),
title: _isDateTimeSectionExpanded
? Text(
'Date et Heure de passage',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Date et Heure de passage',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
Text(
'${_dateController.text} à ${_timeController.text}',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
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 (rétractable)
Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
key: ValueKey(_isAddressSectionExpanded),
initiallyExpanded: _isAddressSectionExpanded,
onExpansionChanged: (expanded) {
setState(() {
_isAddressSectionExpanded = expanded;
});
},
leading: Icon(
Icons.location_on,
color: Theme.of(context).colorScheme.primary,
),
title: _isAddressSectionExpanded
? Text(
'Adresse',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Adresse',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
Text(
'${_numeroController.text} ${_rueBisController.text} ${_rueController.text}, ${_villeController.text}'.trim().replaceAll(RegExp(r'\s+'), ' '),
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
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>(
value: _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)
.withOpacity(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.withOpacity(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) {
await ResultDialog.show(
context: context,
success: true,
message: "Paiement effectué avec succès",
);
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
return true;
}
return false;
} catch (e) {
debugPrint('Erreur Tap to Pay: $e');
if (mounted) {
await ResultDialog.show(
context: context,
success: false,
message: ApiException.fromError(e).message,
);
}
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: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Contenu du formulaire
if (!_showForm) ...[
_buildPassageTypeSelection(),
] else ...[
_buildPassageForm(),
],
// Boutons en bas du scroll
if (_showForm && _selectedPassageType != null) ...[
const SizedBox(height: 32),
_buildFooterButtons(),
const SizedBox(height: 16), // Padding supplémentaire pour le confort
],
],
),
),
),
);
} 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,
);
}
}