En mode dev (URL contient 'mock' ou 'localhost', ou !AppConfig.isProd), le paiement Wave est simulé directement sans ouvrir le navigateur : - Dépôt épargne (depot_epargne_dialog.dart) : simulation 800ms + SnackBar succès - Paiement cotisation (payment_dialog.dart) : même pattern En mode prod : comportement inchangé (ouvre l'app Wave via launchUrl). Pattern identique à wave_payment_page.dart (onboarding souscription) appliqué à tous les flux Wave de l'application.
501 lines
19 KiB
Dart
501 lines
19 KiB
Dart
/// 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<PaymentDialog> createState() => _PaymentDialogState();
|
||
}
|
||
|
||
class _PaymentDialogState extends State<PaymentDialog> {
|
||
final _formKey = GlobalKey<FormState>();
|
||
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<PaymentMethod>(
|
||
value: _selectedMethode,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Méthode de paiement *',
|
||
border: OutlineInputBorder(),
|
||
prefixIcon: Icon(Icons.payment),
|
||
),
|
||
items: PaymentMethod.values.map((methode) {
|
||
return DropdownMenuItem<PaymentMethod>(
|
||
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<void> _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<void> _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<ContributionsBloc>().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<void> _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<IContributionRepository>();
|
||
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<ContributionsBloc>().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<ContributionsBloc>().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);
|
||
}
|
||
}
|
||
}
|
||
|