Files
geo/app/lib/presentation/widgets/payment_method_selection_dialog.dart
Pierre 0687900564 fix: Récupérer l'opération active depuis la table operations
- Corrige l'erreur SQL 'Unknown column fk_operation in users'
- L'opération active est récupérée depuis operations.chk_active = 1
- Jointure avec users pour filtrer par entité de l'admin créateur
- Query: SELECT o.id FROM operations o INNER JOIN users u ON u.fk_entite = o.fk_entite WHERE u.id = ? AND o.chk_active = 1
2026-01-26 16:57:08 +01:00

424 lines
14 KiB
Dart
Executable File

import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/services/stripe_connect_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/repositories/passage_repository.dart';
import 'package:geosector_app/presentation/widgets/qr_code_payment_dialog.dart';
/// Dialog de sélection de la méthode de paiement CB
/// Affiche les options QR Code et/ou Tap to Pay selon la compatibilité
class PaymentMethodSelectionDialog extends StatefulWidget {
final PassageModel passage;
final double amount;
final String habitantName;
final StripeConnectService stripeConnectService;
final PassageRepository? passageRepository;
final VoidCallback? onTapToPaySelected;
final VoidCallback? onQRCodeCompleted;
const PaymentMethodSelectionDialog({
super.key,
required this.passage,
required this.amount,
required this.habitantName,
required this.stripeConnectService,
this.passageRepository,
this.onTapToPaySelected,
this.onQRCodeCompleted,
});
@override
State<PaymentMethodSelectionDialog> createState() => _PaymentMethodSelectionDialogState();
/// Afficher le dialog de sélection de méthode de paiement
static Future<void> show({
required BuildContext context,
required PassageModel passage,
required double amount,
required String habitantName,
required StripeConnectService stripeConnectService,
PassageRepository? passageRepository,
VoidCallback? onTapToPaySelected,
VoidCallback? onQRCodeCompleted,
}) {
return showDialog(
context: context,
barrierDismissible: false, // Ne peut pas fermer en cliquant à côté
builder: (context) => PaymentMethodSelectionDialog(
passage: passage,
amount: amount,
habitantName: habitantName,
stripeConnectService: stripeConnectService,
passageRepository: passageRepository,
onTapToPaySelected: onTapToPaySelected,
onQRCodeCompleted: onQRCodeCompleted,
),
);
}
}
class _PaymentMethodSelectionDialogState extends State<PaymentMethodSelectionDialog> {
String? _tapToPayUnavailableReason;
bool _isCheckingNFC = true;
@override
void initState() {
super.initState();
_checkTapToPayAvailability();
}
Future<void> _checkTapToPayAvailability() async {
final reason = await DeviceInfoService.instance.getTapToPayUnavailableReasonAsync();
setState(() {
_tapToPayUnavailableReason = reason;
_isCheckingNFC = false;
});
}
@override
Widget build(BuildContext context) {
final canUseTapToPay = !_isCheckingNFC && _tapToPayUnavailableReason == null;
final amountEuros = widget.amount.toStringAsFixed(2);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
constraints: const BoxConstraints(maxWidth: 450),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// En-tête
const Text(
'Règlement CB',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
// Informations du paiement
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200),
),
child: Column(
children: [
Row(
children: [
const Icon(Icons.person, color: Colors.blue),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.habitantName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
),
const Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.euro,
color: Colors.blue,
size: 28,
),
const SizedBox(width: 8),
Text(
amountEuros,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
],
),
),
const SizedBox(height: 32),
// Titre section méthodes
const Text(
'Sélectionnez une méthode de paiement :',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 16),
// Bouton QR Code
_buildPaymentButton(
context: context,
icon: Icons.qr_code_2,
label: 'Paiement par QR Code',
description: 'Le client scanne le code avec son téléphone',
onPressed: () => _handleQRCodePayment(context),
color: Colors.blue,
isEnabled: true,
),
const SizedBox(height: 12),
// Bouton Tap to Pay (toujours affiché, désactivé si non disponible)
_buildPaymentButton(
context: context,
icon: Icons.contactless,
label: _isCheckingNFC ? 'Tap to Pay (vérification...)' : 'Tap to Pay',
description: canUseTapToPay
? 'Paiement sans contact sur cet appareil'
: _tapToPayUnavailableReason ?? 'Vérification en cours...',
onPressed: canUseTapToPay
? () {
Navigator.of(context).pop();
widget.onTapToPaySelected?.call();
}
: null,
color: Colors.green,
isEnabled: canUseTapToPay,
),
const SizedBox(height: 24),
// Logo Stripe
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.security,
color: Colors.grey.shade600,
size: 16,
),
const SizedBox(width: 8),
Text(
'Paiements sécurisés par Stripe',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
),
],
),
],
),
),
);
}
Widget _buildPaymentButton({
required BuildContext context,
required IconData icon,
required String label,
required String description,
required VoidCallback? onPressed,
required Color color,
required bool isEnabled,
}) {
// Couleurs selon l'état activé/désactivé
final effectiveColor = isEnabled ? color : Colors.grey;
final backgroundColor = isEnabled ? color.withOpacity(0.1) : Colors.grey.shade100;
final borderColor = isEnabled ? color.withOpacity(0.3) : Colors.grey.shade300;
return InkWell(
onTap: isEnabled ? onPressed : null,
borderRadius: BorderRadius.circular(12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: backgroundColor,
border: Border.all(color: borderColor, width: 2),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isEnabled ? color.withOpacity(0.2) : Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: effectiveColor,
size: 32,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: effectiveColor,
),
),
),
if (!isEnabled)
Icon(
Icons.lock_outline,
color: Colors.grey.shade600,
size: 20,
),
],
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isEnabled) ...[
Icon(
Icons.warning_amber_rounded,
color: Colors.orange.shade700,
size: 16,
),
const SizedBox(width: 4),
],
Expanded(
child: Text(
description,
style: TextStyle(
fontSize: 13,
color: isEnabled ? Colors.grey.shade700 : Colors.orange.shade700,
fontWeight: isEnabled ? FontWeight.normal : FontWeight.w500,
),
),
),
],
),
],
),
),
if (isEnabled)
Icon(Icons.arrow_forward_ios, color: effectiveColor, size: 20),
],
),
),
);
}
/// Gérer le paiement par QR Code
Future<void> _handleQRCodePayment(BuildContext context) async {
// Sauvegarder le navigator avant de fermer les dialogs
final navigator = Navigator.of(context);
bool loaderDisplayed = false;
try {
// Afficher un loader
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
);
loaderDisplayed = true;
// Créer le Payment Link
final amountInCents = (widget.amount * 100).round();
debugPrint('🔵 Création Payment Link : ${amountInCents} cents, passage ${widget.passage.id}');
final paymentLink = await widget.stripeConnectService.createPaymentLink(
amountInCents: amountInCents,
passageId: widget.passage.id,
description: 'Calendrier pompiers - ${widget.habitantName}',
metadata: {
'passage_id': widget.passage.id.toString(),
'habitant_name': widget.habitantName,
'adresse': '${widget.passage.numero} ${widget.passage.rue}, ${widget.passage.ville}',
},
);
debugPrint('🔵 Payment Link reçu : ${paymentLink != null ? "OK" : "NULL"}');
if (paymentLink != null) {
debugPrint(' URL: ${paymentLink.url}');
debugPrint(' ID: ${paymentLink.paymentLinkId}');
}
if (paymentLink == null) {
throw Exception('Impossible de créer le lien de paiement');
}
// Sauvegarder l'URL du Payment Link dans le passage
if (widget.passageRepository != null) {
try {
debugPrint('🔵 Sauvegarde de l\'URL du Payment Link dans le passage...');
final updatedPassage = widget.passage.copyWith(
stripePaymentLinkUrl: paymentLink.url,
);
await widget.passageRepository!.updatePassage(updatedPassage);
debugPrint('✅ URL du Payment Link sauvegardée');
} catch (e) {
debugPrint('⚠️ Erreur lors de la sauvegarde de l\'URL: $e');
// On continue quand même, ce n'est pas bloquant
}
}
// Fermer le loader
navigator.pop();
loaderDisplayed = false;
debugPrint('🔵 Loader fermé');
// Fermer le dialog de sélection (seulement en cas de succès)
navigator.pop();
debugPrint('🔵 Dialog de sélection fermé');
// Attendre un frame pour que les dialogs soient bien fermés
await Future.delayed(const Duration(milliseconds: 100));
// Afficher le QR Code avec le navigator root
debugPrint('🔵 Ouverture dialog QR Code...');
await showDialog(
context: navigator.context,
barrierDismissible: true,
builder: (context) => QRCodePaymentDialog(
paymentLink: paymentLink,
),
);
debugPrint('🔵 Dialog QR Code affiché');
// Notifier que le QR Code est complété
widget.onQRCodeCompleted?.call();
debugPrint('✅ Callback onQRCodeCompleted appelé');
} catch (e, stack) {
debugPrint('❌ Erreur dans _handleQRCodePayment: $e');
debugPrint(' Stack: $stack');
// Fermer le loader si encore ouvert
if (loaderDisplayed) {
try {
navigator.pop();
} catch (_) {}
}
// Afficher l'erreur (le dialogue de sélection reste ouvert)
if (context.mounted) {
ApiException.showError(context, e);
}
}
}
}