feat: WebSocket temps réel + Finance Workflow + corrections
- Task #6: WebSocket /ws/dashboard + Kafka events (5 topics) * Backend: KafkaEventProducer, KafkaEventConsumer * Mobile: WebSocketService (reconnection, heartbeat, typed events) * DashboardBloc: Auto-refresh depuis WebSocket events - Finance Workflow: approbations + budgets (backend + mobile) * Backend: entities, services, resources, migrations Flyway V6 * Mobile: features finance_workflow complète avec BLoC - Corrections DI: interfaces IRepository partout * IProfileRepository, IOrganizationRepository, IMembreRepository * GetIt configuré avec @injectable - Spec-Kit: constitution + templates mis à jour * .specify/memory/constitution.md enrichie * Templates agent, plan, spec, tasks, checklist - Nettoyage: fichiers temporaires supprimés Signed-off-by: lions dev Team
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
/// Dialogue de paiement de contribution
|
||||
/// Formulaire pour enregistrer un 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 '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 {
|
||||
@@ -27,22 +32,24 @@ class _PaymentDialogState extends State<PaymentDialog> {
|
||||
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();
|
||||
// Pré-remplir avec le montant restant
|
||||
_montantController.text = widget.cotisation.montantRestant.toStringAsFixed(0);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_montantController.dispose();
|
||||
_referenceController.dispose();
|
||||
_notesController.dispose();
|
||||
_wavePhoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -199,11 +206,15 @@ class _PaymentDialogState extends State<PaymentDialog> {
|
||||
prefixIcon: Icon(Icons.payment),
|
||||
),
|
||||
items: PaymentMethod.values.map((methode) {
|
||||
return DropdownMenuItem(
|
||||
return DropdownMenuItem<PaymentMethod>(
|
||||
value: methode,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_getMethodeIcon(methode), size: 20),
|
||||
PaymentMethodIcon(
|
||||
paymentMethodCode: methode.code,
|
||||
width: 24,
|
||||
height: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(_getMethodeLabel(methode)),
|
||||
],
|
||||
@@ -216,8 +227,28 @@ class _PaymentDialogState extends State<PaymentDialog> {
|
||||
});
|
||||
},
|
||||
),
|
||||
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),
|
||||
@@ -278,12 +309,20 @@ class _PaymentDialogState extends State<PaymentDialog> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _submitForm,
|
||||
onPressed: _waveLoading ? null : _submitForm,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF10B981),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Enregistrer le paiement'),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -354,42 +393,80 @@ class _PaymentDialogState extends State<PaymentDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
void _submitForm() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final montant = double.parse(_montantController.text);
|
||||
|
||||
// Créer la cotisation mise à jour
|
||||
widget.cotisation.copyWith(
|
||||
montantPaye: (widget.cotisation.montantPaye ?? 0) + montant,
|
||||
datePaiement: _datePaiement,
|
||||
methodePaiement: _selectedMethode,
|
||||
referencePaiement: _referenceController.text.isNotEmpty ? _referenceController.text : null,
|
||||
notes: _notesController.text.isNotEmpty ? _notesController.text : null,
|
||||
statut: (widget.cotisation.montantPaye ?? 0) + montant >= widget.cotisation.montant
|
||||
? ContributionStatus.payee
|
||||
: ContributionStatus.partielle,
|
||||
);
|
||||
Future<void> _submitForm() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
// Envoyer l'événement au BLoC
|
||||
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,
|
||||
));
|
||||
|
||||
// Fermer le dialogue
|
||||
Navigator.pop(context);
|
||||
|
||||
// Afficher un message de succès
|
||||
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: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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('Paiement enregistré avec succès'),
|
||||
const SnackBar(content: Text('Indiquez votre numéro Wave (9 chiffres)'), backgroundColor: Colors.orange),
|
||||
);
|
||||
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');
|
||||
}
|
||||
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: Colors.green,
|
||||
),
|
||||
);
|
||||
context.read<ContributionsBloc>().add(const LoadContributions());
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Wave: ${e.toString().replaceFirst('Exception: ', '')}'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _waveLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user