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:
dahoud
2026-03-15 02:12:17 +00:00
parent bbc409de9d
commit e8ad874015
635 changed files with 58160 additions and 20674 deletions

View File

@@ -3,13 +3,14 @@ library create_contribution_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../../../core/utils/logger.dart';
import 'package:intl/intl.dart';
import '../../bloc/contributions_bloc.dart';
import '../../bloc/contributions_event.dart';
import '../../data/models/contribution_model.dart';
import '../../../members/bloc/membres_bloc.dart';
import '../../../members/bloc/membres_event.dart';
import '../../../members/bloc/membres_state.dart';
import '../../../members/data/models/membre_complete_model.dart';
import '../../../profile/domain/repositories/profile_repository.dart';
class CreateContributionDialog extends StatefulWidget {
@@ -25,15 +26,37 @@ class _CreateContributionDialogState extends State<CreateContributionDialog> {
final _descriptionController = TextEditingController();
ContributionType _selectedType = ContributionType.mensuelle;
dynamic _selectedMembre;
MembreCompletModel? _me;
DateTime _dateEcheance = DateTime.now().add(const Duration(days: 30));
bool _isLoading = false;
bool _isInitLoading = true;
@override
void initState() {
super.initState();
// Charger la liste des membres
context.read<MembresBloc>().add(const LoadMembres());
_loadMe();
}
Future<void> _loadMe() async {
try {
final user = await GetIt.instance<IProfileRepository>().getMe();
if (mounted) {
setState(() {
_me = user;
_isInitLoading = false;
});
}
} catch (e, st) {
AppLogger.error('CreateContributionDialog: chargement profil échoué', error: e, stackTrace: st);
if (mounted) {
setState(() {
_isInitLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible de charger le profil. Réessayez.')),
);
}
}
}
@override
@@ -55,38 +78,21 @@ class _CreateContributionDialogState extends State<CreateContributionDialog> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Sélection du membre
BlocBuilder<MembresBloc, MembresState>(
builder: (context, state) {
if (state is MembresLoaded) {
return DropdownButtonFormField<dynamic>(
value: _selectedMembre,
decoration: const InputDecoration(
labelText: 'Membre',
border: OutlineInputBorder(),
),
items: state.membres.map((membre) {
return DropdownMenuItem(
value: membre,
child: Text('${membre.nom} ${membre.prenom}'),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedMembre = value;
});
},
validator: (value) {
if (value == null) {
return 'Veuillez sélectionner un membre';
}
return null;
},
);
}
return const CircularProgressIndicator();
},
),
// Utilisateur connecté
if (_isInitLoading)
const CircularProgressIndicator()
else if (_me != null)
TextFormField(
initialValue: '${_me!.prenom} ${_me!.nom}',
decoration: const InputDecoration(
labelText: 'Membre',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
enabled: false, // Lecture seule
)
else
const Text('Impossible de récupérer votre profil', style: TextStyle(color: Colors.red)),
const SizedBox(height: 16),
// Type de contribution
@@ -210,15 +216,15 @@ class _CreateContributionDialogState extends State<CreateContributionDialog> {
}
}
void _createContribution() {
Future<void> _createContribution() async {
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedMembre == null) {
if (_me == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez sélectionner un membre'),
content: Text('Profil non chargé'),
backgroundColor: Colors.red,
),
);
@@ -229,10 +235,31 @@ class _CreateContributionDialogState extends State<CreateContributionDialog> {
_isLoading = true;
});
final membre = _me!;
String? organisationId = membre.organisationId?.trim().isNotEmpty == true
? membre.organisationId
: null;
String? organisationNom = membre.organisationNom;
if (organisationId == null || organisationId.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Aucune organisation disponible. Le membre et l\'utilisateur connecté doivent être rattachés à une organisation.'),
backgroundColor: Colors.red,
),
);
setState(() => _isLoading = false);
return;
}
final contribution = ContributionModel(
membreId: _selectedMembre!.id!,
membreNom: _selectedMembre!.nom,
membrePrenom: _selectedMembre!.prenom,
membreId: membre.id!,
membreNom: membre.nom,
membrePrenom: membre.prenom,
organisationId: organisationId,
organisationNom: organisationNom,
type: _selectedType,
annee: DateTime.now().year,
montant: double.parse(_montantController.text),

View File

@@ -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);
// LUI 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);
}
}
}