/// Dialogue de paiement de contribution /// Formulaire pour enregistrer un paiement de contribution. /// Pour Wave : appelle l'API Checkout, ouvre wave_launch_url (app Wave), retour automatique via deep link. library payment_dialog; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../shared/design_system/tokens/color_tokens.dart'; import '../../../../shared/design_system/tokens/app_colors.dart'; import '../../../../core/config/environment.dart'; import 'package:intl/intl.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:unionflow_mobile_apps/core/di/injection.dart'; import 'package:unionflow_mobile_apps/shared/constants/payment_method_assets.dart'; import '../../bloc/contributions_bloc.dart'; import '../../bloc/contributions_event.dart'; import '../../data/models/contribution_model.dart'; import '../../domain/repositories/contribution_repository.dart'; /// Dialogue de paiement de contribution class PaymentDialog extends StatefulWidget { final ContributionModel cotisation; const PaymentDialog({ super.key, required this.cotisation, }); @override State createState() => _PaymentDialogState(); } class _PaymentDialogState extends State { final _formKey = GlobalKey(); final _montantController = TextEditingController(); final _referenceController = TextEditingController(); final _notesController = TextEditingController(); final _wavePhoneController = TextEditingController(); PaymentMethod _selectedMethode = PaymentMethod.waveMoney; DateTime _datePaiement = DateTime.now(); bool _waveLoading = false; @override void initState() { super.initState(); _montantController.text = widget.cotisation.montantRestant.toStringAsFixed(0); } @override void dispose() { _montantController.dispose(); _referenceController.dispose(); _notesController.dispose(); _wavePhoneController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Dialog( child: Container( width: MediaQuery.of(context).size.width * 0.9, constraints: const BoxConstraints(maxHeight: 500), child: Column( mainAxisSize: MainAxisSize.min, children: [ // En-tête Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: ColorTokens.successLight, borderRadius: BorderRadius.only( topLeft: Radius.circular(4), topRight: Radius.circular(4), ), ), child: Row( children: [ const Icon(Icons.payment, color: Colors.white), const SizedBox(width: 12), const Text( 'Enregistrer un paiement', style: TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, ), ), const Spacer(), IconButton( icon: const Icon(Icons.close, color: Colors.white), onPressed: () => Navigator.pop(context), ), ], ), ), // Informations de la cotisation Container( padding: const EdgeInsets.all(16), color: Theme.of(context).colorScheme.surfaceContainerHighest, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.cotisation.membreNomComplet, style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface, ), ), const SizedBox(height: 4), Text( widget.cotisation.libellePeriode, style: TextStyle( fontSize: 14, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Montant total:', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), Text( '${NumberFormat('#,###').format(widget.cotisation.montant)} ${widget.cotisation.devise}', style: TextStyle( fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onSurface, ), ), ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Déjà payé:', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), Text( '${NumberFormat('#,###').format(widget.cotisation.montantPaye ?? 0)} ${widget.cotisation.devise}', style: TextStyle(color: ColorTokens.success), ), ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Restant:', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), Text( '${NumberFormat('#,###').format(widget.cotisation.montantRestant)} ${widget.cotisation.devise}', style: TextStyle( fontWeight: FontWeight.bold, color: ColorTokens.error, ), ), ], ), ], ), ), // Formulaire Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Montant TextFormField( controller: _montantController, decoration: InputDecoration( labelText: 'Montant à payer *', border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.attach_money), suffixText: widget.cotisation.devise, ), keyboardType: TextInputType.number, validator: (value) { if (value == null || value.isEmpty) { return 'Le montant est obligatoire'; } final montant = double.tryParse(value); if (montant == null || montant <= 0) { return 'Montant invalide'; } if (montant > widget.cotisation.montantRestant) { return 'Montant supérieur au restant dû'; } return null; }, ), const SizedBox(height: 12), // Méthode de paiement DropdownButtonFormField( value: _selectedMethode, decoration: const InputDecoration( labelText: 'Méthode de paiement *', border: OutlineInputBorder(), prefixIcon: Icon(Icons.payment), ), items: PaymentMethod.values.map((methode) { return DropdownMenuItem( value: methode, child: Row( children: [ PaymentMethodIcon( paymentMethodCode: methode.code, width: 24, height: 24, ), const SizedBox(width: 8), Text(_getMethodeLabel(methode)), ], ), ); }).toList(), onChanged: (value) { setState(() { _selectedMethode = value!; }); }, ), if (_selectedMethode == PaymentMethod.waveMoney) ...[ const SizedBox(height: 12), TextFormField( controller: _wavePhoneController, decoration: const InputDecoration( labelText: 'Numéro Wave (9 chiffres) *', border: OutlineInputBorder(), prefixIcon: Icon(Icons.phone_android), hintText: 'Ex: 771234567', ), keyboardType: TextInputType.number, validator: (value) { if (_selectedMethode != PaymentMethod.waveMoney) return null; final digits = value?.replaceAll(RegExp(r'\D'), '') ?? ''; if (digits.length < 9) { return 'Numéro Wave requis (9 chiffres) pour payer via Wave'; } return null; }, ), ], const SizedBox(height: 12), // Date de paiement InkWell( onTap: () => _selectDate(context), child: InputDecorator( decoration: const InputDecoration( labelText: 'Date de paiement *', border: OutlineInputBorder(), prefixIcon: Icon(Icons.calendar_today), ), child: Text( DateFormat('dd/MM/yyyy').format(_datePaiement), ), ), ), const SizedBox(height: 12), // Référence TextFormField( controller: _referenceController, decoration: const InputDecoration( labelText: 'Référence de transaction', border: OutlineInputBorder(), prefixIcon: Icon(Icons.receipt), hintText: 'Ex: TRX123456789', ), ), const SizedBox(height: 12), // Notes TextFormField( controller: _notesController, decoration: const InputDecoration( labelText: 'Notes (optionnel)', border: OutlineInputBorder(), prefixIcon: Icon(Icons.note), ), maxLines: 2, ), ], ), ), ), ), // Boutons d'action Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, border: Border(top: BorderSide(color: Theme.of(context).colorScheme.outline)), ), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Annuler'), ), const SizedBox(width: 12), ElevatedButton( onPressed: _waveLoading ? null : _submitForm, style: ElevatedButton.styleFrom( backgroundColor: ColorTokens.successLight, foregroundColor: Colors.white, ), child: _waveLoading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ) : Text(_selectedMethode == PaymentMethod.waveMoney ? 'Ouvrir Wave pour payer' : 'Enregistrer le paiement'), ), ], ), ), ], ), ), ); } IconData _getMethodeIcon(PaymentMethod methode) { switch (methode) { case PaymentMethod.waveMoney: return Icons.phone_android; case PaymentMethod.orangeMoney: return Icons.phone_iphone; case PaymentMethod.freeMoney: return Icons.smartphone; case PaymentMethod.mobileMoney: return Icons.mobile_friendly; case PaymentMethod.especes: return Icons.money; case PaymentMethod.cheque: return Icons.receipt_long; case PaymentMethod.virement: return Icons.account_balance; case PaymentMethod.carteBancaire: return Icons.credit_card; case PaymentMethod.autre: return Icons.more_horiz; } } String _getMethodeLabel(PaymentMethod methode) { switch (methode) { case PaymentMethod.waveMoney: return 'Wave Money'; case PaymentMethod.orangeMoney: return 'Orange Money'; case PaymentMethod.freeMoney: return 'Free Money'; case PaymentMethod.especes: return 'Espèces'; case PaymentMethod.cheque: return 'Chèque'; case PaymentMethod.virement: return 'Virement bancaire'; case PaymentMethod.carteBancaire: return 'Carte bancaire'; case PaymentMethod.mobileMoney: return 'Mobile Money (autre)'; case PaymentMethod.autre: return 'Autre'; } } Future _selectDate(BuildContext context) async { final DateTime? picked = await showDatePicker( context: context, initialDate: _datePaiement, firstDate: DateTime(2020), lastDate: DateTime.now(), ); if (picked != null && picked != _datePaiement) { setState(() { _datePaiement = picked; }); } } Future _submitForm() async { if (!_formKey.currentState!.validate()) return; if (_selectedMethode == PaymentMethod.waveMoney) { await _submitWavePayment(); return; } final montant = double.parse(_montantController.text); // L’UI est rafraîchie par le BLoC après RecordPayment ; pas besoin de copyWith local. context.read().add(RecordPayment( contributionId: widget.cotisation.id!, montant: montant, methodePaiement: _selectedMethode, datePaiement: _datePaiement, reference: _referenceController.text.isNotEmpty ? _referenceController.text : null, notes: _notesController.text.isNotEmpty ? _notesController.text : null, )); Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Paiement enregistré avec succès'), backgroundColor: ColorTokens.success, ), ); } /// Initie le paiement Wave : appel API Checkout, ouverture de l'app Wave, retour via deep link. Future _submitWavePayment() async { if (widget.cotisation.id == null || widget.cotisation.id!.isEmpty) return; final phone = _wavePhoneController.text.replaceAll(RegExp(r'\D'), ''); if (phone.length < 9) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Indiquez votre numéro Wave (9 chiffres)'), backgroundColor: AppColors.warning), ); return; } setState(() => _waveLoading = true); try { final repo = getIt(); final result = await repo.initierPaiementEnLigne( cotisationId: widget.cotisation.id!, methodePaiement: 'WAVE', numeroTelephone: phone, ); final url = result.waveLaunchUrl.isNotEmpty ? result.waveLaunchUrl : result.redirectUrl; if (url.isEmpty) throw Exception('URL Wave non reçue'); // Mode dev/mock : simuler le paiement sans ouvrir le navigateur final isMock = url.contains('mock') || url.contains('localhost') || !AppConfig.isProd; if (isMock) { await Future.delayed(const Duration(milliseconds: 800)); if (!mounted) return; Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Row(children: [ Icon(Icons.science_rounded, color: AppColors.onPrimary, size: 16), const SizedBox(width: 8), const Expanded(child: Text('Paiement simulé avec succès (mode dev)')), ]), backgroundColor: AppColors.success, ), ); context.read().add(const LoadContributions()); return; } // Mode prod : ouvrir Wave final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } else { await launchUrl(uri); } if (!mounted) return; Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(result.message), backgroundColor: AppColors.success, ), ); context.read().add(const LoadContributions()); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Wave: ${e.toString().replaceFirst('Exception: ', '')}'), backgroundColor: AppColors.error, ), ); } finally { if (mounted) setState(() => _waveLoading = false); } } }