Files
geo/app/lib/presentation/widgets/passage_form_dialog.dart
Pierre 8dac04b9b1 docs: Marquer tâche #116 comme terminée (Remarque sous adresse)
La fonctionnalité d'affichage de la remarque dans la première card
du passage_form_dialog.dart était déjà implémentée (lignes 703-719).

Affichage inclut:
- Icône Icons.note
- Texte en italique
- Style grisé (opacité 0.7)
- Condition d'affichage si remarque non vide

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 17:55:34 +01:00

2098 lines
76 KiB
Dart
Executable File
Raw Permalink 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, debugPrint;
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
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/core/services/location_service.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
import 'package:geosector_app/presentation/widgets/form_section.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
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 {
// 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;
_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;
// 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) {
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;
}
// 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')}';
_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
});
}
});
} catch (e, stackTrace) {
debugPrint('❌ Erreur initState PassageFormDialog: $e\n$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 avec protection contre le null
if (_formKey.currentState == null) {
debugPrint('❌ _formKey.currentState est null');
if (mounted) {
await ResultDialog.show(
context: context,
success: false,
message: "Erreur d'initialisation du formulaire",
);
}
return;
}
final isValid = _formKey.currentState!.validate();
if (!isValid) {
if (mounted) {
await ResultDialog.show(
context: context,
success: false,
message: 'Veuillez vérifier tous les champs marqués comme obligatoires',
);
}
return;
}
await _savePassage();
}
Future<void> _savePassage() async {
if (_isSubmitting) return;
setState(() {
_isSubmitting = true;
});
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) {
finalTypeReglement = _fkTypeReglement;
} else {
finalTypeReglement = 4;
}
// Déterminer la valeur de nbPassages selon le type de passage
final int finalNbPassages;
if (widget.passage != null) {
if (_selectedPassageType == 2) {
finalNbPassages = widget.passage!.nbPassages + 1;
} else {
final currentNbPassages = widget.passage!.nbPassages;
finalNbPassages = currentNbPassages == 0 ? 1 : currentNbPassages;
}
} else {
finalNbPassages = 1;
}
// Récupérer les coordonnées GPS pour un nouveau passage
String finalGpsLat = '0.0';
String finalGpsLng = '0.0';
if (widget.passage == null) {
try {
final position = await LocationService.getCurrentPosition();
if (position != null) {
finalGpsLat = position.latitude.toString();
finalGpsLng = position.longitude.toString();
}
} catch (_) {}
} else {
finalGpsLat = widget.passage!.gpsLat;
finalGpsLng = widget.passage!.gpsLng;
}
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 (sera déterminé par l'API)
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: finalGpsLat, // GPS de l'utilisateur ou 0.0 si non disponible
gpsLng: finalGpsLng, // GPS de l'utilisateur ou 0.0 si non disponible
nomRecu: _nameController.text.trim(),
remarque: _remarqueController.text.trim(),
montant: finalMontant,
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
PassageModel? savedPassage;
if (widget.passage == null || widget.passage!.id == 0) {
savedPassage = await widget.passageRepository.createPassageWithReturn(passageData);
if (savedPassage == null) {
throw Exception("Échec de la création du passage");
}
} else {
await widget.passageRepository.updatePassage(passageData);
savedPassage = passageData;
}
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) {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
}
// Si échec, le formulaire reste ouvert pour réessayer
},
onQRCodeCompleted: () {
// Pour QR Code: fermer le formulaire après l'affichage du QR
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
},
);
// NOTE: Le formulaire n'est plus fermé systématiquement ici
// Il est fermé dans onQRCodeCompleted pour QR Code
// ou dans onTapToPaySelected en cas de succès Tap to Pay
}
} else {
// Stripe non activé pour cette amicale
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, stackTrace) {
debugPrint('❌ Erreur sauvegarde passage: $e\n$stackTrace');
LoadingSpinOverlayUtils.hideSpecific(overlay);
final errorMessage = ApiException.fromError(e).message;
if (mounted) {
await ResultDialog.show(
context: context,
success: false,
message: errorMessage,
);
}
} 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());
} catch (e) {
debugPrint('❌ Erreur mémorisation 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();
final addressKey = '${savedPassage.numero}|${savedPassage.rueBis}|${savedPassage.rue}|${savedPassage.ville}';
for (int i = 0; i < passagesBox.length; i++) {
final passage = passagesBox.getAt(i);
if (passage != null) {
final passageAddressKey = '${passage.numero}|${passage.rueBis}|${passage.rue}|${passage.ville}';
if (passage.id != savedPassage.id &&
passage.fkHabitat == 2 &&
passageAddressKey == addressKey &&
passage.residence.trim().isEmpty) {
final updatedPassage = passage.copyWith(residence: residence);
await passagesBox.put(passage.key, updatedPassage);
}
}
}
} catch (e) {
debugPrint('❌ Erreur propagation 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) {
showLotType = userAmicale.chkLotActif;
}
}
// Filtrer les types de passages en fonction de chkLotActif
final filteredTypes = Map<int, Map<String, dynamic>>.from(AppKeys.typesPassages);
if (!showLotType) {
filteredTypes.remove(5);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Afficher les infos du passage si modification
if (widget.passage != null) ...[
// Adresse du passage
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.primary.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Adresse principale
Text(
'${widget.passage!.numero} ${widget.passage!.rueBis} ${widget.passage!.rue}'.trim().replaceAll(RegExp(r'\s+'), ' '),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
widget.passage!.ville,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
// Infos appartement si fkHabitat == 2
if (widget.passage!.fkHabitat == 2) ...[
const SizedBox(height: 8),
Row(
children: [
if (widget.passage!.niveau.isNotEmpty) ...[
Icon(Icons.stairs, size: 16, color: theme.colorScheme.primary),
const SizedBox(width: 4),
Text('Niveau ${widget.passage!.niveau}'),
const SizedBox(width: 12),
],
if (widget.passage!.appt.isNotEmpty) ...[
Icon(Icons.door_front_door, size: 16, color: theme.colorScheme.primary),
const SizedBox(width: 4),
Text('Appt ${widget.passage!.appt}'),
],
],
),
],
// Afficher le nom de l'habitant (pour maison et appartement)
if (widget.passage!.name.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.person, size: 16, color: theme.colorScheme.primary),
const SizedBox(width: 4),
Expanded(child: Text(widget.passage!.name)),
],
),
],
// Afficher la remarque si renseignée
if (widget.passage!.remarque.isNotEmpty) ...[
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.note, size: 16, color: theme.colorScheme.primary),
const SizedBox(width: 4),
Expanded(
child: Text(
widget.passage!.remarque,
style: theme.textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
),
],
),
],
],
),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
],
Text(
widget.passage != null
? 'Choisir le nouveau type de ce passage'
: '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.4 : 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) {
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.30),
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 (_) {
return const SizedBox();
}
},
),
],
);
}
Widget _buildPassageForm() {
try {
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) {
debugPrint('❌ Erreur _buildPassageForm: $e');
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)
_buildPassageTypeSelection()
else
_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).catchError((error) {
debugPrint('❌ Erreur mise à jour passage stripe: $error');
return false;
});
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 {
final isMobile = _isMobile(context);
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) {
debugPrint('❌ Erreur PassageFormDialog.build: $e');
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) {
// Analyser le type d'erreur pour afficher un message clair
final errorMsg = e.toString().toLowerCase();
String userMessage;
bool shouldCancelPayment = true;
if (errorMsg.contains('canceled') || errorMsg.contains('cancelled')) {
// Annulation volontaire par l'utilisateur
userMessage = 'Paiement annulé';
} else if (errorMsg.contains('cardreadtimedout') || errorMsg.contains('timed out')) {
// Timeout de lecture NFC
userMessage = 'Lecture de la carte impossible.\n\n'
'Conseils :\n'
'• Maintenez la carte contre le dos du téléphone\n'
'• Ne bougez pas jusqu\'à confirmation\n'
'• Retirez la coque si nécessaire\n'
'• Essayez différentes positions sur le téléphone';
} else if (errorMsg.contains('already') && errorMsg.contains('payment')) {
// PaymentIntent existe déjà
userMessage = 'Un paiement est déjà en cours pour ce passage.\n'
'Veuillez réessayer dans quelques instants.';
shouldCancelPayment = false; // Déjà en cours, pas besoin d'annuler
} else {
// Autre erreur technique
userMessage = 'Erreur lors du paiement.\n\n$e';
}
// Annuler le PaymentIntent si créé pour permettre une nouvelle tentative
if (shouldCancelPayment && _paymentIntentId != null) {
StripeTapToPayService.instance.cancelPayment(_paymentIntentId!).catchError((_) {});
}
setState(() {
_currentState = 'error';
_errorMessage = userMessage;
});
}
}
@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,
);
}
}