feat: Version 3.3.4 - Nouvelle architecture pages, optimisations widgets Flutter et API
- Mise à jour VERSION vers 3.3.4 - Optimisations et révisions architecture API (deploy-api.sh, scripts de migration) - Ajout documentation Stripe Tap to Pay complète - Migration vers polices Inter Variable pour Flutter - Optimisations build Android et nettoyage fichiers temporaires - Amélioration système de déploiement avec gestion backups - Ajout scripts CRON et migrations base de données 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/operation_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/amicale_repository.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
import 'package:geosector_app/core/services/stripe_tap_to_pay_service.dart';
|
||||
import 'package:geosector_app/core/services/device_info_service.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
@@ -17,6 +24,7 @@ class PassageFormDialog extends StatefulWidget {
|
||||
final PassageRepository passageRepository;
|
||||
final UserRepository userRepository;
|
||||
final OperationRepository operationRepository;
|
||||
final AmicaleRepository amicaleRepository;
|
||||
final VoidCallback? onSuccess;
|
||||
|
||||
const PassageFormDialog({
|
||||
@@ -27,6 +35,7 @@ class PassageFormDialog extends StatefulWidget {
|
||||
required this.passageRepository,
|
||||
required this.userRepository,
|
||||
required this.operationRepository,
|
||||
required this.amicaleRepository,
|
||||
this.onSuccess,
|
||||
});
|
||||
|
||||
@@ -63,6 +72,12 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
int _fkTypeReglement = 4; // Par défaut Non renseigné
|
||||
DateTime _passedAt = DateTime.now(); // Date et heure de passage
|
||||
|
||||
// Variable pour Tap to Pay
|
||||
String? _stripePaymentIntentId;
|
||||
|
||||
// Boîte Hive pour mémoriser la dernière adresse
|
||||
late Box _settingsBox;
|
||||
|
||||
// Helpers de validation
|
||||
String? _validateNumero(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
@@ -93,9 +108,11 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
}
|
||||
|
||||
String? _validateNomOccupant(String? value) {
|
||||
if (_selectedPassageType == 1) {
|
||||
// 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 pour les passages effectués';
|
||||
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';
|
||||
@@ -138,6 +155,9 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
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}');
|
||||
@@ -166,10 +186,10 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
debugPrint('Initialisation des controllers...');
|
||||
|
||||
// S'assurer que toutes les valeurs null deviennent des chaînes vides
|
||||
final String numero = passage?.numero.toString() ?? '';
|
||||
final String rueBis = passage?.rueBis.toString() ?? '';
|
||||
final String rue = passage?.rue.toString() ?? '';
|
||||
final String ville = passage?.ville.toString() ?? '';
|
||||
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() ?? '';
|
||||
@@ -179,11 +199,26 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
(montantRaw == '0.00' || montantRaw == '0' || montantRaw == '0.0')
|
||||
? ''
|
||||
: montantRaw;
|
||||
final String appt = passage?.appt.toString() ?? '';
|
||||
final String niveau = passage?.niveau.toString() ?? '';
|
||||
final String residence = passage?.residence.toString() ?? '';
|
||||
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 =
|
||||
@@ -220,6 +255,16 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
_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 ===');
|
||||
@@ -284,6 +329,11 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toujours sauvegarder le passage en premier
|
||||
await _savePassage();
|
||||
}
|
||||
|
||||
Future<void> _savePassage() async {
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
@@ -314,6 +364,23 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
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(),
|
||||
@@ -330,7 +397,9 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
residence: _residenceController.text.trim(),
|
||||
remarque: _remarqueController.text.trim(),
|
||||
fkTypeReglement: finalTypeReglement,
|
||||
nbPassages: finalNbPassages,
|
||||
passedAt: _passedAt,
|
||||
stripePaymentId: _stripePaymentIntentId,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
) ??
|
||||
PassageModel(
|
||||
@@ -356,43 +425,127 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
montant: finalMontant,
|
||||
fkTypeReglement: finalTypeReglement,
|
||||
emailErreur: '',
|
||||
nbPassages: 1,
|
||||
nbPassages: finalNbPassages,
|
||||
name: _nameController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
phone: _phoneController.text.trim(),
|
||||
stripePaymentId: _stripePaymentIntentId,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: true,
|
||||
isSynced: false,
|
||||
);
|
||||
|
||||
final success = widget.passage == null
|
||||
? await widget.passageRepository.createPassage(passageData)
|
||||
: await widget.passageRepository.updatePassage(passageData);
|
||||
// Sauvegarder le passage d'abord
|
||||
PassageModel? savedPassage;
|
||||
if (widget.passage == null) {
|
||||
// Création d'un nouveau passage
|
||||
savedPassage = await widget.passageRepository.createPassageWithReturn(passageData);
|
||||
} else {
|
||||
// Mise à jour d'un passage existant
|
||||
final success = await widget.passageRepository.updatePassage(passageData);
|
||||
if (success) {
|
||||
savedPassage = passageData;
|
||||
}
|
||||
}
|
||||
|
||||
if (success && mounted) {
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
widget.onSuccess?.call();
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(
|
||||
context,
|
||||
widget.passage == null
|
||||
? "Nouveau passage créé avec succès"
|
||||
: "Passage modifié avec succès",
|
||||
);
|
||||
if (savedPassage == null) {
|
||||
throw Exception(widget.passage == null
|
||||
? "Échec de la création du passage"
|
||||
: "Échec de la mise à jour du passage");
|
||||
}
|
||||
|
||||
// Mémoriser l'adresse pour la prochaine création de passage
|
||||
await _saveLastPassageAddress();
|
||||
|
||||
// Vérifier si paiement CB nécessaire APRÈS la sauvegarde
|
||||
if (finalTypeReglement == 3 &&
|
||||
(_selectedPassageType == 1 || _selectedPassageType == 5)) {
|
||||
final montant = double.tryParse(finalMontant.replaceAll(',', '.')) ?? 0;
|
||||
|
||||
if (montant > 0 && mounted) {
|
||||
// Vérifier si le device supporte Tap to Pay
|
||||
if (DeviceInfoService.instance.canUseTapToPay()) {
|
||||
// Lancer le flow Tap to Pay avec l'ID du passage sauvegardé
|
||||
final paymentSuccess = await _attemptTapToPayWithPassage(savedPassage, montant);
|
||||
|
||||
if (!paymentSuccess) {
|
||||
// Si le paiement échoue, on pourrait marquer le passage comme "À finaliser"
|
||||
// ou le supprimer selon la logique métier
|
||||
debugPrint('⚠️ Paiement échoué pour le passage ${savedPassage.id}');
|
||||
// Optionnel : mettre à jour le passage en type "À finaliser" (7)
|
||||
}
|
||||
} else {
|
||||
// Le device ne supporte pas Tap to Pay (Web ou device non compatible)
|
||||
if (mounted) {
|
||||
// Déterminer le message d'avertissement approprié
|
||||
String warningMessage;
|
||||
if (kIsWeb) {
|
||||
warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Le paiement sans contact n'est pas disponible sur navigateur web. Utilisez l'application mobile native pour cette fonctionnalité.";
|
||||
} else {
|
||||
// Vérifier pourquoi le device n'est pas compatible
|
||||
final deviceInfo = DeviceInfoService.instance.getStoredDeviceInfo();
|
||||
final nfcCapable = deviceInfo['device_nfc_capable'] == true;
|
||||
final stripeCertified = deviceInfo['device_stripe_certified'] == true;
|
||||
final batteryLevel = deviceInfo['battery_level'] as int?;
|
||||
final platform = deviceInfo['platform'];
|
||||
|
||||
if (!nfcCapable) {
|
||||
warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Votre appareil n'a pas de NFC activé ou disponible pour les paiements sans contact.";
|
||||
} else if (!stripeCertified) {
|
||||
if (platform == 'iOS') {
|
||||
warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Votre iPhone n'est pas compatible. Tap to Pay nécessite un iPhone XS ou plus récent avec iOS 16.4+.";
|
||||
} else {
|
||||
warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Votre appareil Android n'est pas certifié par Stripe pour les paiements sans contact en France.";
|
||||
}
|
||||
} else if (batteryLevel != null && batteryLevel < 10) {
|
||||
warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Batterie trop faible ($batteryLevel%). Minimum 10% requis pour les paiements sans contact.";
|
||||
} else {
|
||||
warningMessage = "Passage enregistré avec succès.\n\nℹ️ Note : Votre appareil ne peut pas utiliser le paiement sans contact actuellement.";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Fermer le dialog et afficher le message de succès avec avertissement
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
widget.onSuccess?.call();
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
// Afficher un SnackBar orange pour l'avertissement
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(warningMessage),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (mounted) {
|
||||
ApiException.showError(
|
||||
context,
|
||||
Exception(widget.passage == null
|
||||
? "Échec de la création du passage"
|
||||
: "Échec de la mise à jour du passage"),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Pas de paiement CB, fermer le dialog avec succès
|
||||
if (mounted) {
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
widget.onSuccess?.call();
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(
|
||||
context,
|
||||
widget.passage == null
|
||||
? "Nouveau passage créé avec succès"
|
||||
: "Passage modifié avec succès",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -407,9 +560,47 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mémoriser l'adresse du passage pour la prochaine création
|
||||
Future<void> _saveLastPassageAddress() async {
|
||||
try {
|
||||
await _settingsBox.put('lastPassageNumero', _numeroController.text.trim());
|
||||
await _settingsBox.put('lastPassageRueBis', _rueBisController.text.trim());
|
||||
await _settingsBox.put('lastPassageRue', _rueController.text.trim());
|
||||
await _settingsBox.put('lastPassageVille', _villeController.text.trim());
|
||||
await _settingsBox.put('lastPassageResidence', _residenceController.text.trim());
|
||||
await _settingsBox.put('lastPassageFkHabitat', _fkHabitat);
|
||||
await _settingsBox.put('lastPassageAppt', _apptController.text.trim());
|
||||
await _settingsBox.put('lastPassageNiveau', _niveauController.text.trim());
|
||||
|
||||
debugPrint('✅ Adresse mémorisée pour la prochaine création de passage');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur lors de la mémorisation de l\'adresse: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPassageTypeSelection() {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Récupérer l'amicale de l'utilisateur pour vérifier chkLotActif
|
||||
final currentUser = CurrentUserService.instance.currentUser;
|
||||
bool showLotType = true; // Par défaut, on affiche le type Lot
|
||||
|
||||
if (currentUser != null && currentUser.fkEntite != null) {
|
||||
final userAmicale = widget.amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
||||
if (userAmicale != null) {
|
||||
// Si chkLotActif = false (0), on ne doit pas afficher le type Lot (5)
|
||||
showLotType = userAmicale.chkLotActif;
|
||||
debugPrint('Amicale ${userAmicale.name}: chkLotActif = $showLotType');
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer les types de passages en fonction de chkLotActif
|
||||
final filteredTypes = Map<int, Map<String, dynamic>>.from(AppKeys.typesPassages);
|
||||
if (!showLotType) {
|
||||
filteredTypes.remove(5); // Retirer le type "Lot" si chkLotActif = 0
|
||||
debugPrint('Type Lot (5) masqué car chkLotActif = false');
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -431,11 +622,11 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: AppKeys.typesPassages.length,
|
||||
itemCount: filteredTypes.length,
|
||||
itemBuilder: (context, index) {
|
||||
try {
|
||||
final typeId = AppKeys.typesPassages.keys.elementAt(index);
|
||||
final typeData = AppKeys.typesPassages[typeId];
|
||||
final typeId = filteredTypes.keys.elementAt(index);
|
||||
final typeData = filteredTypes[typeId];
|
||||
|
||||
if (typeData == null) {
|
||||
debugPrint('ERREUR: typeData null pour typeId: $typeId');
|
||||
@@ -523,35 +714,62 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
title: 'Date et Heure de passage',
|
||||
icon: Icons.schedule,
|
||||
children: [
|
||||
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,
|
||||
// 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(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),
|
||||
@@ -619,11 +837,12 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
// TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+)
|
||||
child: RadioListTile<int>(
|
||||
title: const Text('Maison'),
|
||||
value: 1,
|
||||
groupValue: _fkHabitat,
|
||||
onChanged: widget.readOnly
|
||||
groupValue: _fkHabitat, // ignore: deprecated_member_use
|
||||
onChanged: widget.readOnly // ignore: deprecated_member_use
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
@@ -637,8 +856,8 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
child: RadioListTile<int>(
|
||||
title: const Text('Appart'),
|
||||
value: 2,
|
||||
groupValue: _fkHabitat,
|
||||
onChanged: widget.readOnly
|
||||
groupValue: _fkHabitat, // ignore: deprecated_member_use
|
||||
onChanged: widget.readOnly // ignore: deprecated_member_use
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
@@ -705,41 +924,63 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
children: [
|
||||
CustomTextField(
|
||||
controller: _nameController,
|
||||
label: _selectedPassageType == 1
|
||||
? "Nom de l'occupant"
|
||||
: "Nom de l'occupant",
|
||||
isRequired: _selectedPassageType == 1,
|
||||
label: "Nom de l'occupant",
|
||||
isRequired: _emailController.text.trim().isNotEmpty,
|
||||
showLabel: false,
|
||||
readOnly: widget.readOnly,
|
||||
validator: _validateNomOccupant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomTextField(
|
||||
controller: _emailController,
|
||||
label: "Email",
|
||||
showLabel: false,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
readOnly: widget.readOnly,
|
||||
validator: _validateEmail,
|
||||
prefixIcon: Icons.email,
|
||||
// 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(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),
|
||||
@@ -1140,6 +1381,65 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Tente d'effectuer un paiement Tap to Pay avec un passage déjà sauvegardé
|
||||
Future<bool> _attemptTapToPayWithPassage(PassageModel passage, double montant) async {
|
||||
try {
|
||||
// Afficher le dialog de paiement avec l'ID réel du passage
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => _TapToPayFlowDialog(
|
||||
amount: montant,
|
||||
passageId: passage.id, // ID réel du passage sauvegardé
|
||||
onSuccess: (paymentIntentId) {
|
||||
// Mettre à jour le passage avec le stripe_payment_id
|
||||
final updatedPassage = passage.copyWith(
|
||||
stripePaymentId: paymentIntentId,
|
||||
);
|
||||
|
||||
// Envoyer la mise à jour à l'API (sera fait de manière asynchrone)
|
||||
widget.passageRepository.updatePassage(updatedPassage).then((_) {
|
||||
debugPrint('✅ Passage mis à jour avec stripe_payment_id: $paymentIntentId');
|
||||
}).catchError((error) {
|
||||
debugPrint('❌ Erreur mise à jour passage: $error');
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_stripePaymentIntentId = paymentIntentId;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Si paiement réussi, afficher le message de succès et fermer
|
||||
if (result == true && mounted) {
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) {
|
||||
Navigator.of(context, rootNavigator: false).pop();
|
||||
widget.onSuccess?.call();
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(
|
||||
context,
|
||||
"Paiement effectué avec succès",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur Tap to Pay: $e');
|
||||
if (mounted) {
|
||||
ApiException.showError(context, e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
try {
|
||||
@@ -1228,3 +1528,340 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user