Initial commit: unionflow-mobile-apps

Application Flutter complète (sans build artifacts).

Signed-off-by: lions dev Team
This commit is contained in:
dahoud
2026-03-15 16:30:08 +00:00
commit d094d6db9c
1790 changed files with 507435 additions and 0 deletions

View File

@@ -0,0 +1,522 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatform, kIsWeb;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/widgets/core_card.dart';
import '../../../../shared/widgets/info_badge.dart';
/// Page À propos - UnionFlow Mobile
///
/// Page d'informations sur l'application, version, équipe de développement,
/// liens utiles et fonctionnalités de support.
class AboutPage extends StatefulWidget {
const AboutPage({super.key});
@override
State<AboutPage> createState() => _AboutPageState();
}
class _AboutPageState extends State<AboutPage> {
PackageInfo? _packageInfo;
@override
void initState() {
super.initState();
_loadPackageInfo();
}
Future<void> _loadPackageInfo() async {
final info = await PackageInfo.fromPlatform();
setState(() {
_packageInfo = info;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: UFAppBar(
title: 'À PROPOS',
actions: [
IconButton(
icon: const Icon(Icons.share_outlined, size: 20),
onPressed: _shareApp,
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header harmonisé
_buildHeader(),
const SizedBox(height: 16),
// Informations de l'application
_buildAppInfoSection(),
const SizedBox(height: 16),
// Équipe de développement
_buildTeamSection(),
const SizedBox(height: 16),
// Fonctionnalités
_buildFeaturesSection(),
const SizedBox(height: 16),
// Liens utiles
_buildLinksSection(),
const SizedBox(height: 16),
// Support et contact
_buildSupportSection(),
const SizedBox(height: 80),
],
),
),
);
}
/// Header épuré
Widget _buildHeader() {
return Center(
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.primaryGreen.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.account_balance,
color: AppColors.primaryGreen,
size: 48,
),
),
const SizedBox(height: 16),
Text(
'UNIONFLOW MOBILE',
style: AppTypography.headerSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.2),
),
Text(
'Gestion d\'associations et syndicats',
style: AppTypography.subtitleSmall,
),
const SizedBox(height: 8),
if (_packageInfo != null)
InfoBadge(
text: 'VERSION ${_packageInfo!.version}',
backgroundColor: AppColors.lightSurface,
textColor: AppColors.textSecondaryLight,
),
],
),
);
}
/// Section informations de l'application
Widget _buildAppInfoSection() {
return CoreCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'INFORMATIONS',
style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1),
),
const SizedBox(height: 12),
_buildInfoRow('Construction', _packageInfo?.buildNumber ?? '...'),
_buildInfoRow('Package', _packageInfo?.packageName ?? '...'),
_buildInfoRow('Plateforme', 'Android / iOS'),
_buildInfoRow('Framework', 'Flutter 3.x'),
],
),
);
}
/// Ligne d'information
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.textSecondaryLight),
),
Flexible(
child: Text(
value,
style: AppTypography.actionText.copyWith(fontSize: 12),
textAlign: TextAlign.end,
),
),
],
),
);
}
/// Section équipe de développement
Widget _buildTeamSection() {
return CoreCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'ÉQUIPE',
style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1),
),
const SizedBox(height: 12),
_buildTeamMember(
'UnionFlow Team',
'Architecture & Dev',
Icons.code,
AppColors.primaryGreen,
),
_buildTeamMember(
'Design System',
'UI / UX Focus',
Icons.design_services,
AppColors.info,
),
],
),
);
}
/// Membre de l'équipe
Widget _buildTeamMember(String name, String role, IconData icon, Color color) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 16),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: AppTypography.actionText.copyWith(fontSize: 12)),
Text(role, style: AppTypography.subtitleSmall.copyWith(fontSize: 10)),
],
),
),
],
),
);
}
/// Section fonctionnalités
Widget _buildFeaturesSection() {
return CoreCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'FONCTIONNALITÉS',
style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1),
),
const SizedBox(height: 12),
_buildFeatureItem('Membres', 'Administration complète', Icons.people, AppColors.primaryGreen),
_buildFeatureItem('Organisations', 'Syndicats & Fédérations', Icons.business, AppColors.info),
_buildFeatureItem('Événements', 'Planification & Suivi', Icons.event, AppColors.success),
_buildFeatureItem('Sécurité', 'Auth Keycloak OIDC', Icons.security, AppColors.warning),
],
),
);
}
/// Élément de fonctionnalité
Widget _buildFeatureItem(String title, String description, IconData icon, Color color) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: AppTypography.actionText.copyWith(fontSize: 12)),
Text(description, style: AppTypography.subtitleSmall.copyWith(fontSize: 10)),
],
),
),
],
),
);
}
/// Section liens utiles
Widget _buildLinksSection() {
return CoreCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'LIENS UTILES',
style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1),
),
const SizedBox(height: 12),
_buildLinkItem('Site Web', 'https://unionflow.com', Icons.web, () => _launchUrl('https://unionflow.com')),
_buildLinkItem('Documentation', 'Guide d\'utilisation', Icons.book, () => _launchUrl('https://docs.unionflow.com')),
_buildLinkItem('Confidentialité', 'Protection des données', Icons.privacy_tip, () => _launchUrl('https://unionflow.com/privacy')),
_buildLinkItem('Évaluer l\'app', 'Noter sur le store', Icons.star, _showRatingDialog),
],
),
);
}
/// Élément de lien
Widget _buildLinkItem(String title, String subtitle, IconData icon, VoidCallback onTap) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(icon, color: AppColors.primaryGreen, size: 16),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: AppTypography.actionText.copyWith(fontSize: 12)),
Text(subtitle, style: AppTypography.subtitleSmall.copyWith(fontSize: 10)),
],
),
),
const Icon(Icons.chevron_right, color: AppColors.textSecondaryLight, size: 14),
],
),
),
);
}
/// Section support
Widget _buildSupportSection() {
return CoreCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SUPPORT',
style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold, letterSpacing: 1.1),
),
const SizedBox(height: 12),
_buildSupportItem('Email', 'support@unionflow.com', Icons.email, () => _launchUrl('mailto:support@unionflow.com')),
_buildSupportItem('Bug', 'Signaler un problème', Icons.bug_report, () => _showBugReportDialog()),
const SizedBox(height: 24),
const Center(
child: Column(
children: [
Text('© 2024 UNIONFLOW', style: AppTypography.badgeText),
Text('Fait avec ❤️ pour les syndicats', style: AppTypography.subtitleSmall),
],
),
),
],
),
);
}
/// Élément de support
Widget _buildSupportItem(String title, String subtitle, IconData icon, VoidCallback onTap) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(icon, color: AppColors.error, size: 16),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: AppTypography.actionText.copyWith(fontSize: 12)),
Text(subtitle, style: AppTypography.subtitleSmall.copyWith(fontSize: 10)),
],
),
),
const Icon(Icons.chevron_right, color: AppColors.textSecondaryLight, size: 14),
],
),
),
);
}
/// Lancer une URL
Future<void> _launchUrl(String url) async {
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
_showErrorSnackBar('Impossible d\'ouvrir le lien');
}
} catch (e) {
_showErrorSnackBar('Erreur lors de l\'ouverture du lien');
}
}
/// Afficher le dialogue de rapport de bug
void _showBugReportDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Signaler un bug'),
content: const Text(
'Pour signaler un bug, veuillez envoyer un email à support@unionflow.com '
'en décrivant le problème rencontré et les étapes pour le reproduire.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_launchUrl('mailto:support@unionflow.com?subject=Rapport de bug - UnionFlow Mobile');
},
style: ElevatedButton.styleFrom(
backgroundColor: ColorTokens.primary,
foregroundColor: ColorTokens.onPrimary,
),
child: const Text('Envoyer un email'),
),
],
),
);
}
/// Afficher le dialogue de demande de fonctionnalité
void _showFeatureRequestDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Suggérer une amélioration'),
content: const Text(
'Nous sommes toujours à l\'écoute de vos suggestions ! '
'Envoyez-nous vos idées d\'amélioration par email.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_launchUrl('mailto:support@unionflow.com?subject=Suggestion d\'amélioration - UnionFlow Mobile');
},
style: ElevatedButton.styleFrom(
backgroundColor: ColorTokens.primary,
foregroundColor: ColorTokens.onPrimary,
),
child: const Text('Envoyer une suggestion'),
),
],
),
);
}
/// Afficher le dialogue d'évaluation
void _showRatingDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Évaluer l\'application'),
content: const Text(
'Votre avis nous aide à améliorer UnionFlow ! '
'Prenez quelques secondes pour évaluer l\'application sur votre store.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Plus tard'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_launchStoreForRating();
},
style: ElevatedButton.styleFrom(
backgroundColor: ColorTokens.primary,
foregroundColor: ColorTokens.onPrimary,
),
child: const Text('Évaluer maintenant'),
),
],
),
);
}
/// Partager les infos de l'app (titre, description, lien)
Future<void> _shareApp() async {
final version = _packageInfo != null
? '${_packageInfo!.version}+${_packageInfo!.buildNumber}'
: '';
await Share.share(
'Découvrez UnionFlow - Mouvement d\'entraide et de solidarité.\n'
'Version $version\n'
'https://unionflow.com',
subject: 'UnionFlow - Application mobile',
);
}
/// Ouvrir le store (Play Store / App Store) pour noter l'app
Future<void> _launchStoreForRating() async {
try {
final packageName = _packageInfo?.packageName ?? 'dev.lions.unionflow';
String storeUrl;
if (kIsWeb) {
storeUrl = 'https://unionflow.com';
} else if (defaultTargetPlatform == TargetPlatform.android) {
storeUrl = 'https://play.google.com/store/apps/details?id=$packageName';
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
// Remplacer par l'ID App Store réel une fois l'app publiée
storeUrl = 'https://apps.apple.com/app/id0000000000';
} else {
storeUrl = 'https://unionflow.com';
}
final uri = Uri.parse(storeUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
_showErrorSnackBar('Impossible d\'ouvrir le store');
}
} catch (e) {
_showErrorSnackBar('Erreur lors de l\'ouverture du store');
}
}
/// Afficher un message d'erreur
void _showErrorSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: const Color(0xFFE74C3C),
behavior: SnackBarBehavior.floating,
),
);
}
}

View File

@@ -0,0 +1,143 @@
/// BLoC pour la gestion des adhésions (demandes d'adhésion)
library adhesions_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:injectable/injectable.dart';
import '../../../core/utils/logger.dart';
import '../data/models/adhesion_model.dart';
import '../data/repositories/adhesion_repository.dart';
part 'adhesions_event.dart';
part 'adhesions_state.dart';
@injectable
class AdhesionsBloc extends Bloc<AdhesionsEvent, AdhesionsState> {
final AdhesionRepository _repository;
AdhesionsBloc(this._repository) : super(const AdhesionsState()) {
on<LoadAdhesions>(_onLoadAdhesions);
on<LoadAdhesionsByMembre>(_onLoadAdhesionsByMembre);
on<LoadAdhesionsEnAttente>(_onLoadAdhesionsEnAttente);
on<LoadAdhesionsByStatut>(_onLoadAdhesionsByStatut);
on<LoadAdhesionById>(_onLoadAdhesionById);
on<CreateAdhesion>(_onCreateAdhesion);
on<ApprouverAdhesion>(_onApprouverAdhesion);
on<RejeterAdhesion>(_onRejeterAdhesion);
on<EnregistrerPaiementAdhesion>(_onEnregistrerPaiementAdhesion);
on<LoadAdhesionsStats>(_onLoadAdhesionsStats);
}
Future<void> _onLoadAdhesions(LoadAdhesions event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Chargement...'));
try {
final list = await _repository.getAll(page: event.page, size: event.size);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list));
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onLoadAdhesionsByMembre(LoadAdhesionsByMembre event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Chargement...'));
try {
final list = await _repository.getByMembre(event.membreId, page: event.page, size: event.size);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list));
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onLoadAdhesionsEnAttente(LoadAdhesionsEnAttente event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Chargement...'));
try {
final list = await _repository.getEnAttente(page: event.page, size: event.size);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list));
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onLoadAdhesionsByStatut(LoadAdhesionsByStatut event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Chargement...'));
try {
final list = await _repository.getByStatut(event.statut, page: event.page, size: event.size);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesions: list));
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onLoadAdhesionById(LoadAdhesionById event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading));
try {
final adhesion = await _repository.getById(event.id);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: adhesion));
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onCreateAdhesion(CreateAdhesion event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading, message: 'Création...'));
try {
await _repository.create(event.adhesion);
add(const LoadAdhesions());
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onApprouverAdhesion(ApprouverAdhesion event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading));
try {
final updated = await _repository.approuver(event.id, approuvePar: event.approuvePar);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: updated));
add(const LoadAdhesions());
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onRejeterAdhesion(RejeterAdhesion event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading));
try {
final updated = await _repository.rejeter(event.id, event.motifRejet);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: updated));
add(const LoadAdhesions());
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onEnregistrerPaiementAdhesion(EnregistrerPaiementAdhesion event, Emitter<AdhesionsState> emit) async {
emit(state.copyWith(status: AdhesionsStatus.loading));
try {
final updated = await _repository.enregistrerPaiement(
event.id,
montantPaye: event.montantPaye,
methodePaiement: event.methodePaiement,
referencePaiement: event.referencePaiement,
);
emit(state.copyWith(status: AdhesionsStatus.loaded, adhesionDetail: updated));
add(const LoadAdhesions());
} catch (e) {
emit(state.copyWith(status: AdhesionsStatus.error, message: e.toString(), error: e));
}
}
Future<void> _onLoadAdhesionsStats(LoadAdhesionsStats event, Emitter<AdhesionsState> emit) async {
try {
final stats = await _repository.getStats();
emit(state.copyWith(stats: stats));
} catch (e, st) {
AppLogger.error('AdhesionsBloc: chargement stats échoué', error: e, stackTrace: st);
emit(state.copyWith(
status: AdhesionsStatus.error,
message: e.toString(),
error: e,
));
}
}
}

View File

@@ -0,0 +1,90 @@
part of 'adhesions_bloc.dart';
abstract class AdhesionsEvent extends Equatable {
const AdhesionsEvent();
@override
List<Object?> get props => [];
}
class LoadAdhesions extends AdhesionsEvent {
final int page;
final int size;
const LoadAdhesions({this.page = 0, this.size = 20});
@override
List<Object?> get props => [page, size];
}
class LoadAdhesionsByMembre extends AdhesionsEvent {
final String membreId;
final int page;
final int size;
const LoadAdhesionsByMembre(this.membreId, {this.page = 0, this.size = 20});
@override
List<Object?> get props => [membreId, page, size];
}
class LoadAdhesionsEnAttente extends AdhesionsEvent {
final int page;
final int size;
const LoadAdhesionsEnAttente({this.page = 0, this.size = 20});
@override
List<Object?> get props => [page, size];
}
class LoadAdhesionsByStatut extends AdhesionsEvent {
final String statut;
final int page;
final int size;
const LoadAdhesionsByStatut(this.statut, {this.page = 0, this.size = 20});
@override
List<Object?> get props => [statut, page, size];
}
class LoadAdhesionById extends AdhesionsEvent {
final String id;
const LoadAdhesionById(this.id);
@override
List<Object?> get props => [id];
}
class CreateAdhesion extends AdhesionsEvent {
final AdhesionModel adhesion;
const CreateAdhesion(this.adhesion);
@override
List<Object?> get props => [adhesion];
}
class ApprouverAdhesion extends AdhesionsEvent {
final String id;
final String? approuvePar;
const ApprouverAdhesion(this.id, {this.approuvePar});
@override
List<Object?> get props => [id, approuvePar];
}
class RejeterAdhesion extends AdhesionsEvent {
final String id;
final String motifRejet;
const RejeterAdhesion(this.id, this.motifRejet);
@override
List<Object?> get props => [id, motifRejet];
}
class EnregistrerPaiementAdhesion extends AdhesionsEvent {
final String id;
final double montantPaye;
final String? methodePaiement;
final String? referencePaiement;
const EnregistrerPaiementAdhesion(
this.id, {
required this.montantPaye,
this.methodePaiement,
this.referencePaiement,
});
@override
List<Object?> get props => [id, montantPaye, methodePaiement, referencePaiement];
}
class LoadAdhesionsStats extends AdhesionsEvent {
const LoadAdhesionsStats();
}

View File

@@ -0,0 +1,42 @@
part of 'adhesions_bloc.dart';
enum AdhesionsStatus { initial, loading, loaded, error }
class AdhesionsState extends Equatable {
final AdhesionsStatus status;
final List<AdhesionModel> adhesions;
final AdhesionModel? adhesionDetail;
final Map<String, dynamic>? stats;
final String? message;
final Object? error;
const AdhesionsState({
this.status = AdhesionsStatus.initial,
this.adhesions = const [],
this.adhesionDetail,
this.stats,
this.message,
this.error,
});
AdhesionsState copyWith({
AdhesionsStatus? status,
List<AdhesionModel>? adhesions,
AdhesionModel? adhesionDetail,
Map<String, dynamic>? stats,
String? message,
Object? error,
}) {
return AdhesionsState(
status: status ?? this.status,
adhesions: adhesions ?? this.adhesions,
adhesionDetail: adhesionDetail ?? this.adhesionDetail,
stats: stats ?? this.stats,
message: message ?? this.message,
error: error ?? this.error,
);
}
@override
List<Object?> get props => [status, adhesions, adhesionDetail, stats, message, error];
}

View File

@@ -0,0 +1,139 @@
/// Modèle de données pour les adhésions (demandes d'adhésion à une organisation)
/// Correspond à l'API AdhesionResource / AdhesionDTO
library adhesion_model;
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'adhesion_model.g.dart';
/// Statut d'une demande d'adhésion
enum StatutAdhesion {
@JsonValue('EN_ATTENTE')
enAttente,
@JsonValue('APPROUVEE')
approuvee,
@JsonValue('REJETEE')
rejetee,
@JsonValue('ANNULEE')
annulee,
@JsonValue('EN_PAIEMENT')
enPaiement,
@JsonValue('PAYEE')
payee,
}
/// Modèle d'une adhésion
@JsonSerializable(explicitToJson: true)
class AdhesionModel extends Equatable {
final String? id;
final String? numeroReference;
final String? membreId;
final String? numeroMembre;
final String? nomMembre;
final String? emailMembre;
final String? organisationId;
final String? nomOrganisation;
final DateTime? dateDemande;
final double? fraisAdhesion;
final double? montantPaye;
final String? codeDevise;
final String? statut;
final DateTime? dateApprobation;
final DateTime? datePaiement;
final String? methodePaiement;
final String? referencePaiement;
final String? motifRejet;
final String? observations;
final String? approuvePar;
final DateTime? dateCreation;
final DateTime? dateModification;
const AdhesionModel({
this.id,
this.numeroReference,
this.membreId,
this.numeroMembre,
this.nomMembre,
this.emailMembre,
this.organisationId,
this.nomOrganisation,
this.dateDemande,
this.fraisAdhesion,
this.montantPaye,
this.codeDevise,
this.statut,
this.dateApprobation,
this.datePaiement,
this.methodePaiement,
this.referencePaiement,
this.motifRejet,
this.observations,
this.approuvePar,
this.dateCreation,
this.dateModification,
});
factory AdhesionModel.fromJson(Map<String, dynamic> json) =>
_$AdhesionModelFromJson(json);
Map<String, dynamic> toJson() => _$AdhesionModelToJson(this);
/// Montant restant à payer
double get montantRestant {
if (fraisAdhesion == null) return 0;
final paye = montantPaye ?? 0;
final restant = fraisAdhesion! - paye;
return restant > 0 ? restant : 0;
}
/// Pourcentage payé
int get pourcentagePaiement {
if (fraisAdhesion == null || fraisAdhesion! == 0) return 0;
if (montantPaye == null) return 0;
return ((montantPaye! / fraisAdhesion!) * 100).round();
}
bool get estPayeeIntegralement =>
fraisAdhesion != null &&
montantPaye != null &&
montantPaye! >= fraisAdhesion!;
bool get estEnAttentePaiement =>
statut == 'APPROUVEE' && !estPayeeIntegralement;
String get statutLibelle {
switch (statut) {
case 'EN_ATTENTE':
return 'En attente';
case 'APPROUVEE':
return 'Approuvée';
case 'REJETEE':
return 'Rejetée';
case 'ANNULEE':
return 'Annulée';
case 'EN_PAIEMENT':
return 'En paiement';
case 'PAYEE':
return 'Payée';
default:
return statut ?? 'Non défini';
}
}
String get nomMembreComplet =>
[nomMembre, numeroMembre].where((e) => e != null && e.isNotEmpty).join(' ').trim().isEmpty
? (emailMembre ?? 'Membre')
: [nomMembre, numeroMembre].where((e) => e != null && e.isNotEmpty).join(' ').trim();
@override
List<Object?> get props => [
id,
numeroReference,
membreId,
organisationId,
statut,
dateDemande,
fraisAdhesion,
montantPaye,
];
}

View File

@@ -0,0 +1,69 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'adhesion_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AdhesionModel _$AdhesionModelFromJson(Map<String, dynamic> json) =>
AdhesionModel(
id: json['id'] as String?,
numeroReference: json['numeroReference'] as String?,
membreId: json['membreId'] as String?,
numeroMembre: json['numeroMembre'] as String?,
nomMembre: json['nomMembre'] as String?,
emailMembre: json['emailMembre'] as String?,
organisationId: json['organisationId'] as String?,
nomOrganisation: json['nomOrganisation'] as String?,
dateDemande: json['dateDemande'] == null
? null
: DateTime.parse(json['dateDemande'] as String),
fraisAdhesion: (json['fraisAdhesion'] as num?)?.toDouble(),
montantPaye: (json['montantPaye'] as num?)?.toDouble(),
codeDevise: json['codeDevise'] as String?,
statut: json['statut'] as String?,
dateApprobation: json['dateApprobation'] == null
? null
: DateTime.parse(json['dateApprobation'] as String),
datePaiement: json['datePaiement'] == null
? null
: DateTime.parse(json['datePaiement'] as String),
methodePaiement: json['methodePaiement'] as String?,
referencePaiement: json['referencePaiement'] as String?,
motifRejet: json['motifRejet'] as String?,
observations: json['observations'] as String?,
approuvePar: json['approuvePar'] as String?,
dateCreation: json['dateCreation'] == null
? null
: DateTime.parse(json['dateCreation'] as String),
dateModification: json['dateModification'] == null
? null
: DateTime.parse(json['dateModification'] as String),
);
Map<String, dynamic> _$AdhesionModelToJson(AdhesionModel instance) =>
<String, dynamic>{
'id': instance.id,
'numeroReference': instance.numeroReference,
'membreId': instance.membreId,
'numeroMembre': instance.numeroMembre,
'nomMembre': instance.nomMembre,
'emailMembre': instance.emailMembre,
'organisationId': instance.organisationId,
'nomOrganisation': instance.nomOrganisation,
'dateDemande': instance.dateDemande?.toIso8601String(),
'fraisAdhesion': instance.fraisAdhesion,
'montantPaye': instance.montantPaye,
'codeDevise': instance.codeDevise,
'statut': instance.statut,
'dateApprobation': instance.dateApprobation?.toIso8601String(),
'datePaiement': instance.datePaiement?.toIso8601String(),
'methodePaiement': instance.methodePaiement,
'referencePaiement': instance.referencePaiement,
'motifRejet': instance.motifRejet,
'observations': instance.observations,
'approuvePar': instance.approuvePar,
'dateCreation': instance.dateCreation?.toIso8601String(),
'dateModification': instance.dateModification?.toIso8601String(),
};

View File

@@ -0,0 +1,182 @@
/// Repository pour la gestion des adhésions (demandes d'adhésion)
/// Interface avec l'API backend AdhesionResource
library adhesion_repository;
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import '../models/adhesion_model.dart';
abstract class AdhesionRepository {
Future<List<AdhesionModel>> getAll({int page = 0, int size = 20});
Future<AdhesionModel?> getById(String id);
Future<AdhesionModel> create(AdhesionModel adhesion);
Future<AdhesionModel> approuver(String id, {String? approuvePar});
Future<AdhesionModel> rejeter(String id, String motifRejet);
Future<AdhesionModel> enregistrerPaiement(
String id, {
required double montantPaye,
String? methodePaiement,
String? referencePaiement,
});
Future<List<AdhesionModel>> getByMembre(String membreId, {int page = 0, int size = 20});
Future<List<AdhesionModel>> getByOrganisation(String organisationId, {int page = 0, int size = 20});
Future<List<AdhesionModel>> getByStatut(String statut, {int page = 0, int size = 20});
Future<List<AdhesionModel>> getEnAttente({int page = 0, int size = 20});
Future<Map<String, dynamic>?> getStats();
}
@LazySingleton(as: AdhesionRepository)
class AdhesionRepositoryImpl implements AdhesionRepository {
final ApiClient _apiClient;
static const String _base = '/api/adhesions';
AdhesionRepositoryImpl(this._apiClient);
/// Parse une réponse API : liste directe ou objet paginé avec clé "content".
List<AdhesionModel> _parseListResponse(dynamic data) {
if (data is List) {
return data
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
}
if (data is Map && data.containsKey('content')) {
final content = data['content'] as List<dynamic>? ?? [];
return content
.map((e) => AdhesionModel.fromJson(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<List<AdhesionModel>> getAll({int page = 0, int size = 20}) async {
final response = await _apiClient.get(
_base,
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<AdhesionModel?> getById(String id) async {
final response = await _apiClient.get('$_base/$id');
if (response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
if (response.statusCode == 404) return null;
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<AdhesionModel> create(AdhesionModel adhesion) async {
final body = adhesion.toJson();
// Backend attend membreId, organisationId, fraisAdhesion, codeDevise (optionnel)
final response = await _apiClient.post(_base, data: body);
if (response.statusCode == 201 || response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur création: ${response.statusCode}');
}
@override
Future<AdhesionModel> approuver(String id, {String? approuvePar}) async {
final response = await _apiClient.post(
'$_base/$id/approuver',
queryParameters: approuvePar != null ? {'approuvePar': approuvePar} : null,
);
if (response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur approbation: ${response.statusCode}');
}
@override
Future<AdhesionModel> rejeter(String id, String motifRejet) async {
final response = await _apiClient.post(
'$_base/$id/rejeter',
queryParameters: {'motifRejet': motifRejet},
);
if (response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur rejet: ${response.statusCode}');
}
@override
Future<AdhesionModel> enregistrerPaiement(
String id, {
required double montantPaye,
String? methodePaiement,
String? referencePaiement,
}) async {
final q = <String, dynamic>{'montantPaye': montantPaye};
if (methodePaiement != null) q['methodePaiement'] = methodePaiement;
if (referencePaiement != null) q['referencePaiement'] = referencePaiement;
final response = await _apiClient.post('$_base/$id/paiement', queryParameters: q);
if (response.statusCode == 200) {
return AdhesionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur paiement: ${response.statusCode}');
}
@override
Future<List<AdhesionModel>> getByMembre(String membreId, {int page = 0, int size = 20}) async {
final response = await _apiClient.get(
'$_base/membre/$membreId',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<List<AdhesionModel>> getByOrganisation(String organisationId, {int page = 0, int size = 20}) async {
final response = await _apiClient.get(
'$_base/organisation/$organisationId',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<List<AdhesionModel>> getByStatut(String statut, {int page = 0, int size = 20}) async {
final response = await _apiClient.get(
'$_base/statut/$statut',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<List<AdhesionModel>> getEnAttente({int page = 0, int size = 20}) async {
final response = await _apiClient.get(
'$_base/en-attente',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode == 200) {
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<Map<String, dynamic>?> getStats() async {
final response = await _apiClient.get('$_base/stats');
if (response.statusCode == 200) {
return response.data as Map<String, dynamic>;
}
return null;
}
}

View File

@@ -0,0 +1,324 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/widgets/core_card.dart';
import '../../../../shared/widgets/info_badge.dart';
import '../../../../shared/widgets/mini_avatar.dart';
import '../../bloc/adhesions_bloc.dart';
import '../../data/models/adhesion_model.dart';
import '../widgets/paiement_adhesion_dialog.dart';
import '../widgets/rejet_adhesion_dialog.dart';
import '../../../authentication/presentation/bloc/auth_bloc.dart';
class AdhesionDetailPage extends StatefulWidget {
final String adhesionId;
const AdhesionDetailPage({super.key, required this.adhesionId});
@override
State<AdhesionDetailPage> createState() => _AdhesionDetailPageState();
}
class _AdhesionDetailPageState extends State<AdhesionDetailPage> {
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA');
@override
void initState() {
super.initState();
context.read<AdhesionsBloc>().add(LoadAdhesionById(widget.adhesionId));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: const UFAppBar(
title: 'DÉTAIL ADHÉSION',
backgroundColor: AppColors.surface,
foregroundColor: AppColors.textPrimaryLight,
),
body: BlocConsumer<AdhesionsBloc, AdhesionsState>(
listenWhen: (prev, curr) => prev.status != curr.status,
listener: (context, state) {
if (state.status == AdhesionsStatus.error && state.message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message!), backgroundColor: Colors.red),
);
}
},
buildWhen: (prev, curr) =>
prev.adhesionDetail != curr.adhesionDetail || prev.status != curr.status,
builder: (context, state) {
if (state.status == AdhesionsStatus.loading && state.adhesionDetail == null) {
return const Center(child: CircularProgressIndicator());
}
final a = state.adhesionDetail;
if (a == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(
'Adhésion introuvable',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoCard(
title: 'Référence',
value: a.numeroReference ?? a.id ?? '',
),
_InfoCard(
title: 'Statut',
value: a.statutLibelle,
trail: _buildStatutBadge(a.statut),
),
_InfoCard(
title: 'Organisation',
value: a.nomOrganisation ?? a.organisationId ?? '',
),
_InfoCard(
title: 'Membre',
value: a.nomMembreComplet,
),
if (a.emailMembre != null && a.emailMembre!.isNotEmpty)
_InfoCard(title: 'Email', value: a.emailMembre!),
if (a.dateDemande != null)
_InfoCard(
title: 'Date demande',
value: DateFormat('dd/MM/yyyy').format(a.dateDemande!),
),
_InfoCard(
title: 'Frais d\'adhésion',
value: a.fraisAdhesion != null
? _currencyFormat.format(a.fraisAdhesion)
: '',
),
if (a.montantPaye != null && a.montantPaye! > 0)
_InfoCard(
title: 'Montant payé',
value: _currencyFormat.format(a.montantPaye!),
),
if (a.montantRestant > 0)
_InfoCard(
title: 'Montant restant',
value: _currencyFormat.format(a.montantRestant),
),
if (a.motifRejet != null && a.motifRejet!.isNotEmpty)
_InfoCard(title: 'Motif rejet', value: a.motifRejet!),
_ActionsSection(adhesion: a, currencyFormat: _currencyFormat, isGestionnaire: _isGestionnaire()),
],
),
);
},
),
);
}
bool _isGestionnaire() {
final state = context.read<AuthBloc>().state;
if (state is AuthAuthenticated) {
return state.effectiveRole.level >= 50;
}
return false;
}
}
class _InfoCard extends StatelessWidget {
final String title;
final String value;
final Widget? trail;
const _InfoCard({required this.title, required this.value, this.trail});
@override
Widget build(BuildContext context) {
return CoreCard(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.toUpperCase(),
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.bold,
fontSize: 9,
color: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 2),
Text(
value,
style: AppTypography.bodyTextSmall.copyWith(fontSize: 12),
),
],
),
),
if (trail != null) trail!,
],
),
);
}
}
Widget _buildStatutBadge(String? statut) {
Color color;
switch (statut) {
case 'APPROUVEE':
case 'PAYEE':
color = AppColors.success;
break;
case 'REJETEE':
case 'ANNULEE':
color = AppColors.error;
break;
case 'EN_ATTENTE':
color = AppColors.brandGreenLight;
break;
case 'EN_PAIEMENT':
color = Colors.blue;
break;
default:
color = AppColors.textSecondaryLight;
}
return InfoBadge(text: statut ?? 'INCONNU', backgroundColor: color);
}
class _ActionsSection extends StatelessWidget {
final AdhesionModel adhesion;
final NumberFormat currencyFormat;
final bool isGestionnaire;
const _ActionsSection({
required this.adhesion,
required this.currencyFormat,
required this.isGestionnaire,
});
@override
Widget build(BuildContext context) {
if (!isGestionnaire) return const SizedBox.shrink(); // Normal members cannot approve/pay an adhesion on someone else's behalf (or their own) currently in the UI design.
final bloc = context.read<AdhesionsBloc>();
if (adhesion.statut == 'EN_ATTENTE') {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'ACTIONS ADMINISTRATIVES',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
if (adhesion.id == null) return;
bloc.add(ApprouverAdhesion(adhesion.id!));
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.success,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
child: Text('APPROUVER', style: AppTypography.actionText.copyWith(fontSize: 11, color: Colors.white)),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: () {
if (adhesion.id == null) return;
showDialog<void>(
context: context,
builder: (ctx) => BlocProvider.value(
value: bloc,
child: RejetAdhesionDialog(
adhesionId: adhesion.id!,
onRejected: () => Navigator.of(ctx).pop(),
),
),
);
},
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error,
side: const BorderSide(color: AppColors.error),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
child: Text('REJETER', style: AppTypography.actionText.copyWith(fontSize: 11)),
),
),
],
),
],
);
}
if (adhesion.estEnAttentePaiement && adhesion.id != null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'PAIEMENT',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
),
),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () {
showDialog<void>(
context: context,
builder: (ctx) => BlocProvider.value(
value: bloc,
child: PaiementAdhesionDialog(
adhesionId: adhesion.id!,
montantRestant: adhesion.montantRestant,
onPaid: () => Navigator.of(ctx).pop(),
),
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
child: Text('ENREGISTRER UN PAIEMENT', style: AppTypography.actionText.copyWith(fontSize: 11, color: Colors.white)),
),
],
);
}
return const SizedBox.shrink();
}
}

View File

@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/widgets/core_card.dart';
import '../../../../shared/widgets/info_badge.dart';
import '../../../../shared/widgets/mini_avatar.dart';
import '../../bloc/adhesions_bloc.dart';
import '../../data/models/adhesion_model.dart';
import 'adhesion_detail_page.dart';
import '../widgets/create_adhesion_dialog.dart';
import '../../../authentication/presentation/bloc/auth_bloc.dart';
class AdhesionsPage extends StatefulWidget {
const AdhesionsPage({super.key});
@override
State<AdhesionsPage> createState() => _AdhesionsPageState();
}
class _AdhesionsPageState extends State<AdhesionsPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA');
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_loadTab(0);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _loadTab(int index) {
bool isGestionnaire = false;
String? membreId;
final authState = context.read<AuthBloc>().state;
if (authState is AuthAuthenticated) {
isGestionnaire = authState.effectiveRole.level >= 50;
membreId = authState.user.id;
}
if (isGestionnaire) {
switch (index) {
case 0:
context.read<AdhesionsBloc>().add(const LoadAdhesions());
break;
case 1:
context.read<AdhesionsBloc>().add(const LoadAdhesionsEnAttente());
break;
case 2:
context.read<AdhesionsBloc>().add(const LoadAdhesionsByStatut('APPROUVEE'));
break;
case 3:
context.read<AdhesionsBloc>().add(const LoadAdhesionsByStatut('PAYEE'));
break;
}
} else {
// Normal member: always fetch their own records to ensure security
if (membreId != null) {
context.read<AdhesionsBloc>().add(LoadAdhesionsByMembre(membreId));
}
}
}
@override
Widget build(BuildContext context) {
return BlocListener<AdhesionsBloc, AdhesionsState>(
listener: (context, state) {
if (state.status == AdhesionsStatus.error && state.message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message!),
backgroundColor: Colors.red,
action: SnackBarAction(
label: 'Réessayer',
textColor: Colors.white,
onPressed: () => _loadTab(_tabController.index),
),
),
);
}
},
child: Scaffold(
backgroundColor: AppColors.background,
appBar: UFAppBar(
title: 'ADHÉSIONS',
backgroundColor: AppColors.surface,
foregroundColor: AppColors.textPrimaryLight,
actions: [
IconButton(
icon: const Icon(Icons.add, size: 20),
onPressed: () => _showCreateDialog(),
tooltip: 'Nouvelle demande',
),
],
bottom: TabBar(
controller: _tabController,
onTap: _loadTab,
isScrollable: true,
labelColor: AppColors.primaryGreen,
unselectedLabelColor: AppColors.textSecondaryLight,
indicatorColor: AppColors.primaryGreen,
indicatorSize: TabBarIndicatorSize.label,
labelStyle: AppTypography.actionText.copyWith(fontSize: 10, fontWeight: FontWeight.bold),
tabs: const [
Tab(child: Text('TOUTES')),
Tab(child: Text('ATTENTE')),
Tab(child: Text('APPROUVÉES')),
Tab(child: Text('PAYÉES')),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildList(null),
_buildList('EN_ATTENTE'),
_buildList('APPROUVEE'), // tab 2 charge déjà par statut
_buildList('PAYEE'), // tab 3 charge déjà par statut
],
),
),
);
}
Widget _buildList(String? statutFilter) {
return BlocBuilder<AdhesionsBloc, AdhesionsState>(
buildWhen: (prev, curr) =>
prev.status != curr.status || prev.adhesions != curr.adhesions,
builder: (context, state) {
if (state.status == AdhesionsStatus.loading && state.adhesions.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
var list = state.adhesions;
if (statutFilter != null) {
list = list.where((a) => a.statut == statutFilter).toList();
}
if (list.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.assignment_outlined, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'Aucune demande d\'adhésion',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () => _showCreateDialog(),
icon: const Icon(Icons.add),
label: const Text('Créer une demande'),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async => _loadTab(_tabController.index),
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: list.length,
itemBuilder: (context, index) {
final a = list[index];
return _AdhesionCard(
adhesion: a,
currencyFormat: _currencyFormat,
onTap: () => _openDetail(a),
);
},
),
);
},
);
}
void _openDetail(AdhesionModel a) {
if (a.id == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider.value(
value: context.read<AdhesionsBloc>(),
child: AdhesionDetailPage(adhesionId: a.id!),
),
),
).then((_) => _loadTab(_tabController.index));
}
void _showCreateDialog() {
showDialog<void>(
context: context,
builder: (context) => CreateAdhesionDialog(
onCreated: () {
Navigator.of(context).pop();
_loadTab(_tabController.index);
},
),
);
}
}
class _AdhesionCard extends StatelessWidget {
final AdhesionModel adhesion;
final NumberFormat currencyFormat;
final VoidCallback onTap;
const _AdhesionCard({
required this.adhesion,
required this.currencyFormat,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return CoreCard(
margin: const EdgeInsets.only(bottom: 10),
onTap: onTap,
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const MiniAvatar(size: 24, fallbackText: '🏢'),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
adhesion.nomOrganisation ?? adhesion.organisationId ?? 'Organisation',
style: AppTypography.actionText.copyWith(fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
adhesion.numeroReference ?? adhesion.id?.substring(0, 8) ?? '',
style: AppTypography.subtitleSmall.copyWith(fontSize: 9),
),
],
),
),
_buildStatutBadge(adhesion.statut),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('FRAIS D\'ADHÉSION', style: AppTypography.subtitleSmall.copyWith(fontSize: 8, fontWeight: FontWeight.bold)),
Text(
adhesion.fraisAdhesion != null ? currencyFormat.format(adhesion.fraisAdhesion) : '',
style: AppTypography.headerSmall.copyWith(fontSize: 13, color: AppColors.primaryGreen),
),
],
),
if (adhesion.dateDemande != null)
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('DATE', style: AppTypography.subtitleSmall.copyWith(fontSize: 8, fontWeight: FontWeight.bold)),
Text(
DateFormat('dd/MM/yyyy').format(adhesion.dateDemande!),
style: AppTypography.bodyTextSmall.copyWith(fontSize: 10),
),
],
),
],
),
if (adhesion.nomMembreComplet.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'MEMBRE : ${adhesion.nomMembreComplet.toUpperCase()}',
style: AppTypography.subtitleSmall.copyWith(fontSize: 8, color: AppColors.textSecondaryLight),
),
],
],
),
);
}
Widget _buildStatutBadge(String? statut) {
Color color;
switch (statut) {
case 'APPROUVEE':
case 'PAYEE':
color = AppColors.success;
break;
case 'REJETEE':
case 'ANNULEE':
color = AppColors.error;
break;
case 'EN_ATTENTE':
color = AppColors.brandGreenLight;
break;
case 'EN_PAIEMENT':
color = Colors.blue;
break;
default:
color = AppColors.textSecondaryLight;
}
return InfoBadge(text: statut ?? 'INCONNU', backgroundColor: color);
}
}

View File

@@ -0,0 +1,26 @@
/// Wrapper BLoC pour la page des adhésions
library adhesions_page_wrapper;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../bloc/adhesions_bloc.dart';
import 'adhesions_page.dart';
final _getIt = GetIt.instance;
class AdhesionsPageWrapper extends StatelessWidget {
const AdhesionsPageWrapper({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<AdhesionsBloc>(
create: (context) {
final bloc = _getIt<AdhesionsBloc>();
bloc.add(const LoadAdhesions());
return bloc;
},
child: const AdhesionsPage(),
);
}
}

View File

@@ -0,0 +1,175 @@
/// Dialog de création d'une demande d'adhésion
library create_adhesion_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 '../../bloc/adhesions_bloc.dart';
import '../../data/models/adhesion_model.dart';
import '../../../organizations/data/models/organization_model.dart';
import '../../../organizations/domain/repositories/organization_repository.dart';
import '../../../members/data/models/membre_complete_model.dart';
import '../../../profile/domain/repositories/profile_repository.dart';
class CreateAdhesionDialog extends StatefulWidget {
final VoidCallback onCreated;
const CreateAdhesionDialog({super.key, required this.onCreated});
@override
State<CreateAdhesionDialog> createState() => _CreateAdhesionDialogState();
}
class _CreateAdhesionDialogState extends State<CreateAdhesionDialog> {
final _fraisController = TextEditingController();
String? _organisationId;
bool _loading = false;
bool _isInitLoading = true;
List<OrganizationModel> _organisations = [];
MembreCompletModel? _me;
@override
void initState() {
super.initState();
_loadInitialData();
}
Future<void> _loadInitialData() async {
try {
final user = await GetIt.instance<IProfileRepository>().getMe();
final orgRepo = GetIt.instance<IOrganizationRepository>();
final list = await orgRepo.getOrganizations(page: 0, size: 100);
if (mounted) {
setState(() {
_me = user;
_organisations = list;
_isInitLoading = false;
});
}
} catch (e, st) {
AppLogger.error('CreateAdhesionDialog: chargement profil/organisations échoué', error: e, stackTrace: st);
if (mounted) {
setState(() {
_isInitLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible de charger le profil ou les organisations. Réessayez.')),
);
}
}
}
@override
void dispose() {
_fraisController.dispose();
super.dispose();
}
void _submit() {
if (_me == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profil non chargé, veuillez réessayer')),
);
return;
}
if (_organisationId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Veuillez sélectionner un membre et une organisation')),
);
return;
}
final frais = double.tryParse(_fraisController.text.replaceAll(',', '.'));
if (frais == null || frais <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Frais d\'adhésion invalides')),
);
return;
}
setState(() => _loading = true);
final adhesion = AdhesionModel(
membreId: _me!.id,
organisationId: _organisationId,
fraisAdhesion: frais,
codeDevise: 'XOF',
dateDemande: DateTime.now(),
);
context.read<AdhesionsBloc>().add(CreateAdhesion(adhesion));
widget.onCreated();
if (mounted) {
setState(() => _loading = false);
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Nouvelle demande d\'adhésion'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
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,
)
else
const Text('Impossible de récupérer votre profil', style: TextStyle(color: Colors.red)),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _organisationId,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Organisation',
border: OutlineInputBorder(),
),
items: _organisations
.map((o) => DropdownMenuItem<String>(
value: o.id,
child: Text(o.nom, overflow: TextOverflow.ellipsis, maxLines: 1),
))
.toList(),
onChanged: _loading ? null : (v) => setState(() => _organisationId = v),
),
const SizedBox(height: 16),
TextField(
controller: _fraisController,
decoration: const InputDecoration(
labelText: 'Frais d\'adhésion (FCFA)',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
enabled: !_loading,
),
],
),
),
actions: [
TextButton(
onPressed: _loading ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Créer'),
),
],
);
}
}

View File

@@ -0,0 +1,154 @@
/// Dialog pour enregistrer un paiement sur une adhésion
library paiement_adhesion_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../shared/constants/payment_method_assets.dart';
import '../../bloc/adhesions_bloc.dart';
class PaiementAdhesionDialog extends StatefulWidget {
final String adhesionId;
final double montantRestant;
final VoidCallback onPaid;
const PaiementAdhesionDialog({
super.key,
required this.adhesionId,
required this.montantRestant,
required this.onPaid,
});
@override
State<PaiementAdhesionDialog> createState() => _PaiementAdhesionDialogState();
}
class _PaiementAdhesionDialogState extends State<PaiementAdhesionDialog> {
final _montantController = TextEditingController();
final _refController = TextEditingController();
String? _methode;
bool _loading = false;
@override
void initState() {
super.initState();
_montantController.text = widget.montantRestant.toStringAsFixed(0);
}
@override
void dispose() {
_montantController.dispose();
_refController.dispose();
super.dispose();
}
List<DropdownMenuItem<String>> _buildPaymentMethodItems() {
const codes = ['ESPECES', 'VIREMENT', 'WAVE_MONEY', 'ORANGE_MONEY', 'CHEQUE'];
const labels = {'ESPECES': 'Espèces', 'VIREMENT': 'Virement', 'WAVE_MONEY': 'Wave Money', 'ORANGE_MONEY': 'Orange Money', 'CHEQUE': 'Chèque'};
return codes.map((code) {
final label = labels[code] ?? code;
return DropdownMenuItem<String>(
value: code,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
PaymentMethodIcon(paymentMethodCode: code, width: 24, height: 24),
const SizedBox(width: 12),
Text(label),
],
),
);
}).toList();
}
void _submit() {
final montant = double.tryParse(_montantController.text.replaceAll(',', '.'));
if (montant == null || montant <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Montant invalide')),
);
return;
}
if (montant > widget.montantRestant) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Le montant ne peut pas dépasser le restant dû')),
);
return;
}
setState(() => _loading = true);
context.read<AdhesionsBloc>().add(
EnregistrerPaiementAdhesion(
widget.adhesionId,
montantPaye: montant,
methodePaiement: _methode,
referencePaiement: _refController.text.trim().isEmpty
? null
: _refController.text.trim(),
),
);
widget.onPaid();
if (mounted) {
setState(() => _loading = false);
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Enregistrer un paiement'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Restant dû : ${widget.montantRestant.toStringAsFixed(0)} FCFA'),
const SizedBox(height: 16),
TextField(
controller: _montantController,
decoration: const InputDecoration(
labelText: 'Montant payé (FCFA)',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
enabled: !_loading,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _methode,
decoration: const InputDecoration(
labelText: 'Méthode de paiement',
border: OutlineInputBorder(),
),
items: _buildPaymentMethodItems(),
onChanged: _loading ? null : (v) => setState(() => _methode = v),
),
const SizedBox(height: 12),
TextField(
controller: _refController,
decoration: const InputDecoration(
labelText: 'Référence (optionnel)',
border: OutlineInputBorder(),
),
enabled: !_loading,
),
],
),
),
actions: [
TextButton(
onPressed: _loading ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Enregistrer'),
),
],
);
}
}

View File

@@ -0,0 +1,102 @@
/// Dialog pour rejeter une adhésion (saisie du motif)
library rejet_adhesion_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../bloc/adhesions_bloc.dart';
class RejetAdhesionDialog extends StatefulWidget {
final String adhesionId;
final VoidCallback onRejected;
const RejetAdhesionDialog({
super.key,
required this.adhesionId,
required this.onRejected,
});
@override
State<RejetAdhesionDialog> createState() => _RejetAdhesionDialogState();
}
class _RejetAdhesionDialogState extends State<RejetAdhesionDialog> {
final _controller = TextEditingController();
bool _loading = false;
bool _rejectSent = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final motif = _controller.text.trim();
if (motif.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Veuillez saisir un motif de rejet')),
);
return;
}
setState(() {
_loading = true;
_rejectSent = true;
});
context.read<AdhesionsBloc>().add(RejeterAdhesion(widget.adhesionId, motif));
}
@override
Widget build(BuildContext context) {
return BlocListener<AdhesionsBloc, AdhesionsState>(
listenWhen: (_, state) => _rejectSent && (state.status == AdhesionsStatus.loaded || state.status == AdhesionsStatus.error),
listener: (context, state) {
if (!_rejectSent || !mounted) return;
if (state.status == AdhesionsStatus.error) {
setState(() {
_loading = false;
_rejectSent = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message ?? 'Erreur lors du rejet')),
);
return;
}
if (state.status == AdhesionsStatus.loaded) {
setState(() => _rejectSent = false);
widget.onRejected();
Navigator.of(context).pop();
}
},
child: AlertDialog(
title: const Text('Rejeter la demande'),
content: TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Motif du rejet',
hintText: 'Saisir le motif...',
border: OutlineInputBorder(),
),
maxLines: 3,
enabled: !_loading,
),
actions: [
TextButton(
onPressed: _loading ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: _loading ? null : _submit,
style: FilledButton.styleFrom(backgroundColor: Colors.red),
child: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Rejeter'),
),
],
),
);
}
}

View File

@@ -0,0 +1,91 @@
library admin_users_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../data/models/admin_user_model.dart';
import '../data/repositories/admin_user_repository.dart';
part 'admin_users_event.dart';
part 'admin_users_state.dart';
@injectable
class AdminUsersBloc extends Bloc<AdminUsersEvent, AdminUsersState> {
final AdminUserRepository _repository;
AdminUsersBloc(this._repository) : super(AdminUsersInitial()) {
on<AdminUsersLoadRequested>(_onLoadRequested);
on<AdminUserDetailRequested>(_onDetailRequested);
on<AdminUserDetailWithRolesRequested>(_onDetailWithRolesRequested);
on<AdminUserRolesUpdateRequested>(_onRolesUpdateRequested);
on<AdminRolesLoadRequested>(_onRolesLoadRequested);
}
Future<void> _onLoadRequested(AdminUsersLoadRequested e, Emitter<AdminUsersState> emit) async {
emit(AdminUsersLoading());
try {
final result = await _repository.search(
page: e.page ?? 0,
size: e.size ?? 20,
search: e.search,
);
emit(AdminUsersLoaded(
users: result.users,
totalCount: result.totalCount,
currentPage: result.currentPage,
pageSize: result.pageSize,
totalPages: result.totalPages,
));
} catch (err) {
emit(AdminUsersError(err.toString()));
}
}
Future<void> _onDetailRequested(AdminUserDetailRequested e, Emitter<AdminUsersState> emit) async {
emit(AdminUsersLoading());
try {
final user = await _repository.getById(e.userId);
if (user == null) {
emit(AdminUsersError('Utilisateur non trouvé'));
return;
}
final roles = await _repository.getUserRoles(e.userId);
emit(AdminUserDetailLoaded(user: user, userRoles: roles));
} catch (err) {
emit(AdminUsersError(err.toString()));
}
}
Future<void> _onDetailWithRolesRequested(AdminUserDetailWithRolesRequested e, Emitter<AdminUsersState> emit) async {
emit(AdminUsersLoading());
try {
final user = await _repository.getById(e.userId);
if (user == null) {
emit(AdminUsersError('Utilisateur non trouvé'));
return;
}
final userRoles = await _repository.getUserRoles(e.userId);
final allRoles = await _repository.getRealmRoles();
emit(AdminUserDetailLoaded(user: user, userRoles: userRoles, allRoles: allRoles));
} catch (err) {
emit(AdminUsersError(err.toString()));
}
}
Future<void> _onRolesUpdateRequested(AdminUserRolesUpdateRequested e, Emitter<AdminUsersState> emit) async {
try {
await _repository.setUserRoles(e.userId, e.roleNames);
emit(AdminUserRolesUpdated());
add(AdminUserDetailWithRolesRequested(e.userId));
} catch (err) {
emit(AdminUsersError(err.toString()));
}
}
Future<void> _onRolesLoadRequested(AdminRolesLoadRequested e, Emitter<AdminUsersState> emit) async {
try {
final roles = await _repository.getRealmRoles();
emit(AdminRolesLoaded(roles));
} catch (err) {
emit(AdminUsersError(err.toString()));
}
}
}

View File

@@ -0,0 +1,29 @@
part of 'admin_users_bloc.dart';
abstract class AdminUsersEvent {}
class AdminUsersLoadRequested extends AdminUsersEvent {
final int page;
final int size;
final String? search;
AdminUsersLoadRequested({this.page = 0, this.size = 20, this.search});
}
class AdminUserDetailRequested extends AdminUsersEvent {
final String userId;
AdminUserDetailRequested(this.userId);
}
/// Charge détail utilisateur + liste complète des rôles (pour édition)
class AdminUserDetailWithRolesRequested extends AdminUsersEvent {
final String userId;
AdminUserDetailWithRolesRequested(this.userId);
}
class AdminUserRolesUpdateRequested extends AdminUsersEvent {
final String userId;
final List<String> roleNames;
AdminUserRolesUpdateRequested(this.userId, this.roleNames);
}
class AdminRolesLoadRequested extends AdminUsersEvent {}

View File

@@ -0,0 +1,45 @@
part of 'admin_users_bloc.dart';
abstract class AdminUsersState {}
class AdminUsersInitial extends AdminUsersState {}
class AdminUsersLoading extends AdminUsersState {}
class AdminUsersLoaded extends AdminUsersState {
final List<AdminUserModel> users;
final int totalCount;
final int currentPage;
final int pageSize;
final int totalPages;
AdminUsersLoaded({
required this.users,
required this.totalCount,
required this.currentPage,
required this.pageSize,
required this.totalPages,
});
}
class AdminUsersError extends AdminUsersState {
final String message;
AdminUsersError(this.message);
}
class AdminUserDetailLoaded extends AdminUsersState {
final AdminUserModel user;
final List<AdminRoleModel> userRoles;
final List<AdminRoleModel> allRoles;
AdminUserDetailLoaded({
required this.user,
required this.userRoles,
this.allRoles = const [],
});
}
class AdminRolesLoaded extends AdminUsersState {
final List<AdminRoleModel> roles;
AdminRolesLoaded(this.roles);
}
class AdminUserRolesUpdated extends AdminUsersState {}

View File

@@ -0,0 +1,66 @@
/// Modèle pour un utilisateur admin (Keycloak) - aligné sur l'API /api/admin/users
library admin_user_model;
class AdminUserModel {
final String id;
final String? username;
final String? email;
final String? prenom;
final String? nom;
final bool? enabled;
final List<String>? realmRoles;
AdminUserModel({
required this.id,
this.username,
this.email,
this.prenom,
this.nom,
this.enabled,
this.realmRoles,
});
String get displayName {
if (prenom != null && nom != null) return '$prenom $nom';
if (prenom != null) return prenom!;
if (nom != null) return nom!;
return username ?? email ?? id;
}
factory AdminUserModel.fromJson(Map<String, dynamic> json) {
final roles = json['realmRoles'] as List<dynamic>?;
return AdminUserModel(
id: json['id'] as String? ?? '',
username: json['username'] as String?,
email: json['email'] as String?,
prenom: json['prenom'] as String?,
nom: json['nom'] as String?,
enabled: json['enabled'] as bool?,
realmRoles: roles?.map((e) => e is Map ? (e['name'] as String?) ?? e.toString() : e.toString()).toList(),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'username': username,
'email': email,
'prenom': prenom,
'nom': nom,
'enabled': enabled,
'realmRoles': realmRoles,
};
}
class AdminRoleModel {
final String id;
final String name;
final String? description;
AdminRoleModel({required this.id, required this.name, this.description});
factory AdminRoleModel.fromJson(Map<String, dynamic> json) => AdminRoleModel(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
description: json['description'] as String?,
);
}

View File

@@ -0,0 +1,105 @@
/// Repository pour la gestion des utilisateurs admin (API /api/admin/users)
library admin_user_repository;
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import '../models/admin_user_model.dart';
abstract class AdminUserRepository {
Future<AdminUserSearchResult> search({int page = 0, int size = 20, String? search});
Future<AdminUserModel?> getById(String id);
Future<List<AdminRoleModel>> getRealmRoles();
Future<List<AdminRoleModel>> getUserRoles(String userId);
Future<void> setUserRoles(String userId, List<String> roleNames);
/// Associe un utilisateur (email) à une organisation (réservé SUPER_ADMIN).
Future<void> associerOrganisation({required String email, required String organisationId});
}
class AdminUserSearchResult {
final List<AdminUserModel> users;
final int totalCount;
final int currentPage;
final int pageSize;
final int totalPages;
AdminUserSearchResult({
required this.users,
required this.totalCount,
required this.currentPage,
required this.pageSize,
required this.totalPages,
});
}
@LazySingleton(as: AdminUserRepository)
class AdminUserRepositoryImpl implements AdminUserRepository {
final ApiClient _apiClient;
static const String _base = '/api/admin/users';
AdminUserRepositoryImpl(this._apiClient);
@override
Future<AdminUserSearchResult> search({int page = 0, int size = 20, String? search}) async {
final query = <String, dynamic>{'page': page, 'size': size};
if (search != null && search.isNotEmpty) query['search'] = search;
final response = await _apiClient.get(_base, queryParameters: query);
if (response.statusCode != 200) throw Exception('Erreur ${response.statusCode}');
final data = response.data as Map<String, dynamic>;
final list = data['users'] as List<dynamic>? ?? [];
return AdminUserSearchResult(
users: list.map((e) => AdminUserModel.fromJson(e as Map<String, dynamic>)).toList(),
totalCount: (data['totalCount'] as num?)?.toInt() ?? 0,
currentPage: (data['currentPage'] as num?)?.toInt() ?? 0,
pageSize: (data['pageSize'] as num?)?.toInt() ?? size,
totalPages: (data['totalPages'] as num?)?.toInt() ?? 0,
);
}
@override
Future<AdminUserModel?> getById(String id) async {
final response = await _apiClient.get('$_base/$id');
if (response.statusCode == 200) {
return AdminUserModel.fromJson(response.data as Map<String, dynamic>);
}
if (response.statusCode == 404) return null;
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<List<AdminRoleModel>> getRealmRoles() async {
final response = await _apiClient.get('$_base/roles');
if (response.statusCode != 200) return [];
final list = response.data as List<dynamic>? ?? [];
return list.map((e) => AdminRoleModel.fromJson(e as Map<String, dynamic>)).toList();
}
@override
Future<List<AdminRoleModel>> getUserRoles(String userId) async {
final response = await _apiClient.get('$_base/$userId/roles');
if (response.statusCode != 200) return [];
final list = response.data as List<dynamic>? ?? [];
return list.map((e) => AdminRoleModel.fromJson(e as Map<String, dynamic>)).toList();
}
@override
Future<void> setUserRoles(String userId, List<String> roleNames) async {
final response = await _apiClient.put('$_base/$userId/roles', data: roleNames);
if (response.statusCode != 200) throw Exception('Erreur ${response.statusCode}');
}
@override
Future<void> associerOrganisation({required String email, required String organisationId}) async {
const path = '/api/admin/associer-organisation';
final response = await _apiClient.post(
path,
data: {'email': email, 'organisationId': organisationId},
);
if (response.statusCode != 200) {
final msg = response.data is Map && response.data['message'] != null
? response.data['message'] as String
: 'Erreur ${response.statusCode}';
throw Exception(msg);
}
}
}

View File

@@ -0,0 +1,277 @@
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 '../../bloc/admin_users_bloc.dart';
import '../../data/models/admin_user_model.dart';
import '../../data/repositories/admin_user_repository.dart';
import '../../../organizations/data/models/organization_model.dart';
import '../../../organizations/data/services/organization_service.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/widgets/core_card.dart';
import '../../../../shared/design_system/components/uf_app_bar.dart';
import '../../../../shared/design_system/components/uf_buttons.dart';
/// Page détail d'un utilisateur + édition des rôles
class UserManagementDetailPage extends StatelessWidget {
final String userId;
const UserManagementDetailPage({super.key, required this.userId});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: const UFAppBar(
title: 'Détail utilisateur',
),
body: BlocBuilder<AdminUsersBloc, AdminUsersState>(
builder: (context, state) {
if (state is AdminUsersLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is AdminUsersError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(state.message),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.read<AdminUsersBloc>().add(AdminUserDetailRequested(userId)),
child: const Text('Réessayer'),
),
],
),
);
}
if (state is AdminUserDetailLoaded) {
return _UserDetailContent(
user: state.user,
userRoles: state.userRoles,
allRoles: state.allRoles,
userId: userId,
);
}
return const SizedBox();
},
),
);
}
}
class _UserDetailContent extends StatefulWidget {
final AdminUserModel user;
final List<AdminRoleModel> userRoles;
final List<AdminRoleModel> allRoles;
final String userId;
const _UserDetailContent({
required this.user,
required this.userRoles,
required this.allRoles,
required this.userId,
});
@override
State<_UserDetailContent> createState() => _UserDetailContentState();
}
class _UserDetailContentState extends State<_UserDetailContent> {
late Set<String> _selectedRoleNames;
@override
void initState() {
super.initState();
_selectedRoleNames = widget.userRoles.map((r) => r.name).toSet();
}
@override
Widget build(BuildContext context) {
return BlocListener<AdminUsersBloc, AdminUsersState>(
listener: (context, state) {
if (state is AdminUserRolesUpdated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Rôles mis à jour')),
);
}
},
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CoreCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.user.displayName, style: AppTypography.headerSmall),
const SizedBox(height: 8),
if (widget.user.email != null)
Text('Email: ${widget.user.email}', style: AppTypography.bodyTextSmall),
if (widget.user.username != null)
Text('Username: ${widget.user.username}', style: AppTypography.bodyTextSmall),
Text(
'Statut: ${widget.user.enabled == true ? "Actif" : "Inactif"}',
style: AppTypography.bodyTextSmall.copyWith(
color: widget.user.enabled == true ? AppColors.success : AppColors.error,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 16),
Text(
'RÔLES (SÉLECTION)',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
),
),
const SizedBox(height: 8),
...widget.allRoles.map((role) {
final selected = _selectedRoleNames.contains(role.name);
return CheckboxListTile(
title: Text(role.name, style: AppTypography.bodyTextSmall),
activeColor: AppColors.primaryGreen,
contentPadding: EdgeInsets.zero,
dense: true,
value: selected,
onChanged: (v) {
setState(() {
if (v == true) {
_selectedRoleNames.add(role.name);
} else {
_selectedRoleNames.remove(role.name);
}
});
},
);
}),
const SizedBox(height: 24),
UFPrimaryButton(
label: 'Enregistrer les rôles',
onPressed: () {
context.read<AdminUsersBloc>().add(
AdminUserRolesUpdateRequested(widget.userId, _selectedRoleNames.toList()),
);
},
),
const SizedBox(height: 24),
const Divider(height: 1),
const SizedBox(height: 16),
Text(
'ASSOCIER À UNE ORGANISATION',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.bold,
letterSpacing: 1.1,
),
),
const SizedBox(height: 8),
Text(
'Permet à cet utilisateur (ex. admin d\'organisation) de voir « Mes organisations » et d\'accéder au dashboard de l\'organisation.',
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.textSecondaryLight),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: widget.user.email == null || widget.user.email!.isEmpty
? null
: () => _openAssocierOrganisationDialog(context, widget.user.email!),
icon: const Icon(Icons.business, size: 18),
label: const Text('Associer à une organisation'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryGreen,
side: const BorderSide(color: AppColors.primaryGreen),
),
),
],
),
),
);
}
Future<void> _openAssocierOrganisationDialog(BuildContext context, String userEmail) async {
final orgService = GetIt.I<OrganizationService>();
final adminRepo = GetIt.I<AdminUserRepository>();
List<OrganizationModel> organisations = [];
try {
organisations = await orgService.getOrganizations(page: 0, size: 200);
} catch (e, st) {
AppLogger.error('UserManagementDetail: chargement organisations échoué', error: e, stackTrace: st);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible de charger les organisations')),
);
return;
}
if (!context.mounted) return;
final orgsWithId = organisations.where((o) => o.id != null && o.id!.isNotEmpty).toList();
if (orgsWithId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucune organisation disponible. Créez-en une d\'abord.')),
);
return;
}
String? selectedOrgId = orgsWithId.first.id;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx2, setDialogState) {
return AlertDialog(
title: const Text('Associer à une organisation'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Utilisateur: $userEmail', style: AppTypography.bodyTextSmall),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: selectedOrgId,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Organisation',
border: OutlineInputBorder(),
),
items: orgsWithId
.map((o) => DropdownMenuItem(value: o.id, child: Text(o.nom, overflow: TextOverflow.ellipsis)))
.toList(),
onChanged: (v) => setDialogState(() => selectedOrgId = v),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () async {
if (selectedOrgId == null) return;
try {
await adminRepo.associerOrganisation(email: userEmail, organisationId: selectedOrgId!);
if (ctx.mounted) Navigator.of(ctx).pop();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Utilisateur associé à l\'organisation avec succès.')),
);
}
} catch (e) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text('Erreur: ${e.toString().replaceFirst('Exception: ', '')}')),
);
}
}
},
child: const Text('Associer'),
),
],
);
},
),
);
}
}

View File

@@ -0,0 +1,213 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../bloc/admin_users_bloc.dart';
import '../../data/models/admin_user_model.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/widgets/core_card.dart';
import '../../../../shared/widgets/mini_avatar.dart';
import '../../../../shared/design_system/components/uf_app_bar.dart';
import 'user_management_detail_page.dart';
/// Page de gestion des utilisateurs (SUPER_ADMIN) - liste paginée
class UserManagementPage extends StatelessWidget {
const UserManagementPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => GetIt.I<AdminUsersBloc>()..add(AdminUsersLoadRequested()),
child: const _UserManagementView(),
);
}
}
class _UserManagementView extends StatefulWidget {
const _UserManagementView();
@override
State<_UserManagementView> createState() => _UserManagementViewState();
}
class _UserManagementViewState extends State<_UserManagementView> {
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: UFAppBar(
title: 'Gestion des utilisateurs',
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: () => context.read<AdminUsersBloc>().add(AdminUsersLoadRequested()),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher (email, nom...)',
hintStyle: AppTypography.subtitleSmall,
prefixIcon: const Icon(Icons.search, size: 18),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(RadiusTokens.md),
borderSide: const BorderSide(color: AppColors.lightBorder),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(RadiusTokens.md),
borderSide: const BorderSide(color: AppColors.lightBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(RadiusTokens.md),
borderSide: const BorderSide(color: AppColors.primaryGreen),
),
filled: true,
fillColor: AppColors.lightSurface,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
onSubmitted: (v) => context.read<AdminUsersBloc>().add(
AdminUsersLoadRequested(search: v.isEmpty ? null : v),
),
),
),
Expanded(
child: BlocBuilder<AdminUsersBloc, AdminUsersState>(
builder: (context, state) {
if (state is AdminUsersLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is AdminUsersError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(state.message, textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.read<AdminUsersBloc>().add(AdminUsersLoadRequested()),
child: const Text('Réessayer'),
),
],
),
);
}
if (state is AdminUsersLoaded) {
if (state.users.isEmpty) {
return const Center(child: Text('Aucun utilisateur'));
}
return RefreshIndicator(
onRefresh: () async {
context.read<AdminUsersBloc>().add(AdminUsersLoadRequested(
search: _searchController.text.isEmpty ? null : _searchController.text,
));
},
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: state.users.length + 1,
itemBuilder: (context, i) {
if (i == state.users.length) {
return _buildPagination(context, state);
}
return _buildUserTile(context, state.users[i]);
},
),
);
}
return const SizedBox();
},
),
),
],
),
);
}
Widget _buildUserTile(BuildContext context, AdminUserModel user) {
return CoreCard(
margin: const EdgeInsets.only(bottom: 8),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (_) => GetIt.I<AdminUsersBloc>()..add(AdminUserDetailWithRolesRequested(user.id)),
child: UserManagementDetailPage(userId: user.id),
),
),
),
child: Row(
children: [
MiniAvatar(
imageUrl: null, // AdminUserModel n'a pas de champ avatar
fallbackText: (user.prenom?.substring(0, 1) ?? user.username?.substring(0, 1) ?? '?').toUpperCase(),
size: 36,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.displayName,
style: AppTypography.actionText,
),
Text(
user.email ?? user.username ?? user.id,
style: AppTypography.subtitleSmall,
),
],
),
),
const Icon(
Icons.chevron_right,
size: 16,
color: AppColors.textSecondaryLight,
),
],
),
);
}
Widget _buildPagination(BuildContext context, AdminUsersLoaded state) {
if (state.totalPages <= 1) return const SizedBox(height: 24);
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: state.currentPage > 0
? () => context.read<AdminUsersBloc>().add(AdminUsersLoadRequested(
page: state.currentPage - 1,
size: state.pageSize,
search: _searchController.text.isEmpty ? null : _searchController.text,
))
: null,
),
Text('${state.currentPage + 1} / ${state.totalPages}'),
IconButton(
icon: const Icon(Icons.arrow_forward),
onPressed: state.currentPage < state.totalPages - 1
? () => context.read<AdminUsersBloc>().add(AdminUsersLoadRequested(
page: state.currentPage + 1,
size: state.pageSize,
search: _searchController.text.isEmpty ? null : _searchController.text,
))
: null,
),
],
),
);
}
}

View File

@@ -0,0 +1,183 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:injectable/injectable.dart';
import '../models/user.dart';
import '../models/user_role.dart';
import 'keycloak_role_mapper.dart';
import '../../../../core/config/environment.dart';
import '../../../../core/utils/logger.dart';
/// Configuration Keycloak centralisée
class KeycloakConfig {
static String get baseUrl => AppConfig.keycloakBaseUrl;
static const String realm = 'unionflow';
static const String clientId = 'unionflow-mobile';
static const String scopes = 'openid profile email roles';
static String get tokenEndpoint => '$baseUrl/realms/$realm/protocol/openid-connect/token';
static String get logoutEndpoint => '$baseUrl/realms/$realm/protocol/openid-connect/logout';
}
/// Service d'Authentification Keycloak Épuré & DRY
@lazySingleton
class KeycloakAuthService {
final Dio _dio = Dio();
final FlutterSecureStorage _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
);
static const String _accessK = 'kc_access';
static const String _refreshK = 'kc_refresh';
static const String _idK = 'kc_id';
/// Login via Direct Access Grant (Username/Password)
Future<User?> login(String username, String password) async {
try {
final response = await _dio.post(
KeycloakConfig.tokenEndpoint,
data: {
'client_id': KeycloakConfig.clientId,
'grant_type': 'password',
'username': username,
'password': password,
'scope': KeycloakConfig.scopes,
},
options: Options(contentType: Headers.formUrlEncodedContentType),
);
if (response.statusCode == 200) {
await _saveTokens(response.data);
return await getCurrentUser();
}
} catch (e, st) {
AppLogger.error('KeycloakAuthService: auth error', error: e, stackTrace: st);
}
return null;
}
static Future<String?>? _refreshFuture;
/// Rafraîchissement automatique du token avec verrouillage global
Future<String?> refreshToken() async {
if (_refreshFuture != null) {
AppLogger.info('KeycloakAuthService: waiting for ongoing refresh');
return await _refreshFuture;
}
_refreshFuture = _performRefresh();
try {
return await _refreshFuture;
} finally {
_refreshFuture = null;
}
}
Future<String?> _performRefresh() async {
final refresh = await _storage.read(key: _refreshK);
if (refresh == null) {
AppLogger.info('KeycloakAuthService: no refresh token available');
return null;
}
try {
AppLogger.info('KeycloakAuthService: attempting token refresh');
final response = await _dio.post(
KeycloakConfig.tokenEndpoint,
data: {
'client_id': KeycloakConfig.clientId,
'grant_type': 'refresh_token',
'refresh_token': refresh,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
validateStatus: (status) => status == 200,
),
);
if (response.statusCode == 200) {
await _saveTokens(response.data);
AppLogger.info('KeycloakAuthService: token refreshed successfully');
return response.data['access_token'];
}
} on DioException catch (e, st) {
AppLogger.error('KeycloakAuthService: refresh error ${e.response?.statusCode}', error: e, stackTrace: st);
if (e.response?.statusCode == 400) {
AppLogger.info('KeycloakAuthService: refresh token invalid or expired, logging out');
await logout();
}
} catch (e, st) {
AppLogger.error('KeycloakAuthService: critical refresh error', error: e, stackTrace: st);
}
return null;
}
/// Récupération de l'utilisateur courant + Mapage Rôles
Future<User?> getCurrentUser() async {
String? token = await _storage.read(key: _accessK);
final idToken = await _storage.read(key: _idK);
if (token == null || idToken == null) return null;
if (JwtDecoder.isExpired(token)) {
token = await refreshToken();
if (token == null) return null;
}
try {
final payload = JwtDecoder.decode(token);
final idPayload = JwtDecoder.decode(idToken);
final roles = _extractRoles(payload);
final primaryRole = KeycloakRoleMapper.mapToUserRole(roles);
AppLogger.info('KeycloakAuthService: roles mapped', tag: '${primaryRole.name}');
return User(
id: idPayload['sub'] ?? '',
email: idPayload['email'] ?? '',
firstName: idPayload['given_name'] ?? '',
lastName: idPayload['family_name'] ?? '',
primaryRole: primaryRole,
additionalPermissions: KeycloakRoleMapper.mapToPermissions(roles),
isActive: true,
lastLoginAt: DateTime.now(),
createdAt: DateTime.now(),
);
} catch (e, st) {
AppLogger.error('KeycloakAuthService: user parse error', error: e, stackTrace: st);
}
return null;
}
Future<void> logout() async {
await _storage.deleteAll();
AppLogger.info('KeycloakAuthService: session cleared');
}
Future<void> _saveTokens(Map<String, dynamic> data) async {
if (data['access_token'] != null) await _storage.write(key: _accessK, value: data['access_token']);
if (data['refresh_token'] != null) await _storage.write(key: _refreshK, value: data['refresh_token']);
if (data['id_token'] != null) await _storage.write(key: _idK, value: data['id_token']);
}
List<String> _extractRoles(Map<String, dynamic> payload) {
final roles = <String>[];
if (payload['realm_access']?['roles'] != null) {
roles.addAll((payload['realm_access']['roles'] as List).cast<String>());
}
if (payload['resource_access'] != null) {
(payload['resource_access'] as Map).values.forEach((v) {
if (v['roles'] != null) roles.addAll((v['roles'] as List).cast<String>());
});
}
return roles.where((r) => !r.startsWith('default-roles-') && r != 'offline_access').toList();
}
Future<String?> getValidToken() async {
final token = await _storage.read(key: _accessK);
if (token != null && !JwtDecoder.isExpired(token)) return token;
return await refreshToken();
}
}

View File

@@ -0,0 +1,400 @@
/// Mapper de Rôles Keycloak vers UserRole
/// Convertit les rôles Keycloak existants vers notre système de rôles sophistiqué
library keycloak_role_mapper;
import '../models/user_role.dart';
import '../models/permission_matrix.dart';
/// Service de mapping des rôles Keycloak
class KeycloakRoleMapper {
/// Mapping des rôles Keycloak vers UserRole
static const Map<String, UserRole> _keycloakToUserRole = {
// Rôles administratifs
'SUPER_ADMINISTRATEUR': UserRole.superAdmin,
'ADMIN': UserRole.superAdmin,
'ADMIN_ORGANISATION': UserRole.orgAdmin, // Rôle Keycloak (backend)
'ADMINISTRATEUR_ORGANISATION': UserRole.orgAdmin,
'PRESIDENT': UserRole.orgAdmin,
// Rôles de gestion
'RESPONSABLE_TECHNIQUE': UserRole.moderator,
'RESPONSABLE_MEMBRES': UserRole.moderator,
'TRESORIER': UserRole.moderator,
'SECRETAIRE': UserRole.moderator,
'GESTIONNAIRE_MEMBRE': UserRole.moderator,
'ORGANISATEUR_EVENEMENT': UserRole.moderator,
'CONSULTANT': UserRole.consultant,
'GESTIONNAIRE_RH': UserRole.hrManager,
'HR_MANAGER': UserRole.hrManager,
// Rôles membres
'MEMBRE_ACTIF': UserRole.activeMember,
'MEMBRE_SIMPLE': UserRole.simpleMember,
'MEMBRE': UserRole.activeMember,
};
/// Mapping des rôles Keycloak vers permissions spécifiques
static const Map<String, List<String>> _keycloakToPermissions = {
'SUPER_ADMINISTRATEUR': [
// Permissions Super Admin - Accès total
PermissionMatrix.SYSTEM_ADMIN,
PermissionMatrix.SYSTEM_CONFIG,
PermissionMatrix.SYSTEM_SECURITY,
PermissionMatrix.ORG_CREATE,
PermissionMatrix.ORG_DELETE,
PermissionMatrix.ORG_CONFIG,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_DELETE_ALL,
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_EDIT_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_EDIT_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.DASHBOARD_ANALYTICS,
],
'ADMIN': [
// Permissions Super Admin - Accès total (compatibilité)
PermissionMatrix.SYSTEM_ADMIN,
PermissionMatrix.SYSTEM_CONFIG,
PermissionMatrix.SYSTEM_SECURITY,
PermissionMatrix.ORG_CREATE,
PermissionMatrix.ORG_DELETE,
PermissionMatrix.ORG_CONFIG,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_DELETE_ALL,
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_EDIT_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_EDIT_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.DASHBOARD_ANALYTICS,
],
'ADMIN_ORGANISATION': [
// Permissions Admin Organisation (rôle Keycloak backend)
PermissionMatrix.ORG_CONFIG,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_EDIT_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_EDIT_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.DASHBOARD_ANALYTICS,
],
'ADMINISTRATEUR_ORGANISATION': [
// Permissions Admin Organisation
PermissionMatrix.ORG_CONFIG,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_EDIT_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_EDIT_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.DASHBOARD_ANALYTICS,
],
'PRESIDENT': [
// Permissions Président - Gestion organisation
PermissionMatrix.ORG_CONFIG,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_EDIT_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_EDIT_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.DASHBOARD_ANALYTICS,
PermissionMatrix.COMM_SEND_ALL,
],
'RESPONSABLE_TECHNIQUE': [
// Permissions Responsable Technique
PermissionMatrix.SYSTEM_MONITORING,
PermissionMatrix.SYSTEM_MAINTENANCE,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.REPORTS_GENERATE,
],
'RESPONSABLE_MEMBRES': [
// Permissions Responsable Membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_DELETE_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_EDIT_ALL,
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.REPORTS_GENERATE,
],
'TRESORIER': [
// Permissions Trésorier - Focus finances
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_EDIT_ALL,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.DASHBOARD_VIEW,
],
'SECRETAIRE': [
// Permissions Secrétaire - Communication et membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.COMM_SEND_ALL,
PermissionMatrix.COMM_MODERATE,
PermissionMatrix.DASHBOARD_VIEW,
],
'GESTIONNAIRE_MEMBRE': [
// Permissions Gestionnaire de Membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_CREATE,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.COMM_SEND_MEMBERS,
],
'ORGANISATEUR_EVENEMENT': [
// Permissions Organisateur d'Événements
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.EVENTS_CREATE,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.COMM_SEND_MEMBERS,
],
'CONSULTANT': [
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.DASHBOARD_ANALYTICS,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.REPORTS_VIEW_ALL,
PermissionMatrix.REPORTS_GENERATE,
],
'GESTIONNAIRE_RH': [
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.MEMBERS_APPROVE,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.MODERATION_USERS,
],
'HR_MANAGER': [
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.MEMBERS_APPROVE,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.MODERATION_USERS,
],
'MEMBRE_ACTIF': [
// Permissions Membre Actif
PermissionMatrix.MEMBERS_VIEW_OWN,
PermissionMatrix.MEMBERS_EDIT_OWN,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_PARTICIPATE,
PermissionMatrix.EVENTS_CREATE,
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_PARTICIPATE,
PermissionMatrix.SOLIDARITY_CREATE,
PermissionMatrix.FINANCES_VIEW_OWN,
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.COMM_SEND_MEMBERS,
],
'MEMBRE_SIMPLE': [
// Permissions Membre Simple
PermissionMatrix.MEMBERS_VIEW_OWN,
PermissionMatrix.MEMBERS_EDIT_OWN,
PermissionMatrix.EVENTS_VIEW_PUBLIC,
PermissionMatrix.EVENTS_PARTICIPATE,
PermissionMatrix.SOLIDARITY_VIEW_PUBLIC,
PermissionMatrix.SOLIDARITY_PARTICIPATE,
PermissionMatrix.FINANCES_VIEW_OWN,
PermissionMatrix.DASHBOARD_VIEW,
],
'MEMBRE': [
// Permissions Membre Standard (compatibilité)
PermissionMatrix.MEMBERS_VIEW_OWN,
PermissionMatrix.MEMBERS_EDIT_OWN,
PermissionMatrix.EVENTS_VIEW_PUBLIC,
PermissionMatrix.EVENTS_PARTICIPATE,
PermissionMatrix.SOLIDARITY_VIEW_PUBLIC,
PermissionMatrix.SOLIDARITY_PARTICIPATE,
PermissionMatrix.FINANCES_VIEW_OWN,
PermissionMatrix.DASHBOARD_VIEW,
],
};
/// Mappe une liste de rôles Keycloak vers le UserRole principal
static UserRole mapToUserRole(List<String> keycloakRoles) {
// Normaliser en majuscules pour éviter les écarts de casse (ex. admin_organisation)
final normalized = keycloakRoles.map((r) => r.toUpperCase()).toList();
// Priorité des rôles (du plus élevé au plus bas)
const List<String> rolePriority = [
'SUPER_ADMINISTRATEUR',
'ADMIN',
'ADMIN_ORGANISATION',
'ADMINISTRATEUR_ORGANISATION',
'PRESIDENT',
'RESPONSABLE_TECHNIQUE',
'RESPONSABLE_MEMBRES',
'TRESORIER',
'SECRETAIRE',
'GESTIONNAIRE_MEMBRE',
'ORGANISATEUR_EVENEMENT',
'CONSULTANT',
'GESTIONNAIRE_RH',
'HR_MANAGER',
'MEMBRE_ACTIF',
'MEMBRE_SIMPLE',
'MEMBRE',
];
// Trouver le rôle avec la priorité la plus élevée
for (final String priorityRole in rolePriority) {
if (normalized.contains(priorityRole)) {
return _keycloakToUserRole[priorityRole] ?? UserRole.simpleMember;
}
}
// Par défaut, visiteur si aucun rôle reconnu
return UserRole.visitor;
}
/// Mappe une liste de rôles Keycloak vers les permissions
static List<String> mapToPermissions(List<String> keycloakRoles) {
final Set<String> permissions = <String>{};
// Normaliser en majuscules pour cohérence avec le mapping
final normalized = keycloakRoles.map((r) => r.toUpperCase()).toList();
// Ajouter les permissions pour chaque rôle
for (final String role in normalized) {
final List<String>? rolePermissions = _keycloakToPermissions[role];
if (rolePermissions != null) {
permissions.addAll(rolePermissions);
}
}
// Ajouter les permissions de base pour tous les utilisateurs authentifiés
permissions.add(PermissionMatrix.DASHBOARD_VIEW);
permissions.add(PermissionMatrix.MEMBERS_VIEW_OWN);
return permissions.toList();
}
/// Vérifie si un rôle Keycloak est reconnu
static bool isValidKeycloakRole(String role) {
return _keycloakToUserRole.containsKey(role);
}
/// Récupère tous les rôles Keycloak supportés
static List<String> getSupportedKeycloakRoles() {
return _keycloakToUserRole.keys.toList();
}
/// Récupère le UserRole correspondant à un rôle Keycloak spécifique
static UserRole? getUserRoleForKeycloakRole(String keycloakRole) {
return _keycloakToUserRole[keycloakRole];
}
/// Récupère les permissions pour un rôle Keycloak spécifique
static List<String> getPermissionsForKeycloakRole(String keycloakRole) {
return _keycloakToPermissions[keycloakRole] ?? [];
}
/// Analyse détaillée du mapping des rôles
static Map<String, dynamic> analyzeRoleMapping(List<String> keycloakRoles) {
final UserRole primaryRole = mapToUserRole(keycloakRoles);
final List<String> permissions = mapToPermissions(keycloakRoles);
final Map<String, List<String>> roleBreakdown = {};
for (final String role in keycloakRoles) {
if (isValidKeycloakRole(role)) {
roleBreakdown[role] = getPermissionsForKeycloakRole(role);
}
}
return {
'keycloakRoles': keycloakRoles,
'primaryRole': primaryRole.name,
'primaryRoleDisplayName': primaryRole.displayName,
'totalPermissions': permissions.length,
'permissions': permissions,
'roleBreakdown': roleBreakdown,
'unrecognizedRoles': keycloakRoles
.where((role) => !isValidKeycloakRole(role))
.toList(),
};
}
/// Suggestions d'amélioration du mapping
static Map<String, dynamic> getMappingSuggestions(List<String> keycloakRoles) {
final List<String> unrecognized = keycloakRoles
.where((role) => !isValidKeycloakRole(role))
.toList();
final List<String> suggestions = [];
if (unrecognized.isNotEmpty) {
suggestions.add(
'Rôles non reconnus détectés: ${unrecognized.join(", ")}. '
'Considérez ajouter ces rôles au mapping ou les ignorer.',
);
}
if (keycloakRoles.isEmpty) {
suggestions.add(
'Aucun rôle Keycloak détecté. L\'utilisateur sera traité comme visiteur.',
);
}
final UserRole primaryRole = mapToUserRole(keycloakRoles);
if (primaryRole == UserRole.visitor && keycloakRoles.isNotEmpty) {
suggestions.add(
'L\'utilisateur a des rôles Keycloak mais est mappé comme visiteur. '
'Vérifiez la configuration du mapping.',
);
}
return {
'unrecognizedRoles': unrecognized,
'suggestions': suggestions,
'mappingHealth': suggestions.isEmpty ? 'excellent' : 'needs_attention',
};
}
}

View File

@@ -0,0 +1,683 @@
/// Service d'Authentification Keycloak via WebView
///
/// Implémentation professionnelle et sécurisée de l'authentification OAuth2/OIDC
/// avec Keycloak utilisant WebView pour contourner les limitations HTTPS de flutter_appauth.
///
/// Fonctionnalités :
/// - Flow OAuth2 Authorization Code avec PKCE
/// - Gestion sécurisée des tokens JWT
/// - Support HTTP/HTTPS
/// - Gestion complète des erreurs et timeouts
/// - Validation rigoureuse des paramètres
/// - Logging détaillé pour le debugging
library keycloak_webview_auth_service;
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:jwt_decoder/jwt_decoder.dart';
import '../models/user.dart';
import '../models/user_role.dart';
import 'keycloak_role_mapper.dart';
import '../../../../core/config/environment.dart';
/// Configuration Keycloak pour l'authentification WebView
class KeycloakWebViewConfig {
/// URL de base de l'instance Keycloak (depuis AppConfig)
static String get baseUrl => AppConfig.keycloakBaseUrl;
/// Realm UnionFlow
static const String realm = 'unionflow';
/// Client ID pour l'application mobile
static const String clientId = 'unionflow-mobile';
/// URL de redirection après authentification
static const String redirectUrl = 'dev.lions.unionflow-mobile://auth/callback';
/// Scopes OAuth2 demandés
static const List<String> scopes = ['openid', 'profile', 'email', 'roles'];
/// Timeout pour les requêtes HTTP (en secondes)
static const int httpTimeoutSeconds = 30;
/// Timeout pour l'authentification WebView (en secondes)
static const int authTimeoutSeconds = 300; // 5 minutes
/// Endpoints calculés
static String get authorizationEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/auth';
static String get tokenEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/token';
static String get userInfoEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/userinfo';
static String get logoutEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/logout';
static String get jwksEndpoint =>
'$baseUrl/realms/$realm/protocol/openid-connect/certs';
}
/// Résultat de l'authentification WebView
class WebViewAuthResult {
final String accessToken;
final String idToken;
final String? refreshToken;
final int expiresIn;
final String tokenType;
final List<String> scopes;
const WebViewAuthResult({
required this.accessToken,
required this.idToken,
this.refreshToken,
required this.expiresIn,
required this.tokenType,
required this.scopes,
});
/// Création depuis la réponse token de Keycloak
factory WebViewAuthResult.fromTokenResponse(Map<String, dynamic> response) {
return WebViewAuthResult(
accessToken: response['access_token'] ?? '',
idToken: response['id_token'] ?? '',
refreshToken: response['refresh_token'],
expiresIn: response['expires_in'] ?? 3600,
tokenType: response['token_type'] ?? 'Bearer',
scopes: (response['scope'] as String?)?.split(' ') ?? [],
);
}
}
/// Exceptions spécifiques à l'authentification WebView
class KeycloakWebViewAuthException implements Exception {
final String message;
final String? code;
final dynamic originalError;
const KeycloakWebViewAuthException(
this.message, {
this.code,
this.originalError,
});
@override
String toString() => 'KeycloakWebViewAuthException: $message${code != null ? ' (Code: $code)' : ''}';
}
/// Service d'authentification Keycloak via WebView
///
/// Implémentation complète et sécurisée du flow OAuth2 Authorization Code avec PKCE
class KeycloakWebViewAuthService {
// Stockage sécurisé des tokens
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
);
// Clés de stockage sécurisé
static const String _accessTokenKey = 'keycloak_webview_access_token';
static const String _idTokenKey = 'keycloak_webview_id_token';
static const String _refreshTokenKey = 'keycloak_webview_refresh_token';
static const String _userInfoKey = 'keycloak_webview_user_info';
static const String _authStateKey = 'keycloak_webview_auth_state';
// Client HTTP avec timeout configuré
static final http.Client _httpClient = http.Client();
/// Génère un code verifier PKCE sécurisé
static String _generateCodeVerifier() {
const String charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final Random random = Random.secure();
return List.generate(128, (i) => charset[random.nextInt(charset.length)]).join();
}
/// Génère le code challenge PKCE à partir du verifier
static String _generateCodeChallenge(String verifier) {
final List<int> bytes = utf8.encode(verifier);
final Digest digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
/// Génère un state sécurisé pour la protection CSRF
static String _generateState() {
final Random random = Random.secure();
final List<int> bytes = List.generate(32, (i) => random.nextInt(256));
return base64Url.encode(bytes).replaceAll('=', '');
}
/// Construit l'URL d'autorisation Keycloak avec tous les paramètres
static Future<Map<String, String>> _buildAuthorizationUrl() async {
final String codeVerifier = _generateCodeVerifier();
final String codeChallenge = _generateCodeChallenge(codeVerifier);
final String state = _generateState();
// Stocker les paramètres pour la validation ultérieure
await _secureStorage.write(
key: _authStateKey,
value: jsonEncode({
'code_verifier': codeVerifier,
'state': state,
'timestamp': DateTime.now().millisecondsSinceEpoch,
}),
);
final Map<String, String> params = {
'response_type': 'code',
'client_id': KeycloakWebViewConfig.clientId,
'redirect_uri': KeycloakWebViewConfig.redirectUrl,
'scope': KeycloakWebViewConfig.scopes.join(' '),
'state': state,
'code_challenge': codeChallenge,
'code_challenge_method': 'S256',
'kc_locale': 'fr',
'prompt': 'login',
};
final String queryString = params.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
final String authUrl = '${KeycloakWebViewConfig.authorizationEndpoint}?$queryString';
debugPrint('🔐 URL d\'autorisation générée: $authUrl');
return {
'url': authUrl,
'state': state,
'code_verifier': codeVerifier,
};
}
/// Valide la réponse de redirection et extrait le code d'autorisation
static Future<String> _validateCallbackAndExtractCode(
String callbackUrl,
String expectedState,
) async {
debugPrint('🔍 Validation du callback: $callbackUrl');
final Uri uri = Uri.parse(callbackUrl);
// Vérifier que c'est bien notre URL de redirection
if (!callbackUrl.startsWith(KeycloakWebViewConfig.redirectUrl)) {
throw const KeycloakWebViewAuthException(
'URL de callback invalide',
code: 'INVALID_CALLBACK_URL',
);
}
// Vérifier la présence d'erreurs
final String? error = uri.queryParameters['error'];
if (error != null) {
final String? errorDescription = uri.queryParameters['error_description'];
throw KeycloakWebViewAuthException(
'Erreur d\'authentification: ${errorDescription ?? error}',
code: error,
);
}
// Valider le state pour la protection CSRF
final String? receivedState = uri.queryParameters['state'];
if (receivedState != expectedState) {
throw const KeycloakWebViewAuthException(
'State invalide - possible attaque CSRF',
code: 'INVALID_STATE',
);
}
// Extraire le code d'autorisation
final String? code = uri.queryParameters['code'];
if (code == null || code.isEmpty) {
throw const KeycloakWebViewAuthException(
'Code d\'autorisation manquant',
code: 'MISSING_AUTH_CODE',
);
}
debugPrint('✅ Code d\'autorisation extrait avec succès');
return code;
}
/// Échange le code d'autorisation contre des tokens
static Future<WebViewAuthResult> _exchangeCodeForTokens(
String authCode,
String codeVerifier,
) async {
debugPrint('🔄 Échange du code d\'autorisation contre les tokens...');
try {
final Map<String, String> body = {
'grant_type': 'authorization_code',
'client_id': KeycloakWebViewConfig.clientId,
'code': authCode,
'redirect_uri': KeycloakWebViewConfig.redirectUrl,
'code_verifier': codeVerifier,
};
final http.Response response = await _httpClient
.post(
Uri.parse(KeycloakWebViewConfig.tokenEndpoint),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body,
)
.timeout(const Duration(seconds: KeycloakWebViewConfig.httpTimeoutSeconds));
debugPrint('📡 Réponse token endpoint: ${response.statusCode}');
if (response.statusCode != 200) {
final String errorBody = response.body;
debugPrint('❌ Erreur échange tokens: $errorBody');
Map<String, dynamic>? errorJson;
try {
errorJson = jsonDecode(errorBody);
} catch (e) {
// Ignore JSON parsing errors
}
final String errorMessage = errorJson?['error_description'] ??
errorJson?['error'] ??
'Erreur HTTP ${response.statusCode}';
throw KeycloakWebViewAuthException(
'Échec de l\'échange de tokens: $errorMessage',
code: errorJson?['error'],
);
}
final Map<String, dynamic> tokenResponse = jsonDecode(response.body);
// Valider la présence des tokens requis
if (!tokenResponse.containsKey('access_token') ||
!tokenResponse.containsKey('id_token')) {
throw const KeycloakWebViewAuthException(
'Tokens manquants dans la réponse',
code: 'MISSING_TOKENS',
);
}
debugPrint('✅ Tokens reçus avec succès');
return WebViewAuthResult.fromTokenResponse(tokenResponse);
} on TimeoutException {
throw const KeycloakWebViewAuthException(
'Timeout lors de l\'échange des tokens',
code: 'TIMEOUT',
);
} catch (e) {
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors de l\'échange des tokens: $e',
originalError: e,
);
}
}
/// Stocke les tokens de manière sécurisée
static Future<void> _storeTokens(WebViewAuthResult authResult) async {
debugPrint('💾 Stockage sécurisé des tokens...');
try {
await Future.wait([
_secureStorage.write(key: _accessTokenKey, value: authResult.accessToken),
_secureStorage.write(key: _idTokenKey, value: authResult.idToken),
if (authResult.refreshToken != null)
_secureStorage.write(key: _refreshTokenKey, value: authResult.refreshToken!),
]);
debugPrint('✅ Tokens stockés avec succès');
} catch (e) {
throw KeycloakWebViewAuthException(
'Erreur lors du stockage des tokens: $e',
originalError: e,
);
}
}
/// Valide et parse un token JWT
///
/// Stratégie de validation JWT :
/// - Côté mobile : vérification de l'expiration et de l'issuer uniquement.
/// La signature JWT n'est PAS vérifiée côté mobile car :
/// 1. Le token est envoyé au backend dans chaque requête API (header Authorization)
/// 2. Le backend Quarkus valide la signature via JWKS (quarkus-oidc)
/// 3. Toutes les communications passent par HTTPS en production
/// 4. Le token est stocké dans FlutterSecureStorage (EncryptedSharedPreferences / Keychain)
/// - Si une validation de signature locale est requise, implémenter via le
/// endpoint JWKS : `KeycloakWebViewConfig.jwksEndpoint`
static Map<String, dynamic> _parseAndValidateJWT(String token, String tokenType) {
try {
// Vérifier l'expiration
if (JwtDecoder.isExpired(token)) {
throw KeycloakWebViewAuthException(
'$tokenType expiré',
code: 'TOKEN_EXPIRED',
);
}
// Parser le payload
final Map<String, dynamic> payload = JwtDecoder.decode(token);
// Validations de base
if (payload['iss'] == null) {
throw const KeycloakWebViewAuthException(
'Token JWT invalide: issuer manquant',
code: 'INVALID_JWT',
);
}
// Vérifier l'issuer
final String expectedIssuer = '${KeycloakWebViewConfig.baseUrl}/realms/${KeycloakWebViewConfig.realm}';
if (payload['iss'] != expectedIssuer) {
throw KeycloakWebViewAuthException(
'Token JWT invalide: issuer incorrect (attendu: $expectedIssuer, reçu: ${payload['iss']})',
code: 'INVALID_ISSUER',
);
}
debugPrint('$tokenType validé avec succès');
return payload;
} catch (e) {
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors de la validation du $tokenType: $e',
originalError: e,
);
}
}
/// Méthode principale d'authentification
///
/// Retourne les paramètres nécessaires pour lancer la WebView d'authentification
static Future<Map<String, String>> prepareAuthentication() async {
debugPrint('🚀 Préparation de l\'authentification WebView...');
try {
// Nettoyer les données d'authentification précédentes
await clearAuthData();
// Générer l'URL d'autorisation avec PKCE
final Map<String, String> authParams = await _buildAuthorizationUrl();
debugPrint('✅ Authentification préparée avec succès');
return authParams;
} catch (e) {
throw KeycloakWebViewAuthException(
'Erreur lors de la préparation de l\'authentification: $e',
originalError: e,
);
}
}
/// Traite le callback de redirection et finalise l'authentification
static Future<User> handleAuthCallback(String callbackUrl) async {
debugPrint('🔄 Traitement du callback d\'authentification...');
debugPrint('📋 URL de callback: $callbackUrl');
try {
// Récupérer les paramètres d'authentification stockés
debugPrint('🔍 Récupération de l\'état d\'authentification...');
final String? authStateJson = await _secureStorage.read(key: _authStateKey);
if (authStateJson == null) {
debugPrint('❌ État d\'authentification manquant');
throw const KeycloakWebViewAuthException(
'État d\'authentification manquant',
code: 'MISSING_AUTH_STATE',
);
}
final Map<String, dynamic> authState = jsonDecode(authStateJson);
final String expectedState = authState['state'];
final String codeVerifier = authState['code_verifier'];
debugPrint('✅ État d\'authentification récupéré');
// Valider le callback et extraire le code
debugPrint('🔍 Validation du callback...');
final String authCode = await _validateCallbackAndExtractCode(
callbackUrl,
expectedState,
);
debugPrint('✅ Code d\'autorisation extrait: ${authCode.substring(0, 10)}...');
// Échanger le code contre des tokens
debugPrint('🔄 Échange du code contre les tokens...');
final WebViewAuthResult authResult = await _exchangeCodeForTokens(
authCode,
codeVerifier,
);
debugPrint('✅ Tokens reçus avec succès');
// Stocker les tokens
debugPrint('💾 Stockage des tokens...');
await _storeTokens(authResult);
debugPrint('✅ Tokens stockés');
// Créer l'utilisateur depuis les tokens
debugPrint('👤 Création de l\'utilisateur...');
final User user = await _createUserFromTokens(authResult);
debugPrint('✅ Utilisateur créé: ${user.fullName}');
// Nettoyer l'état d'authentification temporaire
await _secureStorage.delete(key: _authStateKey);
debugPrint('🎉 Authentification WebView terminée avec succès');
return user;
} catch (e, stackTrace) {
debugPrint('💥 Erreur lors du traitement du callback: $e');
debugPrint('📋 Stack trace: $stackTrace');
// Nettoyer en cas d'erreur
await _secureStorage.delete(key: _authStateKey);
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors du traitement du callback: $e',
originalError: e,
);
}
}
/// Crée un utilisateur depuis les tokens JWT
static Future<User> _createUserFromTokens(WebViewAuthResult authResult) async {
debugPrint('👤 Création de l\'utilisateur depuis les tokens...');
try {
// Parser et valider les tokens
final Map<String, dynamic> accessTokenPayload = _parseAndValidateJWT(
authResult.accessToken,
'Access Token',
);
final Map<String, dynamic> idTokenPayload = _parseAndValidateJWT(
authResult.idToken,
'ID Token',
);
// Extraire les informations utilisateur
final String userId = idTokenPayload['sub'] ?? '';
final String email = idTokenPayload['email'] ?? '';
final String firstName = idTokenPayload['given_name'] ?? '';
final String lastName = idTokenPayload['family_name'] ?? '';
if (userId.isEmpty || email.isEmpty) {
throw const KeycloakWebViewAuthException(
'Informations utilisateur manquantes dans les tokens',
code: 'MISSING_USER_INFO',
);
}
// Extraire les rôles Keycloak
final List<String> keycloakRoles = _extractKeycloakRoles(accessTokenPayload);
// Mapper vers notre système de rôles
final UserRole primaryRole = KeycloakRoleMapper.mapToUserRole(keycloakRoles);
debugPrint('🔐 [AUTH WebView] Rôles: $keycloakRoles → UserRole: ${primaryRole.name} (${primaryRole.displayName})');
final List<String> permissions = KeycloakRoleMapper.mapToPermissions(keycloakRoles);
// Créer l'utilisateur
final User user = User(
id: userId,
email: email,
firstName: firstName,
lastName: lastName,
primaryRole: primaryRole,
organizationContexts: const [],
additionalPermissions: permissions,
revokedPermissions: const [],
preferences: const UserPreferences(
language: 'fr',
theme: 'system',
notificationsEnabled: true,
emailNotifications: true,
pushNotifications: true,
dashboardLayout: 'adaptive',
timezone: 'Europe/Paris',
),
lastLoginAt: DateTime.now(),
createdAt: DateTime.now(),
isActive: true,
);
// Stocker les informations utilisateur
await _secureStorage.write(
key: _userInfoKey,
value: jsonEncode(user.toJson()),
);
debugPrint('✅ Utilisateur créé: ${user.fullName} (${user.primaryRole.displayName})');
return user;
} catch (e) {
if (e is KeycloakWebViewAuthException) rethrow;
throw KeycloakWebViewAuthException(
'Erreur lors de la création de l\'utilisateur: $e',
originalError: e,
);
}
}
/// Extrait les rôles Keycloak depuis le payload du token
static List<String> _extractKeycloakRoles(Map<String, dynamic> tokenPayload) {
try {
final List<String> roles = <String>[];
// Rôles realm
final Map<String, dynamic>? realmAccess = tokenPayload['realm_access'];
if (realmAccess != null && realmAccess['roles'] is List) {
roles.addAll(List<String>.from(realmAccess['roles']));
}
// Rôles client
final Map<String, dynamic>? resourceAccess = tokenPayload['resource_access'];
if (resourceAccess != null) {
final Map<String, dynamic>? clientAccess = resourceAccess[KeycloakWebViewConfig.clientId];
if (clientAccess != null && clientAccess['roles'] is List) {
roles.addAll(List<String>.from(clientAccess['roles']));
}
}
// Filtrer les rôles système
return roles.where((role) =>
!role.startsWith('default-roles-') &&
role != 'offline_access' &&
role != 'uma_authorization'
).toList();
} catch (e) {
debugPrint('💥 Erreur extraction rôles: $e');
return [];
}
}
/// Nettoie toutes les données d'authentification
static Future<void> clearAuthData() async {
debugPrint('🧹 Nettoyage des données d\'authentification...');
try {
await Future.wait([
_secureStorage.delete(key: _accessTokenKey),
_secureStorage.delete(key: _idTokenKey),
_secureStorage.delete(key: _refreshTokenKey),
_secureStorage.delete(key: _userInfoKey),
_secureStorage.delete(key: _authStateKey),
]);
debugPrint('✅ Données d\'authentification nettoyées');
} catch (e) {
debugPrint('⚠️ Erreur lors du nettoyage: $e');
}
}
/// Vérifie si l'utilisateur est authentifié
static Future<bool> isAuthenticated() async {
try {
final String? accessToken = await _secureStorage.read(key: _accessTokenKey);
if (accessToken == null) {
return false;
}
// Vérifier si le token est expiré
return !JwtDecoder.isExpired(accessToken);
} catch (e) {
debugPrint('💥 Erreur vérification authentification: $e');
return false;
}
}
/// Récupère l'utilisateur authentifié
static Future<User?> getCurrentUser() async {
try {
final String? userInfoJson = await _secureStorage.read(key: _userInfoKey);
if (userInfoJson == null) {
return null;
}
final Map<String, dynamic> userJson = jsonDecode(userInfoJson);
return User.fromJson(userJson);
} catch (e) {
debugPrint('💥 Erreur récupération utilisateur: $e');
return null;
}
}
/// Déconnecte l'utilisateur
static Future<bool> logout() async {
debugPrint('🚪 Déconnexion de l\'utilisateur...');
try {
// Nettoyer les données locales
await clearAuthData();
debugPrint('✅ Déconnexion réussie');
return true;
} catch (e) {
debugPrint('💥 Erreur déconnexion: $e');
return false;
}
}
}

View File

@@ -0,0 +1,376 @@
/// Moteur de permissions ultra-performant avec cache intelligent
/// Vérifications contextuelles et audit trail intégré
library permission_engine;
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/user.dart';
import '../models/user_role.dart';
import '../models/permission_matrix.dart';
/// Moteur de permissions haute performance avec cache multi-niveaux
///
/// Fonctionnalités :
/// - Cache mémoire ultra-rapide avec TTL
/// - Vérifications contextuelles avancées
/// - Audit trail automatique
/// - Support des permissions héritées
/// - Invalidation intelligente du cache
class PermissionEngine {
static final PermissionEngine _instance = PermissionEngine._internal();
factory PermissionEngine() => _instance;
PermissionEngine._internal();
/// Cache mémoire des permissions avec TTL
static final Map<String, _CachedPermission> _permissionCache = {};
/// Cache des permissions effectives par utilisateur
static final Map<String, _CachedUserPermissions> _userPermissionsCache = {};
/// Durée de vie du cache (5 minutes par défaut)
static const Duration _defaultCacheTTL = Duration(minutes: 5);
/// Durée de vie du cache pour les super admins (plus long)
static const Duration _superAdminCacheTTL = Duration(minutes: 15);
/// Compteur de hits/miss du cache pour monitoring
static int _cacheHits = 0;
static int _cacheMisses = 0;
/// Stream pour les événements d'audit
static final StreamController<PermissionAuditEvent> _auditController =
StreamController<PermissionAuditEvent>.broadcast();
/// Stream des événements d'audit
static Stream<PermissionAuditEvent> get auditStream => _auditController.stream;
/// Vérifie si un utilisateur a une permission spécifique
///
/// [user] - Utilisateur à vérifier
/// [permission] - Permission à vérifier
/// [organizationId] - Contexte organisationnel optionnel
/// [auditLog] - Activer l'audit trail (défaut: true)
static Future<bool> hasPermission(
User user,
String permission, {
String? organizationId,
bool auditLog = true,
}) async {
final cacheKey = _generateCacheKey(user.id, permission, organizationId);
// Vérification du cache
final cachedResult = _getCachedPermission(cacheKey);
if (cachedResult != null) {
_cacheHits++;
if (auditLog && !cachedResult.result) {
_logAuditEvent(user, permission, false, 'CACHED_DENIED', organizationId);
}
return cachedResult.result;
}
_cacheMisses++;
// Calcul de la permission
final result = await _computePermission(user, permission, organizationId);
// Mise en cache
_cachePermission(cacheKey, result, user.primaryRole);
// Audit trail
if (auditLog) {
_logAuditEvent(
user,
permission,
result,
result ? 'GRANTED' : 'DENIED',
organizationId,
);
}
return result;
}
/// Vérifie plusieurs permissions en une seule fois
static Future<Map<String, bool>> hasPermissions(
User user,
List<String> permissions, {
String? organizationId,
bool auditLog = true,
}) async {
final results = <String, bool>{};
// Traitement en parallèle pour les performances
final futures = permissions.map((permission) =>
hasPermission(user, permission, organizationId: organizationId, auditLog: auditLog)
.then((result) => MapEntry(permission, result))
);
final entries = await Future.wait(futures);
for (final entry in entries) {
results[entry.key] = entry.value;
}
return results;
}
/// Obtient toutes les permissions effectives d'un utilisateur
static Future<List<String>> getEffectivePermissions(
User user, {
String? organizationId,
}) async {
final cacheKey = '${user.id}_effective_${organizationId ?? 'global'}';
// Vérification du cache utilisateur
final cachedUserPermissions = _getCachedUserPermissions(cacheKey);
if (cachedUserPermissions != null) {
_cacheHits++;
return cachedUserPermissions.permissions;
}
_cacheMisses++;
// Calcul des permissions effectives
final permissions = user.getEffectivePermissions(organizationId: organizationId);
// Mise en cache
_cacheUserPermissions(cacheKey, permissions, user.primaryRole);
return permissions;
}
/// Vérifie si un utilisateur peut effectuer une action sur un domaine
static Future<bool> canPerformAction(
User user,
String domain,
String action, {
String scope = 'own',
String? organizationId,
}) async {
final permission = '$domain.$action.$scope';
return hasPermission(user, permission, organizationId: organizationId);
}
/// Invalide le cache pour un utilisateur spécifique
static void invalidateUserCache(String userId) {
final keysToRemove = <String>[];
// Invalider le cache des permissions
for (final key in _permissionCache.keys) {
if (key.startsWith('${userId}_')) {
keysToRemove.add(key);
}
}
for (final key in keysToRemove) {
_permissionCache.remove(key);
}
// Invalider le cache des permissions utilisateur
final userKeysToRemove = <String>[];
for (final key in _userPermissionsCache.keys) {
if (key.startsWith('${userId}_')) {
userKeysToRemove.add(key);
}
}
for (final key in userKeysToRemove) {
_userPermissionsCache.remove(key);
}
debugPrint('Cache invalidé pour l\'utilisateur: $userId');
}
/// Invalide tout le cache
static void invalidateAllCache() {
_permissionCache.clear();
_userPermissionsCache.clear();
debugPrint('Cache complet invalidé');
}
/// Obtient les statistiques du cache
static Map<String, dynamic> getCacheStats() {
final totalRequests = _cacheHits + _cacheMisses;
final hitRate = totalRequests > 0 ? (_cacheHits / totalRequests * 100) : 0.0;
return {
'cacheHits': _cacheHits,
'cacheMisses': _cacheMisses,
'hitRate': hitRate.toStringAsFixed(2),
'permissionCacheSize': _permissionCache.length,
'userPermissionsCacheSize': _userPermissionsCache.length,
};
}
/// Nettoie le cache expiré
static void cleanExpiredCache() {
final now = DateTime.now();
// Nettoyer le cache des permissions
_permissionCache.removeWhere((key, cached) => cached.expiresAt.isBefore(now));
// Nettoyer le cache des permissions utilisateur
_userPermissionsCache.removeWhere((key, cached) => cached.expiresAt.isBefore(now));
debugPrint('Cache expiré nettoyé');
}
// === MÉTHODES PRIVÉES ===
/// Calcule une permission sans cache
static Future<bool> _computePermission(
User user,
String permission,
String? organizationId,
) async {
// Vérification des permissions publiques
if (PermissionMatrix.isPublicPermission(permission)) {
return true;
}
// Vérification utilisateur actif
if (!user.isActive) return false;
// Vérification directe de l'utilisateur
if (user.hasPermission(permission, organizationId: organizationId)) {
return true;
}
// Vérifications contextuelles avancées
return _checkContextualPermissions(user, permission, organizationId);
}
/// Vérifications contextuelles avancées (intégration serveur).
/// Quand le backend exposera GET /api/permissions/check avec userId, permission, organizationId,
/// remplacer le return false par l'appel API et le résultat.
static Future<bool> _checkContextualPermissions(
User user,
String permission,
String? organizationId,
) async {
// Vérification contextuelle désactivée — endpoint non disponible.
return false;
}
/// Génère une clé de cache unique
static String _generateCacheKey(String userId, String permission, String? organizationId) {
return '${userId}_${permission}_${organizationId ?? 'global'}';
}
/// Obtient une permission depuis le cache
static _CachedPermission? _getCachedPermission(String key) {
final cached = _permissionCache[key];
if (cached != null && cached.expiresAt.isAfter(DateTime.now())) {
return cached;
}
if (cached != null) {
_permissionCache.remove(key);
}
return null;
}
/// Met en cache une permission
static void _cachePermission(String key, bool result, UserRole userRole) {
final ttl = userRole == UserRole.superAdmin ? _superAdminCacheTTL : _defaultCacheTTL;
_permissionCache[key] = _CachedPermission(
result: result,
expiresAt: DateTime.now().add(ttl),
);
}
/// Obtient les permissions utilisateur depuis le cache
static _CachedUserPermissions? _getCachedUserPermissions(String key) {
final cached = _userPermissionsCache[key];
if (cached != null && cached.expiresAt.isAfter(DateTime.now())) {
return cached;
}
if (cached != null) {
_userPermissionsCache.remove(key);
}
return null;
}
/// Met en cache les permissions utilisateur
static void _cacheUserPermissions(String key, List<String> permissions, UserRole userRole) {
final ttl = userRole == UserRole.superAdmin ? _superAdminCacheTTL : _defaultCacheTTL;
_userPermissionsCache[key] = _CachedUserPermissions(
permissions: permissions,
expiresAt: DateTime.now().add(ttl),
);
}
/// Enregistre un événement d'audit
static void _logAuditEvent(
User user,
String permission,
bool granted,
String reason,
String? organizationId,
) {
final event = PermissionAuditEvent(
userId: user.id,
userEmail: user.email,
permission: permission,
granted: granted,
reason: reason,
organizationId: organizationId,
timestamp: DateTime.now(),
);
_auditController.add(event);
}
}
/// Classe pour les permissions mises en cache
class _CachedPermission {
final bool result;
final DateTime expiresAt;
_CachedPermission({required this.result, required this.expiresAt});
}
/// Classe pour les permissions utilisateur mises en cache
class _CachedUserPermissions {
final List<String> permissions;
final DateTime expiresAt;
_CachedUserPermissions({required this.permissions, required this.expiresAt});
}
/// Événement d'audit des permissions
class PermissionAuditEvent {
final String userId;
final String userEmail;
final String permission;
final bool granted;
final String reason;
final String? organizationId;
final DateTime timestamp;
PermissionAuditEvent({
required this.userId,
required this.userEmail,
required this.permission,
required this.granted,
required this.reason,
this.organizationId,
required this.timestamp,
});
Map<String, dynamic> toJson() {
return {
'userId': userId,
'userEmail': userEmail,
'permission': permission,
'granted': granted,
'reason': reason,
'organizationId': organizationId,
'timestamp': timestamp.toIso8601String(),
};
}
}

View File

@@ -0,0 +1,212 @@
/// Système de permissions granulaires ultra-sophistiqué
/// Plus de 50 permissions atomiques avec héritage intelligent
library permission_matrix;
/// Matrice de permissions atomiques pour contrôle granulaire
///
/// Chaque permission suit la convention : `domain.action.scope`
/// Exemples : `members.edit.own`, `finances.view.all`, `system.admin.global`
class PermissionMatrix {
// === PERMISSIONS SYSTÈME ===
static const String SYSTEM_ADMIN = 'system.admin.global';
static const String SYSTEM_CONFIG = 'system.config.global';
static const String SYSTEM_MONITORING = 'system.monitoring.view';
static const String SYSTEM_BACKUP = 'system.backup.manage';
static const String SYSTEM_SECURITY = 'system.security.manage';
static const String SYSTEM_AUDIT = 'system.audit.view';
static const String SYSTEM_LOGS = 'system.logs.view';
static const String SYSTEM_MAINTENANCE = 'system.maintenance.execute';
// === PERMISSIONS ORGANISATION ===
static const String ORG_CREATE = 'organization.create.global';
static const String ORG_DELETE = 'organization.delete.own';
static const String ORG_CONFIG = 'organization.config.own';
static const String ORG_BRANDING = 'organization.branding.manage';
static const String ORG_SETTINGS = 'organization.settings.manage';
static const String ORG_PERMISSIONS = 'organization.permissions.manage';
static const String ORG_WORKFLOWS = 'organization.workflows.manage';
static const String ORG_INTEGRATIONS = 'organization.integrations.manage';
// === PERMISSIONS DASHBOARD ===
static const String DASHBOARD_VIEW = 'dashboard.view.own';
static const String DASHBOARD_ADMIN = 'dashboard.admin.view';
static const String DASHBOARD_ANALYTICS = 'dashboard.analytics.view';
static const String DASHBOARD_REPORTS = 'dashboard.reports.generate';
static const String DASHBOARD_EXPORT = 'dashboard.export.data';
static const String DASHBOARD_CUSTOMIZE = 'dashboard.customize.layout';
// === PERMISSIONS MEMBRES ===
static const String MEMBERS_VIEW_ALL = 'members.view.all';
static const String MEMBERS_VIEW_OWN = 'members.view.own';
static const String MEMBERS_CREATE = 'members.create.organization';
static const String MEMBERS_EDIT_ALL = 'members.edit.all';
static const String MEMBERS_EDIT_OWN = 'members.edit.own';
static const String MEMBERS_EDIT_BASIC = 'members.edit.basic';
static const String MEMBERS_DELETE = 'members.delete.organization';
static const String MEMBERS_DELETE_ALL = 'members.delete.all';
static const String MEMBERS_APPROVE = 'members.approve.requests';
static const String MEMBERS_SUSPEND = 'members.suspend.organization';
static const String MEMBERS_EXPORT = 'members.export.data';
static const String MEMBERS_IMPORT = 'members.import.data';
static const String MEMBERS_COMMUNICATE = 'members.communicate.all';
// === PERMISSIONS FINANCES ===
static const String FINANCES_VIEW_ALL = 'finances.view.all';
static const String FINANCES_VIEW_OWN = 'finances.view.own';
static const String FINANCES_EDIT_ALL = 'finances.edit.all';
static const String FINANCES_MANAGE = 'finances.manage.organization';
static const String FINANCES_APPROVE = 'finances.approve.transactions';
static const String FINANCES_REPORTS = 'finances.reports.generate';
static const String FINANCES_BUDGET = 'finances.budget.manage';
static const String FINANCES_AUDIT = 'finances.audit.access';
// === PERMISSIONS ÉVÉNEMENTS ===
static const String EVENTS_VIEW_ALL = 'events.view.all';
static const String EVENTS_VIEW_PUBLIC = 'events.view.public';
static const String EVENTS_CREATE = 'events.create.organization';
static const String EVENTS_EDIT_ALL = 'events.edit.all';
static const String EVENTS_EDIT_OWN = 'events.edit.own';
static const String EVENTS_DELETE = 'events.delete.organization';
static const String EVENTS_PARTICIPATE = 'events.participate.public';
static const String EVENTS_MODERATE = 'events.moderate.organization';
static const String EVENTS_ANALYTICS = 'events.analytics.view';
// === PERMISSIONS SOLIDARITÉ ===
static const String SOLIDARITY_VIEW_ALL = 'solidarity.view.all';
static const String SOLIDARITY_VIEW_OWN = 'solidarity.view.own';
static const String SOLIDARITY_VIEW_PUBLIC = 'solidarity.view.public';
static const String SOLIDARITY_CREATE = 'solidarity.create.request';
static const String SOLIDARITY_EDIT_ALL = 'solidarity.edit.all';
static const String SOLIDARITY_APPROVE = 'solidarity.approve.requests';
static const String SOLIDARITY_PARTICIPATE = 'solidarity.participate.actions';
static const String SOLIDARITY_MANAGE = 'solidarity.manage.organization';
static const String SOLIDARITY_FUND = 'solidarity.fund.manage';
// === PERMISSIONS COMMUNICATION ===
static const String COMM_SEND_ALL = 'communication.send.all';
static const String COMM_SEND_MEMBERS = 'communication.send.members';
static const String COMM_MODERATE = 'communication.moderate.organization';
static const String COMM_BROADCAST = 'communication.broadcast.organization';
static const String COMM_TEMPLATES = 'communication.templates.manage';
// === PERMISSIONS RAPPORTS ===
static const String REPORTS_VIEW_ALL = 'reports.view.all';
static const String REPORTS_GENERATE = 'reports.generate.organization';
static const String REPORTS_EXPORT = 'reports.export.data';
static const String REPORTS_SCHEDULE = 'reports.schedule.automated';
// === PERMISSIONS MODÉRATION ===
static const String MODERATION_CONTENT = 'moderation.content.manage';
static const String MODERATION_USERS = 'moderation.users.manage';
static const String MODERATION_REPORTS = 'moderation.reports.handle';
/// Toutes les permissions disponibles dans le système
static const List<String> ALL_PERMISSIONS = [
// Système
SYSTEM_ADMIN, SYSTEM_CONFIG, SYSTEM_MONITORING, SYSTEM_BACKUP,
SYSTEM_SECURITY, SYSTEM_AUDIT, SYSTEM_LOGS, SYSTEM_MAINTENANCE,
// Organisation
ORG_CREATE, ORG_DELETE, ORG_CONFIG, ORG_BRANDING, ORG_SETTINGS,
ORG_PERMISSIONS, ORG_WORKFLOWS, ORG_INTEGRATIONS,
// Dashboard
DASHBOARD_VIEW, DASHBOARD_ADMIN, DASHBOARD_ANALYTICS, DASHBOARD_REPORTS,
DASHBOARD_EXPORT, DASHBOARD_CUSTOMIZE,
// Membres
MEMBERS_VIEW_ALL, MEMBERS_VIEW_OWN, MEMBERS_CREATE, MEMBERS_EDIT_ALL,
MEMBERS_EDIT_OWN, MEMBERS_DELETE, MEMBERS_APPROVE, MEMBERS_SUSPEND,
MEMBERS_EXPORT, MEMBERS_IMPORT, MEMBERS_COMMUNICATE,
// Finances
FINANCES_VIEW_ALL, FINANCES_VIEW_OWN, FINANCES_MANAGE, FINANCES_APPROVE,
FINANCES_REPORTS, FINANCES_BUDGET, FINANCES_AUDIT,
// Événements
EVENTS_VIEW_ALL, EVENTS_VIEW_PUBLIC, EVENTS_CREATE, EVENTS_EDIT_ALL,
EVENTS_EDIT_OWN, EVENTS_DELETE, EVENTS_MODERATE, EVENTS_ANALYTICS,
// Solidarité
SOLIDARITY_VIEW_ALL, SOLIDARITY_VIEW_OWN, SOLIDARITY_CREATE,
SOLIDARITY_APPROVE, SOLIDARITY_MANAGE, SOLIDARITY_FUND,
// Communication
COMM_SEND_ALL, COMM_SEND_MEMBERS, COMM_MODERATE, COMM_BROADCAST,
COMM_TEMPLATES,
// Rapports
REPORTS_VIEW_ALL, REPORTS_GENERATE, REPORTS_EXPORT, REPORTS_SCHEDULE,
// Modération
MODERATION_CONTENT, MODERATION_USERS, MODERATION_REPORTS,
];
/// Permissions publiques (accessibles sans authentification)
static const List<String> PUBLIC_PERMISSIONS = [
EVENTS_VIEW_PUBLIC,
];
/// Vérifie si une permission est publique
static bool isPublicPermission(String permission) {
return PUBLIC_PERMISSIONS.contains(permission);
}
/// Obtient le domaine d'une permission (partie avant le premier point)
static String getDomain(String permission) {
return permission.split('.').first;
}
/// Obtient l'action d'une permission (partie du milieu)
static String getAction(String permission) {
final parts = permission.split('.');
return parts.length > 1 ? parts[1] : '';
}
/// Obtient la portée d'une permission (partie après le dernier point)
static String getScope(String permission) {
return permission.split('.').last;
}
/// Vérifie si une permission implique une autre (héritage)
static bool implies(String higherPermission, String lowerPermission) {
// Exemple : 'members.edit.all' implique 'members.view.all'
final higherParts = higherPermission.split('.');
final lowerParts = lowerPermission.split('.');
if (higherParts.length != 3 || lowerParts.length != 3) return false;
// Même domaine requis
if (higherParts[0] != lowerParts[0]) return false;
// Vérification des implications d'actions
return _actionImplies(higherParts[1], lowerParts[1]) &&
_scopeImplies(higherParts[2], lowerParts[2]);
}
/// Vérifie si une action implique une autre
static bool _actionImplies(String higherAction, String lowerAction) {
const actionHierarchy = {
'admin': ['manage', 'edit', 'create', 'delete', 'view'],
'manage': ['edit', 'create', 'delete', 'view'],
'edit': ['view'],
'create': ['view'],
'delete': ['view'],
};
return actionHierarchy[higherAction]?.contains(lowerAction) ??
higherAction == lowerAction;
}
/// Vérifie si une portée implique une autre
static bool _scopeImplies(String higherScope, String lowerScope) {
const scopeHierarchy = {
'global': ['all', 'organization', 'own'],
'all': ['organization', 'own'],
'organization': ['own'],
};
return scopeHierarchy[higherScope]?.contains(lowerScope) ??
higherScope == lowerScope;
}
}

View File

@@ -0,0 +1,359 @@
/// Modèles de données utilisateur avec contexte et permissions
/// Support des relations multi-organisations et permissions contextuelles
library user_models;
import 'package:equatable/equatable.dart';
import 'user_role.dart';
/// Modèle utilisateur principal avec contexte multi-organisations
///
/// Supporte les utilisateurs ayant des rôles différents dans plusieurs organisations
/// avec des permissions contextuelles et des préférences personnalisées
class User extends Equatable {
/// Identifiant unique de l'utilisateur
final String id;
/// Informations personnelles
final String email;
final String firstName;
final String lastName;
final String? avatar;
final String? phone;
/// Rôle principal de l'utilisateur (le plus élevé)
final UserRole primaryRole;
/// Contextes organisationnels (rôles dans différentes organisations)
final List<UserOrganizationContext> organizationContexts;
/// Permissions supplémentaires accordées spécifiquement
final List<String> additionalPermissions;
/// Permissions révoquées spécifiquement
final List<String> revokedPermissions;
/// Préférences utilisateur
final UserPreferences preferences;
/// Métadonnées
final DateTime createdAt;
final DateTime lastLoginAt;
final bool isActive;
final bool isVerified;
/// Constructeur du modèle utilisateur
const User({
required this.id,
required this.email,
required this.firstName,
required this.lastName,
required this.primaryRole,
this.avatar,
this.phone,
this.organizationContexts = const [],
this.additionalPermissions = const [],
this.revokedPermissions = const [],
this.preferences = const UserPreferences(),
required this.createdAt,
required this.lastLoginAt,
this.isActive = true,
this.isVerified = false,
});
/// Nom complet de l'utilisateur
String get fullName => '$firstName $lastName';
/// Initiales de l'utilisateur
String get initials => '${firstName[0]}${lastName[0]}'.toUpperCase();
/// Vérifie si l'utilisateur a une permission dans le contexte actuel
bool hasPermission(String permission, {String? organizationId}) {
// Vérification des permissions révoquées
if (revokedPermissions.contains(permission)) return false;
// Vérification des permissions additionnelles
if (additionalPermissions.contains(permission)) return true;
// Vérification du rôle principal
if (primaryRole.hasPermission(permission)) return true;
// Vérification dans le contexte organisationnel spécifique
if (organizationId != null) {
final context = getOrganizationContext(organizationId);
if (context?.role.hasPermission(permission) == true) return true;
}
// Vérification dans tous les contextes organisationnels
return organizationContexts.any((context) =>
context.role.hasPermission(permission));
}
/// Obtient le contexte organisationnel pour une organisation
UserOrganizationContext? getOrganizationContext(String organizationId) {
try {
return organizationContexts.firstWhere(
(context) => context.organizationId == organizationId,
);
} catch (e) {
return null;
}
}
/// Obtient le rôle dans une organisation spécifique
UserRole getRoleInOrganization(String organizationId) {
final context = getOrganizationContext(organizationId);
return context?.role ?? primaryRole;
}
/// Vérifie si l'utilisateur est membre d'une organisation
bool isMemberOfOrganization(String organizationId) {
return organizationContexts.any(
(context) => context.organizationId == organizationId,
);
}
/// Obtient toutes les permissions effectives de l'utilisateur
List<String> getEffectivePermissions({String? organizationId}) {
final permissions = <String>{};
// Permissions du rôle principal
permissions.addAll(primaryRole.getEffectivePermissions());
// Permissions des contextes organisationnels
if (organizationId != null) {
final context = getOrganizationContext(organizationId);
if (context != null) {
permissions.addAll(context.role.getEffectivePermissions());
}
} else {
for (final context in organizationContexts) {
permissions.addAll(context.role.getEffectivePermissions());
}
}
// Permissions additionnelles
permissions.addAll(additionalPermissions);
// Retirer les permissions révoquées
permissions.removeAll(revokedPermissions);
return permissions.toList()..sort();
}
/// Crée une copie de l'utilisateur avec des modifications
User copyWith({
String? email,
String? firstName,
String? lastName,
String? avatar,
String? phone,
UserRole? primaryRole,
List<UserOrganizationContext>? organizationContexts,
List<String>? additionalPermissions,
List<String>? revokedPermissions,
UserPreferences? preferences,
DateTime? lastLoginAt,
bool? isActive,
bool? isVerified,
}) {
return User(
id: id,
email: email ?? this.email,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
avatar: avatar ?? this.avatar,
phone: phone ?? this.phone,
primaryRole: primaryRole ?? this.primaryRole,
organizationContexts: organizationContexts ?? this.organizationContexts,
additionalPermissions: additionalPermissions ?? this.additionalPermissions,
revokedPermissions: revokedPermissions ?? this.revokedPermissions,
preferences: preferences ?? this.preferences,
createdAt: createdAt,
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
isActive: isActive ?? this.isActive,
isVerified: isVerified ?? this.isVerified,
);
}
/// Conversion vers Map pour sérialisation
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'firstName': firstName,
'lastName': lastName,
'avatar': avatar,
'phone': phone,
'primaryRole': primaryRole.name,
'organizationContexts': organizationContexts.map((c) => c.toJson()).toList(),
'additionalPermissions': additionalPermissions,
'revokedPermissions': revokedPermissions,
'preferences': preferences.toJson(),
'createdAt': createdAt.toIso8601String(),
'lastLoginAt': lastLoginAt.toIso8601String(),
'isActive': isActive,
'isVerified': isVerified,
};
}
/// Création depuis Map pour désérialisation
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
email: json['email'],
firstName: json['firstName'],
lastName: json['lastName'],
avatar: json['avatar'],
phone: json['phone'],
primaryRole: UserRole.fromString(json['primaryRole']) ?? UserRole.visitor,
organizationContexts: (json['organizationContexts'] as List?)
?.map((c) => UserOrganizationContext.fromJson(c))
.toList() ?? [],
additionalPermissions: List<String>.from(json['additionalPermissions'] ?? []),
revokedPermissions: List<String>.from(json['revokedPermissions'] ?? []),
preferences: UserPreferences.fromJson(json['preferences'] ?? {}),
createdAt: DateTime.parse(json['createdAt']),
lastLoginAt: DateTime.parse(json['lastLoginAt']),
isActive: json['isActive'] ?? true,
isVerified: json['isVerified'] ?? false,
);
}
@override
List<Object?> get props => [
id, email, firstName, lastName, avatar, phone, primaryRole,
organizationContexts, additionalPermissions, revokedPermissions,
preferences, createdAt, lastLoginAt, isActive, isVerified,
];
}
/// Contexte organisationnel d'un utilisateur
///
/// Définit le rôle et les permissions spécifiques dans une organisation
class UserOrganizationContext extends Equatable {
/// Identifiant de l'organisation
final String organizationId;
/// Nom de l'organisation
final String organizationName;
/// Rôle de l'utilisateur dans cette organisation
final UserRole role;
/// Permissions spécifiques dans cette organisation
final List<String> specificPermissions;
/// Date d'adhésion à l'organisation
final DateTime joinedAt;
/// Statut dans l'organisation
final bool isActive;
/// Constructeur du contexte organisationnel
const UserOrganizationContext({
required this.organizationId,
required this.organizationName,
required this.role,
this.specificPermissions = const [],
required this.joinedAt,
this.isActive = true,
});
/// Conversion vers Map
Map<String, dynamic> toJson() {
return {
'organizationId': organizationId,
'organizationName': organizationName,
'role': role.name,
'specificPermissions': specificPermissions,
'joinedAt': joinedAt.toIso8601String(),
'isActive': isActive,
};
}
/// Création depuis Map
factory UserOrganizationContext.fromJson(Map<String, dynamic> json) {
return UserOrganizationContext(
organizationId: json['organizationId'],
organizationName: json['organizationName'],
role: UserRole.fromString(json['role']) ?? UserRole.visitor,
specificPermissions: List<String>.from(json['specificPermissions'] ?? []),
joinedAt: DateTime.parse(json['joinedAt']),
isActive: json['isActive'] ?? true,
);
}
@override
List<Object?> get props => [
organizationId, organizationName, role, specificPermissions, joinedAt, isActive,
];
}
/// Préférences utilisateur personnalisables
class UserPreferences extends Equatable {
/// Langue préférée
final String language;
/// Thème préféré
final String theme;
/// Notifications activées
final bool notificationsEnabled;
/// Notifications par email
final bool emailNotifications;
/// Notifications push
final bool pushNotifications;
/// Layout du dashboard préféré
final String dashboardLayout;
/// Timezone
final String timezone;
/// Constructeur des préférences
const UserPreferences({
this.language = 'fr',
this.theme = 'system',
this.notificationsEnabled = true,
this.emailNotifications = true,
this.pushNotifications = true,
this.dashboardLayout = 'default',
this.timezone = 'Europe/Paris',
});
/// Conversion vers Map
Map<String, dynamic> toJson() {
return {
'language': language,
'theme': theme,
'notificationsEnabled': notificationsEnabled,
'emailNotifications': emailNotifications,
'pushNotifications': pushNotifications,
'dashboardLayout': dashboardLayout,
'timezone': timezone,
};
}
/// Création depuis Map
factory UserPreferences.fromJson(Map<String, dynamic> json) {
return UserPreferences(
language: json['language'] ?? 'fr',
theme: json['theme'] ?? 'system',
notificationsEnabled: json['notificationsEnabled'] ?? true,
emailNotifications: json['emailNotifications'] ?? true,
pushNotifications: json['pushNotifications'] ?? true,
dashboardLayout: json['dashboardLayout'] ?? 'default',
timezone: json['timezone'] ?? 'Europe/Paris',
);
}
@override
List<Object?> get props => [
language, theme, notificationsEnabled, emailNotifications,
pushNotifications, dashboardLayout, timezone,
];
}

View File

@@ -0,0 +1,359 @@
/// Système de rôles utilisateurs avec hiérarchie intelligente
/// 6 niveaux de rôles avec permissions héritées et contextuelles
library user_role;
import 'permission_matrix.dart';
/// Énumération des rôles utilisateurs avec hiérarchie et permissions
///
/// Chaque rôle a un niveau numérique pour faciliter les comparaisons
/// et une liste de permissions spécifiques avec héritage intelligent
enum UserRole {
/// Super Administrateur - Niveau système (100)
/// Accès complet à toutes les fonctionnalités multi-organisations
superAdmin(
level: 100,
displayName: 'Super Administrateur',
description: 'Accès complet système et multi-organisations',
color: 0xFF6C5CE7, // Violet sophistiqué
permissions: _superAdminPermissions,
),
/// Administrateur d'Organisation - Niveau organisation (80)
/// Gestion complète de son organisation uniquement
orgAdmin(
level: 80,
displayName: 'Administrateur',
description: 'Gestion complète de l\'organisation',
color: 0xFF0984E3, // Bleu corporate
permissions: _orgAdminPermissions,
),
/// Modérateur/Gestionnaire - Niveau intermédiaire (60)
/// Gestion partielle selon permissions accordées
moderator(
level: 60,
displayName: 'Modérateur',
description: 'Gestion partielle et modération',
color: 0xFFE17055, // Orange focus
permissions: _moderatorPermissions,
),
/// Consultant - Niveau intermédiaire (58)
/// Accès consultant / conseil
consultant(
level: 58,
displayName: 'Consultant',
description: 'Accès consultant et conseil',
color: 0xFF6C5CE7, // Violet
permissions: _consultantPermissions,
),
/// Gestionnaire RH - Niveau intermédiaire (52)
/// Gestion des ressources humaines
hrManager(
level: 52,
displayName: 'Gestionnaire RH',
description: 'Gestion des ressources humaines',
color: 0xFF0984E3, // Bleu
permissions: _hrManagerPermissions,
),
/// Membre Actif - Niveau utilisateur (40)
/// Accès aux fonctionnalités membres avec participation active
activeMember(
level: 40,
displayName: 'Membre Actif',
description: 'Participation active aux activités',
color: 0xFF00B894, // Vert communauté
permissions: _activeMemberPermissions,
),
/// Membre Simple - Niveau basique (20)
/// Accès limité aux informations personnelles
simpleMember(
level: 20,
displayName: 'Membre',
description: 'Accès aux informations de base',
color: 0xFF00CEC9, // Teal simple
permissions: _simpleMemberPermissions,
),
/// Visiteur/Invité - Niveau public (0)
/// Accès aux informations publiques uniquement
visitor(
level: 0,
displayName: 'Visiteur',
description: 'Accès aux informations publiques',
color: 0xFF6C5CE7, // Indigo accueillant
permissions: _visitorPermissions,
);
/// Constructeur du rôle avec toutes ses propriétés
const UserRole({
required this.level,
required this.displayName,
required this.description,
required this.color,
required this.permissions,
});
/// Niveau numérique du rôle (0-100)
final int level;
/// Nom d'affichage du rôle
final String displayName;
/// Description détaillée du rôle
final String description;
/// Couleur thématique du rôle (format 0xFFRRGGBB)
final int color;
/// Liste des permissions spécifiques au rôle
final List<String> permissions;
/// Vérifie si ce rôle a un niveau supérieur ou égal à un autre
bool hasLevelOrAbove(UserRole other) => level >= other.level;
/// Vérifie si ce rôle a un niveau strictement supérieur à un autre
bool hasLevelAbove(UserRole other) => level > other.level;
/// Vérifie si ce rôle possède une permission spécifique
bool hasPermission(String permission) {
// Vérification directe
if (permissions.contains(permission)) return true;
// Vérification par héritage (permissions impliquées)
return permissions.any((p) => PermissionMatrix.implies(p, permission));
}
/// Obtient toutes les permissions effectives (directes + héritées)
List<String> getEffectivePermissions() {
final effective = <String>{};
// Ajouter les permissions directes
effective.addAll(permissions);
// Ajouter les permissions impliquées
for (final permission in permissions) {
for (final allPermission in PermissionMatrix.ALL_PERMISSIONS) {
if (PermissionMatrix.implies(permission, allPermission)) {
effective.add(allPermission);
}
}
}
return effective.toList()..sort();
}
/// Vérifie si ce rôle peut effectuer une action sur un domaine
bool canPerformAction(String domain, String action, {String scope = 'own'}) {
final permission = '$domain.$action.$scope';
return hasPermission(permission);
}
/// Obtient le rôle à partir de son nom
static UserRole? fromString(String roleName) {
return UserRole.values.firstWhere(
(role) => role.name == roleName,
orElse: () => UserRole.visitor,
);
}
/// Obtient tous les rôles avec un niveau inférieur ou égal
List<UserRole> getSubordinateRoles() {
return UserRole.values.where((role) => role.level < level).toList();
}
/// Obtient tous les rôles avec un niveau supérieur ou égal
List<UserRole> getSuperiorRoles() {
return UserRole.values.where((role) => role.level >= level).toList();
}
}
// === DÉFINITIONS DES PERMISSIONS PAR RÔLE ===
/// Permissions du Super Administrateur (accès complet)
const List<String> _superAdminPermissions = [
// Toutes les permissions système
PermissionMatrix.SYSTEM_ADMIN,
PermissionMatrix.SYSTEM_CONFIG,
PermissionMatrix.SYSTEM_MONITORING,
PermissionMatrix.SYSTEM_BACKUP,
PermissionMatrix.SYSTEM_SECURITY,
PermissionMatrix.SYSTEM_AUDIT,
PermissionMatrix.SYSTEM_LOGS,
PermissionMatrix.SYSTEM_MAINTENANCE,
// Gestion globale des organisations
PermissionMatrix.ORG_CREATE,
PermissionMatrix.ORG_DELETE,
PermissionMatrix.ORG_CONFIG,
// Accès complet aux dashboards
PermissionMatrix.DASHBOARD_ADMIN,
PermissionMatrix.DASHBOARD_ANALYTICS,
PermissionMatrix.DASHBOARD_REPORTS,
PermissionMatrix.DASHBOARD_EXPORT,
// Gestion complète des membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_DELETE,
PermissionMatrix.MEMBERS_EXPORT,
PermissionMatrix.MEMBERS_IMPORT,
// Accès complet aux finances
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_MANAGE,
PermissionMatrix.FINANCES_AUDIT,
// Tous les rapports
PermissionMatrix.REPORTS_VIEW_ALL,
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.REPORTS_EXPORT,
PermissionMatrix.REPORTS_SCHEDULE,
];
/// Permissions de l'Administrateur d'Organisation
const List<String> _orgAdminPermissions = [
// Configuration organisation
PermissionMatrix.ORG_CONFIG,
PermissionMatrix.ORG_BRANDING,
PermissionMatrix.ORG_SETTINGS,
PermissionMatrix.ORG_PERMISSIONS,
PermissionMatrix.ORG_WORKFLOWS,
// Dashboard organisation
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.DASHBOARD_ANALYTICS,
PermissionMatrix.DASHBOARD_REPORTS,
PermissionMatrix.DASHBOARD_CUSTOMIZE,
// Gestion des membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_CREATE,
PermissionMatrix.MEMBERS_EDIT_ALL,
PermissionMatrix.MEMBERS_APPROVE,
PermissionMatrix.MEMBERS_SUSPEND,
PermissionMatrix.MEMBERS_COMMUNICATE,
// Gestion financière
PermissionMatrix.FINANCES_VIEW_ALL,
PermissionMatrix.FINANCES_MANAGE,
PermissionMatrix.FINANCES_REPORTS,
PermissionMatrix.FINANCES_BUDGET,
// Gestion des événements
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_CREATE,
PermissionMatrix.EVENTS_EDIT_ALL,
PermissionMatrix.EVENTS_DELETE,
PermissionMatrix.EVENTS_ANALYTICS,
// Gestion de la solidarité
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_APPROVE,
PermissionMatrix.SOLIDARITY_MANAGE,
PermissionMatrix.SOLIDARITY_FUND,
// Communication
PermissionMatrix.COMM_SEND_ALL,
PermissionMatrix.COMM_BROADCAST,
PermissionMatrix.COMM_TEMPLATES,
// Rapports organisation
PermissionMatrix.REPORTS_GENERATE,
PermissionMatrix.REPORTS_EXPORT,
];
/// Permissions du Modérateur
const List<String> _moderatorPermissions = [
// Dashboard limité
PermissionMatrix.DASHBOARD_VIEW,
// Modération des membres
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_APPROVE,
PermissionMatrix.MODERATION_USERS,
// Modération du contenu
PermissionMatrix.MODERATION_CONTENT,
PermissionMatrix.MODERATION_REPORTS,
// Événements limités
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_MODERATE,
// Communication modérée
PermissionMatrix.COMM_MODERATE,
PermissionMatrix.COMM_SEND_MEMBERS,
];
/// Permissions du Consultant
const List<String> _consultantPermissions = [
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.DASHBOARD_ANALYTICS,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.REPORTS_VIEW_ALL,
PermissionMatrix.REPORTS_GENERATE,
];
/// Permissions du Gestionnaire RH
const List<String> _hrManagerPermissions = [
PermissionMatrix.DASHBOARD_VIEW,
PermissionMatrix.MEMBERS_VIEW_ALL,
PermissionMatrix.MEMBERS_EDIT_BASIC,
PermissionMatrix.MEMBERS_APPROVE,
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.MODERATION_USERS,
];
/// Permissions du Membre Actif
const List<String> _activeMemberPermissions = [
// Dashboard personnel
PermissionMatrix.DASHBOARD_VIEW,
// Profil personnel
PermissionMatrix.MEMBERS_VIEW_OWN,
PermissionMatrix.MEMBERS_EDIT_OWN,
// Finances personnelles
PermissionMatrix.FINANCES_VIEW_OWN,
// Événements
PermissionMatrix.EVENTS_VIEW_ALL,
PermissionMatrix.EVENTS_CREATE,
PermissionMatrix.EVENTS_EDIT_OWN,
// Solidarité
PermissionMatrix.SOLIDARITY_VIEW_ALL,
PermissionMatrix.SOLIDARITY_CREATE,
];
/// Permissions du Membre Simple
const List<String> _simpleMemberPermissions = [
// Dashboard basique
PermissionMatrix.DASHBOARD_VIEW,
// Profil personnel uniquement
PermissionMatrix.MEMBERS_VIEW_OWN,
PermissionMatrix.MEMBERS_EDIT_OWN,
// Finances personnelles
PermissionMatrix.FINANCES_VIEW_OWN,
// Événements publics
PermissionMatrix.EVENTS_VIEW_PUBLIC,
// Solidarité consultation
PermissionMatrix.SOLIDARITY_VIEW_OWN,
];
/// Permissions du Visiteur
const List<String> _visitorPermissions = [
// Événements publics uniquement
PermissionMatrix.EVENTS_VIEW_PUBLIC,
];

View File

@@ -0,0 +1,139 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:injectable/injectable.dart';
import '../../data/models/user.dart';
import '../../data/models/user_role.dart';
import '../../data/datasources/keycloak_auth_service.dart';
import '../../data/datasources/permission_engine.dart';
import '../../../../core/storage/dashboard_cache_manager.dart';
// === ÉVÉNEMENTS ===
abstract class AuthEvent extends Equatable {
const AuthEvent();
@override
List<Object?> get props => [];
}
class AuthLoginRequested extends AuthEvent {
final String email;
final String password;
const AuthLoginRequested(this.email, this.password);
@override
List<Object?> get props => [email, password];
}
class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); }
class AuthStatusChecked extends AuthEvent { const AuthStatusChecked(); }
class AuthTokenRefreshRequested extends AuthEvent { const AuthTokenRefreshRequested(); }
// === ÉTATS ===
abstract class AuthState extends Equatable {
const AuthState();
@override
List<Object?> get props => [];
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthUnauthenticated extends AuthState {}
class AuthAuthenticated extends AuthState {
final User user;
final UserRole effectiveRole;
final List<String> effectivePermissions;
final String accessToken;
const AuthAuthenticated({
required this.user,
required this.effectiveRole,
required this.effectivePermissions,
required this.accessToken,
});
@override
List<Object?> get props => [user, effectiveRole, effectivePermissions, accessToken];
}
class AuthError extends AuthState {
final String message;
const AuthError(this.message);
@override
List<Object?> get props => [message];
}
// === BLOC ===
@lazySingleton
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final KeycloakAuthService _authService;
AuthBloc(this._authService) : super(AuthInitial()) {
on<AuthLoginRequested>(_onLoginRequested);
on<AuthLogoutRequested>(_onLogoutRequested);
on<AuthStatusChecked>(_onStatusChecked);
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
}
Future<void> _onLoginRequested(AuthLoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
final user = await _authService.login(event.email, event.password);
if (user != null) {
final permissions = await PermissionEngine.getEffectivePermissions(user);
final token = await _authService.getValidToken();
await DashboardCacheManager.invalidateForRole(user.primaryRole);
emit(AuthAuthenticated(
user: user,
effectiveRole: user.primaryRole,
effectivePermissions: permissions,
accessToken: token ?? '',
));
} else {
emit(const AuthError('Identifiants incorrects.'));
}
} catch (e) {
emit(AuthError('Erreur de connexion: $e'));
}
}
Future<void> _onLogoutRequested(AuthLogoutRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
await _authService.logout();
await DashboardCacheManager.clear();
emit(AuthUnauthenticated());
}
Future<void> _onStatusChecked(AuthStatusChecked event, Emitter<AuthState> emit) async {
final tokenValid = await _authService.getValidToken();
final isAuth = tokenValid != null;
if (!isAuth) {
emit(AuthUnauthenticated());
return;
}
final user = await _authService.getCurrentUser();
if (user == null) {
emit(AuthUnauthenticated());
return;
}
final permissions = await PermissionEngine.getEffectivePermissions(user);
final token = await _authService.getValidToken();
emit(AuthAuthenticated(
user: user,
effectiveRole: user.primaryRole,
effectivePermissions: permissions,
accessToken: token ?? '',
));
}
Future<void> _onTokenRefreshRequested(AuthTokenRefreshRequested event, Emitter<AuthState> emit) async {
if (state is AuthAuthenticated) {
final newToken = await _authService.refreshToken();
final success = newToken != null;
if (success) {
add(AuthStatusChecked());
} else {
add(AuthLogoutRequested());
}
}
}
}

View File

@@ -0,0 +1,583 @@
/// Page d'Authentification UnionFlow
///
/// Interface utilisateur pour la connexion sécurisée
/// avec gestion complète des états et des erreurs.
library keycloak_webview_auth_page;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../data/datasources/keycloak_webview_auth_service.dart';
import '../../data/models/user.dart';
import '../../../../shared/design_system/tokens/color_tokens.dart';
import '../../../../shared/design_system/tokens/spacing_tokens.dart';
import '../../../../shared/design_system/tokens/typography_tokens.dart';
/// États de l'authentification WebView
enum KeycloakWebViewAuthState {
/// Initialisation en cours
initializing,
/// Chargement de la page d'authentification
loading,
/// Page d'authentification affichée
ready,
/// Authentification en cours
authenticating,
/// Authentification réussie
success,
/// Erreur d'authentification
error,
/// Timeout
timeout,
}
/// Page d'authentification Keycloak avec WebView
class KeycloakWebViewAuthPage extends StatefulWidget {
/// Callback appelé en cas de succès d'authentification
final Function(User user) onAuthSuccess;
/// Callback appelé en cas d'erreur
final Function(String error) onAuthError;
/// Callback appelé en cas d'annulation
final VoidCallback? onAuthCancel;
/// Timeout pour l'authentification (en secondes)
final int timeoutSeconds;
const KeycloakWebViewAuthPage({
super.key,
required this.onAuthSuccess,
required this.onAuthError,
this.onAuthCancel,
this.timeoutSeconds = 300, // 5 minutes par défaut
});
@override
State<KeycloakWebViewAuthPage> createState() => _KeycloakWebViewAuthPageState();
}
class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
with TickerProviderStateMixin {
// Contrôleurs et état
late WebViewController _webViewController;
late AnimationController _progressAnimationController;
late Animation<double> _progressAnimation;
Timer? _timeoutTimer;
// État de l'authentification
KeycloakWebViewAuthState _authState = KeycloakWebViewAuthState.initializing;
String? _errorMessage;
double _loadingProgress = 0.0;
// Paramètres d'authentification
String? _authUrl;
@override
void initState() {
super.initState();
_initializeAnimations();
_initializeAuthentication();
}
@override
void dispose() {
_progressAnimationController.dispose();
_timeoutTimer?.cancel();
super.dispose();
}
/// Initialise les animations
void _initializeAnimations() {
_progressAnimationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_progressAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _progressAnimationController,
curve: Curves.easeInOut,
));
}
/// Initialise l'authentification
Future<void> _initializeAuthentication() async {
try {
debugPrint('🚀 Initialisation de l\'authentification WebView...');
setState(() {
_authState = KeycloakWebViewAuthState.initializing;
});
// Préparer l'authentification
final Map<String, String> authParams =
await KeycloakWebViewAuthService.prepareAuthentication();
_authUrl = authParams['url'];
if (_authUrl == null) {
throw Exception('URL d\'authentification manquante');
}
// Initialiser la WebView
await _initializeWebView();
// Démarrer le timer de timeout
_startTimeoutTimer();
debugPrint('✅ Authentification initialisée avec succès');
} catch (e) {
debugPrint('💥 Erreur initialisation authentification: $e');
_handleError('Erreur d\'initialisation: $e');
}
}
/// Initialise la WebView
Future<void> _initializeWebView() async {
_webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(ColorTokens.surface)
..setNavigationDelegate(
NavigationDelegate(
onProgress: _onLoadingProgress,
onPageStarted: _onPageStarted,
onPageFinished: _onPageFinished,
onWebResourceError: _onWebResourceError,
onNavigationRequest: _onNavigationRequest,
),
);
// Charger l'URL d'authentification
if (_authUrl != null) {
await _webViewController.loadRequest(Uri.parse(_authUrl!));
setState(() {
_authState = KeycloakWebViewAuthState.loading;
});
}
}
/// Démarre le timer de timeout
void _startTimeoutTimer() {
_timeoutTimer = Timer(Duration(seconds: widget.timeoutSeconds), () {
if (_authState != KeycloakWebViewAuthState.success) {
debugPrint('⏰ Timeout d\'authentification atteint');
_handleTimeout();
}
});
}
/// Gère la progression du chargement
void _onLoadingProgress(int progress) {
setState(() {
_loadingProgress = progress / 100.0;
});
if (progress == 100) {
_progressAnimationController.forward();
}
}
/// Gère le début du chargement d'une page
void _onPageStarted(String url) {
debugPrint('📄 Chargement de la page: $url');
setState(() {
_loadingProgress = 0.0;
});
_progressAnimationController.reset();
}
/// Gère la fin du chargement d'une page
void _onPageFinished(String url) {
debugPrint('✅ Page chargée: $url');
setState(() {
if (_authState == KeycloakWebViewAuthState.loading) {
_authState = KeycloakWebViewAuthState.ready;
}
});
}
/// Gère les erreurs de ressources web
void _onWebResourceError(WebResourceError error) {
debugPrint('💥 Erreur WebView: ${error.description}');
// Ignorer certaines erreurs non critiques
if (error.errorCode == -999) { // Code d'erreur pour annulation
return;
}
_handleError('Erreur de chargement: ${error.description}');
}
/// Gère les requêtes de navigation
NavigationDecision _onNavigationRequest(NavigationRequest request) {
final String url = request.url;
debugPrint('🔗 Navigation vers: $url');
// Vérifier si c'est notre URL de callback
if (url.startsWith('dev.lions.unionflow-mobile://auth/callback')) {
debugPrint('🎯 URL de callback détectée: $url');
_handleAuthCallback(url);
return NavigationDecision.prevent;
}
// Vérifier d'autres patterns de callback possibles
if (url.contains('code=') && url.contains('state=')) {
debugPrint('🎯 Callback potentiel détecté (avec code et state): $url');
_handleAuthCallback(url);
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
}
/// Traite le callback d'authentification
Future<void> _handleAuthCallback(String callbackUrl) async {
try {
setState(() {
_authState = KeycloakWebViewAuthState.authenticating;
});
debugPrint('🔄 Traitement du callback d\'authentification...');
debugPrint('📋 URL de callback reçue: $callbackUrl');
// Traiter le callback via le service
final User user = await KeycloakWebViewAuthService.handleAuthCallback(callbackUrl);
setState(() {
_authState = KeycloakWebViewAuthState.success;
});
// Annuler le timer de timeout
_timeoutTimer?.cancel();
debugPrint('🎉 Authentification réussie pour: ${user.fullName}');
debugPrint('👤 Rôle: ${user.primaryRole.displayName}');
debugPrint('🔐 Permissions: ${user.additionalPermissions.length}');
// Notifier le succès avec un délai pour l'animation
Future.delayed(const Duration(milliseconds: 500), () {
widget.onAuthSuccess(user);
});
} catch (e, stackTrace) {
debugPrint('💥 Erreur traitement callback: $e');
debugPrint('📋 Stack trace: $stackTrace');
// Essayer de donner plus d'informations sur l'erreur
String errorMessage = 'Erreur d\'authentification: $e';
if (e.toString().contains('MISSING_AUTH_STATE')) {
errorMessage = 'Session expirée. Veuillez réessayer.';
} else if (e.toString().contains('INVALID_STATE')) {
errorMessage = 'Erreur de sécurité. Veuillez réessayer.';
} else if (e.toString().contains('MISSING_AUTH_CODE')) {
errorMessage = 'Code d\'autorisation manquant. Veuillez réessayer.';
}
_handleError(errorMessage);
}
}
/// Gère les erreurs
void _handleError(String error) {
setState(() {
_authState = KeycloakWebViewAuthState.error;
_errorMessage = error;
});
_timeoutTimer?.cancel();
// Vibration pour indiquer l'erreur
HapticFeedback.lightImpact();
widget.onAuthError(error);
}
/// Gère le timeout
void _handleTimeout() {
setState(() {
_authState = KeycloakWebViewAuthState.timeout;
_errorMessage = 'Timeout d\'authentification atteint';
});
HapticFeedback.lightImpact();
widget.onAuthError('Timeout d\'authentification');
}
/// Gère l'annulation
void _handleCancel() {
debugPrint('❌ Authentification annulée par l\'utilisateur');
_timeoutTimer?.cancel();
if (widget.onAuthCancel != null) {
widget.onAuthCancel!();
} else {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorTokens.surface,
appBar: _buildAppBar(),
body: _buildBody(),
);
}
/// Construit l'AppBar
PreferredSizeWidget _buildAppBar() {
return AppBar(
backgroundColor: ColorTokens.primary,
foregroundColor: ColorTokens.onPrimary,
elevation: 0,
title: Text(
'Connexion Sécurisée',
style: TypographyTokens.headlineSmall.copyWith(
color: ColorTokens.onPrimary,
fontWeight: FontWeight.w600,
),
),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: _handleCancel,
tooltip: 'Annuler',
),
actions: [
if (_authState == KeycloakWebViewAuthState.ready)
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => _webViewController.reload(),
tooltip: 'Actualiser',
),
],
bottom: _buildProgressIndicator(),
);
}
/// Construit l'indicateur de progression
PreferredSizeWidget? _buildProgressIndicator() {
if (_authState == KeycloakWebViewAuthState.loading ||
_authState == KeycloakWebViewAuthState.authenticating) {
return PreferredSize(
preferredSize: const Size.fromHeight(4.0),
child: AnimatedBuilder(
animation: _progressAnimation,
builder: (context, child) {
return LinearProgressIndicator(
value: _authState == KeycloakWebViewAuthState.authenticating
? null
: _loadingProgress,
backgroundColor: ColorTokens.onPrimary.withOpacity(0.3),
valueColor: const AlwaysStoppedAnimation<Color>(ColorTokens.onPrimary),
);
},
),
);
}
return null;
}
/// Construit le corps de la page
Widget _buildBody() {
switch (_authState) {
case KeycloakWebViewAuthState.initializing:
return _buildInitializingView();
case KeycloakWebViewAuthState.loading:
case KeycloakWebViewAuthState.ready:
return _buildWebView();
case KeycloakWebViewAuthState.authenticating:
return _buildAuthenticatingView();
case KeycloakWebViewAuthState.success:
return _buildSuccessView();
case KeycloakWebViewAuthState.error:
case KeycloakWebViewAuthState.timeout:
return _buildErrorView();
}
}
/// Vue d'initialisation
Widget _buildInitializingView() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: SpacingTokens.xl),
Text(
'Initialisation...',
style: TypographyTokens.bodyLarge.copyWith(
color: ColorTokens.onSurface,
),
),
],
),
);
}
/// Vue WebView
Widget _buildWebView() {
return WebViewWidget(controller: _webViewController);
}
/// Vue d'authentification en cours
Widget _buildAuthenticatingView() {
return Container(
color: ColorTokens.surface,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: SpacingTokens.xxxl),
Text(
'Connexion en cours...',
style: TypographyTokens.headlineSmall.copyWith(
color: ColorTokens.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: SpacingTokens.xl),
Text(
'Veuillez patienter pendant que nous\nvérifions vos informations.',
textAlign: TextAlign.center,
style: TypographyTokens.bodyMedium.copyWith(
color: ColorTokens.onSurface.withOpacity(0.7),
),
),
],
),
),
);
}
/// Vue de succès
Widget _buildSuccessView() {
return Container(
color: ColorTokens.surface,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
child: const Icon(
Icons.check,
color: Colors.white,
size: 48,
),
),
const SizedBox(height: SpacingTokens.xxxl),
Text(
'Connexion réussie !',
style: TypographyTokens.headlineSmall.copyWith(
color: ColorTokens.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: SpacingTokens.xl),
Text(
'Redirection vers l\'application...',
style: TypographyTokens.bodyMedium.copyWith(
color: ColorTokens.onSurface.withOpacity(0.7),
),
),
],
),
),
);
}
/// Vue d'erreur
Widget _buildErrorView() {
return Container(
color: ColorTokens.surface,
padding: const EdgeInsets.all(SpacingTokens.xxxl),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
color: ColorTokens.error,
shape: BoxShape.circle,
),
child: Icon(
_authState == KeycloakWebViewAuthState.timeout
? Icons.access_time
: Icons.error_outline,
color: ColorTokens.onError,
size: 48,
),
),
const SizedBox(height: SpacingTokens.xxxl),
Text(
_authState == KeycloakWebViewAuthState.timeout
? 'Délai d\'attente dépassé'
: 'Erreur de connexion',
style: TypographyTokens.headlineSmall.copyWith(
color: ColorTokens.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: SpacingTokens.xl),
Text(
_errorMessage ?? 'Une erreur inattendue s\'est produite',
textAlign: TextAlign.center,
style: TypographyTokens.bodyMedium.copyWith(
color: ColorTokens.onSurface.withOpacity(0.7),
),
),
const SizedBox(height: SpacingTokens.huge),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
onPressed: _initializeAuthentication,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: ColorTokens.primary,
foregroundColor: ColorTokens.onPrimary,
),
),
OutlinedButton.icon(
onPressed: _handleCancel,
icon: const Icon(Icons.close),
label: const Text('Annuler'),
style: OutlinedButton.styleFrom(
foregroundColor: ColorTokens.onSurface,
),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
import '../bloc/auth_bloc.dart';
import '../../../../core/config/environment.dart';
import '../../../../shared/widgets/core_text_field.dart';
import '../../../../shared/widgets/dynamic_fab.dart';
import '../../../../shared/design_system/tokens/app_typography.dart';
import '../../../../shared/design_system/tokens/app_colors.dart';
/// UnionFlow Mobile - Écran de connexion (Mode DRY & Minimaliste)
class LoginPage extends StatefulWidget {
const LoginPage({Key? key}) : super(key: key);
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _openForgotPassword(BuildContext context) async {
final url = Uri.parse(
'${AppConfig.keycloakRealmUrl}/protocol/openid-connect/auth'
'?client_id=unionflow-mobile'
'&redirect_uri=${Uri.encodeComponent('http://localhost')}'
'&response_type=code'
'&scope=openid'
'&kc_action=reset_credentials',
);
try {
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible d\'ouvrir la page de réinitialisation')),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Erreur lors de l\'ouverture du lien')),
);
}
}
}
void _onLogin() {
final email = _emailController.text;
final password = _passwordController.text;
if (email.isNotEmpty && password.isNotEmpty) {
context.read<AuthBloc>().add(AuthLoginRequested(email, password));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthAuthenticated) {
// Navigator 1.0 : Le BlocBuilder dans AppRouter gérera la transition vers MainNavigationLayout
} else if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message, style: AppTypography.bodyTextSmall),
backgroundColor: AppColors.error,
),
);
}
},
builder: (context, state) {
final isLoading = state is AuthLoading;
return SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo minimaliste (Texte seul)
Center(
child: Text(
'UnionFlow',
style: AppTypography.headerSmall.copyWith(
fontSize: 24, // Exception unique pour le logo
color: AppColors.primaryGreen,
letterSpacing: 1.2,
),
),
),
const SizedBox(height: 8),
Center(
child: Text(
'Connexion à votre espace.',
style: AppTypography.subtitleSmall,
),
),
const SizedBox(height: 48),
// Champs de texte DRY
CoreTextField(
controller: _emailController,
hintText: 'Email ou Identifiant',
prefixIcon: Icons.person_outline,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
CoreTextField(
controller: _passwordController,
hintText: 'Mot de passe',
prefixIcon: Icons.lock_outline,
obscureText: true,
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => _openForgotPassword(context),
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'Oublié ?',
style: AppTypography.subtitleSmall.copyWith(
color: AppColors.primaryGreen,
),
),
),
),
const SizedBox(height: 32),
// Bouton centralisé avec chargement intégré
Center(
child: isLoading
? const CircularProgressIndicator(color: AppColors.primaryGreen)
: DynamicFAB(
icon: Icons.arrow_forward,
label: 'Se Connecter',
onPressed: _onLogin,
),
),
],
),
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,63 @@
/// Modèle de configuration des sauvegardes
/// Correspond à BackupConfigResponse du backend
library backup_config_model;
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'backup_config_model.g.dart';
@JsonSerializable(explicitToJson: true)
class BackupConfigModel extends Equatable {
final bool? autoBackupEnabled;
final String? frequency; // HOURLY, DAILY, WEEKLY
final String? retention;
final int? retentionDays;
final String? backupTime;
final bool? includeDatabase;
final bool? includeFiles;
final bool? includeConfiguration;
final DateTime? lastBackup;
final DateTime? nextScheduledBackup;
final int? totalBackups;
final int? totalSizeBytes;
final String? totalSizeFormatted;
const BackupConfigModel({
this.autoBackupEnabled,
this.frequency,
this.retention,
this.retentionDays,
this.backupTime,
this.includeDatabase,
this.includeFiles,
this.includeConfiguration,
this.lastBackup,
this.nextScheduledBackup,
this.totalBackups,
this.totalSizeBytes,
this.totalSizeFormatted,
});
factory BackupConfigModel.fromJson(Map<String, dynamic> json) =>
_$BackupConfigModelFromJson(json);
Map<String, dynamic> toJson() => _$BackupConfigModelToJson(this);
@override
List<Object?> get props => [
autoBackupEnabled,
frequency,
retention,
retentionDays,
backupTime,
includeDatabase,
includeFiles,
includeConfiguration,
lastBackup,
nextScheduledBackup,
totalBackups,
totalSizeBytes,
totalSizeFormatted,
];
}

View File

@@ -0,0 +1,45 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'backup_config_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
BackupConfigModel _$BackupConfigModelFromJson(Map<String, dynamic> json) =>
BackupConfigModel(
autoBackupEnabled: json['autoBackupEnabled'] as bool?,
frequency: json['frequency'] as String?,
retention: json['retention'] as String?,
retentionDays: (json['retentionDays'] as num?)?.toInt(),
backupTime: json['backupTime'] as String?,
includeDatabase: json['includeDatabase'] as bool?,
includeFiles: json['includeFiles'] as bool?,
includeConfiguration: json['includeConfiguration'] as bool?,
lastBackup: json['lastBackup'] == null
? null
: DateTime.parse(json['lastBackup'] as String),
nextScheduledBackup: json['nextScheduledBackup'] == null
? null
: DateTime.parse(json['nextScheduledBackup'] as String),
totalBackups: (json['totalBackups'] as num?)?.toInt(),
totalSizeBytes: (json['totalSizeBytes'] as num?)?.toInt(),
totalSizeFormatted: json['totalSizeFormatted'] as String?,
);
Map<String, dynamic> _$BackupConfigModelToJson(BackupConfigModel instance) =>
<String, dynamic>{
'autoBackupEnabled': instance.autoBackupEnabled,
'frequency': instance.frequency,
'retention': instance.retention,
'retentionDays': instance.retentionDays,
'backupTime': instance.backupTime,
'includeDatabase': instance.includeDatabase,
'includeFiles': instance.includeFiles,
'includeConfiguration': instance.includeConfiguration,
'lastBackup': instance.lastBackup?.toIso8601String(),
'nextScheduledBackup': instance.nextScheduledBackup?.toIso8601String(),
'totalBackups': instance.totalBackups,
'totalSizeBytes': instance.totalSizeBytes,
'totalSizeFormatted': instance.totalSizeFormatted,
};

View File

@@ -0,0 +1,69 @@
/// Modèle de sauvegarde
/// Correspond à BackupResponse du backend
library backup_model;
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'backup_model.g.dart';
@JsonSerializable(explicitToJson: true)
class BackupModel extends Equatable {
final String? id;
final String? name;
final String? description;
final String? type; // AUTO, MANUAL, RESTORE_POINT
final int? sizeBytes;
final String? sizeFormatted;
final String? status; // PENDING, IN_PROGRESS, COMPLETED, FAILED
final DateTime? createdAt;
final DateTime? completedAt;
final String? createdBy;
final bool? includesDatabase;
final bool? includesFiles;
final bool? includesConfiguration;
final String? filePath;
final String? errorMessage;
const BackupModel({
this.id,
this.name,
this.description,
this.type,
this.sizeBytes,
this.sizeFormatted,
this.status,
this.createdAt,
this.completedAt,
this.createdBy,
this.includesDatabase,
this.includesFiles,
this.includesConfiguration,
this.filePath,
this.errorMessage,
});
factory BackupModel.fromJson(Map<String, dynamic> json) =>
_$BackupModelFromJson(json);
Map<String, dynamic> toJson() => _$BackupModelToJson(this);
@override
List<Object?> get props => [
id,
name,
description,
type,
sizeBytes,
sizeFormatted,
status,
createdAt,
completedAt,
createdBy,
includesDatabase,
includesFiles,
includesConfiguration,
filePath,
errorMessage,
];
}

View File

@@ -0,0 +1,48 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'backup_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
BackupModel _$BackupModelFromJson(Map<String, dynamic> json) => BackupModel(
id: json['id'] as String?,
name: json['name'] as String?,
description: json['description'] as String?,
type: json['type'] as String?,
sizeBytes: (json['sizeBytes'] as num?)?.toInt(),
sizeFormatted: json['sizeFormatted'] as String?,
status: json['status'] as String?,
createdAt: json['createdAt'] == null
? null
: DateTime.parse(json['createdAt'] as String),
completedAt: json['completedAt'] == null
? null
: DateTime.parse(json['completedAt'] as String),
createdBy: json['createdBy'] as String?,
includesDatabase: json['includesDatabase'] as bool?,
includesFiles: json['includesFiles'] as bool?,
includesConfiguration: json['includesConfiguration'] as bool?,
filePath: json['filePath'] as String?,
errorMessage: json['errorMessage'] as String?,
);
Map<String, dynamic> _$BackupModelToJson(BackupModel instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'description': instance.description,
'type': instance.type,
'sizeBytes': instance.sizeBytes,
'sizeFormatted': instance.sizeFormatted,
'status': instance.status,
'createdAt': instance.createdAt?.toIso8601String(),
'completedAt': instance.completedAt?.toIso8601String(),
'createdBy': instance.createdBy,
'includesDatabase': instance.includesDatabase,
'includesFiles': instance.includesFiles,
'includesConfiguration': instance.includesConfiguration,
'filePath': instance.filePath,
'errorMessage': instance.errorMessage,
};

View File

@@ -0,0 +1,131 @@
/// Repository pour la gestion des sauvegardes
/// Interface avec l'API backend BackupResource
library backup_repository;
import 'package:injectable/injectable.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import '../models/backup_model.dart';
import '../models/backup_config_model.dart';
abstract class BackupRepository {
Future<List<BackupModel>> getAll();
Future<BackupModel> getById(String id);
Future<BackupModel> create(String name, {String? description});
Future<void> restore(String backupId, {bool createRestorePoint = true});
Future<void> delete(String id);
Future<BackupConfigModel> getConfig();
Future<BackupConfigModel> updateConfig(Map<String, dynamic> config);
Future<BackupModel> createRestorePoint();
}
@LazySingleton(as: BackupRepository)
class BackupRepositoryImpl implements BackupRepository {
final ApiClient _apiClient;
static const String _base = '/api/backups';
BackupRepositoryImpl(this._apiClient);
List<BackupModel> _parseListResponse(dynamic data) {
if (data is List) {
return data
.map((e) => BackupModel.fromJson(e as Map<String, dynamic>))
.toList();
}
if (data is Map && data.containsKey('content')) {
final content = data['content'] as List<dynamic>? ?? [];
return content
.map((e) => BackupModel.fromJson(e as Map<String, dynamic>))
.toList();
}
return [];
}
@override
Future<List<BackupModel>> getAll() async {
final response = await _apiClient.get(_base);
if (response.statusCode == 200) {
return _parseListResponse(response.data);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<BackupModel> getById(String id) async {
final response = await _apiClient.get('$_base/$id');
if (response.statusCode == 200) {
return BackupModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<BackupModel> create(String name, {String? description}) async {
final response = await _apiClient.post(
_base,
data: {
'name': name,
'description': description,
'type': 'MANUAL',
'includeDatabase': true,
'includeFiles': true,
'includeConfiguration': true,
},
);
if (response.statusCode == 201 || response.statusCode == 200) {
return BackupModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<void> restore(String backupId, {bool createRestorePoint = true}) async {
final response = await _apiClient.post(
'$_base/restore',
data: {
'backupId': backupId,
'restoreDatabase': true,
'restoreFiles': true,
'restoreConfiguration': true,
'createRestorePoint': createRestorePoint,
},
);
if (response.statusCode != 200) {
throw Exception('Erreur ${response.statusCode}');
}
}
@override
Future<void> delete(String id) async {
final response = await _apiClient.delete('$_base/$id');
if (response.statusCode != 200) {
throw Exception('Erreur ${response.statusCode}');
}
}
@override
Future<BackupConfigModel> getConfig() async {
final response = await _apiClient.get('$_base/config');
if (response.statusCode == 200) {
return BackupConfigModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<BackupConfigModel> updateConfig(Map<String, dynamic> config) async {
final response = await _apiClient.put('$_base/config', data: config);
if (response.statusCode == 200) {
return BackupConfigModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur ${response.statusCode}');
}
@override
Future<BackupModel> createRestorePoint() async {
final response = await _apiClient.post('$_base/restore-point');
if (response.statusCode == 201 || response.statusCode == 200) {
return BackupModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur ${response.statusCode}');
}
}

View File

@@ -0,0 +1,166 @@
/// BLoC pour la gestion des sauvegardes
library backup_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import 'package:equatable/equatable.dart';
import '../../data/repositories/backup_repository.dart';
import '../../data/models/backup_model.dart';
import '../../data/models/backup_config_model.dart';
// Events
abstract class BackupEvent extends Equatable {
@override
List<Object?> get props => [];
}
class LoadBackups extends BackupEvent {}
class CreateBackup extends BackupEvent {
final String name;
final String? description;
CreateBackup(this.name, {this.description});
@override
List<Object?> get props => [name, description];
}
class RestoreBackup extends BackupEvent {
final String backupId;
RestoreBackup(this.backupId);
@override
List<Object?> get props => [backupId];
}
class DeleteBackup extends BackupEvent {
final String backupId;
DeleteBackup(this.backupId);
@override
List<Object?> get props => [backupId];
}
class LoadBackupConfig extends BackupEvent {}
class UpdateBackupConfig extends BackupEvent {
final Map<String, dynamic> config;
UpdateBackupConfig(this.config);
@override
List<Object?> get props => [config];
}
// States
abstract class BackupState extends Equatable {
@override
List<Object?> get props => [];
}
class BackupInitial extends BackupState {}
class BackupLoading extends BackupState {}
class BackupsLoaded extends BackupState {
final List<BackupModel> backups;
BackupsLoaded(this.backups);
@override
List<Object?> get props => [backups];
}
class BackupConfigLoaded extends BackupState {
final BackupConfigModel config;
BackupConfigLoaded(this.config);
@override
List<Object?> get props => [config];
}
class BackupSuccess extends BackupState {
final String message;
BackupSuccess(this.message);
@override
List<Object?> get props => [message];
}
class BackupError extends BackupState {
final String error;
BackupError(this.error);
@override
List<Object?> get props => [error];
}
// Bloc
@injectable
class BackupBloc extends Bloc<BackupEvent, BackupState> {
final BackupRepository _repository;
BackupBloc(this._repository) : super(BackupInitial()) {
on<LoadBackups>(_onLoadBackups);
on<CreateBackup>(_onCreateBackup);
on<RestoreBackup>(_onRestoreBackup);
on<DeleteBackup>(_onDeleteBackup);
on<LoadBackupConfig>(_onLoadBackupConfig);
on<UpdateBackupConfig>(_onUpdateBackupConfig);
}
Future<void> _onLoadBackups(LoadBackups event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
final backups = await _repository.getAll();
emit(BackupsLoaded(backups));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
Future<void> _onCreateBackup(CreateBackup event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
await _repository.create(event.name, description: event.description);
final backups = await _repository.getAll();
emit(BackupsLoaded(backups));
emit(BackupSuccess('Sauvegarde créée'));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
Future<void> _onRestoreBackup(RestoreBackup event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
await _repository.restore(event.backupId);
emit(BackupSuccess('Restauration en cours'));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
Future<void> _onDeleteBackup(DeleteBackup event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
await _repository.delete(event.backupId);
final backups = await _repository.getAll();
emit(BackupsLoaded(backups));
emit(BackupSuccess('Sauvegarde supprimée'));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
Future<void> _onLoadBackupConfig(LoadBackupConfig event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
final config = await _repository.getConfig();
emit(BackupConfigLoaded(config));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
Future<void> _onUpdateBackupConfig(UpdateBackupConfig event, Emitter<BackupState> emit) async {
emit(BackupLoading());
try {
final config = await _repository.updateConfig(event.config);
emit(BackupConfigLoaded(config));
emit(BackupSuccess('Configuration mise à jour'));
} catch (e) {
emit(BackupError('Erreur: ${e.toString()}'));
}
}
}

View File

@@ -0,0 +1,773 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:file_picker/file_picker.dart';
import 'package:share_plus/share_plus.dart';
import '../../../../shared/design_system/tokens/color_tokens.dart';
import '../../../../shared/design_system/tokens/spacing_tokens.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../core/utils/logger.dart';
import '../../data/models/backup_model.dart';
import '../../data/models/backup_config_model.dart';
import '../../data/repositories/backup_repository.dart';
import '../bloc/backup_bloc.dart';
/// Page Sauvegarde & Restauration - UnionFlow Mobile
///
/// Page complète de gestion des sauvegardes avec création, restauration,
/// planification et monitoring des sauvegardes système.
class BackupPage extends StatefulWidget {
const BackupPage({super.key});
@override
State<BackupPage> createState() => _BackupPageState();
}
class _BackupPageState extends State<BackupPage>
with TickerProviderStateMixin {
late TabController _tabController;
bool _autoBackupEnabled = true;
String _selectedFrequency = 'Quotidien';
String _selectedRetention = '30 jours';
List<BackupModel>? _cachedBackups;
BackupConfigModel? _cachedConfig;
final List<String> _frequencies = ['Horaire', 'Quotidien', 'Hebdomadaire'];
final List<String> _retentions = ['7 jours', '30 jours', '90 jours', '1 an'];
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => sl<BackupBloc>()
..add(LoadBackups())
..add(LoadBackupConfig()),
child: BlocConsumer<BackupBloc, BackupState>(
listener: (context, state) {
if (state is BackupsLoaded) {
_cachedBackups = state.backups;
} else if (state is BackupConfigLoaded) {
_cachedConfig = state.config;
}
if (state is BackupSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: const Color(0xFF00B894),
behavior: SnackBarBehavior.floating,
),
);
} else if (state is BackupError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.error),
backgroundColor: const Color(0xFFD63031),
behavior: SnackBarBehavior.floating,
),
);
}
},
builder: (context, state) {
return Scaffold(
backgroundColor: ColorTokens.background,
body: Column(
children: [
_buildHeader(),
_buildTabBar(),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildBackupsTab(state),
_buildScheduleTab(),
_buildRestoreTab(),
],
),
),
],
),
);
},
),
);
}
/// Header harmonisé
Widget _buildHeader() {
return Container(
margin: const EdgeInsets.all(SpacingTokens.lg),
padding: const EdgeInsets.all(SpacingTokens.xl),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: ColorTokens.primaryGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(SpacingTokens.radiusXl),
boxShadow: [
BoxShadow(
color: ColorTokens.primary.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Column(
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.backup,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Sauvegarde & Restauration',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Text(
'Gestion des sauvegardes système',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.8),
),
),
],
),
),
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: IconButton(
onPressed: () => _createBackupNow(),
icon: const Icon(
Icons.save,
color: Colors.white,
),
tooltip: 'Sauvegarde immédiate',
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildStatCard(
'Dernière sauvegarde',
_lastBackupDisplay(),
Icons.schedule,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
'Taille totale',
_totalSizeDisplay(),
Icons.storage,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
'Statut',
_statusDisplay(),
Icons.check_circle,
),
),
],
),
],
),
);
}
String _lastBackupDisplay() {
if (_cachedConfig?.lastBackup != null) {
final d = _cachedConfig!.lastBackup!;
final diff = DateTime.now().difference(d);
if (diff.inMinutes < 60) return '${diff.inMinutes} min';
if (diff.inHours < 24) return '${diff.inHours}h';
if (diff.inDays < 7) return '${diff.inDays} j';
return '${d.day}/${d.month}/${d.year}';
}
if (_cachedBackups != null && _cachedBackups!.isNotEmpty) {
final sorted = List<BackupModel>.from(_cachedBackups!)
..sort((a, b) => (b.createdAt ?? DateTime(0)).compareTo(a.createdAt ?? DateTime(0)));
final d = sorted.first.createdAt;
if (d != null) {
final diff = DateTime.now().difference(d);
if (diff.inMinutes < 60) return '${diff.inMinutes} min';
if (diff.inHours < 24) return '${diff.inHours}h';
return '${diff.inDays} j';
}
}
return '';
}
String _totalSizeDisplay() {
if (_cachedConfig?.totalSizeFormatted != null && _cachedConfig!.totalSizeFormatted!.isNotEmpty) {
return _cachedConfig!.totalSizeFormatted!;
}
if (_cachedBackups != null && _cachedBackups!.isNotEmpty) {
int total = 0;
for (final b in _cachedBackups!) {
total += b.sizeBytes ?? 0;
}
if (total >= 1024 * 1024 * 1024) return '${(total / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
if (total >= 1024 * 1024) return '${(total / (1024 * 1024)).toStringAsFixed(1)} MB';
if (total >= 1024) return '${(total / 1024).toStringAsFixed(0)} KB';
return '$total B';
}
return '0 B';
}
String _statusDisplay() {
if (_cachedBackups != null && _cachedBackups!.isNotEmpty) {
final sorted = List<BackupModel>.from(_cachedBackups!)
..sort((a, b) => (b.createdAt ?? DateTime(0)).compareTo(a.createdAt ?? DateTime(0)));
final s = sorted.first.status;
if (s == 'COMPLETED') return 'OK';
if (s == 'FAILED') return 'Erreur';
if (s == 'IN_PROGRESS') return 'En cours';
}
return 'OK';
}
/// Carte de statistique
Widget _buildStatCard(String label, String value, IconData icon) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Icon(icon, color: Colors.white, size: 20),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Text(
label,
style: TextStyle(
fontSize: 10,
color: Colors.white.withOpacity(0.8),
),
textAlign: TextAlign.center,
),
],
),
);
}
/// Barre d'onglets
Widget _buildTabBar() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: TabBar(
controller: _tabController,
labelColor: const Color(0xFF6C5CE7),
unselectedLabelColor: Colors.grey[600],
indicatorColor: const Color(0xFF6C5CE7),
indicatorWeight: 3,
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12),
tabs: const [
Tab(icon: Icon(Icons.folder, size: 18), text: 'Sauvegardes'),
Tab(icon: Icon(Icons.schedule, size: 18), text: 'Planification'),
Tab(icon: Icon(Icons.restore, size: 18), text: 'Restauration'),
],
),
);
}
/// Onglet sauvegardes
Widget _buildBackupsTab(BackupState state) {
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
children: [
const SizedBox(height: 16),
state is BackupLoading
? const Center(child: CircularProgressIndicator())
: _buildBackupsList(state is BackupsLoaded ? state.backups : (_cachedBackups ?? [])),
const SizedBox(height: 80),
],
),
);
}
/// Liste des sauvegardes
Widget _buildBackupsList(List<dynamic> backupsData) {
final backups = backupsData.map((backup) => {
'id': backup.id?.toString() ?? '',
'name': backup.name ?? 'Sans nom',
'date': backup.createdAt?.toString() ?? '',
'size': backup.sizeFormatted ?? '0 B',
'type': backup.type ?? 'Manual',
}).toList();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.folder, color: Color(0xFF6C5CE7), size: 20),
const SizedBox(width: 8),
Text(
'Sauvegardes disponibles',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
],
),
const SizedBox(height: 16),
...backups.map((backup) => _buildBackupItem(backup)),
],
),
);
}
/// Élément de sauvegarde
Widget _buildBackupItem(Map<String, dynamic> backup) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
backup['type'] == 'Auto' ? Icons.schedule : Icons.touch_app,
color: backup['type'] == 'Auto' ? Colors.blue : Colors.green,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
backup['name']!,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1F2937),
),
),
Text(
'${backup['date']}${backup['size']}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
PopupMenuButton<String>(
onSelected: (action) => _handleBackupAction(backup, action),
itemBuilder: (context) => [
const PopupMenuItem(value: 'restore', child: Text('Restaurer')),
const PopupMenuItem(value: 'download', child: Text('Télécharger')),
const PopupMenuItem(value: 'delete', child: Text('Supprimer')),
],
child: const Icon(Icons.more_vert, color: Colors.grey),
),
],
),
);
}
/// Onglet planification
Widget _buildScheduleTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
children: [
const SizedBox(height: 16),
_buildScheduleSettings(),
const SizedBox(height: 80),
],
),
);
}
/// Paramètres de planification
Widget _buildScheduleSettings() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.schedule, color: Color(0xFF6C5CE7), size: 20),
const SizedBox(width: 8),
Text(
'Configuration automatique',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
],
),
const SizedBox(height: 16),
_buildSwitchSetting(
'Sauvegarde automatique',
'Activer les sauvegardes programmées',
_autoBackupEnabled,
(value) => setState(() => _autoBackupEnabled = value),
),
const SizedBox(height: 12),
_buildDropdownSetting(
'Fréquence',
_selectedFrequency,
_frequencies,
(value) => setState(() => _selectedFrequency = value!),
),
const SizedBox(height: 12),
_buildDropdownSetting(
'Rétention',
_selectedRetention,
_retentions,
(value) => setState(() => _selectedRetention = value!),
),
],
),
);
}
/// Onglet restauration
Widget _buildRestoreTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
children: [
const SizedBox(height: 16),
_buildRestoreOptions(),
const SizedBox(height: 80),
],
),
);
}
/// Options de restauration
Widget _buildRestoreOptions() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.restore, color: Color(0xFF6C5CE7), size: 20),
const SizedBox(width: 8),
Text(
'Options de restauration',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
),
),
],
),
const SizedBox(height: 16),
_buildActionButton(
'Restaurer depuis un fichier',
'Importer une sauvegarde externe',
Icons.file_upload,
const Color(0xFF0984E3),
() => _restoreFromFile(),
),
const SizedBox(height: 12),
_buildActionButton(
'Restauration sélective',
'Restaurer uniquement certaines données',
Icons.checklist,
const Color(0xFF00B894),
() => _selectiveRestore(),
),
const SizedBox(height: 12),
_buildActionButton(
'Point de restauration',
'Créer un point de restauration avant modification',
Icons.bookmark,
const Color(0xFFE17055),
() => _createRestorePoint(),
),
],
),
);
}
// Méthodes de construction des composants
Widget _buildSwitchSetting(String title, String subtitle, bool value, Function(bool) onChanged) {
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
Text(subtitle, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
),
),
Switch(value: value, onChanged: onChanged, activeColor: const Color(0xFF6C5CE7)),
],
);
}
Widget _buildDropdownSetting(String title, String value, List<String> options, Function(String?) onChanged) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[300]!),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: value,
isExpanded: true,
onChanged: onChanged,
items: options.map((option) => DropdownMenuItem(value: option, child: Text(option))).toList(),
),
),
),
],
);
}
Widget _buildActionButton(String title, String subtitle, IconData icon, Color color, VoidCallback onTap) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.1)),
),
child: Row(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: color)),
Text(subtitle, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
),
),
Icon(Icons.arrow_forward_ios, color: Colors.grey[400], size: 16),
],
),
),
);
}
// Méthodes d'action
void _createBackupNow() {
context.read<BackupBloc>().add(CreateBackup('Sauvegarde manuelle', description: 'Créée depuis l\'application mobile'));
}
void _handleBackupAction(Map<String, dynamic> backup, String action) {
final backupId = backup['id'];
if (backupId == null) return;
if (action == 'restore') {
context.read<BackupBloc>().add(RestoreBackup(backupId));
} else if (action == 'delete') {
context.read<BackupBloc>().add(DeleteBackup(backupId));
} else if (action == 'download') {
_downloadBackup(backupId);
} else {
_showSuccessSnackBar('Action "$action" exécutée');
}
}
Future<void> _downloadBackup(String backupId) async {
try {
final repo = sl<BackupRepository>();
final b = await repo.getById(backupId);
if (b.filePath != null && b.filePath!.isNotEmpty) {
try {
await Share.share(
b.filePath!,
subject: 'Sauvegarde ${b.name ?? backupId}',
);
_showSuccessSnackBar('Partage du lien de téléchargement');
} catch (e, st) {
AppLogger.error('BackupPage: partage échoué', error: e, stackTrace: st);
_showSuccessSnackBar('Téléchargement: configurez l\'URL de téléchargement côté backend');
}
} else {
_showSuccessSnackBar('Téléchargement: l\'API ne fournit pas encore de lien (filePath).');
}
} catch (e, st) {
AppLogger.error('BackupPage: téléchargement échoué', error: e, stackTrace: st);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible de récupérer la sauvegarde.'), backgroundColor: Color(0xFFD63031)),
);
}
}
}
Future<void> _restoreFromFile() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.any,
allowMultiple: false,
);
if (result == null || result.files.isEmpty) return;
final path = result.files.single.path;
if (path != null && path.isNotEmpty) {
_showSuccessSnackBar('Fichier sélectionné. Restauration depuis fichier à brancher côté API.');
} else {
_showSuccessSnackBar('Restauration depuis fichier à brancher côté API.');
}
} catch (e, st) {
AppLogger.error('BackupPage: restauration depuis fichier', error: e, stackTrace: st);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sélection de fichier impossible.'), backgroundColor: Color(0xFFD63031)),
);
}
}
}
Future<void> _selectiveRestore() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.any,
allowMultiple: true,
);
if (result == null || result.files.isEmpty) {
_showSuccessSnackBar('Restauration sélective: sélectionnez un ou plusieurs fichiers.');
return;
}
final paths = result.files.map((f) => f.path).whereType<String>().toList();
if (paths.isNotEmpty) {
_showSuccessSnackBar('Restauration sélective: ${paths.length} fichier(s) (API à brancher).');
}
} catch (e, st) {
AppLogger.error('BackupPage: restauration sélective', error: e, stackTrace: st);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sélection impossible.'), backgroundColor: Color(0xFFD63031)),
);
}
}
}
void _createRestorePoint() {
context.read<BackupBloc>().add(CreateBackup('Point de restauration', description: 'Point de restauration'));
}
void _showSuccessSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: const Color(0xFF00B894), behavior: SnackBarBehavior.floating),
);
}
}

View File

@@ -0,0 +1,192 @@
# Feature Communication/Messaging
**Status**: ✅ **Implémenté** (MVP Fonctionnel)
**Date**: 2026-03-13
**Priorité**: P0 (Bloquant Production)
## 📋 Vue d'ensemble
Module de communication permettant la messagerie entre membres et les broadcasts organisation selon les permissions RBAC.
## 🎯 Fonctionnalités Implémentées
### ✅ MVP (V1.0)
1. **Liste des Conversations**
- Affichage conversations triées par date
- Badge compteur messages non lus
- Indicateurs visuels (pinned, muted)
- Pull-to-refresh
- Navigation vers détail conversation
2. **Permissions Respectées**
- `COMM_SEND_ALL` - OrgAdmin, SuperAdmin
- `COMM_SEND_MEMBERS` - Moderator
- `COMM_BROADCAST` - OrgAdmin
- Menu "Messages" visible selon rôle (OrgAdmin, SuperAdmin, Moderator)
3. **Architecture Clean + BLoC**
- Domain : Entities (Message, Conversation, MessageTemplate)
- Data : Models avec JSON serialization, Repository, Datasource
- Presentation : BLoC (Events, States), Pages, Widgets
4. **Intégration App**
- Routes : `/messages`, `/communication`
- Navigation : Menu "Plus" avec vérification permissions
- DI : Injectable + GetIt
## 🏗️ Architecture
```
communication/
├── domain/
│ ├── entities/
│ │ ├── message.dart (Message, MessageType, MessageStatus, MessagePriority)
│ │ ├── conversation.dart (Conversation, ConversationType)
│ │ └── message_template.dart (MessageTemplate, TemplateCategory)
│ ├── repositories/
│ │ └── messaging_repository.dart (interface)
│ └── usecases/
│ ├── get_conversations.dart
│ ├── get_messages.dart
│ ├── send_message.dart
│ └── send_broadcast.dart
├── data/
│ ├── models/
│ │ ├── message_model.dart (.g.dart généré)
│ │ └── conversation_model.dart (.g.dart généré)
│ ├── datasources/
│ │ └── messaging_remote_datasource.dart (API REST)
│ └── repositories/
│ └── messaging_repository_impl.dart
└── presentation/
├── bloc/
│ ├── messaging_event.dart
│ ├── messaging_state.dart
│ └── messaging_bloc.dart
├── pages/
│ └── conversations_page.dart
└── widgets/
└── conversation_tile.dart
```
## 📡 API Endpoints Utilisés
| Endpoint | Méthode | Description |
|----------|---------|-------------|
| `/api/messaging/conversations` | GET | Liste conversations |
| `/api/messaging/conversations/:id` | GET | Détail conversation |
| `/api/messaging/conversations` | POST | Créer conversation |
| `/api/messaging/conversations/:id/messages` | GET | Messages d'une conversation |
| `/api/messaging/conversations/:id/messages` | POST | Envoyer message |
| `/api/messaging/broadcast` | POST | Envoyer broadcast |
| `/api/messaging/messages/:id/read` | PUT | Marquer message lu |
| `/api/messaging/unread/count` | GET | Compteur non lus |
**⚠️ Note**: Backend endpoints à implémenter côté serveur Quarkus
## 🔄 États BLoC
- `MessagingInitial` - État initial
- `MessagingLoading` - Chargement en cours
- `ConversationsLoaded` - Conversations chargées avec compteur non lus
- `MessagesLoaded` - Messages d'une conversation chargés
- `MessageSent` - Message envoyé avec succès
- `BroadcastSent` - Broadcast envoyé avec succès
- `MessagingError` - Erreur avec message utilisateur
## 🚀 Prochaines Étapes (V2.0+)
### P1 - Fonctionnalités Avancées
- [ ] Page détail conversation (chat thread)
- [ ] Envoi pièces jointes (images, documents)
- [ ] Édition/suppression messages
- [ ] Recherche dans conversations
- [ ] Filtres conversations (non lus, pinned, archivées)
- [ ] Templates messages personnalisables (CRUD)
- [ ] Messages ciblés par rôles (COMM_TARGETED)
- [ ] Modération messages (MODERATION_CONTENT)
- [ ] Statistiques communication (dashboard analytics)
### P2 - Optimisations
- [ ] WebSocket temps réel pour nouveaux messages
- [ ] Cache local conversations récentes
- [ ] Pagination messages (infinite scroll)
- [ ] Compression images avant envoi
- [ ] Mode offline avec synchronisation
- [ ] Notifications push (FCM)
- [ ] Read receipts (accusés de lecture)
- [ ] Typing indicators (en train d'écrire)
## 🧪 Tests
### À Implémenter
- [ ] Unit tests BLoC (bloc_test)
- [ ] Unit tests UseCases (mockito)
- [ ] Unit tests Repository (mockito)
- [ ] Widget tests ConversationsPage
- [ ] Integration tests flux complet
## 📝 Notes Techniques
### JSON Serialization
Le champ `lastMessage` dans `Conversation` utilise une sérialisation custom car `Message` est un type nested :
```dart
@JsonKey(
fromJson: _messageFromJson,
toJson: _messageToJson,
)
final Message? lastMessage;
```
### Gestion d'Erreurs
Toutes les méthodes repository retournent `Either<Failure, T>` pour une gestion fonctionnelle des erreurs :
- `NetworkFailure` - Pas de connexion Internet
- `UnauthorizedFailure` - Token expiré (401)
- `ForbiddenFailure` - Permission insuffisante (403)
- `NotFoundFailure` - Ressource non trouvée (404)
- `ServerFailure` - Erreur serveur (5xx)
- `ValidationFailure` - Données invalides
- `UnexpectedFailure` - Erreur inattendue
- `NotImplementedFailure` - Fonctionnalité en développement
### Dépendances Externes
Module `RegisterModule` enregistre :
- `http.Client` pour requêtes HTTP
- `FlutterSecureStorage` pour tokens
- `Connectivity` pour état réseau
## 📚 Documentation Connexe
- [Permission Matrix](../../features/authentication/data/models/permission_matrix.dart)
- [User Roles](../../features/authentication/data/models/user_role.dart)
- [API Design](../../specs/000-unionflow-baseline/spec.md)
- [Audit Métier](../../AUDIT_METIER_COMPLET.md)
## ✅ Critères d'Acceptation
- [x] Architecture Clean + BLoC respectée
- [x] Permissions RBAC vérifiées (OrgAdmin, SuperAdmin, Moderator)
- [x] Routes intégrées (/messages, /communication)
- [x] Menu navigation avec vérification rôles
- [x] Page liste conversations fonctionnelle
- [x] Gestion erreurs complète (Failures)
- [x] DI configuré (Injectable + GetIt)
- [x] JSON serialization (.g.dart générés)
- [x] Code compilable sans erreurs
- [ ] Backend endpoints implémentés (Quarkus)
- [ ] Tests unitaires BLoC
- [ ] Tests intégration E2E
---
**Développé avec**: Flutter 3.5.3+, Dart 3.x, BLoC 8.1.6, Clean Architecture
**Gap comblé**: Communication/Messaging (P0 Bloquant Production)

View File

@@ -0,0 +1,230 @@
/// Datasource distant pour la communication (API)
library messaging_remote_datasource;
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/config/environment.dart';
import '../../../../core/error/exceptions.dart';
import '../models/message_model.dart';
import '../models/conversation_model.dart';
import '../../domain/entities/message.dart';
@lazySingleton
class MessagingRemoteDatasource {
final http.Client client;
final FlutterSecureStorage secureStorage;
MessagingRemoteDatasource({
required this.client,
required this.secureStorage,
});
/// Headers HTTP avec authentification
Future<Map<String, String>> _getHeaders() async {
final token = await secureStorage.read(key: 'access_token');
return {
'Content-Type': 'application/json',
'Accept': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
}
// === CONVERSATIONS ===
Future<List<ConversationModel>> getConversations({
String? organizationId,
bool includeArchived = false,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messaging/conversations')
.replace(queryParameters: {
if (organizationId != null) 'organizationId': organizationId,
'includeArchived': includeArchived.toString(),
});
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList
.map((json) => ConversationModel.fromJson(json))
.toList();
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération des conversations');
}
}
Future<ConversationModel> getConversationById(String conversationId) async {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/messaging/conversations/$conversationId');
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
return ConversationModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 404) {
throw NotFoundException('Conversation non trouvée');
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération de la conversation');
}
}
Future<ConversationModel> createConversation({
required String name,
required List<String> participantIds,
String? organizationId,
String? description,
}) async {
final uri =
Uri.parse('${AppConfig.apiBaseUrl}/api/messaging/conversations');
final body = json.encode({
'name': name,
'participantIds': participantIds,
if (organizationId != null) 'organizationId': organizationId,
if (description != null) 'description': description,
});
final response = await client.post(
uri,
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 201 || response.statusCode == 200) {
return ConversationModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la création de la conversation');
}
}
// === MESSAGES ===
Future<List<MessageModel>> getMessages({
required String conversationId,
int? limit,
String? beforeMessageId,
}) async {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/messaging/conversations/$conversationId/messages')
.replace(queryParameters: {
if (limit != null) 'limit': limit.toString(),
if (beforeMessageId != null) 'beforeMessageId': beforeMessageId,
});
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList.map((json) => MessageModel.fromJson(json)).toList();
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération des messages');
}
}
Future<MessageModel> sendMessage({
required String conversationId,
required String content,
List<String>? attachments,
MessagePriority priority = MessagePriority.normal,
}) async {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/messaging/conversations/$conversationId/messages');
final body = json.encode({
'content': content,
if (attachments != null) 'attachments': attachments,
'priority': priority.name,
});
final response = await client.post(
uri,
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 201 || response.statusCode == 200) {
return MessageModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de l\'envoi du message');
}
}
Future<MessageModel> sendBroadcast({
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messaging/broadcast');
final body = json.encode({
'organizationId': organizationId,
'subject': subject,
'content': content,
'priority': priority.name,
if (attachments != null) 'attachments': attachments,
});
final response = await client.post(
uri,
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 201 || response.statusCode == 200) {
return MessageModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else if (response.statusCode == 403) {
throw ForbiddenException('Permission insuffisante pour envoyer un broadcast');
} else {
throw ServerException('Erreur lors de l\'envoi du broadcast');
}
}
Future<void> markMessageAsRead(String messageId) async {
final uri =
Uri.parse('${AppConfig.apiBaseUrl}/api/messaging/messages/$messageId/read');
final response = await client.put(uri, headers: await _getHeaders());
if (response.statusCode != 200 && response.statusCode != 204) {
if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors du marquage du message comme lu');
}
}
}
Future<int> getUnreadCount({String? organizationId}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/messaging/unread/count')
.replace(queryParameters: {
if (organizationId != null) 'organizationId': organizationId,
});
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
final data = json.decode(response.body);
return data['count'] as int;
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération du compte non lu');
}
}
}

View File

@@ -0,0 +1,70 @@
/// Model de données Conversation avec sérialisation JSON
library conversation_model;
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/conversation.dart';
import '../../domain/entities/message.dart';
import 'message_model.dart';
part 'conversation_model.g.dart';
@JsonSerializable(explicitToJson: true)
class ConversationModel extends Conversation {
@JsonKey(
fromJson: _messageFromJson,
toJson: _messageToJson,
)
@override
final Message? lastMessage;
const ConversationModel({
required super.id,
required super.name,
super.description,
required super.type,
required super.participantIds,
super.organizationId,
this.lastMessage,
super.unreadCount,
super.isMuted,
super.isPinned,
super.isArchived,
required super.createdAt,
super.updatedAt,
super.avatarUrl,
super.metadata,
}) : super(lastMessage: lastMessage);
static Message? _messageFromJson(Map<String, dynamic>? json) =>
json == null ? null : MessageModel.fromJson(json);
static Map<String, dynamic>? _messageToJson(Message? message) =>
message == null ? null : MessageModel.fromEntity(message).toJson();
factory ConversationModel.fromJson(Map<String, dynamic> json) =>
_$ConversationModelFromJson(json);
Map<String, dynamic> toJson() => _$ConversationModelToJson(this);
factory ConversationModel.fromEntity(Conversation conversation) {
return ConversationModel(
id: conversation.id,
name: conversation.name,
description: conversation.description,
type: conversation.type,
participantIds: conversation.participantIds,
organizationId: conversation.organizationId,
lastMessage: conversation.lastMessage,
unreadCount: conversation.unreadCount,
isMuted: conversation.isMuted,
isPinned: conversation.isPinned,
isArchived: conversation.isArchived,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt,
avatarUrl: conversation.avatarUrl,
metadata: conversation.metadata,
);
}
Conversation toEntity() => this;
}

View File

@@ -0,0 +1,57 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'conversation_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ConversationModel _$ConversationModelFromJson(Map<String, dynamic> json) =>
ConversationModel(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String?,
type: $enumDecode(_$ConversationTypeEnumMap, json['type']),
participantIds: (json['participantIds'] as List<dynamic>)
.map((e) => e as String)
.toList(),
organizationId: json['organizationId'] as String?,
lastMessage: ConversationModel._messageFromJson(
json['lastMessage'] as Map<String, dynamic>?),
unreadCount: (json['unreadCount'] as num?)?.toInt() ?? 0,
isMuted: json['isMuted'] as bool? ?? false,
isPinned: json['isPinned'] as bool? ?? false,
isArchived: json['isArchived'] as bool? ?? false,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: json['updatedAt'] == null
? null
: DateTime.parse(json['updatedAt'] as String),
avatarUrl: json['avatarUrl'] as String?,
metadata: json['metadata'] as Map<String, dynamic>?,
);
Map<String, dynamic> _$ConversationModelToJson(ConversationModel instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'description': instance.description,
'type': _$ConversationTypeEnumMap[instance.type]!,
'participantIds': instance.participantIds,
'organizationId': instance.organizationId,
'unreadCount': instance.unreadCount,
'isMuted': instance.isMuted,
'isPinned': instance.isPinned,
'isArchived': instance.isArchived,
'createdAt': instance.createdAt.toIso8601String(),
'updatedAt': instance.updatedAt?.toIso8601String(),
'avatarUrl': instance.avatarUrl,
'metadata': instance.metadata,
'lastMessage': ConversationModel._messageToJson(instance.lastMessage),
};
const _$ConversationTypeEnumMap = {
ConversationType.individual: 'individual',
ConversationType.group: 'group',
ConversationType.broadcast: 'broadcast',
ConversationType.announcement: 'announcement',
};

View File

@@ -0,0 +1,83 @@
/// Model de données Message avec sérialisation JSON
library message_model;
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/message.dart';
part 'message_model.g.dart';
@JsonSerializable(explicitToJson: true)
class MessageModel extends Message {
const MessageModel({
required super.id,
required super.conversationId,
required super.senderId,
required super.senderName,
super.senderAvatar,
required super.content,
required super.type,
required super.status,
super.priority,
required super.recipientIds,
super.recipientRoles,
super.organizationId,
required super.createdAt,
super.readAt,
super.metadata,
super.attachments,
super.isEdited,
super.editedAt,
super.isDeleted,
});
factory MessageModel.fromJson(Map<String, dynamic> json) =>
_$MessageModelFromJson(json);
Map<String, dynamic> toJson() => _$MessageModelToJson(this);
factory MessageModel.fromEntity(Message message) {
return MessageModel(
id: message.id,
conversationId: message.conversationId,
senderId: message.senderId,
senderName: message.senderName,
senderAvatar: message.senderAvatar,
content: message.content,
type: message.type,
status: message.status,
priority: message.priority,
recipientIds: message.recipientIds,
recipientRoles: message.recipientRoles,
organizationId: message.organizationId,
createdAt: message.createdAt,
readAt: message.readAt,
metadata: message.metadata,
attachments: message.attachments,
isEdited: message.isEdited,
editedAt: message.editedAt,
isDeleted: message.isDeleted,
);
}
Message toEntity() => Message(
id: id,
conversationId: conversationId,
senderId: senderId,
senderName: senderName,
senderAvatar: senderAvatar,
content: content,
type: type,
status: status,
priority: priority,
recipientIds: recipientIds,
recipientRoles: recipientRoles,
organizationId: organizationId,
createdAt: createdAt,
readAt: readAt,
metadata: metadata,
attachments: attachments,
isEdited: isEdited,
editedAt: editedAt,
isDeleted: isDeleted,
);
}

View File

@@ -0,0 +1,84 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'message_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MessageModel _$MessageModelFromJson(Map<String, dynamic> json) => MessageModel(
id: json['id'] as String,
conversationId: json['conversationId'] as String,
senderId: json['senderId'] as String,
senderName: json['senderName'] as String,
senderAvatar: json['senderAvatar'] as String?,
content: json['content'] as String,
type: $enumDecode(_$MessageTypeEnumMap, json['type']),
status: $enumDecode(_$MessageStatusEnumMap, json['status']),
priority:
$enumDecodeNullable(_$MessagePriorityEnumMap, json['priority']) ??
MessagePriority.normal,
recipientIds: (json['recipientIds'] as List<dynamic>)
.map((e) => e as String)
.toList(),
recipientRoles: (json['recipientRoles'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
organizationId: json['organizationId'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
readAt: json['readAt'] == null
? null
: DateTime.parse(json['readAt'] as String),
metadata: json['metadata'] as Map<String, dynamic>?,
attachments: (json['attachments'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
isEdited: json['isEdited'] as bool? ?? false,
editedAt: json['editedAt'] == null
? null
: DateTime.parse(json['editedAt'] as String),
isDeleted: json['isDeleted'] as bool? ?? false,
);
Map<String, dynamic> _$MessageModelToJson(MessageModel instance) =>
<String, dynamic>{
'id': instance.id,
'conversationId': instance.conversationId,
'senderId': instance.senderId,
'senderName': instance.senderName,
'senderAvatar': instance.senderAvatar,
'content': instance.content,
'type': _$MessageTypeEnumMap[instance.type]!,
'status': _$MessageStatusEnumMap[instance.status]!,
'priority': _$MessagePriorityEnumMap[instance.priority]!,
'recipientIds': instance.recipientIds,
'recipientRoles': instance.recipientRoles,
'organizationId': instance.organizationId,
'createdAt': instance.createdAt.toIso8601String(),
'readAt': instance.readAt?.toIso8601String(),
'metadata': instance.metadata,
'attachments': instance.attachments,
'isEdited': instance.isEdited,
'editedAt': instance.editedAt?.toIso8601String(),
'isDeleted': instance.isDeleted,
};
const _$MessageTypeEnumMap = {
MessageType.individual: 'individual',
MessageType.broadcast: 'broadcast',
MessageType.targeted: 'targeted',
MessageType.system: 'system',
};
const _$MessageStatusEnumMap = {
MessageStatus.sent: 'sent',
MessageStatus.delivered: 'delivered',
MessageStatus.read: 'read',
MessageStatus.failed: 'failed',
};
const _$MessagePriorityEnumMap = {
MessagePriority.normal: 'normal',
MessagePriority.high: 'high',
MessagePriority.urgent: 'urgent',
};

View File

@@ -0,0 +1,329 @@
/// Implémentation du repository de messagerie
library messaging_repository_impl;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/network/network_info.dart';
import '../../domain/entities/conversation.dart';
import '../../domain/entities/message.dart';
import '../../domain/entities/message_template.dart';
import '../../domain/repositories/messaging_repository.dart';
import '../datasources/messaging_remote_datasource.dart';
@LazySingleton(as: MessagingRepository)
class MessagingRepositoryImpl implements MessagingRepository {
final MessagingRemoteDatasource remoteDatasource;
final NetworkInfo networkInfo;
MessagingRepositoryImpl({
required this.remoteDatasource,
required this.networkInfo,
});
@override
Future<Either<Failure, List<Conversation>>> getConversations({
String? organizationId,
bool includeArchived = false,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final conversations = await remoteDatasource.getConversations(
organizationId: organizationId,
includeArchived: includeArchived,
);
return Right(conversations);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, Conversation>> getConversationById(
String conversationId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final conversation =
await remoteDatasource.getConversationById(conversationId);
return Right(conversation);
} on NotFoundException {
return Left(NotFoundFailure('Conversation non trouvée'));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, Conversation>> createConversation({
required String name,
required List<String> participantIds,
String? organizationId,
String? description,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final conversation = await remoteDatasource.createConversation(
name: name,
participantIds: participantIds,
organizationId: organizationId,
description: description,
);
return Right(conversation);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, List<Message>>> getMessages({
required String conversationId,
int? limit,
String? beforeMessageId,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final messages = await remoteDatasource.getMessages(
conversationId: conversationId,
limit: limit,
beforeMessageId: beforeMessageId,
);
return Right(messages);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, Message>> sendMessage({
required String conversationId,
required String content,
List<String>? attachments,
MessagePriority priority = MessagePriority.normal,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final message = await remoteDatasource.sendMessage(
conversationId: conversationId,
content: content,
attachments: attachments,
priority: priority,
);
return Right(message);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, Message>> sendBroadcast({
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final message = await remoteDatasource.sendBroadcast(
organizationId: organizationId,
subject: subject,
content: content,
priority: priority,
attachments: attachments,
);
return Right(message);
} on ForbiddenException catch (e) {
return Left(ForbiddenFailure(e.message));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, void>> markMessageAsRead(String messageId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
await remoteDatasource.markMessageAsRead(messageId);
return const Right(null);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, int>> getUnreadCount({String? organizationId}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final count =
await remoteDatasource.getUnreadCount(organizationId: organizationId);
return Right(count);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
// === MÉTHODES NON IMPLÉMENTÉES (Stubs pour compilation) ===
// À implémenter selon besoins backend
@override
Future<Either<Failure, void>> archiveConversation(String conversationId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Message>> sendTargetedMessage({
required String organizationId,
required List<String> targetRoles,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, void>> markConversationAsRead(String conversationId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, void>> toggleMuteConversation(String conversationId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, void>> togglePinConversation(String conversationId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Message>> editMessage({
required String messageId,
required String newContent,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, void>> deleteMessage(String messageId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, List<MessageTemplate>>> getTemplates({
String? organizationId,
TemplateCategory? category,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, MessageTemplate>> getTemplateById(String templateId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, MessageTemplate>> createTemplate({
required String name,
required String description,
required TemplateCategory category,
required String subject,
required String body,
List<Map<String, dynamic>>? variables,
String? organizationId,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, MessageTemplate>> updateTemplate({
required String templateId,
String? name,
String? description,
String? subject,
String? body,
bool? isActive,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, void>> deleteTemplate(String templateId) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Message>> sendFromTemplate({
required String templateId,
required Map<String, String> variables,
required List<String> recipientIds,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Map<String, dynamic>>> getMessagingStats({
required String organizationId,
DateTime? startDate,
DateTime? endDate,
}) async {
return Left(NotImplementedFailure('Fonctionnalité en cours de développement'));
}
}

View File

@@ -0,0 +1,127 @@
/// Entité métier Conversation
///
/// Représente une conversation (fil de messages) dans UnionFlow
library conversation;
import 'package:equatable/equatable.dart';
import 'message.dart';
/// Type de conversation
enum ConversationType {
/// Conversation individuelle (1-1)
individual,
/// Conversation de groupe
group,
/// Canal broadcast (lecture seule pour la plupart)
broadcast,
/// Canal d'annonces organisation
announcement,
}
/// Entité Conversation
class Conversation extends Equatable {
final String id;
final String name;
final String? description;
final ConversationType type;
final List<String> participantIds;
final String? organizationId;
final Message? lastMessage;
final int unreadCount;
final bool isMuted;
final bool isPinned;
final bool isArchived;
final DateTime createdAt;
final DateTime? updatedAt;
final String? avatarUrl;
final Map<String, dynamic>? metadata;
const Conversation({
required this.id,
required this.name,
this.description,
required this.type,
required this.participantIds,
this.organizationId,
this.lastMessage,
this.unreadCount = 0,
this.isMuted = false,
this.isPinned = false,
this.isArchived = false,
required this.createdAt,
this.updatedAt,
this.avatarUrl,
this.metadata,
});
/// Vérifie si la conversation a des messages non lus
bool get hasUnread => unreadCount > 0;
/// Vérifie si c'est une conversation individuelle
bool get isIndividual => type == ConversationType.individual;
/// Vérifie si c'est un broadcast
bool get isBroadcast => type == ConversationType.broadcast;
/// Nombre de participants
int get participantCount => participantIds.length;
/// Copie avec modifications
Conversation copyWith({
String? id,
String? name,
String? description,
ConversationType? type,
List<String>? participantIds,
String? organizationId,
Message? lastMessage,
int? unreadCount,
bool? isMuted,
bool? isPinned,
bool? isArchived,
DateTime? createdAt,
DateTime? updatedAt,
String? avatarUrl,
Map<String, dynamic>? metadata,
}) {
return Conversation(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
type: type ?? this.type,
participantIds: participantIds ?? this.participantIds,
organizationId: organizationId ?? this.organizationId,
lastMessage: lastMessage ?? this.lastMessage,
unreadCount: unreadCount ?? this.unreadCount,
isMuted: isMuted ?? this.isMuted,
isPinned: isPinned ?? this.isPinned,
isArchived: isArchived ?? this.isArchived,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
avatarUrl: avatarUrl ?? this.avatarUrl,
metadata: metadata ?? this.metadata,
);
}
@override
List<Object?> get props => [
id,
name,
description,
type,
participantIds,
organizationId,
lastMessage,
unreadCount,
isMuted,
isPinned,
isArchived,
createdAt,
updatedAt,
avatarUrl,
metadata,
];
}

View File

@@ -0,0 +1,173 @@
/// Entité métier Message
///
/// Représente un message dans le système de communication UnionFlow
library message;
import 'package:equatable/equatable.dart';
/// Type de message
enum MessageType {
/// Message individuel (membre à membre)
individual,
/// Broadcast organisation (OrgAdmin → tous)
broadcast,
/// Message ciblé par rôle (Moderator → groupe)
targeted,
/// Notification système
system,
}
/// Statut de lecture du message
enum MessageStatus {
/// Envoyé mais non lu
sent,
/// Livré (reçu par le serveur)
delivered,
/// Lu par le destinataire
read,
/// Échec d'envoi
failed,
}
/// Priorité du message
enum MessagePriority {
/// Priorité normale
normal,
/// Priorité élevée (important)
high,
/// Priorité urgente (critique)
urgent,
}
/// Entité Message
class Message extends Equatable {
final String id;
final String conversationId;
final String senderId;
final String senderName;
final String? senderAvatar;
final String content;
final MessageType type;
final MessageStatus status;
final MessagePriority priority;
final List<String> recipientIds;
final List<String>? recipientRoles;
final String? organizationId;
final DateTime createdAt;
final DateTime? readAt;
final Map<String, dynamic>? metadata;
final List<String>? attachments;
final bool isEdited;
final DateTime? editedAt;
final bool isDeleted;
const Message({
required this.id,
required this.conversationId,
required this.senderId,
required this.senderName,
this.senderAvatar,
required this.content,
required this.type,
required this.status,
this.priority = MessagePriority.normal,
required this.recipientIds,
this.recipientRoles,
this.organizationId,
required this.createdAt,
this.readAt,
this.metadata,
this.attachments,
this.isEdited = false,
this.editedAt,
this.isDeleted = false,
});
/// Vérifie si le message a été lu
bool get isRead => status == MessageStatus.read;
/// Vérifie si le message est urgent
bool get isUrgent => priority == MessagePriority.urgent;
/// Vérifie si le message est un broadcast
bool get isBroadcast => type == MessageType.broadcast;
/// Vérifie si le message a des pièces jointes
bool get hasAttachments => attachments != null && attachments!.isNotEmpty;
/// Copie avec modifications
Message copyWith({
String? id,
String? conversationId,
String? senderId,
String? senderName,
String? senderAvatar,
String? content,
MessageType? type,
MessageStatus? status,
MessagePriority? priority,
List<String>? recipientIds,
List<String>? recipientRoles,
String? organizationId,
DateTime? createdAt,
DateTime? readAt,
Map<String, dynamic>? metadata,
List<String>? attachments,
bool? isEdited,
DateTime? editedAt,
bool? isDeleted,
}) {
return Message(
id: id ?? this.id,
conversationId: conversationId ?? this.conversationId,
senderId: senderId ?? this.senderId,
senderName: senderName ?? this.senderName,
senderAvatar: senderAvatar ?? this.senderAvatar,
content: content ?? this.content,
type: type ?? this.type,
status: status ?? this.status,
priority: priority ?? this.priority,
recipientIds: recipientIds ?? this.recipientIds,
recipientRoles: recipientRoles ?? this.recipientRoles,
organizationId: organizationId ?? this.organizationId,
createdAt: createdAt ?? this.createdAt,
readAt: readAt ?? this.readAt,
metadata: metadata ?? this.metadata,
attachments: attachments ?? this.attachments,
isEdited: isEdited ?? this.isEdited,
editedAt: editedAt ?? this.editedAt,
isDeleted: isDeleted ?? this.isDeleted,
);
}
@override
List<Object?> get props => [
id,
conversationId,
senderId,
senderName,
senderAvatar,
content,
type,
status,
priority,
recipientIds,
recipientRoles,
organizationId,
createdAt,
readAt,
metadata,
attachments,
isEdited,
editedAt,
isDeleted,
];
}

View File

@@ -0,0 +1,154 @@
/// Entité métier Template de Message
///
/// Templates réutilisables pour notifications et broadcasts
library message_template;
import 'package:equatable/equatable.dart';
/// Catégorie de template
enum TemplateCategory {
/// Événements
events,
/// Finances
finances,
/// Adhésions
membership,
/// Solidarité
solidarity,
/// Système
system,
/// Personnalisé
custom,
}
/// Variables dynamiques dans les templates
class TemplateVariable {
final String name;
final String description;
final String placeholder;
final bool required;
const TemplateVariable({
required this.name,
required this.description,
required this.placeholder,
this.required = true,
});
}
/// Entité Template de Message
class MessageTemplate extends Equatable {
final String id;
final String name;
final String description;
final TemplateCategory category;
final String subject;
final String body;
final List<TemplateVariable> variables;
final String? organizationId;
final String createdBy;
final DateTime createdAt;
final DateTime? updatedAt;
final bool isActive;
final bool isSystem;
final int usageCount;
final Map<String, dynamic>? metadata;
const MessageTemplate({
required this.id,
required this.name,
required this.description,
required this.category,
required this.subject,
required this.body,
this.variables = const [],
this.organizationId,
required this.createdBy,
required this.createdAt,
this.updatedAt,
this.isActive = true,
this.isSystem = false,
this.usageCount = 0,
this.metadata,
});
/// Vérifie si le template est éditable (pas système)
bool get isEditable => !isSystem;
/// Génère un message à partir du template avec des valeurs
String generateMessage(Map<String, String> values) {
String result = body;
for (final variable in variables) {
final value = values[variable.name];
if (value != null) {
result = result.replaceAll('{{${variable.name}}}', value);
} else if (variable.required) {
throw ArgumentError('Variable requise manquante: ${variable.name}');
}
}
return result;
}
/// Copie avec modifications
MessageTemplate copyWith({
String? id,
String? name,
String? description,
TemplateCategory? category,
String? subject,
String? body,
List<TemplateVariable>? variables,
String? organizationId,
String? createdBy,
DateTime? createdAt,
DateTime? updatedAt,
bool? isActive,
bool? isSystem,
int? usageCount,
Map<String, dynamic>? metadata,
}) {
return MessageTemplate(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
category: category ?? this.category,
subject: subject ?? this.subject,
body: body ?? this.body,
variables: variables ?? this.variables,
organizationId: organizationId ?? this.organizationId,
createdBy: createdBy ?? this.createdBy,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
isActive: isActive ?? this.isActive,
isSystem: isSystem ?? this.isSystem,
usageCount: usageCount ?? this.usageCount,
metadata: metadata ?? this.metadata,
);
}
@override
List<Object?> get props => [
id,
name,
description,
category,
subject,
body,
variables,
organizationId,
createdBy,
createdAt,
updatedAt,
isActive,
isSystem,
usageCount,
metadata,
];
}

View File

@@ -0,0 +1,145 @@
/// Repository interface pour la communication
///
/// Contrat de données pour les messages, conversations et templates
library messaging_repository;
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../entities/conversation.dart';
import '../entities/message_template.dart';
/// Interface du repository de messagerie
abstract class MessagingRepository {
// === CONVERSATIONS ===
/// Récupère toutes les conversations de l'utilisateur
Future<Either<Failure, List<Conversation>>> getConversations({
String? organizationId,
bool includeArchived = false,
});
/// Récupère une conversation par son ID
Future<Either<Failure, Conversation>> getConversationById(String conversationId);
/// Crée une nouvelle conversation
Future<Either<Failure, Conversation>> createConversation({
required String name,
required List<String> participantIds,
String? organizationId,
String? description,
});
/// Archive une conversation
Future<Either<Failure, void>> archiveConversation(String conversationId);
/// Marque une conversation comme lue
Future<Either<Failure, void>> markConversationAsRead(String conversationId);
/// Mute/démute une conversation
Future<Either<Failure, void>> toggleMuteConversation(String conversationId);
/// Pin/unpin une conversation
Future<Either<Failure, void>> togglePinConversation(String conversationId);
// === MESSAGES ===
/// Récupère les messages d'une conversation
Future<Either<Failure, List<Message>>> getMessages({
required String conversationId,
int? limit,
String? beforeMessageId,
});
/// Envoie un message individuel
Future<Either<Failure, Message>> sendMessage({
required String conversationId,
required String content,
List<String>? attachments,
MessagePriority priority = MessagePriority.normal,
});
/// Envoie un broadcast à toute l'organisation
Future<Either<Failure, Message>> sendBroadcast({
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
});
/// Envoie un message ciblé par rôles
Future<Either<Failure, Message>> sendTargetedMessage({
required String organizationId,
required List<String> targetRoles,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
});
/// Marque un message comme lu
Future<Either<Failure, void>> markMessageAsRead(String messageId);
/// Édite un message
Future<Either<Failure, Message>> editMessage({
required String messageId,
required String newContent,
});
/// Supprime un message
Future<Either<Failure, void>> deleteMessage(String messageId);
// === TEMPLATES ===
/// Récupère tous les templates disponibles
Future<Either<Failure, List<MessageTemplate>>> getTemplates({
String? organizationId,
TemplateCategory? category,
});
/// Récupère un template par son ID
Future<Either<Failure, MessageTemplate>> getTemplateById(String templateId);
/// Crée un nouveau template
Future<Either<Failure, MessageTemplate>> createTemplate({
required String name,
required String description,
required TemplateCategory category,
required String subject,
required String body,
List<Map<String, dynamic>>? variables,
String? organizationId,
});
/// Met à jour un template
Future<Either<Failure, MessageTemplate>> updateTemplate({
required String templateId,
String? name,
String? description,
String? subject,
String? body,
bool? isActive,
});
/// Supprime un template
Future<Either<Failure, void>> deleteTemplate(String templateId);
/// Envoie un message à partir d'un template
Future<Either<Failure, Message>> sendFromTemplate({
required String templateId,
required Map<String, String> variables,
required List<String> recipientIds,
});
// === STATISTIQUES ===
/// Récupère le nombre de messages non lus
Future<Either<Failure, int>> getUnreadCount({String? organizationId});
/// Récupère les statistiques de communication
Future<Either<Failure, Map<String, dynamic>>> getMessagingStats({
required String organizationId,
DateTime? startDate,
DateTime? endDate,
});
}

View File

@@ -0,0 +1,25 @@
/// Use case: Récupérer les conversations
library get_conversations;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/conversation.dart';
import '../repositories/messaging_repository.dart';
@lazySingleton
class GetConversations {
final MessagingRepository repository;
GetConversations(this.repository);
Future<Either<Failure, List<Conversation>>> call({
String? organizationId,
bool includeArchived = false,
}) async {
return await repository.getConversations(
organizationId: organizationId,
includeArchived: includeArchived,
);
}
}

View File

@@ -0,0 +1,31 @@
/// Use case: Récupérer les messages d'une conversation
library get_messages;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../repositories/messaging_repository.dart';
@lazySingleton
class GetMessages {
final MessagingRepository repository;
GetMessages(this.repository);
Future<Either<Failure, List<Message>>> call({
required String conversationId,
int? limit,
String? beforeMessageId,
}) async {
if (conversationId.isEmpty) {
return Left(ValidationFailure('ID conversation requis'));
}
return await repository.getMessages(
conversationId: conversationId,
limit: limit,
beforeMessageId: beforeMessageId,
);
}
}

View File

@@ -0,0 +1,44 @@
/// Use case: Envoyer un broadcast organisation
library send_broadcast;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../repositories/messaging_repository.dart';
@lazySingleton
class SendBroadcast {
final MessagingRepository repository;
SendBroadcast(this.repository);
Future<Either<Failure, Message>> call({
required String organizationId,
required String subject,
required String content,
MessagePriority priority = MessagePriority.normal,
List<String>? attachments,
}) async {
// Validation
if (subject.trim().isEmpty) {
return Left(ValidationFailure('Le sujet ne peut pas être vide'));
}
if (content.trim().isEmpty) {
return Left(ValidationFailure('Le message ne peut pas être vide'));
}
if (organizationId.isEmpty) {
return Left(ValidationFailure('ID organisation requis'));
}
return await repository.sendBroadcast(
organizationId: organizationId,
subject: subject,
content: content,
priority: priority,
attachments: attachments,
);
}
}

View File

@@ -0,0 +1,34 @@
/// Use case: Envoyer un message
library send_message;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/message.dart';
import '../repositories/messaging_repository.dart';
@lazySingleton
class SendMessage {
final MessagingRepository repository;
SendMessage(this.repository);
Future<Either<Failure, Message>> call({
required String conversationId,
required String content,
List<String>? attachments,
MessagePriority priority = MessagePriority.normal,
}) async {
// Validation
if (content.trim().isEmpty) {
return Left(ValidationFailure('Le message ne peut pas être vide'));
}
return await repository.sendMessage(
conversationId: conversationId,
content: content,
attachments: attachments,
priority: priority,
);
}
}

View File

@@ -0,0 +1,105 @@
/// BLoC de gestion de la messagerie
library messaging_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../../domain/usecases/get_conversations.dart';
import '../../domain/usecases/get_messages.dart';
import '../../domain/usecases/send_message.dart';
import '../../domain/usecases/send_broadcast.dart';
import 'messaging_event.dart';
import 'messaging_state.dart';
@injectable
class MessagingBloc extends Bloc<MessagingEvent, MessagingState> {
final GetConversations getConversations;
final GetMessages getMessages;
final SendMessage sendMessage;
final SendBroadcast sendBroadcast;
MessagingBloc({
required this.getConversations,
required this.getMessages,
required this.sendMessage,
required this.sendBroadcast,
}) : super(MessagingInitial()) {
on<LoadConversations>(_onLoadConversations);
on<LoadMessages>(_onLoadMessages);
on<SendMessageEvent>(_onSendMessage);
on<SendBroadcastEvent>(_onSendBroadcast);
}
Future<void> _onLoadConversations(
LoadConversations event,
Emitter<MessagingState> emit,
) async {
emit(MessagingLoading());
final result = await getConversations(
organizationId: event.organizationId,
includeArchived: event.includeArchived,
);
result.fold(
(failure) => emit(MessagingError(failure.message)),
(conversations) => emit(ConversationsLoaded(conversations: conversations)),
);
}
Future<void> _onLoadMessages(
LoadMessages event,
Emitter<MessagingState> emit,
) async {
emit(MessagingLoading());
final result = await getMessages(
conversationId: event.conversationId,
limit: event.limit,
beforeMessageId: event.beforeMessageId,
);
result.fold(
(failure) => emit(MessagingError(failure.message)),
(messages) => emit(MessagesLoaded(
conversationId: event.conversationId,
messages: messages,
hasMore: messages.length == (event.limit ?? 50),
)),
);
}
Future<void> _onSendMessage(
SendMessageEvent event,
Emitter<MessagingState> emit,
) async {
final result = await sendMessage(
conversationId: event.conversationId,
content: event.content,
attachments: event.attachments,
priority: event.priority,
);
result.fold(
(failure) => emit(MessagingError(failure.message)),
(message) => emit(MessageSent(message)),
);
}
Future<void> _onSendBroadcast(
SendBroadcastEvent event,
Emitter<MessagingState> emit,
) async {
final result = await sendBroadcast(
organizationId: event.organizationId,
subject: event.subject,
content: event.content,
priority: event.priority,
attachments: event.attachments,
);
result.fold(
(failure) => emit(MessagingError(failure.message)),
(message) => emit(BroadcastSent(message)),
);
}
}

View File

@@ -0,0 +1,118 @@
/// Événements du BLoC Messaging
library messaging_event;
import 'package:equatable/equatable.dart';
import '../../domain/entities/message.dart';
abstract class MessagingEvent extends Equatable {
const MessagingEvent();
@override
List<Object?> get props => [];
}
/// Charger les conversations
class LoadConversations extends MessagingEvent {
final String? organizationId;
final bool includeArchived;
const LoadConversations({
this.organizationId,
this.includeArchived = false,
});
@override
List<Object?> get props => [organizationId, includeArchived];
}
/// Charger les messages d'une conversation
class LoadMessages extends MessagingEvent {
final String conversationId;
final int? limit;
final String? beforeMessageId;
const LoadMessages({
required this.conversationId,
this.limit,
this.beforeMessageId,
});
@override
List<Object?> get props => [conversationId, limit, beforeMessageId];
}
/// Envoyer un message
class SendMessageEvent extends MessagingEvent {
final String conversationId;
final String content;
final List<String>? attachments;
final MessagePriority priority;
const SendMessageEvent({
required this.conversationId,
required this.content,
this.attachments,
this.priority = MessagePriority.normal,
});
@override
List<Object?> get props => [conversationId, content, attachments, priority];
}
/// Envoyer un broadcast
class SendBroadcastEvent extends MessagingEvent {
final String organizationId;
final String subject;
final String content;
final MessagePriority priority;
final List<String>? attachments;
const SendBroadcastEvent({
required this.organizationId,
required this.subject,
required this.content,
this.priority = MessagePriority.normal,
this.attachments,
});
@override
List<Object?> get props => [organizationId, subject, content, priority, attachments];
}
/// Marquer un message comme lu
class MarkMessageAsReadEvent extends MessagingEvent {
final String messageId;
const MarkMessageAsReadEvent(this.messageId);
@override
List<Object?> get props => [messageId];
}
/// Charger le nombre de messages non lus
class LoadUnreadCount extends MessagingEvent {
final String? organizationId;
const LoadUnreadCount({this.organizationId});
@override
List<Object?> get props => [organizationId];
}
/// Créer une nouvelle conversation
class CreateConversationEvent extends MessagingEvent {
final String name;
final List<String> participantIds;
final String? organizationId;
final String? description;
const CreateConversationEvent({
required this.name,
required this.participantIds,
this.organizationId,
this.description,
});
@override
List<Object?> get props => [name, participantIds, organizationId, description];
}

View File

@@ -0,0 +1,99 @@
/// États du BLoC Messaging
library messaging_state;
import 'package:equatable/equatable.dart';
import '../../domain/entities/conversation.dart';
import '../../domain/entities/message.dart';
abstract class MessagingState extends Equatable {
const MessagingState();
@override
List<Object?> get props => [];
}
/// État initial
class MessagingInitial extends MessagingState {}
/// Chargement en cours
class MessagingLoading extends MessagingState {}
/// Conversations chargées
class ConversationsLoaded extends MessagingState {
final List<Conversation> conversations;
final int unreadCount;
const ConversationsLoaded({
required this.conversations,
this.unreadCount = 0,
});
@override
List<Object?> get props => [conversations, unreadCount];
}
/// Messages d'une conversation chargés
class MessagesLoaded extends MessagingState {
final String conversationId;
final List<Message> messages;
final bool hasMore;
const MessagesLoaded({
required this.conversationId,
required this.messages,
this.hasMore = false,
});
@override
List<Object?> get props => [conversationId, messages, hasMore];
}
/// Message envoyé avec succès
class MessageSent extends MessagingState {
final Message message;
const MessageSent(this.message);
@override
List<Object?> get props => [message];
}
/// Broadcast envoyé avec succès
class BroadcastSent extends MessagingState {
final Message message;
const BroadcastSent(this.message);
@override
List<Object?> get props => [message];
}
/// Conversation créée
class ConversationCreated extends MessagingState {
final Conversation conversation;
const ConversationCreated(this.conversation);
@override
List<Object?> get props => [conversation];
}
/// Compteur de non lus chargé
class UnreadCountLoaded extends MessagingState {
final int count;
const UnreadCountLoaded(this.count);
@override
List<Object?> get props => [count];
}
/// Erreur
class MessagingError extends MessagingState {
final String message;
const MessagingError(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,150 @@
/// Page liste des conversations
library conversations_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../bloc/messaging_bloc.dart';
import '../bloc/messaging_event.dart';
import '../bloc/messaging_state.dart';
import '../widgets/conversation_tile.dart';
class ConversationsPage extends StatelessWidget {
final String? organizationId;
const ConversationsPage({
super.key,
this.organizationId,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => sl<MessagingBloc>()
..add(LoadConversations(organizationId: organizationId)),
child: Scaffold(
backgroundColor: ColorTokens.background,
appBar: const UFAppBar(
title: 'MESSAGES',
automaticallyImplyLeading: true,
),
body: BlocBuilder<MessagingBloc, MessagingState>(
builder: (context, state) {
if (state is MessagingLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is MessagingError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: AppColors.error,
),
const SizedBox(height: SpacingTokens.md),
Text(
'Erreur',
style: AppTypography.headerSmall,
),
const SizedBox(height: SpacingTokens.sm),
Text(
state.message,
style: AppTypography.bodyTextSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: SpacingTokens.lg),
UFPrimaryButton(
label: 'Réessayer',
onPressed: () {
context.read<MessagingBloc>().add(
LoadConversations(organizationId: organizationId),
);
},
),
],
),
);
}
if (state is ConversationsLoaded) {
final conversations = state.conversations;
if (conversations.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.chat_bubble_outline,
size: 64,
color: AppColors.textSecondaryLight,
),
const SizedBox(height: SpacingTokens.md),
Text(
'Aucune conversation',
style: AppTypography.headerSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
const SizedBox(height: SpacingTokens.sm),
Text(
'Commencez une nouvelle conversation',
style: AppTypography.bodyTextSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
context.read<MessagingBloc>().add(
LoadConversations(organizationId: organizationId),
);
},
child: ListView.separated(
padding: const EdgeInsets.all(SpacingTokens.md),
itemCount: conversations.length,
separatorBuilder: (_, __) => const SizedBox(height: SpacingTokens.sm),
itemBuilder: (context, index) {
final conversation = conversations[index];
return ConversationTile(
conversation: conversation,
onTap: () {
// Navigation vers la page de chat
// TODO: Implémenter navigation
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Ouvrir conversation: ${conversation.name}'),
),
);
},
);
},
),
);
}
return const SizedBox.shrink();
},
),
floatingActionButton: FloatingActionButton(
backgroundColor: AppColors.primaryGreen,
onPressed: () {
// TODO: Ouvrir dialogue nouvelle conversation
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Nouvelle conversation (à implémenter)')),
);
},
child: const Icon(Icons.add, color: Colors.white),
),
),
);
}
}

View File

@@ -0,0 +1,166 @@
/// Widget tuile de conversation
library conversation_tile;
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../domain/entities/conversation.dart';
class ConversationTile extends StatelessWidget {
final Conversation conversation;
final VoidCallback onTap;
const ConversationTile({
super.key,
required this.conversation,
required this.onTap,
});
String _formatDate(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return DateFormat('HH:mm').format(date);
} else if (difference.inDays == 1) {
return 'Hier';
} else if (difference.inDays < 7) {
return DateFormat('EEEE', 'fr_FR').format(date);
} else {
return DateFormat('dd/MM/yy').format(date);
}
}
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
child: Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
border: Border.all(
color: conversation.hasUnread
? AppColors.primaryGreen.withOpacity(0.3)
: ColorTokens.outline,
),
),
child: Row(
children: [
// Avatar
CircleAvatar(
radius: 24,
backgroundColor: AppColors.primaryGreen.withOpacity(0.1),
backgroundImage: conversation.avatarUrl != null
? NetworkImage(conversation.avatarUrl!)
: null,
child: conversation.avatarUrl == null
? Text(
conversation.name.isNotEmpty
? conversation.name[0].toUpperCase()
: '?',
style: AppTypography.actionText.copyWith(
color: AppColors.primaryGreen,
),
)
: null,
),
const SizedBox(width: SpacingTokens.md),
// Contenu
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
conversation.name,
style: AppTypography.actionText.copyWith(
fontWeight: conversation.hasUnread
? FontWeight.bold
: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (conversation.lastMessage != null)
Text(
_formatDate(conversation.lastMessage!.createdAt),
style: AppTypography.subtitleSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
],
),
if (conversation.lastMessage != null) ...[
const SizedBox(height: 4),
Text(
conversation.lastMessage!.content,
style: AppTypography.bodyTextSmall.copyWith(
color: AppColors.textSecondaryLight,
fontWeight: conversation.hasUnread
? FontWeight.w600
: FontWeight.normal,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// Badge non lus
if (conversation.hasUnread) ...[
const SizedBox(width: SpacingTokens.sm),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.primaryGreen,
borderRadius: BorderRadius.circular(SpacingTokens.radiusCircular),
),
child: Text(
'${conversation.unreadCount}',
style: AppTypography.badgeText.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
// Icônes statut
if (conversation.isPinned || conversation.isMuted) ...[
const SizedBox(width: SpacingTokens.sm),
Column(
children: [
if (conversation.isPinned)
Icon(
Icons.push_pin,
size: 16,
color: AppColors.textSecondaryLight,
),
if (conversation.isMuted)
Icon(
Icons.volume_off,
size: 16,
color: AppColors.textSecondaryLight,
),
],
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,367 @@
/// BLoC pour la gestion des contributions
library contributions_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../../../core/utils/logger.dart';
import '../data/models/contribution_model.dart';
import '../data/repositories/contribution_repository.dart' show ContributionPageResult;
import '../domain/usecases/get_contributions.dart';
import '../domain/usecases/get_contribution_by_id.dart';
import '../domain/usecases/create_contribution.dart' as uc;
import '../domain/usecases/update_contribution.dart' as uc;
import '../domain/usecases/delete_contribution.dart' as uc;
import '../domain/usecases/pay_contribution.dart';
import '../domain/usecases/get_contribution_stats.dart';
import '../domain/repositories/contribution_repository.dart';
import 'contributions_event.dart';
import 'contributions_state.dart';
/// BLoC pour gérer l'état des contributions via les use cases (Clean Architecture)
@injectable
class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
final GetContributions _getContributions;
final GetContributionById _getContributionById;
final uc.CreateContribution _createContribution;
final uc.UpdateContribution _updateContribution;
final uc.DeleteContribution _deleteContribution;
final PayContribution _payContribution;
final GetContributionStats _getContributionStats;
final IContributionRepository _repository; // Pour méthodes non-couvertes par use cases
ContributionsBloc(
this._getContributions,
this._getContributionById,
this._createContribution,
this._updateContribution,
this._deleteContribution,
this._payContribution,
this._getContributionStats,
this._repository,
) : super(const ContributionsInitial()) {
on<LoadContributions>(_onLoadContributions);
on<LoadContributionById>(_onLoadContributionById);
on<CreateContribution>(_onCreateContribution);
on<UpdateContribution>(_onUpdateContribution);
on<DeleteContribution>(_onDeleteContribution);
on<SearchContributions>(_onSearchContributions);
on<LoadContributionsByMembre>(_onLoadContributionsByMembre);
on<LoadContributionsPayees>(_onLoadContributionsPayees);
on<LoadContributionsNonPayees>(_onLoadContributionsNonPayees);
on<LoadContributionsEnRetard>(_onLoadContributionsEnRetard);
on<RecordPayment>(_onRecordPayment);
on<LoadContributionsStats>(_onLoadContributionsStats);
on<GenerateAnnualContributions>(_onGenerateAnnualContributions);
on<SendPaymentReminder>(_onSendPaymentReminder);
}
Future<void> _onLoadContributions(
LoadContributions event,
Emitter<ContributionsState> emit,
) async {
try {
AppLogger.blocEvent('ContributionsBloc', 'LoadContributions', data: {
'page': event.page,
'size': event.size,
});
emit(const ContributionsLoading(message: 'Chargement des contributions...'));
// Use case: Get contributions
final result = await _getContributions(page: event.page, size: event.size);
emit(ContributionsLoaded(
contributions: result.contributions,
total: result.total,
page: result.page,
size: result.size,
totalPages: result.totalPages,
));
AppLogger.blocState('ContributionsBloc', 'ContributionsLoaded', data: {
'count': result.contributions.length,
'total': result.total,
});
} catch (e, stackTrace) {
AppLogger.error('Erreur lors du chargement des contributions', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Erreur lors du chargement des contributions', error: e));
}
}
Future<void> _onLoadContributionById(
LoadContributionById event,
Emitter<ContributionsState> emit,
) async {
try {
emit(const ContributionsLoading(message: 'Chargement de la contribution...'));
final contribution = await _getContributionById(event.id);
emit(ContributionDetailLoaded(contribution: contribution));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Contribution non trouvée', error: e));
}
}
Future<void> _onCreateContribution(
CreateContribution event,
Emitter<ContributionsState> emit,
) async {
try {
emit(const ContributionsLoading(message: 'Création de la contribution...'));
final created = await _createContribution(event.contribution);
emit(ContributionCreated(contribution: created));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Erreur lors de la création de la contribution', error: e));
}
}
Future<void> _onUpdateContribution(
UpdateContribution event,
Emitter<ContributionsState> emit,
) async {
try {
emit(const ContributionsLoading(message: 'Mise à jour de la contribution...'));
final updated = await _updateContribution(event.id, event.contribution);
emit(ContributionUpdated(contribution: updated));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Erreur lors de la mise à jour', error: e));
}
}
Future<void> _onDeleteContribution(
DeleteContribution event,
Emitter<ContributionsState> emit,
) async {
try {
emit(const ContributionsLoading(message: 'Suppression de la contribution...'));
await _deleteContribution(event.id);
emit(ContributionDeleted(id: event.id));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Erreur lors de la suppression', error: e));
}
}
Future<void> _onSearchContributions(
SearchContributions event,
Emitter<ContributionsState> emit,
) async {
try {
emit(const ContributionsLoading(message: 'Recherche en cours...'));
final result = await _repository.getCotisations(
page: event.page,
size: event.size,
membreId: event.membreId,
statut: event.statut?.name,
type: event.type?.name,
annee: event.annee,
);
emit(ContributionsLoaded(
contributions: result.contributions,
total: result.total,
page: result.page,
size: result.size,
totalPages: result.totalPages,
));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Erreur lors de la recherche', error: e));
}
}
Future<void> _onLoadContributionsByMembre(
LoadContributionsByMembre event,
Emitter<ContributionsState> emit,
) async {
try {
emit(const ContributionsLoading(message: 'Chargement des contributions du membre...'));
final result = await _repository.getCotisations(
page: event.page,
size: event.size,
membreId: event.membreId,
);
emit(ContributionsLoaded(
contributions: result.contributions,
total: result.total,
page: result.page,
size: result.size,
totalPages: result.totalPages,
));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Erreur lors du chargement', error: e));
}
}
Future<void> _onLoadContributionsPayees(
LoadContributionsPayees event,
Emitter<ContributionsState> emit,
) async {
try {
emit(const ContributionsLoading(message: 'Chargement des contributions payées...'));
final result = await _repository.getMesCotisations();
final payees = result.contributions.where((c) => c.statut == ContributionStatus.payee).toList();
emit(ContributionsLoaded(
contributions: payees,
total: payees.length,
page: 0,
size: payees.length,
totalPages: payees.isEmpty ? 0 : 1,
));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Erreur', error: e));
}
}
Future<void> _onLoadContributionsNonPayees(
LoadContributionsNonPayees event,
Emitter<ContributionsState> emit,
) async {
try {
emit(const ContributionsLoading(message: 'Chargement des contributions non payées...'));
final result = await _repository.getMesCotisations();
final nonPayees = result.contributions.where((c) => c.statut != ContributionStatus.payee).toList();
emit(ContributionsLoaded(
contributions: nonPayees,
total: nonPayees.length,
page: 0,
size: nonPayees.length,
totalPages: nonPayees.isEmpty ? 0 : 1,
));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Erreur', error: e));
}
}
Future<void> _onLoadContributionsEnRetard(
LoadContributionsEnRetard event,
Emitter<ContributionsState> emit,
) async {
try {
emit(const ContributionsLoading(message: 'Chargement des contributions en retard...'));
final result = await _repository.getMesCotisations();
final enRetard = result.contributions.where((c) => c.statut == ContributionStatus.enRetard || c.estEnRetard).toList();
emit(ContributionsLoaded(
contributions: enRetard,
total: enRetard.length,
page: 0,
size: enRetard.length,
totalPages: enRetard.isEmpty ? 0 : 1,
));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Erreur', error: e));
}
}
Future<void> _onRecordPayment(
RecordPayment event,
Emitter<ContributionsState> emit,
) async {
try {
emit(const ContributionsLoading(message: 'Enregistrement du paiement...'));
final updated = await _payContribution(
cotisationId: event.contributionId,
montant: event.montant,
datePaiement: event.datePaiement,
methodePaiement: event.methodePaiement.name,
numeroPaiement: event.numeroPaiement,
referencePaiement: event.referencePaiement,
);
emit(PaymentRecorded(contribution: updated));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Erreur lors de l\'enregistrement du paiement', error: e));
}
}
Future<void> _onLoadContributionsStats(
LoadContributionsStats event,
Emitter<ContributionsState> emit,
) async {
List<ContributionModel>? preservedList = state is ContributionsLoaded ? (state as ContributionsLoaded).contributions : null;
try {
// Charger synthèse + liste pour que la page « Mes statistiques » ait toujours donut et prochaines échéances
final mesSynthese = await _getContributionStats();
final listResult = preservedList == null ? await _getContributions() : null;
final contributions = preservedList ?? listResult?.contributions;
if (mesSynthese != null && mesSynthese.isNotEmpty) {
final normalized = _normalizeSyntheseForStats(mesSynthese);
emit(ContributionsStatsLoaded(stats: normalized, contributions: contributions));
return;
}
final stats = await _repository.getStatistiques();
emit(ContributionsStatsLoaded(
stats: stats.map((k, v) => MapEntry(k, v is num ? v.toDouble() : (v is int ? v.toDouble() : 0.0))),
contributions: contributions,
));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Erreur', error: e));
}
}
/// Normalise la réponse synthese (mes) pour l'affichage stats (clés numériques + isMesSynthese).
Map<String, dynamic> _normalizeSyntheseForStats(Map<String, dynamic> s) {
final montantDu = _toDouble(s['montantDu']);
final totalPayeAnnee = _toDouble(s['totalPayeAnnee']);
final totalAnnee = montantDu + totalPayeAnnee;
final taux = totalAnnee > 0 ? (totalPayeAnnee / totalAnnee * 100) : 0.0;
return {
'isMesSynthese': true,
'cotisationsEnAttente': (s['cotisationsEnAttente'] is int) ? s['cotisationsEnAttente'] as int : ((s['cotisationsEnAttente'] as num?)?.toInt() ?? 0),
'montantDu': montantDu,
'totalPayeAnnee': totalPayeAnnee,
'totalMontant': totalAnnee,
'tauxPaiement': taux,
'prochaineEcheance': s['prochaineEcheance']?.toString(),
'anneeEnCours': s['anneeEnCours'] is int ? s['anneeEnCours'] as int : ((s['anneeEnCours'] as num?)?.toInt() ?? DateTime.now().year),
};
}
double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
Future<void> _onGenerateAnnualContributions(
GenerateAnnualContributions event,
Emitter<ContributionsState> emit,
) async {
try {
emit(const ContributionsLoading(message: 'Génération des contributions...'));
final count = await _repository.genererCotisationsAnnuelles(event.annee);
emit(ContributionsGenerated(nombreGenere: count));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Erreur', error: e));
}
}
Future<void> _onSendPaymentReminder(
SendPaymentReminder event,
Emitter<ContributionsState> emit,
) async {
try {
emit(const ContributionsLoading(message: 'Envoi du rappel...'));
await _repository.envoyerRappel(event.contributionId);
emit(ReminderSent(contributionId: event.contributionId));
} catch (e, stackTrace) {
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
emit(ContributionsError(message: 'Erreur', error: e));
}
}
}

View File

@@ -0,0 +1,225 @@
/// Événements pour le BLoC des contributions
library contributions_event;
import 'package:equatable/equatable.dart';
import '../data/models/contribution_model.dart';
/// Classe de base pour tous les événements de contributions
abstract class ContributionsEvent extends Equatable {
const ContributionsEvent();
@override
List<Object?> get props => [];
}
/// Charger la liste des contributions
class LoadContributions extends ContributionsEvent {
final int page;
final int size;
const LoadContributions({
this.page = 0,
this.size = 20,
});
@override
List<Object?> get props => [page, size];
}
/// Charger une contribution par ID
class LoadContributionById extends ContributionsEvent {
final String id;
const LoadContributionById({required this.id});
@override
List<Object?> get props => [id];
}
/// Créer une nouvelle contribution
class CreateContribution extends ContributionsEvent {
final ContributionModel contribution;
const CreateContribution({required this.contribution});
@override
List<Object?> get props => [contribution];
}
/// Mettre à jour une contribution
class UpdateContribution extends ContributionsEvent {
final String id;
final ContributionModel contribution;
const UpdateContribution({
required this.id,
required this.contribution,
});
@override
List<Object?> get props => [id, contribution];
}
/// Supprimer une contribution
class DeleteContribution extends ContributionsEvent {
final String id;
const DeleteContribution({required this.id});
@override
List<Object?> get props => [id];
}
/// Rechercher des contributions
class SearchContributions extends ContributionsEvent {
final String? membreId;
final ContributionStatus? statut;
final ContributionType? type;
final int? annee;
final String? query;
final int page;
final int size;
const SearchContributions({
this.membreId,
this.statut,
this.type,
this.annee,
this.query,
this.page = 0,
this.size = 20,
});
@override
List<Object?> get props => [membreId, statut, type, annee, query, page, size];
}
/// Charger les contributions d'un membre
class LoadContributionsByMembre extends ContributionsEvent {
final String membreId;
final int page;
final int size;
const LoadContributionsByMembre({
required this.membreId,
this.page = 0,
this.size = 20,
});
@override
List<Object?> get props => [membreId, page, size];
}
/// Charger les contributions payées
class LoadContributionsPayees extends ContributionsEvent {
final int page;
final int size;
const LoadContributionsPayees({
this.page = 0,
this.size = 20,
});
@override
List<Object?> get props => [page, size];
}
/// Charger les contributions non payées
class LoadContributionsNonPayees extends ContributionsEvent {
final int page;
final int size;
const LoadContributionsNonPayees({
this.page = 0,
this.size = 20,
});
@override
List<Object?> get props => [page, size];
}
/// Charger les contributions en retard
class LoadContributionsEnRetard extends ContributionsEvent {
final int page;
final int size;
const LoadContributionsEnRetard({
this.page = 0,
this.size = 20,
});
@override
List<Object?> get props => [page, size];
}
/// Enregistrer un paiement
class RecordPayment extends ContributionsEvent {
final String contributionId;
final double montant;
final PaymentMethod methodePaiement;
final String? numeroPaiement;
final String? referencePaiement;
final DateTime datePaiement;
final String? notes;
final String? reference;
const RecordPayment({
required this.contributionId,
required this.montant,
required this.methodePaiement,
this.numeroPaiement,
this.referencePaiement,
required this.datePaiement,
this.notes,
this.reference,
});
@override
List<Object?> get props => [
contributionId,
montant,
methodePaiement,
numeroPaiement,
referencePaiement,
datePaiement,
notes,
reference,
];
}
/// Charger les statistiques des contributions
class LoadContributionsStats extends ContributionsEvent {
final int? annee;
const LoadContributionsStats({this.annee});
@override
List<Object?> get props => [annee];
}
/// Générer les contributions annuelles
class GenerateAnnualContributions extends ContributionsEvent {
final int annee;
final double montant;
final DateTime dateEcheance;
const GenerateAnnualContributions({
required this.annee,
required this.montant,
required this.dateEcheance,
});
@override
List<Object?> get props => [annee, montant, dateEcheance];
}
/// Envoyer un rappel de paiement
class SendPaymentReminder extends ContributionsEvent {
final String contributionId;
const SendPaymentReminder({required this.contributionId});
@override
List<Object?> get props => [contributionId];
}

View File

@@ -0,0 +1,174 @@
/// États pour le BLoC des contributions
library contributions_state;
import 'package:equatable/equatable.dart';
import '../data/models/contribution_model.dart';
/// Classe de base pour tous les états de contributions
abstract class ContributionsState extends Equatable {
const ContributionsState();
@override
List<Object?> get props => [];
}
/// État initial
class ContributionsInitial extends ContributionsState {
const ContributionsInitial();
}
/// État de chargement
class ContributionsLoading extends ContributionsState {
final String? message;
const ContributionsLoading({this.message});
@override
List<Object?> get props => [message];
}
/// État de rafraîchissement
class ContributionsRefreshing extends ContributionsState {
const ContributionsRefreshing();
}
/// État chargé avec succès
class ContributionsLoaded extends ContributionsState {
final List<ContributionModel> contributions;
final int total;
final int page;
final int size;
final int totalPages;
const ContributionsLoaded({
required this.contributions,
required this.total,
required this.page,
required this.size,
required this.totalPages,
});
@override
List<Object?> get props => [contributions, total, page, size, totalPages];
}
/// État détail d'une contribution chargé
class ContributionDetailLoaded extends ContributionsState {
final ContributionModel contribution;
const ContributionDetailLoaded({required this.contribution});
@override
List<Object?> get props => [contribution];
}
/// État contribution créée
class ContributionCreated extends ContributionsState {
final ContributionModel contribution;
const ContributionCreated({required this.contribution});
@override
List<Object?> get props => [contribution];
}
/// État contribution mise à jour
class ContributionUpdated extends ContributionsState {
final ContributionModel contribution;
const ContributionUpdated({required this.contribution});
@override
List<Object?> get props => [contribution];
}
/// État contribution supprimée
class ContributionDeleted extends ContributionsState {
final String id;
const ContributionDeleted({required this.id});
@override
List<Object?> get props => [id];
}
/// État paiement enregistré
class PaymentRecorded extends ContributionsState {
final ContributionModel contribution;
const PaymentRecorded({required this.contribution});
@override
List<Object?> get props => [contribution];
}
/// État statistiques chargées (liste optionnelle conservée pour ne pas perdre l'onglet Toutes au retour)
class ContributionsStatsLoaded extends ContributionsState {
final Map<String, dynamic> stats;
/// Liste des contributions conservée depuis l'état précédent (ex: au retour de la page Stats).
final List<ContributionModel>? contributions;
const ContributionsStatsLoaded({required this.stats, this.contributions});
@override
List<Object?> get props => [stats, contributions];
}
/// État contributions générées
class ContributionsGenerated extends ContributionsState {
final int nombreGenere;
const ContributionsGenerated({required this.nombreGenere});
@override
List<Object?> get props => [nombreGenere];
}
/// État rappel envoyé
class ReminderSent extends ContributionsState {
final String contributionId;
const ReminderSent({required this.contributionId});
@override
List<Object?> get props => [contributionId];
}
/// État d'erreur générique
class ContributionsError extends ContributionsState {
final String message;
final dynamic error;
const ContributionsError({
required this.message,
this.error,
});
@override
List<Object?> get props => [message, error];
}
/// État d'erreur réseau
class ContributionsNetworkError extends ContributionsState {
final String message;
const ContributionsNetworkError({required this.message});
@override
List<Object?> get props => [message];
}
/// État d'erreur de validation
class ContributionsValidationError extends ContributionsState {
final String message;
final Map<String, String>? fieldErrors;
const ContributionsValidationError({
required this.message,
this.fieldErrors,
});
@override
List<Object?> get props => [message, fieldErrors];
}

View File

@@ -0,0 +1,335 @@
/// Modèle de données pour les contributions
library contribution_model;
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'contribution_model.g.dart';
/// Statut d'une contribution
enum ContributionStatus {
@JsonValue('PAYEE')
payee,
@JsonValue('NON_PAYEE')
nonPayee,
@JsonValue('EN_ATTENTE')
enAttente,
@JsonValue('EN_RETARD')
enRetard,
@JsonValue('PARTIELLE')
partielle,
@JsonValue('ANNULEE')
annulee,
}
/// Type de contribution
enum ContributionType {
@JsonValue('ANNUELLE')
annuelle,
@JsonValue('MENSUELLE')
mensuelle,
@JsonValue('TRIMESTRIELLE')
trimestrielle,
@JsonValue('SEMESTRIELLE')
semestrielle,
@JsonValue('EXCEPTIONNELLE')
exceptionnelle,
}
/// Méthode de paiement
enum PaymentMethod {
@JsonValue('ESPECES')
especes,
@JsonValue('CHEQUE')
cheque,
@JsonValue('VIREMENT')
virement,
@JsonValue('CARTE_BANCAIRE')
carteBancaire,
@JsonValue('WAVE_MONEY')
waveMoney,
@JsonValue('ORANGE_MONEY')
orangeMoney,
@JsonValue('FREE_MONEY')
freeMoney,
@JsonValue('MOBILE_MONEY')
mobileMoney,
@JsonValue('AUTRE')
autre,
}
/// Extension pour obtenir le code API d'une méthode de paiement (ex: pour icônes assets).
extension PaymentMethodCode on PaymentMethod {
String get code {
switch (this) {
case PaymentMethod.especes: return 'ESPECES';
case PaymentMethod.cheque: return 'CHEQUE';
case PaymentMethod.virement: return 'VIREMENT';
case PaymentMethod.carteBancaire: return 'CARTE_BANCAIRE';
case PaymentMethod.waveMoney: return 'WAVE_MONEY';
case PaymentMethod.orangeMoney: return 'ORANGE_MONEY';
case PaymentMethod.freeMoney: return 'FREE_MONEY';
case PaymentMethod.mobileMoney: return 'MOBILE_MONEY';
case PaymentMethod.autre: return 'AUTRE';
}
}
}
/// Modèle complet d'une contribution
@JsonSerializable(explicitToJson: true)
class ContributionModel extends Equatable {
/// Identifiant unique
final String? id;
/// Membre concerné
final String membreId;
final String? membreNom;
final String? membrePrenom;
/// Organisation
final String? organisationId;
final String? organisationNom;
/// Informations de la contribution
final ContributionType type;
final ContributionStatus statut;
final double montant;
final double? montantPaye;
final String devise;
/// Dates
final DateTime dateEcheance;
final DateTime? datePaiement;
final DateTime? dateRappel;
/// Paiement
final PaymentMethod? methodePaiement;
final String? numeroPaiement;
final String? referencePaiement;
/// Période
final int annee;
final int? mois;
final int? trimestre;
final int? semestre;
/// Informations complémentaires
final String? description;
final String? notes;
final String? recu;
/// Métadonnées
final DateTime? dateCreation;
final DateTime? dateModification;
final String? creeParId;
final String? modifieParId;
const ContributionModel({
this.id,
required this.membreId,
this.membreNom,
this.membrePrenom,
this.organisationId,
this.organisationNom,
this.type = ContributionType.annuelle,
this.statut = ContributionStatus.nonPayee,
required this.montant,
this.montantPaye,
this.devise = 'XOF',
required this.dateEcheance,
this.datePaiement,
this.dateRappel,
this.methodePaiement,
this.numeroPaiement,
this.referencePaiement,
required this.annee,
this.mois,
this.trimestre,
this.semestre,
this.description,
this.notes,
this.recu,
this.dateCreation,
this.dateModification,
this.creeParId,
this.modifieParId,
});
/// Désérialisation depuis JSON
factory ContributionModel.fromJson(Map<String, dynamic> json) =>
_$ContributionModelFromJson(json);
/// Sérialisation vers JSON
Map<String, dynamic> toJson() => _$ContributionModelToJson(this);
/// Copie avec modifications
ContributionModel copyWith({
String? id,
String? membreId,
String? membreNom,
String? membrePrenom,
String? organisationId,
String? organisationNom,
ContributionType? type,
ContributionStatus? statut,
double? montant,
double? montantPaye,
String? devise,
DateTime? dateEcheance,
DateTime? datePaiement,
DateTime? dateRappel,
PaymentMethod? methodePaiement,
String? numeroPaiement,
String? referencePaiement,
int? annee,
int? mois,
int? trimestre,
int? semestre,
String? description,
String? notes,
String? recu,
DateTime? dateCreation,
DateTime? dateModification,
String? creeParId,
String? modifieParId,
}) {
return ContributionModel(
id: id ?? this.id,
membreId: membreId ?? this.membreId,
membreNom: membreNom ?? this.membreNom,
membrePrenom: membrePrenom ?? this.membrePrenom,
organisationId: organisationId ?? this.organisationId,
organisationNom: organisationNom ?? this.organisationNom,
type: type ?? this.type,
statut: statut ?? this.statut,
montant: montant ?? this.montant,
montantPaye: montantPaye ?? this.montantPaye,
devise: devise ?? this.devise,
dateEcheance: dateEcheance ?? this.dateEcheance,
datePaiement: datePaiement ?? this.datePaiement,
dateRappel: dateRappel ?? this.dateRappel,
methodePaiement: methodePaiement ?? this.methodePaiement,
numeroPaiement: numeroPaiement ?? this.numeroPaiement,
referencePaiement: referencePaiement ?? this.referencePaiement,
annee: annee ?? this.annee,
mois: mois ?? this.mois,
trimestre: trimestre ?? this.trimestre,
semestre: semestre ?? this.semestre,
description: description ?? this.description,
notes: notes ?? this.notes,
recu: recu ?? this.recu,
dateCreation: dateCreation ?? this.dateCreation,
dateModification: dateModification ?? this.dateModification,
creeParId: creeParId ?? this.creeParId,
modifieParId: modifieParId ?? this.modifieParId,
);
}
/// Nom complet du membre
String get membreNomComplet {
if (membreNom != null && membrePrenom != null) {
return '$membrePrenom $membreNom';
}
return membreNom ?? membrePrenom ?? 'Membre inconnu';
}
/// Montant restant à payer
double get montantRestant {
if (montantPaye == null) return montant;
return montant - montantPaye!;
}
/// Pourcentage payé
double get pourcentagePaye {
if (montantPaye == null || montant == 0) return 0;
return (montantPaye! / montant) * 100;
}
/// Vérifie si la contribution est payée
bool get estPayee => statut == ContributionStatus.payee;
/// Vérifie si la contribution est en retard
bool get estEnRetard {
if (estPayee) return false;
return DateTime.now().isAfter(dateEcheance);
}
/// Nombre de jours avant/après l'échéance
int get joursAvantEcheance {
return dateEcheance.difference(DateTime.now()).inDays;
}
/// Libellé de la période
String get libellePeriode {
switch (type) {
case ContributionType.annuelle:
return 'Année $annee';
case ContributionType.mensuelle:
if (mois != null) {
return '${_getNomMois(mois!)} $annee';
}
return 'Année $annee';
case ContributionType.trimestrielle:
if (trimestre != null) {
return 'T$trimestre $annee';
}
return 'Année $annee';
case ContributionType.semestrielle:
if (semestre != null) {
return 'S$semestre $annee';
}
return 'Année $annee';
case ContributionType.exceptionnelle:
return 'Exceptionnelle $annee';
}
}
/// Nom du mois
String _getNomMois(int mois) {
const moisFr = [
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
];
if (mois >= 1 && mois <= 12) {
return moisFr[mois - 1];
}
return 'Mois $mois';
}
@override
List<Object?> get props => [
id,
membreId,
membreNom,
membrePrenom,
organisationId,
organisationNom,
type,
statut,
montant,
montantPaye,
devise,
dateEcheance,
datePaiement,
dateRappel,
methodePaiement,
numeroPaiement,
referencePaiement,
annee,
mois,
trimestre,
semestre,
description,
notes,
recu,
dateCreation,
dateModification,
creeParId,
modifieParId,
];
@override
String toString() =>
'ContributionModel(id: $id, membre: $membreNomComplet, montant: $montant $devise, statut: $statut)';
}

View File

@@ -0,0 +1,112 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'contribution_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ContributionModel _$ContributionModelFromJson(Map<String, dynamic> json) =>
ContributionModel(
id: json['id'] as String?,
membreId: json['membreId'] as String,
membreNom: json['membreNom'] as String?,
membrePrenom: json['membrePrenom'] as String?,
organisationId: json['organisationId'] as String?,
organisationNom: json['organisationNom'] as String?,
type: $enumDecodeNullable(_$ContributionTypeEnumMap, json['type']) ??
ContributionType.annuelle,
statut:
$enumDecodeNullable(_$ContributionStatusEnumMap, json['statut']) ??
ContributionStatus.nonPayee,
montant: (json['montant'] as num).toDouble(),
montantPaye: (json['montantPaye'] as num?)?.toDouble(),
devise: json['devise'] as String? ?? 'XOF',
dateEcheance: DateTime.parse(json['dateEcheance'] as String),
datePaiement: json['datePaiement'] == null
? null
: DateTime.parse(json['datePaiement'] as String),
dateRappel: json['dateRappel'] == null
? null
: DateTime.parse(json['dateRappel'] as String),
methodePaiement:
$enumDecodeNullable(_$PaymentMethodEnumMap, json['methodePaiement']),
numeroPaiement: json['numeroPaiement'] as String?,
referencePaiement: json['referencePaiement'] as String?,
annee: (json['annee'] as num).toInt(),
mois: (json['mois'] as num?)?.toInt(),
trimestre: (json['trimestre'] as num?)?.toInt(),
semestre: (json['semestre'] as num?)?.toInt(),
description: json['description'] as String?,
notes: json['notes'] as String?,
recu: json['recu'] as String?,
dateCreation: json['dateCreation'] == null
? null
: DateTime.parse(json['dateCreation'] as String),
dateModification: json['dateModification'] == null
? null
: DateTime.parse(json['dateModification'] as String),
creeParId: json['creeParId'] as String?,
modifieParId: json['modifieParId'] as String?,
);
Map<String, dynamic> _$ContributionModelToJson(ContributionModel instance) =>
<String, dynamic>{
'id': instance.id,
'membreId': instance.membreId,
'membreNom': instance.membreNom,
'membrePrenom': instance.membrePrenom,
'organisationId': instance.organisationId,
'organisationNom': instance.organisationNom,
'type': _$ContributionTypeEnumMap[instance.type]!,
'statut': _$ContributionStatusEnumMap[instance.statut]!,
'montant': instance.montant,
'montantPaye': instance.montantPaye,
'devise': instance.devise,
'dateEcheance': instance.dateEcheance.toIso8601String(),
'datePaiement': instance.datePaiement?.toIso8601String(),
'dateRappel': instance.dateRappel?.toIso8601String(),
'methodePaiement': _$PaymentMethodEnumMap[instance.methodePaiement],
'numeroPaiement': instance.numeroPaiement,
'referencePaiement': instance.referencePaiement,
'annee': instance.annee,
'mois': instance.mois,
'trimestre': instance.trimestre,
'semestre': instance.semestre,
'description': instance.description,
'notes': instance.notes,
'recu': instance.recu,
'dateCreation': instance.dateCreation?.toIso8601String(),
'dateModification': instance.dateModification?.toIso8601String(),
'creeParId': instance.creeParId,
'modifieParId': instance.modifieParId,
};
const _$ContributionTypeEnumMap = {
ContributionType.annuelle: 'ANNUELLE',
ContributionType.mensuelle: 'MENSUELLE',
ContributionType.trimestrielle: 'TRIMESTRIELLE',
ContributionType.semestrielle: 'SEMESTRIELLE',
ContributionType.exceptionnelle: 'EXCEPTIONNELLE',
};
const _$ContributionStatusEnumMap = {
ContributionStatus.payee: 'PAYEE',
ContributionStatus.nonPayee: 'NON_PAYEE',
ContributionStatus.enAttente: 'EN_ATTENTE',
ContributionStatus.enRetard: 'EN_RETARD',
ContributionStatus.partielle: 'PARTIELLE',
ContributionStatus.annulee: 'ANNULEE',
};
const _$PaymentMethodEnumMap = {
PaymentMethod.especes: 'ESPECES',
PaymentMethod.cheque: 'CHEQUE',
PaymentMethod.virement: 'VIREMENT',
PaymentMethod.carteBancaire: 'CARTE_BANCAIRE',
PaymentMethod.waveMoney: 'WAVE_MONEY',
PaymentMethod.orangeMoney: 'ORANGE_MONEY',
PaymentMethod.freeMoney: 'FREE_MONEY',
PaymentMethod.mobileMoney: 'MOBILE_MONEY',
PaymentMethod.autre: 'AUTRE',
};

View File

@@ -0,0 +1,404 @@
/// Implémentation du repository des cotisations via l'API backend
library contribution_repository_impl;
import 'package:injectable/injectable.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import 'package:unionflow_mobile_apps/core/utils/logger.dart';
import '../../domain/repositories/contribution_repository.dart';
import '../models/contribution_model.dart';
/// Implémentation du repository des cotisations - appels API réels vers /api/cotisations
@LazySingleton(as: IContributionRepository)
class ContributionRepositoryImpl implements IContributionRepository {
final ApiClient _apiClient;
static const String _baseUrl = '/api/cotisations';
ContributionRepositoryImpl(this._apiClient);
/// Toutes les cotisations du membre connecté (GET /api/cotisations/mes-cotisations).
Future<ContributionPageResult> getMesCotisations({int page = 0, int size = 50}) async {
final response = await _apiClient.get(
'$_baseUrl/mes-cotisations',
queryParameters: {'page': page, 'size': size},
);
if (response.statusCode != 200) {
throw Exception(
'Erreur lors de la récupération des cotisations: ${response.statusCode}',
);
}
final data = response.data;
final List<dynamic> list = data is List ? data as List<dynamic> : <dynamic>[];
final contributions = list.map((e) => _summaryToModel(e as Map<String, dynamic>)).toList();
return ContributionPageResult(
contributions: contributions,
total: contributions.length,
page: page,
size: size,
totalPages: list.isEmpty ? 0 : 1,
);
}
/// Récupère les cotisations en attente du membre connecté (endpoint dédié).
Future<ContributionPageResult> getMesCotisationsEnAttente() async {
final path = '$_baseUrl/mes-cotisations/en-attente';
final response = await _apiClient.get(path);
if (response.statusCode != 200) {
throw Exception(
'Erreur lors de la récupération des cotisations: ${response.statusCode}',
);
}
final data = response.data;
final List<dynamic> list = data is List ? data : (data is Map ? (data['data'] ?? data['content'] ?? []) as List<dynamic>? ?? [] : []);
final contributions = list
.map((e) => _summaryToModel(e as Map<String, dynamic>))
.toList();
return ContributionPageResult(
contributions: contributions,
total: contributions.length,
page: 0,
size: contributions.length,
totalPages: contributions.isEmpty ? 0 : 1,
);
}
static ContributionModel _summaryToModel(Map<String, dynamic> json) {
final id = json['id']?.toString();
final statutStr = json['statut'] as String? ?? 'EN_ATTENTE';
final statut = _mapStatut(statutStr);
final montantDu = (json['montantDu'] as num?)?.toDouble() ?? 0.0;
final montantPaye = (json['montantPaye'] as num?)?.toDouble();
final dateEcheanceStr = json['dateEcheance'] as String?;
final dateEcheance = dateEcheanceStr != null
? DateTime.tryParse(dateEcheanceStr) ?? DateTime.now()
: DateTime.now();
final annee = (json['annee'] as num?)?.toInt() ?? dateEcheance.year;
return ContributionModel(
id: id,
membreId: '', // membre implicite (endpoint "mes cotisations")
membreNom: (json['nomMembre'] ?? json['nomCompletMembre']) as String?,
type: ContributionType.annuelle,
statut: statut,
montant: montantDu,
montantPaye: montantPaye,
devise: 'XOF',
dateEcheance: dateEcheance,
annee: annee,
);
}
static ContributionStatus _mapStatut(String code) {
switch (code.toUpperCase()) {
case 'PAYEE':
return ContributionStatus.payee;
case 'EN_RETARD':
return ContributionStatus.enRetard;
case 'PARTIELLE':
return ContributionStatus.partielle;
case 'ANNULEE':
return ContributionStatus.annulee;
case 'EN_ATTENTE':
case 'NON_PAYEE':
default:
return ContributionStatus.nonPayee;
}
}
/// Récupère la liste des cotisations avec pagination (toutes cotisations, nécessite droits admin)
Future<ContributionPageResult> getCotisations({
int page = 0,
int size = 20,
String? membreId,
String? statut,
String? type,
int? annee,
}) async {
final queryParams = <String, dynamic>{
'page': page,
'size': size,
};
if (membreId != null) queryParams['membreId'] = membreId;
if (statut != null) queryParams['statut'] = statut;
if (type != null) queryParams['type'] = type;
if (annee != null) queryParams['annee'] = annee;
final response = await _apiClient.get(
_baseUrl,
queryParameters: queryParams,
);
if (response.statusCode == 200) {
final data = response.data;
if (data is List) {
final contributions = data
.map((json) => ContributionModel.fromJson(json as Map<String, dynamic>))
.toList();
return ContributionPageResult(
contributions: contributions,
total: contributions.length,
page: page,
size: size,
totalPages: 1,
);
} else if (data is Map<String, dynamic>) {
final List<dynamic> content = data['content'] ?? data['items'] ?? [];
final contributions = content
.map((json) => ContributionModel.fromJson(json as Map<String, dynamic>))
.toList();
return ContributionPageResult(
contributions: contributions,
total: data['totalElements'] ?? data['total'] ?? contributions.length,
page: data['number'] ?? page,
size: data['size'] ?? size,
totalPages: data['totalPages'] ?? 1,
);
}
}
throw Exception('Erreur lors de la récupération des cotisations: ${response.statusCode}');
}
/// Récupère une cotisation par ID
Future<ContributionModel> getCotisationById(String id) async {
final response = await _apiClient.get('$_baseUrl/$id');
if (response.statusCode == 200) {
return ContributionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Cotisation non trouvée');
}
/// Crée une nouvelle cotisation (payload conforme au backend CreateCotisationRequest)
Future<ContributionModel> createCotisation(ContributionModel contribution) async {
final body = _toCreateCotisationRequest(contribution);
final response = await _apiClient.post(_baseUrl, data: body);
if (response.statusCode == 201 || response.statusCode == 200) {
final data = Map<String, dynamic>.from(response.data as Map<String, dynamic>);
_normalizeCotisationResponse(data);
return ContributionModel.fromJson(data);
}
final message = response.data is Map
? (response.data as Map)['error'] ?? response.data.toString()
: response.data?.toString() ?? 'Erreur ${response.statusCode}';
throw Exception('Erreur lors de la création: $message');
}
/// Construit le body attendu par POST /api/cotisations (CreateCotisationRequest)
static Map<String, dynamic> _toCreateCotisationRequest(ContributionModel c) {
if (c.organisationId == null || c.organisationId!.trim().isEmpty) {
throw Exception('L\'organisation du membre est requise pour créer une cotisation.');
}
final typeStr = _contributionTypeToBackend(c.type);
final dateStr = _formatLocalDate(c.dateEcheance);
final desc = c.description?.trim();
final libelle = desc != null && desc.isNotEmpty
? (desc.length > 100 ? desc.substring(0, 100) : desc)
: 'Cotisation $typeStr ${c.annee}';
final description = desc != null && desc.isNotEmpty
? (desc.length > 500 ? desc.substring(0, 500) : desc)
: null;
return {
'membreId': c.membreId,
'organisationId': c.organisationId!.trim(),
'typeCotisation': typeStr,
'libelle': libelle,
if (description != null) 'description': description,
'montantDu': c.montant,
'codeDevise': c.devise.length == 3 ? c.devise : 'XOF',
'dateEcheance': dateStr,
'periode': '${_monthName(c.dateEcheance.month)} ${c.dateEcheance.year}',
'annee': c.annee,
'mois': c.mois ?? c.dateEcheance.month,
'recurrente': false,
if (c.notes != null && c.notes!.isNotEmpty) 'observations': c.notes,
};
}
static String _contributionTypeToBackend(ContributionType t) {
switch (t) {
case ContributionType.mensuelle:
return 'MENSUELLE';
case ContributionType.trimestrielle:
return 'TRIMESTRIELLE';
case ContributionType.semestrielle:
return 'SEMESTRIELLE';
case ContributionType.annuelle:
return 'ANNUELLE';
case ContributionType.exceptionnelle:
return 'EXCEPTIONNELLE';
}
}
static String _formatLocalDate(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
static String _monthName(int month) {
const names = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
return month >= 1 && month <= 12 ? names[month - 1] : 'Mois $month';
}
/// Adapte les clés de la réponse backend (CotisationResponse) vers le modèle mobile
static void _normalizeCotisationResponse(Map<String, dynamic> data) {
if (data.containsKey('nomMembre') && !data.containsKey('membreNom')) data['membreNom'] = data['nomMembre'];
if (data.containsKey('nomOrganisation') && !data.containsKey('organisationNom')) data['organisationNom'] = data['nomOrganisation'];
if (data.containsKey('codeDevise') && !data.containsKey('devise')) data['devise'] = data['codeDevise'];
if (data.containsKey('montantDu') && !data.containsKey('montant')) data['montant'] = data['montantDu'];
if (data['id'] != null && data['id'] is! String) data['id'] = data['id'].toString();
if (data['membreId'] != null && data['membreId'] is! String) data['membreId'] = data['membreId'].toString();
if (data['organisationId'] != null && data['organisationId'] is! String) data['organisationId'] = data['organisationId'].toString();
}
/// Met à jour une cotisation
Future<ContributionModel> updateCotisation(String id, ContributionModel contribution) async {
final response = await _apiClient.put('$_baseUrl/$id', data: contribution.toJson());
if (response.statusCode == 200) {
return ContributionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur lors de la mise à jour: ${response.statusCode}');
}
/// Supprime une cotisation
Future<void> deleteCotisation(String id) async {
final response = await _apiClient.delete('$_baseUrl/$id');
if (response.statusCode != 200 && response.statusCode != 204) {
throw Exception('Erreur lors de la suppression: ${response.statusCode}');
}
}
/// Initie un paiement en ligne (Wave Checkout API).
/// Retourne l'URL à ouvrir (wave_launch_url) pour que le membre confirme dans l'app Wave.
/// Spec: https://docs.wave.com/checkout
Future<WavePaiementInitResult> initierPaiementEnLigne({
required String cotisationId,
required String methodePaiement,
required String numeroTelephone,
}) async {
final response = await _apiClient.post(
'/api/paiements/initier-paiement-en-ligne',
data: {
'cotisationId': cotisationId,
'methodePaiement': methodePaiement,
'numeroTelephone': numeroTelephone.replaceAll(RegExp(r'\D'), ''),
},
);
if (response.statusCode != 201 && response.statusCode != 200) {
final msg = response.data is Map
? (response.data['message'] ?? response.data['error'] ?? response.statusCode)
: response.statusCode;
throw Exception('Impossible d\'initier le paiement: $msg');
}
final data = response.data is Map<String, dynamic>
? response.data as Map<String, dynamic>
: Map<String, dynamic>.from(response.data as Map);
return WavePaiementInitResult(
redirectUrl: data['redirectUrl'] as String? ?? data['waveLaunchUrl'] as String? ?? '',
waveLaunchUrl: data['waveLaunchUrl'] as String? ?? data['redirectUrl'] as String? ?? '',
waveCheckoutSessionId: data['waveCheckoutSessionId'] as String?,
clientReference: data['clientReference'] as String?,
message: data['message'] as String? ?? 'Ouvrez Wave pour confirmer le paiement.',
);
}
/// Enregistre un paiement
Future<ContributionModel> enregistrerPaiement(
String cotisationId, {
required double montant,
required DateTime datePaiement,
required String methodePaiement,
String? numeroPaiement,
String? referencePaiement,
}) async {
final response = await _apiClient.post(
'$_baseUrl/$cotisationId/paiement',
data: {
'montant': montant,
'datePaiement': datePaiement.toIso8601String(),
'methodePaiement': methodePaiement,
if (numeroPaiement != null) 'numeroPaiement': numeroPaiement,
if (referencePaiement != null) 'referencePaiement': referencePaiement,
},
);
if (response.statusCode == 200) {
return ContributionModel.fromJson(response.data as Map<String, dynamic>);
}
throw Exception('Erreur lors de l\'enregistrement du paiement: ${response.statusCode}');
}
/// Synthèse personnelle du membre connecté (GET /api/cotisations/mes-cotisations/synthese)
Future<Map<String, dynamic>?> getMesCotisationsSynthese() async {
try {
final response = await _apiClient.get('$_baseUrl/mes-cotisations/synthese');
if (response.statusCode == 200 && response.data != null) {
final data = response.data is Map<String, dynamic>
? response.data as Map<String, dynamic>
: Map<String, dynamic>.from(response.data as Map);
data['isMesSynthese'] = true;
return data;
}
return null;
} catch (e, st) {
AppLogger.error('ContributionRepository: getMesCotisationsSynthese échoué', error: e, stackTrace: st);
rethrow;
}
}
/// Récupère les statistiques des cotisations (globales ou mes selon usage)
Future<Map<String, dynamic>> getStatistiques() async {
final response = await _apiClient.get('$_baseUrl/statistiques');
if (response.statusCode == 200) {
return response.data as Map<String, dynamic>;
}
throw Exception('Erreur lors de la récupération des statistiques');
}
/// Envoie un rappel de paiement
Future<void> envoyerRappel(String cotisationId) async {
final response = await _apiClient.post('$_baseUrl/$cotisationId/rappel');
if (response.statusCode != 200) {
throw Exception('Erreur lors de l\'envoi du rappel');
}
}
/// Génère les cotisations annuelles
Future<int> genererCotisationsAnnuelles(int annee) async {
final response = await _apiClient.post(
'$_baseUrl/generer',
data: {'annee': annee},
);
if (response.statusCode == 200) {
return response.data['nombreGenere'] ?? 0;
}
throw Exception('Erreur lors de la génération');
}
}
/// Résultat de l'initiation d'un paiement Wave (redirection vers l'app Wave).
class WavePaiementInitResult {
final String redirectUrl;
final String waveLaunchUrl;
final String? waveCheckoutSessionId;
final String? clientReference;
final String message;
const WavePaiementInitResult({
required this.redirectUrl,
required this.waveLaunchUrl,
this.waveCheckoutSessionId,
this.clientReference,
required this.message,
});
}
/// Résultat paginé de cotisations
class ContributionPageResult {
final List<ContributionModel> contributions;
final int total;
final int page;
final int size;
final int totalPages;
const ContributionPageResult({
required this.contributions,
required this.total,
required this.page,
required this.size,
required this.totalPages,
});
}

View File

@@ -0,0 +1,66 @@
/// Interface du repository des contributions (Clean Architecture)
library contribution_repository_interface;
import '../../data/models/contribution_model.dart';
import '../../data/repositories/contribution_repository.dart' show ContributionPageResult, WavePaiementInitResult;
/// Interface définissant le contrat du repository des contributions
/// Implémentée par ContributionRepositoryImpl dans la couche data
abstract class IContributionRepository {
/// Récupère toutes les cotisations du membre connecté
Future<ContributionPageResult> getMesCotisations({int page = 0, int size = 50});
/// Récupère une cotisation par ID
Future<ContributionModel> getCotisationById(String id);
/// Crée une nouvelle cotisation
Future<ContributionModel> createCotisation(ContributionModel contribution);
/// Met à jour une cotisation existante
Future<ContributionModel> updateCotisation(String id, ContributionModel contribution);
/// Supprime une cotisation
Future<void> deleteCotisation(String id);
/// Enregistre un paiement pour une cotisation
Future<ContributionModel> enregistrerPaiement(
String cotisationId, {
required double montant,
required DateTime datePaiement,
required String methodePaiement,
String? numeroPaiement,
String? referencePaiement,
});
/// Initie un paiement en ligne (Wave)
Future<WavePaiementInitResult> initierPaiementEnLigne({
required String cotisationId,
required String methodePaiement,
required String numeroTelephone,
});
/// Récupère la synthèse des cotisations du membre
Future<Map<String, dynamic>?> getMesCotisationsSynthese();
/// Récupère les statistiques globales
Future<Map<String, dynamic>> getStatistiques();
/// Récupère les cotisations en attente
Future<ContributionPageResult> getMesCotisationsEnAttente();
/// Récupère les cotisations avec filtres (admin)
Future<ContributionPageResult> getCotisations({
int page = 0,
int size = 20,
String? membreId,
String? statut,
String? type,
int? annee,
});
/// Envoie un rappel de paiement
Future<void> envoyerRappel(String cotisationId);
/// Génère les cotisations annuelles pour tous les membres
Future<int> genererCotisationsAnnuelles(int annee);
}

View File

@@ -0,0 +1,24 @@
/// Use case: Créer une nouvelle contribution
library create_contribution;
import 'package:injectable/injectable.dart';
import '../../data/models/contribution_model.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour créer une nouvelle cotisation
@injectable
class CreateContribution {
final IContributionRepository _repository;
CreateContribution(this._repository);
/// Exécute le use case
///
/// [contribution] - Modèle de la cotisation à créer
///
/// Retourne la contribution créée avec son ID généré
/// Lève une exception en cas d'erreur de validation ou de création
Future<ContributionModel> call(ContributionModel contribution) async {
return _repository.createCotisation(contribution);
}
}

View File

@@ -0,0 +1,23 @@
/// Use case: Supprimer une contribution
library delete_contribution;
import 'package:injectable/injectable.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour supprimer une cotisation
@injectable
class DeleteContribution {
final IContributionRepository _repository;
DeleteContribution(this._repository);
/// Exécute le use case
///
/// [id] - UUID de la cotisation à supprimer
///
/// Supprime la contribution de manière définitive
/// Lève une exception si la contribution n'existe pas ou ne peut être supprimée
Future<void> call(String id) async {
return _repository.deleteCotisation(id);
}
}

View File

@@ -0,0 +1,24 @@
/// Use case: Récupérer une contribution par son ID
library get_contribution_by_id;
import 'package:injectable/injectable.dart';
import '../../data/models/contribution_model.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour récupérer le détail d'une contribution
@injectable
class GetContributionById {
final IContributionRepository _repository;
GetContributionById(this._repository);
/// Exécute le use case
///
/// [id] - UUID de la cotisation
///
/// Retourne le détail complet de la contribution
/// Lève une exception si la contribution n'existe pas
Future<ContributionModel> call(String id) async {
return _repository.getCotisationById(id);
}
}

View File

@@ -0,0 +1,33 @@
/// Use case: Récupérer l'historique des contributions d'un membre
library get_contribution_history;
import 'package:injectable/injectable.dart';
import '../../data/models/contribution_model.dart';
import '../../data/repositories/contribution_repository.dart' show ContributionPageResult;
import '../repositories/contribution_repository.dart';
/// Use case pour récupérer l'historique des paiements de cotisations
@injectable
class GetContributionHistory {
final IContributionRepository _repository;
GetContributionHistory(this._repository);
/// Exécute le use case
///
/// [page] - Numéro de page (pagination)
/// [size] - Taille de la page
/// [annee] - Filtrer par année (optionnel)
/// [statut] - Filtrer par statut (optionnel)
///
/// Retourne l'historique paginé des cotisations du membre
/// Inclut toutes les cotisations (payées, en attente, en retard)
Future<ContributionPageResult> call({
int page = 0,
int size = 50,
int? annee,
ContributionStatus? statut,
}) async {
return _repository.getMesCotisations(page: page, size: size);
}
}

View File

@@ -0,0 +1,27 @@
/// Use case: Récupérer les statistiques personnelles des contributions
library get_contribution_stats;
import 'package:injectable/injectable.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour récupérer les statistiques de cotisations du membre
@injectable
class GetContributionStats {
final IContributionRepository _repository;
GetContributionStats(this._repository);
/// Exécute le use case
///
/// Retourne un Map contenant les statistiques personnelles:
/// - montantDu: Montant total dû pour l'année en cours
/// - totalPayeAnnee: Montant total payé pour l'année
/// - cotisationsEnAttente: Nombre de cotisations en attente
/// - prochaineEcheance: Date de la prochaine échéance
/// - tauxPaiement: Taux de paiement en pourcentage
///
/// Retourne null si aucune donnée n'est disponible
Future<Map<String, dynamic>?> call() async {
return _repository.getMesCotisationsSynthese();
}
}

View File

@@ -0,0 +1,22 @@
/// Use case: Récupérer toutes les contributions du membre connecté
library get_contributions;
import 'package:injectable/injectable.dart';
import '../../data/repositories/contribution_repository.dart' show ContributionPageResult;
import '../repositories/contribution_repository.dart';
/// Use case pour récupérer la liste des contributions du membre connecté
@injectable
class GetContributions {
final IContributionRepository _repository;
GetContributions(this._repository);
/// Exécute le use case
///
/// Retourne la liste paginée des cotisations du membre connecté
/// via l'endpoint GET /api/cotisations/mes-cotisations
Future<ContributionPageResult> call({int page = 0, int size = 50}) async {
return _repository.getMesCotisations(page: page, size: size);
}
}

View File

@@ -0,0 +1,43 @@
/// Use case: Enregistrer un paiement pour une contribution
library pay_contribution;
import 'package:injectable/injectable.dart';
import '../../data/models/contribution_model.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour enregistrer un paiement de cotisation
@injectable
class PayContribution {
final IContributionRepository _repository;
PayContribution(this._repository);
/// Exécute le use case
///
/// [cotisationId] - UUID de la cotisation à payer
/// [montant] - Montant du paiement
/// [datePaiement] - Date du paiement
/// [methodePaiement] - Méthode de paiement (WAVE, ESPECES, VIREMENT, etc.)
/// [numeroPaiement] - Numéro de transaction (optionnel)
/// [referencePaiement] - Référence du paiement (optionnel)
///
/// Retourne la contribution mise à jour avec le paiement enregistré
/// Lève une exception en cas d'erreur de validation ou d'enregistrement
Future<ContributionModel> call({
required String cotisationId,
required double montant,
required DateTime datePaiement,
required String methodePaiement,
String? numeroPaiement,
String? referencePaiement,
}) async {
return _repository.enregistrerPaiement(
cotisationId,
montant: montant,
datePaiement: datePaiement,
methodePaiement: methodePaiement,
numeroPaiement: numeroPaiement,
referencePaiement: referencePaiement,
);
}
}

View File

@@ -0,0 +1,25 @@
/// Use case: Mettre à jour une contribution existante
library update_contribution;
import 'package:injectable/injectable.dart';
import '../../data/models/contribution_model.dart';
import '../repositories/contribution_repository.dart';
/// Use case pour modifier une cotisation
@injectable
class UpdateContribution {
final IContributionRepository _repository;
UpdateContribution(this._repository);
/// Exécute le use case
///
/// [id] - UUID de la cotisation à modifier
/// [contribution] - Données mises à jour
///
/// Retourne la contribution modifiée
/// Lève une exception si la contribution n'existe pas ou erreur de validation
Future<ContributionModel> call(String id, ContributionModel contribution) async {
return _repository.updateCotisation(id, contribution);
}
}

View File

@@ -0,0 +1,385 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/design_system/tokens/app_typography.dart';
import '../../../../shared/widgets/info_badge.dart';
import '../../../../shared/widgets/loading_widget.dart';
import '../../../../shared/widgets/error_widget.dart';
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_bloc.dart';
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_event.dart';
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_state.dart';
import 'package:unionflow_mobile_apps/features/contributions/data/models/contribution_model.dart';
import 'package:unionflow_mobile_apps/features/contributions/presentation/widgets/payment_dialog.dart';
import 'package:unionflow_mobile_apps/features/contributions/presentation/widgets/create_contribution_dialog.dart';
import 'package:unionflow_mobile_apps/features/contributions/presentation/pages/mes_statistiques_cotisations_page.dart';
/// Page de gestion des contributions - Version Design System
class ContributionsPage extends StatefulWidget {
const ContributionsPage({super.key});
@override
State<ContributionsPage> createState() => _ContributionsPageState();
}
class _ContributionsPageState extends State<ContributionsPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA', decimalDigits: 0);
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _loadContributions() {
final currentTab = _tabController.index;
switch (currentTab) {
case 0:
context.read<ContributionsBloc>().add(const LoadContributions());
break;
case 1:
context.read<ContributionsBloc>().add(const LoadContributionsPayees());
break;
case 2:
context.read<ContributionsBloc>().add(const LoadContributionsNonPayees());
break;
case 3:
context.read<ContributionsBloc>().add(const LoadContributionsEnRetard());
break;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorTokens.background,
appBar: UFAppBar(
title: 'Cotisations',
actions: [
IconButton(
icon: const Icon(Icons.bar_chart, size: 20),
onPressed: () => _showStats(),
),
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 20),
onPressed: () => _showCreateDialog(),
),
],
bottom: TabBar(
controller: _tabController,
onTap: (_) => _loadContributions(),
labelColor: ColorTokens.onPrimary,
unselectedLabelColor: ColorTokens.onPrimary.withOpacity(0.7),
indicatorColor: ColorTokens.onPrimary,
labelStyle: AppTypography.badgeText.copyWith(fontWeight: FontWeight.bold),
tabs: const [
Tab(text: 'Toutes'),
Tab(text: 'Payées'),
Tab(text: 'Dues'),
Tab(text: 'Retard'),
],
),
),
body: TabBarView(
controller: _tabController,
children: List.generate(4, (_) => _buildContributionsList()),
),
);
}
Widget _buildContributionsList() {
return BlocBuilder<ContributionsBloc, ContributionsState>(
builder: (context, state) {
if (state is ContributionsLoading) {
return const Center(child: AppLoadingWidget());
}
if (state is ContributionsError) {
return Center(
child: AppErrorWidget(
message: state.message,
onRetry: _loadContributions,
),
);
}
if (state is ContributionsLoaded) {
return _buildListOrEmpty(state.contributions);
}
// Au retour de "Mes Statistiques", la liste peut être conservée dans ContributionsStatsLoaded
if (state is ContributionsStatsLoaded) {
if (state.contributions != null) {
return _buildListOrEmpty(state.contributions!);
}
// Stats ouverts sans liste préalable : charger les contributions une fois
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context.read<ContributionsBloc>().add(const LoadContributions());
}
});
return const Center(child: Text('Initialisation...'));
}
return const Center(child: Text('Initialisation...'));
},
);
}
Widget _buildListOrEmpty(List<ContributionModel> contributions) {
if (contributions.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.payment_outlined, size: 48, color: ColorTokens.onSurfaceVariant.withOpacity(0.5)),
const SizedBox(height: SpacingTokens.md),
Text('Aucune contribution', style: AppTypography.bodyTextSmall),
],
),
);
}
return Column(
children: [
_buildMiniStats(contributions),
Expanded(
child: RefreshIndicator(
onRefresh: () async => _loadContributions(),
child: ListView.builder(
padding: const EdgeInsets.all(SpacingTokens.md),
itemCount: contributions.length,
itemBuilder: (context, index) => _buildContributionCard(contributions[index]),
),
),
),
],
);
}
Widget _buildMiniStats(List<ContributionModel> contributions) {
final totalDue = contributions.fold(0.0, (sum, c) => sum + c.montant);
final totalPaid = contributions.fold(0.0, (sum, c) => sum + (c.montantPaye ?? 0.0));
return Container(
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.md, vertical: SpacingTokens.sm),
color: ColorTokens.surfaceVariant.withOpacity(0.3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildMetric('DU', _currencyFormat.format(totalDue), ColorTokens.secondary),
_buildMetric('PAYÉ', _currencyFormat.format(totalPaid), ColorTokens.success),
_buildMetric('RESTANT', _currencyFormat.format(totalDue - totalPaid), ColorTokens.error),
],
),
);
}
Widget _buildMetric(String label, String value, Color color) {
return Column(
children: [
Text(label, style: AppTypography.badgeText.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(value, style: AppTypography.headerSmall.copyWith(color: color, fontWeight: FontWeight.bold)),
],
);
}
Widget _buildContributionCard(ContributionModel contribution) {
return UFCard(
margin: const EdgeInsets.only(bottom: SpacingTokens.sm),
onTap: () => _showContributionDetails(contribution),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(contribution.membreNomComplet, style: AppTypography.headerSmall),
Text(contribution.libellePeriode, style: AppTypography.subtitleSmall),
],
),
),
_buildStatutBadge(contribution.statut, contribution.estEnRetard),
],
),
const SizedBox(height: SpacingTokens.md),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildAmountValue('Montant', contribution.montant),
if (contribution.montantPaye != null && contribution.montantPaye! > 0)
_buildAmountValue('Payé', contribution.montantPaye!, color: ColorTokens.success),
_buildAmountValue('Échéance', contribution.dateEcheance, isDate: true),
],
),
if (contribution.statut == ContributionStatus.partielle) ...[
const SizedBox(height: SpacingTokens.sm),
ClipRRect(
borderRadius: BorderRadius.circular(RadiusTokens.sm),
child: LinearProgressIndicator(
value: contribution.pourcentagePaye / 100,
backgroundColor: ColorTokens.surfaceVariant,
valueColor: const AlwaysStoppedAnimation<Color>(ColorTokens.primary),
minHeight: 4,
),
),
],
],
),
);
}
Widget _buildAmountValue(String label, dynamic value, {Color? color, bool isDate = false}) {
String displayValue = isDate
? DateFormat('dd/MM/yy').format(value as DateTime)
: _currencyFormat.format(value as double);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: AppTypography.badgeText.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(displayValue, style: AppTypography.bodyTextSmall.copyWith(
color: color ?? ColorTokens.onSurface,
fontWeight: FontWeight.w600,
)),
],
);
}
Widget _buildStatutBadge(ContributionStatus statut, bool enRetard) {
if (enRetard && statut != ContributionStatus.payee) {
return const InfoBadge(text: 'RETARD', backgroundColor: Color(0xFFFFEBEB), textColor: ColorTokens.error);
}
switch (statut) {
case ContributionStatus.payee:
return const InfoBadge(text: 'PAYÉE', backgroundColor: Color(0xFFE3F9E5), textColor: ColorTokens.success);
case ContributionStatus.nonPayee:
case ContributionStatus.enAttente:
return const InfoBadge(text: 'DUE', backgroundColor: Color(0xFFFFF4E5), textColor: ColorTokens.warning);
case ContributionStatus.partielle:
return const InfoBadge(text: 'PARTIELLE', backgroundColor: Color(0xFFE5F1FF), textColor: ColorTokens.info);
case ContributionStatus.annulee:
return InfoBadge.neutral('ANNULÉE');
default:
return InfoBadge.neutral(statut.name.toUpperCase());
}
}
void _showContributionDetails(ContributionModel contribution) {
showModalBottomSheet(
context: context,
backgroundColor: ColorTokens.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(RadiusTokens.lg))),
builder: (context) => Padding(
padding: const EdgeInsets.all(SpacingTokens.xl),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(contribution.membreNomComplet, style: AppTypography.headerSmall),
Text(contribution.libellePeriode, style: AppTypography.subtitleSmall),
const Divider(height: SpacingTokens.xl),
_buildDetailRow('Montant Total', _currencyFormat.format(contribution.montant)),
_buildDetailRow('Montant Payé', _currencyFormat.format(contribution.montantPaye ?? 0.0)),
_buildDetailRow('Reste à payer', _currencyFormat.format(contribution.montantRestant), isCritical: contribution.montantRestant > 0),
_buildDetailRow('Date d\'échéance', DateFormat('dd MMMM yyyy').format(contribution.dateEcheance)),
if (contribution.description != null) ...[
const SizedBox(height: SpacingTokens.md),
Text(contribution.description!, style: AppTypography.bodyTextSmall),
],
const SizedBox(height: SpacingTokens.xl),
Row(
children: [
if (contribution.statut != ContributionStatus.payee)
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: UFPrimaryButton(
label: 'Enregistrer Paiement',
onPressed: () {
Navigator.pop(context);
_showPaymentDialog(contribution);
},
),
),
),
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: ColorTokens.outline),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(RadiusTokens.md)),
),
child: Text('Fermer', style: AppTypography.actionText.copyWith(color: ColorTokens.onSurface)),
),
),
],
),
],
),
),
);
}
Widget _buildDetailRow(String label, String value, {bool isCritical = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.xs),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(value, style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.bold,
color: isCritical ? ColorTokens.error : ColorTokens.onSurface,
)),
],
),
);
}
void _showPaymentDialog(ContributionModel contribution) {
final contributionsBloc = context.read<ContributionsBloc>();
showDialog(
context: context,
builder: (context) => BlocProvider.value(
value: contributionsBloc,
child: PaymentDialog(cotisation: contribution),
),
);
}
void _showCreateDialog() {
final contributionsBloc = context.read<ContributionsBloc>();
showDialog(
context: context,
builder: (context) => BlocProvider.value(
value: contributionsBloc,
child: const CreateContributionDialog(),
),
);
}
void _showStats() {
final contributionsBloc = context.read<ContributionsBloc>();
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => BlocProvider.value(
value: contributionsBloc,
child: const MesStatistiquesCotisationsPage(),
),
),
);
}
}

View File

@@ -0,0 +1,45 @@
/// Wrapper BLoC pour la page des cotisations
library cotisations_page_wrapper;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_bloc.dart';
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_event.dart';
import 'package:unionflow_mobile_apps/features/contributions/presentation/pages/contributions_page.dart';
import 'package:unionflow_mobile_apps/features/members/bloc/membres_bloc.dart';
final _getIt = GetIt.instance;
/// Wrapper qui fournit les BLoCs à la page des cotisations (et au dialogue de création)
class CotisationsPageWrapper extends StatelessWidget {
const CotisationsPageWrapper({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<ContributionsBloc>(
create: (context) {
final bloc = _getIt<ContributionsBloc>();
bloc.add(const LoadContributions());
return bloc;
},
),
BlocProvider<MembresBloc>(
create: (context) => _getIt<MembresBloc>(),
),
],
child: const ContributionsPage(),
);
}
}
/// Alias pour la route /finances et références anglaises
class ContributionsPageWrapper extends StatelessWidget {
const ContributionsPageWrapper({super.key});
@override
Widget build(BuildContext context) => const CotisationsPageWrapper();
}

View File

@@ -0,0 +1,564 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/utils/logger.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/design_system/tokens/unionflow_colors.dart';
import '../../../../shared/widgets/loading_widget.dart';
import '../../../../shared/widgets/error_widget.dart';
import '../../bloc/contributions_bloc.dart';
import '../../bloc/contributions_event.dart';
import '../../bloc/contributions_state.dart';
import '../../data/models/contribution_model.dart';
/// Page dédiée « Mes statistiques cotisations » : KPIs, graphiques et synthèse.
/// Données réelles via GET /api/cotisations/mes-cotisations/synthese + liste des cotisations.
class MesStatistiquesCotisationsPage extends StatefulWidget {
const MesStatistiquesCotisationsPage({super.key});
@override
State<MesStatistiquesCotisationsPage> createState() => _MesStatistiquesCotisationsPageState();
}
class _MesStatistiquesCotisationsPageState extends State<MesStatistiquesCotisationsPage> {
Map<String, dynamic>? _synthese;
List<ContributionModel>? _cotisations;
String? _error;
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA', decimalDigits: 0);
@override
void initState() {
super.initState();
// Charge uniquement la synthèse ; la liste est conservée dans l'état pour ne pas perdre l'onglet Toutes au retour.
context.read<ContributionsBloc>().add(const LoadContributionsStats());
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorTokens.background,
appBar: UFAppBar(
title: 'Mes statistiques cotisations',
backgroundColor: ColorTokens.surface,
foregroundColor: ColorTokens.onSurface,
),
body: BlocListener<ContributionsBloc, ContributionsState>(
listener: (context, state) {
if (state is ContributionsStatsLoaded) {
setState(() {
_synthese = state.stats;
_cotisations = state.contributions;
_error = null;
});
}
if (state is ContributionsLoaded) {
setState(() {
_cotisations = state.contributions;
_error = null;
});
}
if (state is ContributionsError) {
setState(() => _error = state.message);
}
},
child: RefreshIndicator(
onRefresh: () async {
context.read<ContributionsBloc>().add(const LoadContributionsStats());
},
child: _buildBody(),
),
),
);
}
Widget _buildBody() {
if (_error != null) {
return Center(
child: AppErrorWidget(
message: _error!,
onRetry: () {
context.read<ContributionsBloc>().add(const LoadContributionsStats());
context.read<ContributionsBloc>().add(const LoadContributions());
},
),
);
}
if (_synthese == null && _cotisations == null) {
return const Center(child: AppLoadingWidget());
}
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(),
const SizedBox(height: 24),
_buildKpiCards(),
const SizedBox(height: 20),
_buildTauxSection(),
const SizedBox(height: 20),
if (_cotisations != null && _cotisations!.isNotEmpty) _buildRepartitionChart(),
if (_cotisations != null && _cotisations!.isNotEmpty) const SizedBox(height: 20),
if (_cotisations != null && _cotisations!.isNotEmpty) _buildEvolutionSection(),
const SizedBox(height: 20),
_buildProchainesEcheances(),
const SizedBox(height: 32),
],
),
);
}
Widget _buildHeader() {
final annee = _synthese?['anneeEnCours'] is int
? _synthese!['anneeEnCours'] as int
: DateTime.now().year;
return Column(
children: [
Text(
'Synthèse $annee',
style: AppTypography.headerSmall.copyWith(fontSize: 20),
),
const SizedBox(height: 4),
Text(
'Votre situation cotisations',
style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant),
),
],
);
}
Widget _buildKpiCards() {
final montantDu = _toDouble(_synthese?['montantDu']);
final totalPayeAnnee = _toDouble(_synthese?['totalPayeAnnee']);
final enAttente = _synthese?['cotisationsEnAttente'] is int
? _synthese!['cotisationsEnAttente'] as int
: ((_synthese?['cotisationsEnAttente'] as num?)?.toInt() ?? 0);
final prochaineStr = _synthese?['prochaineEcheance']?.toString();
return Column(
children: [
Row(
children: [
Expanded(
child: _kpiCard(
'Montant dû',
_currencyFormat.format(montantDu),
icon: Icons.pending_actions_outlined,
color: montantDu > 0 ? UnionFlowColors.terracotta : UnionFlowColors.success,
),
),
const SizedBox(width: 12),
Expanded(
child: _kpiCard(
'Payé cette année',
_currencyFormat.format(totalPayeAnnee),
icon: Icons.check_circle_outline,
color: UnionFlowColors.unionGreen,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _kpiCard(
'En attente',
'$enAttente',
icon: Icons.schedule,
color: enAttente > 0 ? UnionFlowColors.gold : UnionFlowColors.success,
),
),
const SizedBox(width: 12),
Expanded(
child: _kpiCard(
'Prochaine échéance',
prochaineStr != null && prochaineStr.isNotEmpty && prochaineStr != 'null'
? _formatDate(prochaineStr)
: '',
icon: Icons.event,
color: UnionFlowColors.indigo,
),
),
],
),
],
);
}
Widget _kpiCard(String label, String value, {required IconData icon, required Color color}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Text(
value,
style: AppTypography.headerSmall.copyWith(color: color, fontSize: 15),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildTauxSection() {
final montantDu = _toDouble(_synthese?['montantDu']);
final totalPayeAnnee = _toDouble(_synthese?['totalPayeAnnee']);
final total = montantDu + totalPayeAnnee;
final taux = total > 0 ? (totalPayeAnnee / total * 100) : 0.0;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Taux de paiement',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: (taux / 100).clamp(0.0, 1.0),
minHeight: 12,
backgroundColor: ColorTokens.onSurfaceVariant.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation<Color>(
taux >= 75 ? UnionFlowColors.success : (taux >= 50 ? UnionFlowColors.gold : UnionFlowColors.terracotta),
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('0 %', style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(
'${taux.toStringAsFixed(0)} %',
style: AppTypography.headerSmall.copyWith(color: UnionFlowColors.unionGreen, fontWeight: FontWeight.w700),
),
Text('100 %', style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
],
),
],
),
);
}
Widget _buildRepartitionChart() {
final paye = _cotisations!
.where((c) => c.statut == ContributionStatus.payee)
.fold<double>(0, (s, c) => s + (c.montantPaye ?? c.montant));
final du = _cotisations!
.where((c) => c.statut != ContributionStatus.payee && c.statut != ContributionStatus.annulee)
.fold<double>(0, (s, c) => s + c.montant);
if (paye + du <= 0) return const SizedBox.shrink();
final sections = <PieChartSectionData>[];
if (paye > 0) {
sections.add(PieChartSectionData(
color: UnionFlowColors.unionGreen,
value: paye,
title: 'Payé',
radius: 60,
titleStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white),
));
}
if (du > 0) {
sections.add(PieChartSectionData(
color: UnionFlowColors.terracotta,
value: du,
title: '',
radius: 60,
titleStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white),
));
}
if (sections.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Répartition Payé / Dû',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: PieChart(
PieChartData(
sectionsSpace: 2,
centerSpaceRadius: 40,
sections: sections,
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_legendItem(UnionFlowColors.unionGreen, 'Payé', _currencyFormat.format(paye)),
_legendItem(UnionFlowColors.terracotta, '', _currencyFormat.format(du)),
],
),
],
),
);
}
Widget _legendItem(Color color, String label, String value) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 12, height: 12, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(value, style: AppTypography.bodyTextSmall.copyWith(fontWeight: FontWeight.w600)),
],
),
],
);
}
Widget _buildEvolutionSection() {
final payees = _cotisations!.where((c) => c.statut == ContributionStatus.payee).toList();
if (payees.isEmpty) return const SizedBox.shrink();
final byMonth = <int, double>{};
for (final c in payees) {
final d = c.datePaiement ?? c.dateEcheance;
final month = d.month + d.year * 12;
byMonth[month] = (byMonth[month] ?? 0) + (c.montantPaye ?? c.montant);
}
final entries = byMonth.entries.toList()..sort((a, b) => a.key.compareTo(b.key));
if (entries.isEmpty) return const SizedBox.shrink();
final dataMaxY = entries.map((e) => e.value).reduce((a, b) => a > b ? a : b);
final yMax = (dataMaxY * 1.1 + 1).clamp(1.0, double.infinity);
final yInterval = yMax / 4;
final spots = entries.asMap().entries.map((e) => FlSpot(e.key.toDouble(), e.value.value)).toList();
final n = spots.length;
final xInterval = n <= 5 ? 1.0 : (n - 1) / 4;
final xIntervalSafe = xInterval < 1 ? 1.0 : xInterval;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Paiements par période',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SizedBox(
height: 180,
child: LineChart(
LineChartData(
gridData: FlGridData(show: true, drawVerticalLine: false),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 44,
interval: yInterval,
getTitlesWidget: (v, _) => Text(_formatAxisAmount(v), style: const TextStyle(fontSize: 10)),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: xIntervalSafe,
getTitlesWidget: (v, _) {
final i = v.round();
if (i >= 0 && i < entries.length) {
final k = entries[i].key;
final m = k % 12 == 0 ? 12 : k % 12;
final y = k % 12 == 0 ? (k ~/ 12) - 1 : (k ~/ 12);
return Text(_formatAxisPeriod(m, y), style: const TextStyle(fontSize: 10));
}
return const SizedBox.shrink();
},
),
),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: true, border: Border(bottom: BorderSide(color: ColorTokens.outline), left: BorderSide(color: ColorTokens.outline))),
minX: 0,
maxX: (spots.length - 1).toDouble(),
minY: 0,
maxY: yMax,
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
color: UnionFlowColors.unionGreen,
barWidth: 2,
isStrokeCapRound: true,
dotData: const FlDotData(show: true),
belowBarData: BarAreaData(show: true, color: UnionFlowColors.unionGreen.withOpacity(0.15)),
),
],
),
),
),
],
),
);
}
Widget _buildProchainesEcheances() {
final list = _cotisations ?? [];
final aRegler = list.where((c) => c.statut != ContributionStatus.payee && c.statut != ContributionStatus.annulee).toList();
aRegler.sort((a, b) => a.dateEcheance.compareTo(b.dateEcheance));
final top = aRegler.take(5).toList();
if (top.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Prochaines échéances à régler',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 12),
...top.map((c) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDate(c.dateEcheance.toIso8601String()),
style: AppTypography.bodyTextSmall,
),
Text(
_currencyFormat.format(c.montant),
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w600,
color: UnionFlowColors.terracotta,
),
),
],
),
)),
],
),
);
}
double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
String _formatDate(String isoOrRaw) {
try {
final dt = DateTime.tryParse(isoOrRaw);
if (dt != null) {
const months = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sep', 'Oct', 'Nov', 'Déc'];
return '${dt.day} ${months[dt.month - 1]} ${dt.year}';
}
} catch (e, st) {
AppLogger.warning('MesStatistiquesCotisations: format date invalide', tag: isoOrRaw);
}
return isoOrRaw;
}
String _formatShortAmount(double v) {
if (v >= 1000) return '${(v / 1000).toStringAsFixed(0)}k';
return v.toStringAsFixed(0);
}
/// Format court pour laxe Y : 0, 25 k, 50 k, 1 M — peu de libellés, lisibles.
String _formatAxisAmount(double v) {
if (v >= 1000000) return '${(v / 1000000).toStringAsFixed(1)} M';
if (v >= 1000) return '${(v / 1000).toStringAsFixed(0)} k';
if (v < 1) return '0';
return v.toStringAsFixed(0);
}
String _monthShort(int m) {
const t = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sep', 'Oct', 'Nov', 'Déc'];
return m >= 1 && m <= 12 ? t[m - 1] : '';
}
/// Libellé court pour laxe X : "Jan 25", "Avr 25" — peu de caractères.
String _formatAxisPeriod(int month, int year) {
final shortYear = year % 100;
return '${_monthShort(month)} $shortYear';
}
}

View File

@@ -0,0 +1,283 @@
/// Dialogue de création de contribution
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/data/models/membre_complete_model.dart';
import '../../../profile/domain/repositories/profile_repository.dart';
class CreateContributionDialog extends StatefulWidget {
const CreateContributionDialog({super.key});
@override
State<CreateContributionDialog> createState() => _CreateContributionDialogState();
}
class _CreateContributionDialogState extends State<CreateContributionDialog> {
final _formKey = GlobalKey<FormState>();
final _montantController = TextEditingController();
final _descriptionController = TextEditingController();
ContributionType _selectedType = ContributionType.mensuelle;
MembreCompletModel? _me;
DateTime _dateEcheance = DateTime.now().add(const Duration(days: 30));
bool _isLoading = false;
bool _isInitLoading = true;
@override
void initState() {
super.initState();
_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
void dispose() {
_montantController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Nouvelle contribution'),
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.8,
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 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
DropdownButtonFormField<ContributionType>(
value: _selectedType,
decoration: const InputDecoration(
labelText: 'Type de contribution',
border: OutlineInputBorder(),
),
items: ContributionType.values.map((type) {
return DropdownMenuItem(
value: type,
child: Text(_getTypeLabel(type)),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedType = value;
});
}
},
),
const SizedBox(height: 16),
// Montant
TextFormField(
controller: _montantController,
decoration: const InputDecoration(
labelText: 'Montant (FCFA)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.attach_money),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir un montant';
}
if (double.tryParse(value) == null) {
return 'Montant invalide';
}
return null;
},
),
const SizedBox(height: 16),
// Date d'échéance
InkWell(
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: _dateEcheance,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (date != null) {
setState(() {
_dateEcheance = date;
});
}
},
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Date d\'échéance',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.calendar_today),
),
child: Text(
DateFormat('dd/MM/yyyy').format(_dateEcheance),
),
),
),
const SizedBox(height: 16),
// Description
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description (optionnel)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.description),
),
maxLines: 3,
),
],
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: _isLoading ? null : _createContribution,
child: _isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Créer'),
),
],
);
}
String _getTypeLabel(ContributionType type) {
switch (type) {
case ContributionType.mensuelle:
return 'Mensuelle';
case ContributionType.trimestrielle:
return 'Trimestrielle';
case ContributionType.semestrielle:
return 'Semestrielle';
case ContributionType.annuelle:
return 'Annuelle';
case ContributionType.exceptionnelle:
return 'Exceptionnelle';
}
}
Future<void> _createContribution() async {
if (!_formKey.currentState!.validate()) {
return;
}
if (_me == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Profil non chargé'),
backgroundColor: Colors.red,
),
);
return;
}
setState(() {
_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: membre.id!,
membreNom: membre.nom,
membrePrenom: membre.prenom,
organisationId: organisationId,
organisationNom: organisationNom,
type: _selectedType,
annee: DateTime.now().year,
montant: double.parse(_montantController.text),
dateEcheance: _dateEcheance,
description: _descriptionController.text.isNotEmpty ? _descriptionController.text : null,
statut: ContributionStatus.nonPayee,
dateCreation: DateTime.now(),
dateModification: DateTime.now(),
);
context.read<ContributionsBloc>().add(CreateContribution(contribution: contribution));
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Contribution créée avec succès'),
backgroundColor: Colors.green,
),
);
}
}

View File

@@ -0,0 +1,473 @@
/// 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 '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: const BoxDecoration(
color: Color(0xFF10B981),
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: Colors.grey[100],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.cotisation.membreNomComplet,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
widget.cotisation.libellePeriode,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Montant total:',
style: TextStyle(color: Colors.grey[600]),
),
Text(
'${NumberFormat('#,###').format(widget.cotisation.montant)} ${widget.cotisation.devise}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Déjà payé:',
style: TextStyle(color: Colors.grey[600]),
),
Text(
'${NumberFormat('#,###').format(widget.cotisation.montantPaye ?? 0)} ${widget.cotisation.devise}',
style: const TextStyle(color: Colors.green),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Restant:',
style: TextStyle(color: Colors.grey[600]),
),
Text(
'${NumberFormat('#,###').format(widget.cotisation.montantRestant)} ${widget.cotisation.devise}',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
],
),
],
),
),
// 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: Colors.grey[100],
border: Border(top: BorderSide(color: Colors.grey[300]!)),
),
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: const Color(0xFF10B981),
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);
// 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('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);
}
}
}

View File

@@ -0,0 +1,305 @@
import '../../../core/config/environment.dart';
/// Configuration globale du Dashboard UnionFlow
class DashboardConfig {
// Version du dashboard
static const String version = '1.0.0';
static const String buildNumber = '2024.10.06.001';
// Configuration des couleurs
static const bool useCustomTheme = true;
static const String primaryColorHex = '#4169E1'; // Bleu Roi
static const String secondaryColorHex = '#008B8B'; // Bleu Pétrole
// Configuration des données (toujours API réelle, pas de données fictives)
static String get apiBaseUrl => AppConfig.apiBaseUrl;
static const Duration networkTimeout = Duration(seconds: 30);
// Configuration du rafraîchissement
static const Duration autoRefreshInterval = Duration(minutes: 5);
static const Duration cacheExpiration = Duration(minutes: 10);
static const bool enableAutoRefresh = true;
static const bool enablePullToRefresh = true;
// Configuration des animations
static const bool enableAnimations = true;
static const Duration animationDuration = Duration(milliseconds: 300);
static const Duration chartAnimationDuration = Duration(milliseconds: 1500);
static const Duration counterAnimationDuration = Duration(milliseconds: 2000);
// Configuration des widgets
static const int maxRecentActivities = 10;
static const int maxUpcomingEvents = 5;
static const int maxNotifications = 5;
static const int maxShortcuts = 6;
// Configuration des graphiques
static const bool enableCharts = true;
static const bool enableInteractiveCharts = true;
static const double chartHeight = 200.0;
static const double largeChartHeight = 300.0;
// Configuration des métriques temps réel
static const bool enableRealTimeMetrics = true;
static const Duration metricsUpdateInterval = Duration(seconds: 30);
static const bool enableMetricsAnimations = true;
// Configuration des notifications
static const bool enableNotifications = true;
static const bool enableUrgentNotifications = true;
static const int maxUrgentNotifications = 3;
// Configuration de la recherche
static const bool enableSearch = true;
static const int maxSearchSuggestions = 5;
static const Duration searchDebounceDelay = Duration(milliseconds: 300);
// Configuration des raccourcis
static const bool enableShortcuts = true;
static const bool enableShortcutBadges = true;
static const bool enableShortcutCustomization = true;
// Configuration du logging
static const bool enableLogging = true;
static const bool enableVerboseLogging = false;
static const bool enableErrorReporting = true;
// Configuration de la performance
static const bool enablePerformanceMonitoring = true;
static const Duration performanceCheckInterval = Duration(minutes: 1);
static const double memoryWarningThreshold = 500.0; // MB
static const double cpuWarningThreshold = 80.0; // %
// Configuration de l'accessibilité
static const bool enableAccessibility = true;
static const bool enableHighContrast = false;
static const bool enableLargeText = false;
// Configuration des fonctionnalités expérimentales
static const bool enableExperimentalFeatures = false;
static const bool enableBetaWidgets = false;
static const bool enableAdvancedAnalytics = false;
// Seuils d'alerte
static const Map<String, dynamic> alertThresholds = {
'memoryUsage': 400.0, // MB
'cpuUsage': 70.0, // %
'networkLatency': 1000, // ms
'frameRate': 30.0, // fps
'batteryLevel': 20.0, // %
'errorRate': 5.0, // %
'crashRate': 1.0, // %
};
// Configuration des endpoints API
static const Map<String, String> apiEndpoints = {
'dashboard': '/api/v1/dashboard/data',
'stats': '/api/v1/dashboard/stats',
'activities': '/api/v1/dashboard/activities',
'events': '/api/v1/dashboard/events/upcoming',
'refresh': '/api/v1/dashboard/refresh',
'health': '/api/v1/dashboard/health',
};
// Configuration des préférences utilisateur par défaut
static const Map<String, dynamic> defaultUserPreferences = {
'theme': 'royal_teal',
'language': 'fr',
'notifications': true,
'autoRefresh': true,
'refreshInterval': 300, // 5 minutes
'enableAnimations': true,
'enableCharts': true,
'enableRealTimeMetrics': true,
'maxRecentActivities': 10,
'maxUpcomingEvents': 5,
'enableShortcuts': true,
'shortcuts': [
'new_member',
'create_event',
'add_contribution',
'send_message',
'generate_report',
'settings',
],
};
// Configuration des widgets par défaut
static const Map<String, dynamic> defaultWidgetConfig = {
'statsCards': {
'enabled': true,
'columns': 2,
'aspectRatio': 1.2,
'showSubtitle': true,
'showIcon': true,
},
'charts': {
'enabled': true,
'showLegend': true,
'showGrid': true,
'enableInteraction': true,
'animationDuration': 1500,
},
'activities': {
'enabled': true,
'showAvatar': true,
'showTimeAgo': true,
'maxItems': 10,
'enableActions': true,
},
'events': {
'enabled': true,
'showProgress': true,
'showTags': true,
'maxItems': 5,
'enableNavigation': true,
},
'notifications': {
'enabled': true,
'showBadges': true,
'enableActions': true,
'maxItems': 5,
'autoHide': false,
},
'search': {
'enabled': true,
'showSuggestions': true,
'enableHistory': true,
'maxSuggestions': 5,
'debounceDelay': 300,
},
'shortcuts': {
'enabled': true,
'columns': 3,
'showBadges': true,
'enableCustomization': true,
'maxItems': 6,
},
'metrics': {
'enabled': true,
'enableAnimations': true,
'updateInterval': 30,
'showProgress': true,
'enableAlerts': true,
},
};
// Configuration des couleurs du thème
static const Map<String, String> themeColors = {
'royalBlue': '#4169E1',
'royalBlueLight': '#6A8EF7',
'royalBlueDark': '#2E4BC6',
'tealBlue': '#008B8B',
'tealBlueLight': '#20B2AA',
'tealBlueDark': '#006666',
'success': '#10B981',
'warning': '#F59E0B',
'error': '#EF4444',
'info': '#3B82F6',
'grey50': '#F9FAFB',
'grey100': '#F3F4F6',
'grey200': '#E5E7EB',
'grey300': '#D1D5DB',
'grey400': '#9CA3AF',
'grey500': '#6B7280',
'grey600': '#4B5563',
'grey700': '#374151',
'grey800': '#1F2937',
'grey900': '#111827',
'white': '#FFFFFF',
'black': '#000000',
};
// Configuration des espacements
static const Map<String, double> spacing = {
'spacing2': 2.0,
'spacing4': 4.0,
'spacing6': 6.0,
'spacing8': 8.0,
'spacing12': 12.0,
'spacing16': 16.0,
'spacing20': 20.0,
'spacing24': 24.0,
'spacing32': 32.0,
'spacing40': 40.0,
};
// Configuration des bordures
static const Map<String, double> borderRadius = {
'borderRadiusSmall': 4.0,
'borderRadius': 8.0,
'borderRadiusLarge': 16.0,
'borderRadiusXLarge': 24.0,
};
// Configuration des ombres
static const Map<String, Map<String, dynamic>> shadows = {
'subtleShadow': {
'color': '#00000010',
'blurRadius': 4.0,
'offset': {'dx': 0.0, 'dy': 2.0},
},
'elevatedShadow': {
'color': '#00000020',
'blurRadius': 8.0,
'offset': {'dx': 0.0, 'dy': 4.0},
},
};
// Configuration des polices
static const Map<String, Map<String, dynamic>> typography = {
'titleLarge': {
'fontSize': 24.0,
'fontWeight': 'bold',
'letterSpacing': 0.0,
},
'titleMedium': {
'fontSize': 20.0,
'fontWeight': 'w600',
'letterSpacing': 0.0,
},
'titleSmall': {
'fontSize': 16.0,
'fontWeight': 'w600',
'letterSpacing': 0.0,
},
'bodyLarge': {
'fontSize': 16.0,
'fontWeight': 'normal',
'letterSpacing': 0.0,
},
'bodyMedium': {
'fontSize': 14.0,
'fontWeight': 'normal',
'letterSpacing': 0.0,
},
'bodySmall': {
'fontSize': 12.0,
'fontWeight': 'normal',
'letterSpacing': 0.0,
},
};
// Méthodes utilitaires
static String get fullVersion => '$version+$buildNumber';
static Duration get effectiveRefreshInterval =>
enableAutoRefresh ? autoRefreshInterval : Duration.zero;
static Map<String, dynamic> getUserPreference(String key) {
return defaultUserPreferences[key] ?? {};
}
static Map<String, dynamic> getWidgetConfig(String widget) {
return defaultWidgetConfig[widget] ?? {};
}
static String getApiEndpoint(String endpoint) {
final path = apiEndpoints[endpoint] ?? '';
return '$apiBaseUrl$path';
}
static double getAlertThreshold(String metric) {
return alertThresholds[metric]?.toDouble() ?? 0.0;
}
}

View File

@@ -0,0 +1,200 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/utils/logger.dart';
import '../models/dashboard_stats_model.dart';
import '../models/membre_dashboard_synthese_model.dart';
import '../models/compte_adherent_model.dart';
import '../../../../core/error/exceptions.dart';
abstract class DashboardRemoteDataSource {
Future<DashboardDataModel> getDashboardData(String organizationId, String userId);
/// Dashboard personnel du membre connecté (sans organisationId). GET /api/dashboard/membre/me
Future<MembreDashboardSyntheseModel> getMemberDashboardData();
/// Synthèse des cotisations du membre connecté. GET /api/cotisations/mes-cotisations/synthese
/// Utilisé en fallback quand les montants de getMemberDashboardData() sont à 0.
Future<Map<String, dynamic>?> getMesCotisationsSynthese();
/// Compte adhérent unifié (soldes, crédits, capacité d'emprunt). GET /api/membres/mon-compte
Future<CompteAdherentModel> getCompteAdherent();
Future<DashboardStatsModel> getDashboardStats(String organizationId, String userId);
Future<List<RecentActivityModel>> getRecentActivities(String organizationId, String userId, {int limit = 10});
Future<List<UpcomingEventModel>> getUpcomingEvents(String organizationId, String userId, {int limit = 5});
}
@Injectable(as: DashboardRemoteDataSource)
class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
final ApiClient apiClient;
DashboardRemoteDataSourceImpl(this.apiClient);
@override
Future<DashboardDataModel> getDashboardData(String organizationId, String userId) async {
try {
final response = await apiClient.get(
'/api/v1/dashboard/data',
queryParameters: {
'organizationId': organizationId,
'userId': userId,
},
);
if (response.statusCode == 200) {
return DashboardDataModel.fromJson(response.data);
} else {
throw ServerException('Failed to load dashboard data: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getDashboardData', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getDashboardData', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<MembreDashboardSyntheseModel> getMemberDashboardData() async {
try {
final response = await apiClient.get('/api/dashboard/membre/me');
if (response.statusCode == 200) {
return MembreDashboardSyntheseModel.fromJson(
response.data is Map<String, dynamic> ? response.data as Map<String, dynamic> : Map<String, dynamic>.from(response.data as Map),
);
} else {
throw ServerException('Failed to load member dashboard: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getMemberDashboardData', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getMemberDashboardData', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<Map<String, dynamic>?> getMesCotisationsSynthese() async {
try {
final response = await apiClient.get('/api/cotisations/mes-cotisations/synthese');
if (response.statusCode == 200 && response.data != null) {
return response.data is Map<String, dynamic>
? response.data as Map<String, dynamic>
: Map<String, dynamic>.from(response.data as Map);
}
return null;
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getMesCotisationsSynthese échoué', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<CompteAdherentModel> getCompteAdherent() async {
try {
final response = await apiClient.get('/api/membres/mon-compte');
if (response.statusCode == 200) {
return CompteAdherentModel.fromJson(
response.data is Map<String, dynamic> ? response.data as Map<String, dynamic> : Map<String, dynamic>.from(response.data as Map),
);
} else {
throw ServerException('Failed to load adherent account: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getCompteAdherent', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getCompteAdherent', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<DashboardStatsModel> getDashboardStats(String organizationId, String userId) async {
try {
final response = await apiClient.get(
'/api/v1/dashboard/stats',
queryParameters: {
'organizationId': organizationId,
'userId': userId,
},
);
if (response.statusCode == 200) {
return DashboardStatsModel.fromJson(response.data);
} else {
throw ServerException('Failed to load dashboard stats: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getDashboardStats', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getDashboardStats', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<List<RecentActivityModel>> getRecentActivities(
String organizationId,
String userId, {
int limit = 10,
}) async {
try {
final response = await apiClient.get(
'/api/v1/dashboard/activities',
queryParameters: {
'organizationId': organizationId,
'userId': userId,
'limit': limit,
},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data['activities'] ?? [];
return data.map((json) => RecentActivityModel.fromJson(json)).toList();
} else {
throw ServerException('Failed to load recent activities: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getRecentActivities', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getRecentActivities', error: e, stackTrace: st);
rethrow;
}
}
@override
Future<List<UpcomingEventModel>> getUpcomingEvents(
String organizationId,
String userId, {
int limit = 5,
}) async {
try {
final response = await apiClient.get(
'/api/v1/dashboard/events/upcoming',
queryParameters: {
'organizationId': organizationId,
'userId': userId,
'limit': limit,
},
);
if (response.statusCode == 200) {
final List<dynamic> data = response.data['events'] ?? [];
return data.map((json) => UpcomingEventModel.fromJson(json)).toList();
} else {
throw ServerException('Failed to load upcoming events: ${response.statusCode}');
}
} on DioException catch (e) {
AppLogger.error('DashboardRemoteDataSource: getUpcomingEvents', error: e);
throw ServerException('Network error: ${e.message}');
} catch (e, st) {
AppLogger.error('DashboardRemoteDataSource: getUpcomingEvents', error: e, stackTrace: st);
rethrow;
}
}
}

View File

@@ -0,0 +1,72 @@
/// Modèle pour le "compte adhérent" unifié (GET /api/membres/mon-compte).
class CompteAdherentModel {
final String numeroMembre;
final String nomComplet;
final String? organisationNom;
final String? dateAdhesion;
final String statutCompte;
final double soldeCotisations;
final double soldeEpargne;
final double soldeBloque;
final double soldeTotalDisponible;
final double encoursCreditTotal;
final double capaciteEmprunt;
final int nombreCotisationsPayees;
final int nombreCotisationsTotal;
final int nombreCotisationsEnRetard;
final int? tauxEngagement;
final int nombreComptesEpargne;
final String dateCalcul;
const CompteAdherentModel({
required this.numeroMembre,
required this.nomComplet,
this.organisationNom,
this.dateAdhesion,
this.statutCompte = 'ACTIF',
this.soldeCotisations = 0,
this.soldeEpargne = 0,
this.soldeBloque = 0,
this.soldeTotalDisponible = 0,
this.encoursCreditTotal = 0,
this.capaciteEmprunt = 0,
this.nombreCotisationsPayees = 0,
this.nombreCotisationsTotal = 0,
this.nombreCotisationsEnRetard = 0,
this.tauxEngagement,
this.nombreComptesEpargne = 0,
required this.dateCalcul,
});
factory CompteAdherentModel.fromJson(Map<String, dynamic> json) {
return CompteAdherentModel(
numeroMembre: json['numeroMembre'] as String? ?? 'N/A',
nomComplet: json['nomComplet'] as String? ?? '',
organisationNom: json['organisationNom'] as String?,
dateAdhesion: json['dateAdhesion'] as String?,
statutCompte: json['statutCompte'] as String? ?? 'ACTIF',
soldeCotisations: _toDouble(json['soldeCotisations']),
soldeEpargne: _toDouble(json['soldeEpargne']),
soldeBloque: _toDouble(json['soldeBloque']),
soldeTotalDisponible: _toDouble(json['soldeTotalDisponible']),
encoursCreditTotal: _toDouble(json['encoursCreditTotal']),
capaciteEmprunt: _toDouble(json['capaciteEmprunt']),
nombreCotisationsPayees: (json['nombreCotisationsPayees'] as num?)?.toInt() ?? 0,
nombreCotisationsTotal: (json['nombreCotisationsTotal'] as num?)?.toInt() ?? 0,
nombreCotisationsEnRetard: (json['nombreCotisationsEnRetard'] as num?)?.toInt() ?? 0,
tauxEngagement: (json['tauxEngagement'] as num?)?.toInt(),
nombreComptesEpargne: (json['nombreComptesEpargne'] as num?)?.toInt() ?? 0,
dateCalcul: json['dateCalcul'] as String? ?? '',
);
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
}

View File

@@ -0,0 +1,222 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'dashboard_stats_model.g.dart';
/// Modèle pour les statistiques du dashboard
@JsonSerializable()
class DashboardStatsModel extends Equatable {
final int totalMembers;
final int activeMembers;
final int totalEvents;
final int upcomingEvents;
final int totalContributions;
final double totalContributionAmount;
final int pendingRequests;
final int completedProjects;
final double monthlyGrowth;
final double engagementRate;
final DateTime lastUpdated;
final int? totalOrganizations;
final Map<String, int>? organizationTypeDistribution;
const DashboardStatsModel({
required this.totalMembers,
required this.activeMembers,
required this.totalEvents,
required this.upcomingEvents,
required this.totalContributions,
required this.totalContributionAmount,
required this.pendingRequests,
required this.completedProjects,
required this.monthlyGrowth,
required this.engagementRate,
required this.lastUpdated,
this.totalOrganizations,
this.organizationTypeDistribution,
});
factory DashboardStatsModel.fromJson(Map<String, dynamic> json) =>
_$DashboardStatsModelFromJson(json);
Map<String, dynamic> toJson() => _$DashboardStatsModelToJson(this);
// Getters calculés
String get formattedContributionAmount {
return '${totalContributionAmount.toStringAsFixed(2)}';
}
bool get hasGrowth => monthlyGrowth > 0;
bool get isHighEngagement => engagementRate > 0.7;
double get activeMemberPercentage {
return totalMembers > 0 ? (activeMembers / totalMembers) : 0.0;
}
@override
List<Object?> get props => [
totalMembers,
activeMembers,
totalEvents,
upcomingEvents,
totalContributions,
totalContributionAmount,
pendingRequests,
completedProjects,
monthlyGrowth,
engagementRate,
lastUpdated,
totalOrganizations,
organizationTypeDistribution,
];
}
/// Modèle pour les activités récentes
@JsonSerializable()
class RecentActivityModel extends Equatable {
final String id;
final String type;
final String title;
final String description;
final String? userAvatar;
final String userName;
final DateTime timestamp;
final String? actionUrl;
final Map<String, dynamic>? metadata;
const RecentActivityModel({
required this.id,
required this.type,
required this.title,
required this.description,
this.userAvatar,
required this.userName,
required this.timestamp,
this.actionUrl,
this.metadata,
});
factory RecentActivityModel.fromJson(Map<String, dynamic> json) =>
_$RecentActivityModelFromJson(json);
Map<String, dynamic> toJson() => _$RecentActivityModelToJson(this);
// Getter calculé pour l'affichage du temps
String get timeAgo {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inDays > 0) {
return 'il y a ${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
} else if (difference.inHours > 0) {
return 'il y a ${difference.inHours} heure${difference.inHours > 1 ? 's' : ''}';
} else if (difference.inMinutes > 0) {
return 'il y a ${difference.inMinutes} minute${difference.inMinutes > 1 ? 's' : ''}';
} else {
return 'à l\'instant';
}
}
@override
List<Object?> get props => [
id,
type,
title,
description,
userAvatar,
userName,
timestamp,
actionUrl,
metadata,
];
}
/// Modèle pour les événements à venir
@JsonSerializable()
class UpcomingEventModel extends Equatable {
final String id;
final String title;
final String description;
final DateTime startDate;
final DateTime? endDate;
final String location;
final int maxParticipants;
final int currentParticipants;
final String status;
final String? imageUrl;
final List<String> tags;
const UpcomingEventModel({
required this.id,
required this.title,
required this.description,
required this.startDate,
this.endDate,
required this.location,
required this.maxParticipants,
required this.currentParticipants,
required this.status,
this.imageUrl,
required this.tags,
});
factory UpcomingEventModel.fromJson(Map<String, dynamic> json) =>
_$UpcomingEventModelFromJson(json);
Map<String, dynamic> toJson() => _$UpcomingEventModelToJson(this);
bool get isAlmostFull => currentParticipants >= (maxParticipants * 0.8);
bool get isFull => currentParticipants >= maxParticipants;
double get fillPercentage => maxParticipants > 0 ? currentParticipants / maxParticipants : 0.0;
@override
List<Object?> get props => [
id,
title,
description,
startDate,
endDate,
location,
maxParticipants,
currentParticipants,
status,
imageUrl,
tags,
];
}
/// Modèle pour les données du dashboard complet
@JsonSerializable()
class DashboardDataModel extends Equatable {
final DashboardStatsModel stats;
final List<RecentActivityModel> recentActivities;
final List<UpcomingEventModel> upcomingEvents;
final Map<String, dynamic> userPreferences;
final String organizationId;
final String userId;
const DashboardDataModel({
required this.stats,
required this.recentActivities,
required this.upcomingEvents,
required this.userPreferences,
required this.organizationId,
required this.userId,
});
factory DashboardDataModel.fromJson(Map<String, dynamic> json) =>
_$DashboardDataModelFromJson(json);
Map<String, dynamic> toJson() => _$DashboardDataModelToJson(this);
@override
List<Object?> get props => [
stats,
recentActivities,
upcomingEvents,
userPreferences,
organizationId,
userId,
];
}

View File

@@ -0,0 +1,130 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dashboard_stats_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DashboardStatsModel _$DashboardStatsModelFromJson(Map<String, dynamic> json) =>
DashboardStatsModel(
totalMembers: (json['totalMembers'] as num).toInt(),
activeMembers: (json['activeMembers'] as num).toInt(),
totalEvents: (json['totalEvents'] as num).toInt(),
upcomingEvents: (json['upcomingEvents'] as num).toInt(),
totalContributions: (json['totalContributions'] as num).toInt(),
totalContributionAmount:
(json['totalContributionAmount'] as num).toDouble(),
pendingRequests: (json['pendingRequests'] as num).toInt(),
completedProjects: (json['completedProjects'] as num).toInt(),
monthlyGrowth: (json['monthlyGrowth'] as num).toDouble(),
engagementRate: (json['engagementRate'] as num).toDouble(),
lastUpdated: DateTime.parse(json['lastUpdated'] as String),
totalOrganizations: (json['totalOrganizations'] as num?)?.toInt(),
organizationTypeDistribution:
(json['organizationTypeDistribution'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toInt()),
),
);
Map<String, dynamic> _$DashboardStatsModelToJson(
DashboardStatsModel instance) =>
<String, dynamic>{
'totalMembers': instance.totalMembers,
'activeMembers': instance.activeMembers,
'totalEvents': instance.totalEvents,
'upcomingEvents': instance.upcomingEvents,
'totalContributions': instance.totalContributions,
'totalContributionAmount': instance.totalContributionAmount,
'pendingRequests': instance.pendingRequests,
'completedProjects': instance.completedProjects,
'monthlyGrowth': instance.monthlyGrowth,
'engagementRate': instance.engagementRate,
'lastUpdated': instance.lastUpdated.toIso8601String(),
'totalOrganizations': instance.totalOrganizations,
'organizationTypeDistribution': instance.organizationTypeDistribution,
};
RecentActivityModel _$RecentActivityModelFromJson(Map<String, dynamic> json) =>
RecentActivityModel(
id: json['id'] as String,
type: json['type'] as String,
title: json['title'] as String,
description: json['description'] as String,
userAvatar: json['userAvatar'] as String?,
userName: json['userName'] as String,
timestamp: DateTime.parse(json['timestamp'] as String),
actionUrl: json['actionUrl'] as String?,
metadata: json['metadata'] as Map<String, dynamic>?,
);
Map<String, dynamic> _$RecentActivityModelToJson(
RecentActivityModel instance) =>
<String, dynamic>{
'id': instance.id,
'type': instance.type,
'title': instance.title,
'description': instance.description,
'userAvatar': instance.userAvatar,
'userName': instance.userName,
'timestamp': instance.timestamp.toIso8601String(),
'actionUrl': instance.actionUrl,
'metadata': instance.metadata,
};
UpcomingEventModel _$UpcomingEventModelFromJson(Map<String, dynamic> json) =>
UpcomingEventModel(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String,
startDate: DateTime.parse(json['startDate'] as String),
endDate: json['endDate'] == null
? null
: DateTime.parse(json['endDate'] as String),
location: json['location'] as String,
maxParticipants: (json['maxParticipants'] as num).toInt(),
currentParticipants: (json['currentParticipants'] as num).toInt(),
status: json['status'] as String,
imageUrl: json['imageUrl'] as String?,
tags: (json['tags'] as List<dynamic>).map((e) => e as String).toList(),
);
Map<String, dynamic> _$UpcomingEventModelToJson(UpcomingEventModel instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'description': instance.description,
'startDate': instance.startDate.toIso8601String(),
'endDate': instance.endDate?.toIso8601String(),
'location': instance.location,
'maxParticipants': instance.maxParticipants,
'currentParticipants': instance.currentParticipants,
'status': instance.status,
'imageUrl': instance.imageUrl,
'tags': instance.tags,
};
DashboardDataModel _$DashboardDataModelFromJson(Map<String, dynamic> json) =>
DashboardDataModel(
stats:
DashboardStatsModel.fromJson(json['stats'] as Map<String, dynamic>),
recentActivities: (json['recentActivities'] as List<dynamic>)
.map((e) => RecentActivityModel.fromJson(e as Map<String, dynamic>))
.toList(),
upcomingEvents: (json['upcomingEvents'] as List<dynamic>)
.map((e) => UpcomingEventModel.fromJson(e as Map<String, dynamic>))
.toList(),
userPreferences: json['userPreferences'] as Map<String, dynamic>,
organizationId: json['organizationId'] as String,
userId: json['userId'] as String,
);
Map<String, dynamic> _$DashboardDataModelToJson(DashboardDataModel instance) =>
<String, dynamic>{
'stats': instance.stats,
'recentActivities': instance.recentActivities,
'upcomingEvents': instance.upcomingEvents,
'userPreferences': instance.userPreferences,
'organizationId': instance.organizationId,
'userId': instance.userId,
};

View File

@@ -0,0 +1,85 @@
/// Modèle pour la réponse GET /api/dashboard/membre/me (backend MembreDashboardSyntheseResponse).
/// Utilisé quand l'utilisateur est un membre sans organisationId (dashboard personnel).
class MembreDashboardSyntheseModel {
final String prenom;
final String nom;
final String? dateInscription; // ISO date string
final double mesCotisationsPaiement;
/// Total des cotisations payées sur l'année (pour dashboard).
final double totalCotisationsPayeesAnnee;
/// Total des cotisations payées tout temps (pour carte « Contribution Totale »).
final double totalCotisationsPayeesToutTemps;
/// Nombre de cotisations payées (pour carte « Cotisations »).
final int nombreCotisationsPayees;
/// Nombre total de cotisations (toutes années, tous statuts).
final int nombreCotisationsTotal;
final String statutCotisations;
final int? tauxCotisationsPerso;
final double monSoldeEpargne;
final double evolutionEpargneNombre;
final String evolutionEpargne;
final int objectifEpargne;
final int mesEvenementsInscrits;
final int evenementsAVenir;
final int? tauxParticipationPerso;
final int mesDemandesAide;
final int aidesEnCours;
final int? tauxAidesApprouvees;
const MembreDashboardSyntheseModel({
required this.prenom,
required this.nom,
this.dateInscription,
this.mesCotisationsPaiement = 0,
this.totalCotisationsPayeesAnnee = 0,
this.totalCotisationsPayeesToutTemps = 0,
this.nombreCotisationsPayees = 0,
this.nombreCotisationsTotal = 0,
this.statutCotisations = 'À jour',
this.tauxCotisationsPerso,
this.monSoldeEpargne = 0,
this.evolutionEpargneNombre = 0,
this.evolutionEpargne = '+0%',
this.objectifEpargne = 0,
this.mesEvenementsInscrits = 0,
this.evenementsAVenir = 0,
this.tauxParticipationPerso,
this.mesDemandesAide = 0,
this.aidesEnCours = 0,
this.tauxAidesApprouvees,
});
factory MembreDashboardSyntheseModel.fromJson(Map<String, dynamic> json) {
return MembreDashboardSyntheseModel(
prenom: json['prenom'] as String? ?? '',
nom: json['nom'] as String? ?? '',
dateInscription: json['dateInscription'] as String?,
mesCotisationsPaiement: _toDouble(json['mesCotisationsPaiement']),
totalCotisationsPayeesAnnee: _toDouble(json['totalCotisationsPayeesAnnee']),
totalCotisationsPayeesToutTemps: _toDouble(json['totalCotisationsPayeesToutTemps']),
nombreCotisationsPayees: (json['nombreCotisationsPayees'] as num?)?.toInt() ?? 0,
nombreCotisationsTotal: (json['nombreCotisationsTotal'] as num?)?.toInt() ??
(json['nombreCotisationsPayees'] as num?)?.toInt() ?? 0,
statutCotisations: json['statutCotisations'] as String? ?? 'À jour',
tauxCotisationsPerso: (json['tauxCotisationsPerso'] as num?)?.toInt(),
monSoldeEpargne: _toDouble(json['monSoldeEpargne']),
evolutionEpargneNombre: _toDouble(json['evolutionEpargneNombre']),
evolutionEpargne: json['evolutionEpargne'] as String? ?? '+0%',
objectifEpargne: (json['objectifEpargne'] as num?)?.toInt() ?? 0,
mesEvenementsInscrits: (json['mesEvenementsInscrits'] as num?)?.toInt() ?? 0,
evenementsAVenir: (json['evenementsAVenir'] as num?)?.toInt() ?? 0,
tauxParticipationPerso: (json['tauxParticipationPerso'] as num?)?.toInt(),
mesDemandesAide: (json['mesDemandesAide'] as num?)?.toInt() ?? 0,
aidesEnCours: (json['aidesEnCours'] as num?)?.toInt() ?? 0,
tauxAidesApprouvees: (json['tauxAidesApprouvees'] as num?)?.toInt(),
);
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
}

View File

@@ -0,0 +1,318 @@
import 'package:injectable/injectable.dart';
import 'package:dartz/dartz.dart';
import '../../domain/entities/dashboard_entity.dart';
import '../../domain/entities/compte_adherent_entity.dart';
import '../../domain/repositories/dashboard_repository.dart';
import '../datasources/dashboard_remote_datasource.dart';
import '../models/dashboard_stats_model.dart';
import '../models/membre_dashboard_synthese_model.dart';
import '../models/compte_adherent_model.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/network/network_info.dart';
@LazySingleton(as: DashboardRepository)
class DashboardRepositoryImpl implements DashboardRepository {
final DashboardRemoteDataSource remoteDataSource;
final NetworkInfo networkInfo;
DashboardRepositoryImpl({
required this.remoteDataSource,
required this.networkInfo,
});
@override
Future<Either<Failure, CompteAdherentEntity>> getCompteAdherent() async {
if (!await networkInfo.isConnected) {
return const Left(NetworkFailure('No internet connection'));
}
try {
final model = await remoteDataSource.getCompteAdherent();
return Right(_mapCompteToEntity(model));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error: $e'));
}
}
@override
Future<Either<Failure, DashboardEntity>> getDashboardData(
String organizationId,
String userId,
) async {
if (!await networkInfo.isConnected) {
return const Left(NetworkFailure('No internet connection'));
}
try {
// Membre sans contexte org : utiliser l'API dashboard membre (GET /api/dashboard/membre/me)
final useMemberDashboard = organizationId.trim().isEmpty;
if (useMemberDashboard) {
// Chargement parallèle de la synthèse et du compte adhérent unifié
final results = await Future.wait([
remoteDataSource.getMemberDashboardData(),
remoteDataSource.getCompteAdherent(),
]);
final synthese = results[0] as MembreDashboardSyntheseModel;
final compteModel = results[1] as CompteAdherentModel;
// Fallback : si les montants sont à zéro mais qu'il y a des cotisations,
// on complète avec /api/cotisations/mes-cotisations/synthese
Map<String, dynamic>? cotSynthese;
if (synthese.totalCotisationsPayeesToutTemps == 0 ||
synthese.tauxCotisationsPerso == null ||
(synthese.tauxCotisationsPerso ?? 0) == 0) {
cotSynthese = await remoteDataSource.getMesCotisationsSynthese();
}
return Right(_mapMemberSyntheseToEntity(
synthese,
userId,
cotSynthese: cotSynthese,
compteModel: compteModel,
));
}
final dashboardData = await remoteDataSource.getDashboardData(organizationId, userId);
return Right(_mapToEntity(dashboardData));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error: $e'));
}
}
/// Construit une DashboardEntity à partir de la synthèse membre.
/// [cotSynthese] est optionnel : utilisé en fallback quand les montants du dashboard
/// membre sont à zéro (incohérence backend entre /api/dashboard/membre/me
/// et /api/cotisations/mes-cotisations/synthese).
DashboardEntity _mapMemberSyntheseToEntity(
MembreDashboardSyntheseModel s,
String userId, {
Map<String, dynamic>? cotSynthese,
CompteAdherentModel? compteModel,
}) {
final now = DateTime.now();
// ------------------------------------------------------------------
// Montant des cotisations payées tout temps
// ------------------------------------------------------------------
double totalCotisationsToutTemps = s.totalCotisationsPayeesToutTemps;
if (totalCotisationsToutTemps == 0 && cotSynthese != null) {
// totalPayeAnnee = montant payé sur l'année en cours (meilleure approximation disponible)
final totalPayeAnnee = _toDouble(cotSynthese['totalPayeAnnee']);
if (totalPayeAnnee > 0) totalCotisationsToutTemps = totalPayeAnnee;
}
// ------------------------------------------------------------------
// MON SOLDE TOTAL = cotisations payées + épargne
// ------------------------------------------------------------------
final monSoldeTotal = totalCotisationsToutTemps + s.monSoldeEpargne;
// ------------------------------------------------------------------
// Taux d'engagement (en %)
// Priorité : tauxParticipationPerso > tauxCotisationsPerso > calculé depuis cotSynthese
// ------------------------------------------------------------------
int? tauxBrut = s.tauxParticipationPerso ?? s.tauxCotisationsPerso;
double engagementRate = (tauxBrut ?? 0) / 100.0;
if (engagementRate == 0 && cotSynthese != null) {
final montantDu = _toDouble(cotSynthese['montantDu']);
final totalPayeAnnee = _toDouble(cotSynthese['totalPayeAnnee']);
final total = montantDu + totalPayeAnnee;
if (total > 0) engagementRate = totalPayeAnnee / total;
}
// ------------------------------------------------------------------
// Nombre de cotisations — utilize NEW nombreCotisationsTotal if available
// ------------------------------------------------------------------
final int nombreCotisations = s.nombreCotisationsTotal > 0
? s.nombreCotisationsTotal
: s.nombreCotisationsPayees;
final stats = DashboardStatsEntity(
totalMembers: 0,
activeMembers: 0,
totalEvents: 0,
upcomingEvents: s.evenementsAVenir,
totalContributions: nombreCotisations,
totalContributionAmount: monSoldeTotal,
contributionsAmountOnly: totalCotisationsToutTemps,
pendingRequests: 0,
completedProjects: 0,
monthlyGrowth: s.evolutionEpargneNombre,
engagementRate: engagementRate,
lastUpdated: now,
totalOrganizations: null,
organizationTypeDistribution: null,
);
return DashboardEntity(
stats: stats,
recentActivities: const [],
upcomingEvents: const [],
userPreferences: const <String, dynamic>{},
organizationId: '',
userId: userId,
monCompte: compteModel != null ? _mapCompteToEntity(compteModel) : null,
);
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
@override
Future<Either<Failure, DashboardStatsEntity>> getDashboardStats(
String organizationId,
String userId,
) async {
if (await networkInfo.isConnected) {
try {
final stats = await remoteDataSource.getDashboardStats(organizationId, userId);
return Right(_mapStatsToEntity(stats));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error: $e'));
}
} else {
return const Left(NetworkFailure('No internet connection'));
}
}
@override
Future<Either<Failure, List<RecentActivityEntity>>> getRecentActivities(
String organizationId,
String userId, {
int limit = 10,
}) async {
if (await networkInfo.isConnected) {
try {
final activities = await remoteDataSource.getRecentActivities(
organizationId,
userId,
limit: limit,
);
return Right(activities.map(_mapActivityToEntity).toList());
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error: $e'));
}
} else {
return const Left(NetworkFailure('No internet connection'));
}
}
@override
Future<Either<Failure, List<UpcomingEventEntity>>> getUpcomingEvents(
String organizationId,
String userId, {
int limit = 5,
}) async {
if (await networkInfo.isConnected) {
try {
final events = await remoteDataSource.getUpcomingEvents(
organizationId,
userId,
limit: limit,
);
return Right(events.map(_mapEventToEntity).toList());
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} catch (e) {
return Left(ServerFailure('Unexpected error: $e'));
}
} else {
return const Left(NetworkFailure('No internet connection'));
}
}
CompteAdherentEntity _mapCompteToEntity(CompteAdherentModel model) {
return CompteAdherentEntity(
numeroMembre: model.numeroMembre,
nomComplet: model.nomComplet,
organisationNom: model.organisationNom,
dateAdhesion: model.dateAdhesion != null ? DateTime.tryParse(model.dateAdhesion!) : null,
statutCompte: model.statutCompte,
soldeCotisations: model.soldeCotisations,
soldeEpargne: model.soldeEpargne,
soldeBloque: model.soldeBloque,
soldeTotalDisponible: model.soldeTotalDisponible,
encoursCreditTotal: model.encoursCreditTotal,
capaciteEmprunt: model.capaciteEmprunt,
nombreCotisationsPayees: model.nombreCotisationsPayees,
nombreCotisationsTotal: model.nombreCotisationsTotal,
nombreCotisationsEnRetard: model.nombreCotisationsEnRetard,
engagementRate: (model.tauxEngagement ?? 0) / 100.0,
nombreComptesEpargne: model.nombreComptesEpargne,
dateCalcul: DateTime.tryParse(model.dateCalcul) ?? DateTime.now(),
);
}
// Mappers
DashboardEntity _mapToEntity(DashboardDataModel model) {
return DashboardEntity(
stats: _mapStatsToEntity(model.stats),
recentActivities: model.recentActivities.map(_mapActivityToEntity).toList(),
upcomingEvents: model.upcomingEvents.map(_mapEventToEntity).toList(),
userPreferences: model.userPreferences,
organizationId: model.organizationId,
userId: model.userId,
);
}
DashboardStatsEntity _mapStatsToEntity(DashboardStatsModel model) {
return DashboardStatsEntity(
totalMembers: model.totalMembers,
activeMembers: model.activeMembers,
totalEvents: model.totalEvents,
upcomingEvents: model.upcomingEvents,
totalContributions: model.totalContributions,
totalContributionAmount: model.totalContributionAmount,
contributionsAmountOnly: null,
pendingRequests: model.pendingRequests,
completedProjects: model.completedProjects,
monthlyGrowth: model.monthlyGrowth,
engagementRate: model.engagementRate,
lastUpdated: model.lastUpdated,
totalOrganizations: null,
organizationTypeDistribution: null,
);
}
RecentActivityEntity _mapActivityToEntity(RecentActivityModel model) {
return RecentActivityEntity(
id: model.id,
type: model.type,
title: model.title,
description: model.description,
userAvatar: model.userAvatar,
userName: model.userName,
timestamp: model.timestamp,
actionUrl: model.actionUrl,
metadata: model.metadata,
);
}
UpcomingEventEntity _mapEventToEntity(UpcomingEventModel model) {
return UpcomingEventEntity(
id: model.id,
title: model.title,
description: model.description,
startDate: model.startDate,
endDate: model.endDate,
location: model.location,
maxParticipants: model.maxParticipants,
currentParticipants: model.currentParticipants,
status: model.status,
imageUrl: model.imageUrl,
tags: model.tags,
);
}
}

View File

@@ -0,0 +1,89 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import 'package:unionflow_mobile_apps/core/utils/logger.dart';
import '../../presentation/bloc/finance_state.dart';
/// Repository pour les données financières (cotisations, synthèse).
/// Appelle les endpoints /api/cotisations/mes-cotisations/*.
@lazySingleton
class FinanceRepository {
final ApiClient _apiClient;
FinanceRepository(this._apiClient);
/// Synthèse des cotisations du membre connecté (GET /api/cotisations/mes-cotisations/synthese).
Future<FinanceSummary> getFinancialSummary() async {
try {
final response = await _apiClient.get('/api/cotisations/mes-cotisations/synthese');
final data = response.data as Map<String, dynamic>;
final totalPayeAnnee = (data['totalPayeAnnee'] is num)
? (data['totalPayeAnnee'] as num).toDouble()
: 0.0;
final montantDu = (data['montantDu'] is num)
? (data['montantDu'] as num).toDouble()
: 0.0;
final epargneBalance = (data['epargneBalance'] is num)
? (data['epargneBalance'] as num).toDouble()
: 0.0;
return FinanceSummary(
totalContributionsPaid: totalPayeAnnee,
totalContributionsPending: montantDu,
epargneBalance: epargneBalance,
);
} on DioException catch (e, st) {
AppLogger.error('FinanceRepository: getFinancialSummary échoué', error: e, stackTrace: st);
rethrow;
} catch (e, st) {
AppLogger.error('FinanceRepository: getFinancialSummary erreur inattendue', error: e, stackTrace: st);
rethrow;
}
}
/// Cotisations en attente du membre connecté (GET /api/cotisations/mes-cotisations/en-attente).
Future<List<FinanceTransaction>> getTransactions() async {
try {
final response = await _apiClient.get('/api/cotisations/mes-cotisations/en-attente');
final List<dynamic> data = response.data is List ? response.data as List : [];
return data
.map((json) => _transactionFromJson(json as Map<String, dynamic>))
.toList();
} on DioException catch (e, st) {
AppLogger.error('FinanceRepository: getTransactions échoué', error: e, stackTrace: st);
if (e.response?.statusCode == 404) return [];
rethrow;
}
}
static FinanceTransaction _transactionFromJson(Map<String, dynamic> json) {
final id = json['id']?.toString() ?? '';
final ref = json['numeroReference']?.toString() ?? '';
final nomMembre = json['nomMembre']?.toString() ?? 'Cotisation';
final montantDu = (json['montantDu'] is num)
? (json['montantDu'] as num).toDouble()
: 0.0;
final statutLibelle = json['statutLibelle']?.toString() ?? 'En attente';
final dateEcheance = json['dateEcheance']?.toString();
final dateStr = dateEcheance != null
? _parseDateToDisplay(dateEcheance)
: '';
return FinanceTransaction(
id: id,
title: nomMembre.isNotEmpty ? nomMembre : 'Cotisation $ref',
date: dateStr,
amount: montantDu,
status: statutLibelle,
);
}
static String _parseDateToDisplay(String isoDate) {
try {
final d = DateTime.parse(isoDate);
return '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}';
} catch (e) {
AppLogger.warning('FinanceRepository: _parseDateToDisplay date invalide', tag: isoDate);
return isoDate;
}
}
}

View File

@@ -0,0 +1,507 @@
import 'dart:io';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:flutter/services.dart';
import '../models/dashboard_stats_model.dart';
/// Service d'export de rapports PDF pour le Dashboard
class DashboardExportService {
static const String _reportsFolder = 'UnionFlow_Reports';
/// Exporte un rapport complet du dashboard en PDF
Future<String> exportDashboardReport({
required DashboardDataModel dashboardData,
required String organizationName,
required String reportTitle,
bool includeCharts = true,
bool includeActivities = true,
bool includeEvents = true,
}) async {
final pdf = pw.Document();
// Charger les polices personnalisées si disponibles
final font = await _loadFont();
// Page 1: Couverture et statistiques principales
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
theme: _createTheme(font),
build: (context) => [
_buildHeader(organizationName, reportTitle),
pw.SizedBox(height: 20),
_buildStatsSection(dashboardData.stats),
pw.SizedBox(height: 20),
_buildSummarySection(dashboardData),
],
),
);
// Page 2: Activités récentes (si incluses)
if (includeActivities && dashboardData.recentActivities.isNotEmpty) {
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
theme: _createTheme(font),
build: (context) => [
_buildSectionTitle('Activités Récentes'),
pw.SizedBox(height: 10),
_buildActivitiesSection(dashboardData.recentActivities),
],
),
);
}
// Page 3: Événements à venir (si inclus)
if (includeEvents && dashboardData.upcomingEvents.isNotEmpty) {
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
theme: _createTheme(font),
build: (context) => [
_buildSectionTitle('Événements à Venir'),
pw.SizedBox(height: 10),
_buildEventsSection(dashboardData.upcomingEvents),
],
),
);
}
// Page 4: Graphiques et analyses (si inclus)
if (includeCharts) {
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
theme: _createTheme(font),
build: (context) => [
_buildSectionTitle('Analyses et Tendances'),
pw.SizedBox(height: 10),
_buildAnalyticsSection(dashboardData.stats),
],
),
);
}
// Sauvegarder le PDF
final fileName = _generateFileName(reportTitle);
final filePath = await _savePdf(pdf, fileName);
return filePath;
}
/// Exporte uniquement les statistiques en PDF
Future<String> exportStatsReport({
required DashboardStatsModel stats,
required String organizationName,
String? customTitle,
}) async {
final pdf = pw.Document();
final font = await _loadFont();
final title = customTitle ?? 'Rapport Statistiques - ${DateTime.now().day}/${DateTime.now().month}/${DateTime.now().year}';
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
theme: _createTheme(font),
build: (context) => [
_buildHeader(organizationName, title),
pw.SizedBox(height: 30),
_buildStatsSection(stats),
pw.SizedBox(height: 30),
_buildStatsAnalysis(stats),
],
),
);
final fileName = _generateFileName('Stats_${DateTime.now().millisecondsSinceEpoch}');
final filePath = await _savePdf(pdf, fileName);
return filePath;
}
/// Charge une police personnalisée
Future<pw.Font?> _loadFont() async {
try {
final fontData = await rootBundle.load('assets/fonts/Inter-Regular.ttf');
return pw.Font.ttf(fontData);
} catch (e) {
// Police par défaut si la police personnalisée n'est pas disponible
return null;
}
}
/// Crée le thème PDF
pw.ThemeData _createTheme(pw.Font? font) {
return pw.ThemeData.withFont(
base: font ?? pw.Font.helvetica(),
bold: font ?? pw.Font.helveticaBold(),
);
}
/// Construit l'en-tête du rapport
pw.Widget _buildHeader(String organizationName, String reportTitle) {
return pw.Container(
width: double.infinity,
padding: const pw.EdgeInsets.all(20),
decoration: pw.BoxDecoration(
gradient: pw.LinearGradient(
colors: [
PdfColor.fromHex('#4169E1'), // Bleu Roi
PdfColor.fromHex('#008B8B'), // Bleu Pétrole
],
),
borderRadius: pw.BorderRadius.circular(10),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
organizationName,
style: pw.TextStyle(
fontSize: 24,
fontWeight: pw.FontWeight.bold,
color: PdfColors.white,
),
),
pw.SizedBox(height: 5),
pw.Text(
reportTitle,
style: const pw.TextStyle(
fontSize: 16,
color: PdfColors.white,
),
),
pw.SizedBox(height: 10),
pw.Text(
'Généré le ${DateTime.now().day}/${DateTime.now().month}/${DateTime.now().year} à ${DateTime.now().hour}:${DateTime.now().minute.toString().padLeft(2, '0')}',
style: const pw.TextStyle(
fontSize: 12,
color: PdfColors.white,
),
),
],
),
);
}
/// Construit la section des statistiques
pw.Widget _buildStatsSection(DashboardStatsModel stats) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
_buildSectionTitle('Statistiques Principales'),
pw.SizedBox(height: 15),
pw.Row(
children: [
pw.Expanded(
child: _buildStatCard('Membres Total', stats.totalMembers.toString(), PdfColor.fromHex('#4169E1')),
),
pw.SizedBox(width: 10),
pw.Expanded(
child: _buildStatCard('Membres Actifs', stats.activeMembers.toString(), PdfColor.fromHex('#10B981')),
),
],
),
pw.SizedBox(height: 10),
pw.Row(
children: [
pw.Expanded(
child: _buildStatCard('Événements', stats.totalEvents.toString(), PdfColor.fromHex('#008B8B')),
),
pw.SizedBox(width: 10),
pw.Expanded(
child: _buildStatCard('Contributions', stats.formattedContributionAmount, PdfColor.fromHex('#F59E0B')),
),
],
),
pw.SizedBox(height: 10),
pw.Row(
children: [
pw.Expanded(
child: _buildStatCard('Croissance', '${stats.monthlyGrowth.toStringAsFixed(1)}%',
stats.hasGrowth ? PdfColor.fromHex('#10B981') : PdfColor.fromHex('#EF4444')),
),
pw.SizedBox(width: 10),
pw.Expanded(
child: _buildStatCard('Engagement', '${(stats.engagementRate * 100).toStringAsFixed(1)}%',
stats.isHighEngagement ? PdfColor.fromHex('#10B981') : PdfColor.fromHex('#F59E0B')),
),
],
),
],
);
}
/// Construit une carte de statistique
pw.Widget _buildStatCard(String title, String value, PdfColor color) {
return pw.Container(
padding: const pw.EdgeInsets.all(15),
decoration: pw.BoxDecoration(
border: pw.Border.all(color: color, width: 2),
borderRadius: pw.BorderRadius.circular(8),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
title,
style: const pw.TextStyle(
fontSize: 12,
color: PdfColors.grey700,
),
),
pw.SizedBox(height: 5),
pw.Text(
value,
style: pw.TextStyle(
fontSize: 20,
fontWeight: pw.FontWeight.bold,
color: color,
),
),
],
),
);
}
/// Construit un titre de section
pw.Widget _buildSectionTitle(String title) {
return pw.Text(
title,
style: pw.TextStyle(
fontSize: 18,
fontWeight: pw.FontWeight.bold,
color: PdfColor.fromHex('#1F2937'),
),
);
}
/// Construit la section de résumé
pw.Widget _buildSummarySection(DashboardDataModel data) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
_buildSectionTitle('Résumé Exécutif'),
pw.SizedBox(height: 10),
pw.Text(
'Ce rapport présente un aperçu complet de l\'activité de l\'organisation. '
'Avec ${data.stats.totalMembers} membres dont ${data.stats.activeMembers} actifs '
'(${data.stats.activeMemberPercentage.toStringAsFixed(1)}%), l\'organisation maintient '
'un niveau d\'engagement de ${(data.stats.engagementRate * 100).toStringAsFixed(1)}%.',
style: const pw.TextStyle(fontSize: 12, lineSpacing: 1.5),
),
pw.SizedBox(height: 10),
pw.Text(
'La croissance mensuelle de ${data.stats.monthlyGrowth.toStringAsFixed(1)}% '
'${data.stats.hasGrowth ? 'indique une tendance positive' : 'nécessite une attention particulière'}. '
'Les contributions totales s\'élèvent à ${data.stats.formattedContributionAmount} XOF.',
style: const pw.TextStyle(fontSize: 12, lineSpacing: 1.5),
),
],
);
}
/// Construit la section des activités
pw.Widget _buildActivitiesSection(List<RecentActivityModel> activities) {
return pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300),
children: [
// En-tête
pw.TableRow(
decoration: pw.BoxDecoration(color: PdfColor.fromHex('#F3F4F6')),
children: [
_buildTableHeader('Type'),
_buildTableHeader('Description'),
_buildTableHeader('Utilisateur'),
_buildTableHeader('Date'),
],
),
// Données
...activities.take(10).map((activity) => pw.TableRow(
children: [
_buildTableCell(activity.type),
_buildTableCell(activity.title),
_buildTableCell(activity.userName),
_buildTableCell(activity.timeAgo),
],
)),
],
);
}
/// Construit la section des événements
pw.Widget _buildEventsSection(List<UpcomingEventModel> events) {
return pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300),
children: [
// En-tête
pw.TableRow(
decoration: pw.BoxDecoration(color: PdfColor.fromHex('#F3F4F6')),
children: [
_buildTableHeader('Événement'),
_buildTableHeader('Date'),
_buildTableHeader('Lieu'),
_buildTableHeader('Participants'),
],
),
// Données
...events.take(10).map((event) => pw.TableRow(
children: [
_buildTableCell(event.title),
_buildTableCell('${event.startDate.day}/${event.startDate.month}'),
_buildTableCell(event.location),
_buildTableCell('${event.currentParticipants}/${event.maxParticipants}'),
],
)),
],
);
}
/// Construit l'en-tête de tableau
pw.Widget _buildTableHeader(String text) {
return pw.Padding(
padding: const pw.EdgeInsets.all(8),
child: pw.Text(
text,
style: pw.TextStyle(
fontWeight: pw.FontWeight.bold,
fontSize: 10,
),
),
);
}
/// Construit une cellule de tableau
pw.Widget _buildTableCell(String text) {
return pw.Padding(
padding: const pw.EdgeInsets.all(8),
child: pw.Text(
text,
style: const pw.TextStyle(fontSize: 9),
),
);
}
/// Construit la section d'analyse des statistiques
pw.Widget _buildStatsAnalysis(DashboardStatsModel stats) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
_buildSectionTitle('Analyse des Performances'),
pw.SizedBox(height: 10),
_buildAnalysisPoint('Taux d\'activité des membres',
'${stats.activeMemberPercentage.toStringAsFixed(1)}%',
stats.activeMemberPercentage > 70 ? 'Excellent' : 'À améliorer'),
_buildAnalysisPoint('Croissance mensuelle',
'${stats.monthlyGrowth.toStringAsFixed(1)}%',
stats.hasGrowth ? 'Positive' : 'Négative'),
_buildAnalysisPoint('Niveau d\'engagement',
'${(stats.engagementRate * 100).toStringAsFixed(1)}%',
stats.isHighEngagement ? 'Élevé' : 'Modéré'),
],
);
}
/// Construit un point d'analyse
pw.Widget _buildAnalysisPoint(String metric, String value, String assessment) {
return pw.Padding(
padding: const pw.EdgeInsets.symmetric(vertical: 5),
child: pw.Row(
children: [
pw.Expanded(flex: 2, child: pw.Text(metric, style: const pw.TextStyle(fontSize: 11))),
pw.Expanded(flex: 1, child: pw.Text(value, style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold))),
pw.Expanded(flex: 1, child: pw.Text(assessment, style: const pw.TextStyle(fontSize: 11))),
],
),
);
}
/// Construit la section d'analytics
pw.Widget _buildAnalyticsSection(DashboardStatsModel stats) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('Tendances et Projections', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
pw.SizedBox(height: 15),
pw.Text('Basé sur les données actuelles, voici les principales tendances observées:', style: const pw.TextStyle(fontSize: 11)),
pw.SizedBox(height: 10),
pw.Bullet(text: 'Évolution du nombre de membres: ${stats.hasGrowth ? 'Croissance' : 'Déclin'} de ${stats.monthlyGrowth.abs().toStringAsFixed(1)}% ce mois'),
pw.Bullet(text: 'Participation aux événements: ${stats.upcomingEvents} événements programmés'),
pw.Bullet(text: 'Volume des contributions: ${stats.formattedContributionAmount} XOF collectés'),
pw.Bullet(text: 'Demandes en attente: ${stats.pendingRequests} nécessitent un traitement'),
],
);
}
/// Génère un nom de fichier unique
String _generateFileName(String baseName) {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final cleanName = baseName.replaceAll(RegExp(r'[^\w\s-]'), '').replaceAll(' ', '_');
return '${cleanName}_$timestamp.pdf';
}
/// Sauvegarde le PDF et retourne le chemin
Future<String> _savePdf(pw.Document pdf, String fileName) async {
final directory = await getApplicationDocumentsDirectory();
final reportsDir = Directory('${directory.path}/$_reportsFolder');
if (!await reportsDir.exists()) {
await reportsDir.create(recursive: true);
}
final file = File('${reportsDir.path}/$fileName');
await file.writeAsBytes(await pdf.save());
return file.path;
}
/// Partage un rapport PDF
Future<void> shareReport(String filePath, {String? subject}) async {
await Share.shareXFiles(
[XFile(filePath)],
subject: subject ?? 'Rapport Dashboard UnionFlow',
text: 'Rapport généré par l\'application UnionFlow',
);
}
/// Obtient la liste des rapports sauvegardés
Future<List<File>> getSavedReports() async {
final directory = await getApplicationDocumentsDirectory();
final reportsDir = Directory('${directory.path}/$_reportsFolder');
if (!await reportsDir.exists()) {
return [];
}
final files = await reportsDir.list().where((entity) =>
entity is File && entity.path.endsWith('.pdf')).cast<File>().toList();
// Trier par date de modification (plus récent en premier)
files.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()));
return files;
}
/// Supprime un rapport
Future<void> deleteReport(String filePath) async {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
}
}
/// Supprime tous les rapports anciens (plus de 30 jours)
Future<void> cleanupOldReports() async {
final reports = await getSavedReports();
final cutoffDate = DateTime.now().subtract(const Duration(days: 30));
for (final report in reports) {
final lastModified = await report.lastModified();
if (lastModified.isBefore(cutoffDate)) {
await report.delete();
}
}
}
}

View File

@@ -0,0 +1,392 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../models/dashboard_stats_model.dart';
import '../../config/dashboard_config.dart';
import '../../../../core/config/environment.dart';
/// Service de notifications temps réel pour le Dashboard
class DashboardNotificationService {
static String get _wsEndpoint => AppConfig.wsDashboardUrl;
WebSocketChannel? _channel;
StreamSubscription? _subscription;
Timer? _reconnectTimer;
Timer? _heartbeatTimer;
bool _isConnected = false;
bool _shouldReconnect = true;
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5;
static const Duration _reconnectDelay = Duration(seconds: 5);
static const Duration _heartbeatInterval = Duration(seconds: 30);
// Streams pour les différents types de notifications
final StreamController<DashboardStatsModel> _statsController =
StreamController<DashboardStatsModel>.broadcast();
final StreamController<RecentActivityModel> _activityController =
StreamController<RecentActivityModel>.broadcast();
final StreamController<UpcomingEventModel> _eventController =
StreamController<UpcomingEventModel>.broadcast();
final StreamController<DashboardNotification> _notificationController =
StreamController<DashboardNotification>.broadcast();
final StreamController<ConnectionStatus> _connectionController =
StreamController<ConnectionStatus>.broadcast();
// Getters pour les streams
Stream<DashboardStatsModel> get statsStream => _statsController.stream;
Stream<RecentActivityModel> get activityStream => _activityController.stream;
Stream<UpcomingEventModel> get eventStream => _eventController.stream;
Stream<DashboardNotification> get notificationStream => _notificationController.stream;
Stream<ConnectionStatus> get connectionStream => _connectionController.stream;
/// Initialise le service de notifications
Future<void> initialize(String organizationId, String userId) async {
if (!DashboardConfig.enableNotifications) {
debugPrint('📱 Notifications désactivées dans la configuration');
return;
}
debugPrint('📱 Initialisation du service de notifications...');
await _connect(organizationId, userId);
}
/// Établit la connexion WebSocket
Future<void> _connect(String organizationId, String userId) async {
if (_isConnected) return;
try {
final uri = Uri.parse('$_wsEndpoint?orgId=$organizationId&userId=$userId');
_channel = WebSocketChannel.connect(uri);
debugPrint('📱 Connexion WebSocket en cours...');
_connectionController.add(ConnectionStatus.connecting);
// Écouter les messages
_subscription = _channel!.stream.listen(
_handleMessage,
onError: _handleError,
onDone: _handleDisconnection,
);
_isConnected = true;
_reconnectAttempts = 0;
_connectionController.add(ConnectionStatus.connected);
// Démarrer le heartbeat
_startHeartbeat();
debugPrint('✅ Connexion WebSocket établie');
} catch (e) {
debugPrint('❌ Erreur de connexion WebSocket: $e');
_connectionController.add(ConnectionStatus.error);
_scheduleReconnect(organizationId, userId);
}
}
/// Gère les messages reçus
void _handleMessage(dynamic message) {
try {
final data = jsonDecode(message as String);
final type = data['type'] as String?;
final payload = data['payload'];
debugPrint('📨 Message reçu: $type');
switch (type) {
case 'stats_update':
final stats = DashboardStatsModel.fromJson(payload);
_statsController.add(stats);
break;
case 'new_activity':
final activity = RecentActivityModel.fromJson(payload);
_activityController.add(activity);
break;
case 'event_update':
final event = UpcomingEventModel.fromJson(payload);
_eventController.add(event);
break;
case 'notification':
final notification = DashboardNotification.fromJson(payload);
_notificationController.add(notification);
break;
case 'pong':
// Réponse au heartbeat
debugPrint('💓 Heartbeat reçu');
break;
default:
debugPrint('⚠️ Type de message inconnu: $type');
}
} catch (e) {
debugPrint('❌ Erreur de parsing du message: $e');
}
}
/// Gère les erreurs de connexion
void _handleError(error) {
debugPrint('❌ Erreur WebSocket: $error');
_isConnected = false;
_connectionController.add(ConnectionStatus.error);
}
/// Gère la déconnexion
void _handleDisconnection() {
debugPrint('🔌 Connexion WebSocket fermée');
_isConnected = false;
_connectionController.add(ConnectionStatus.disconnected);
if (_shouldReconnect) {
// Programmer une reconnexion
_scheduleReconnect('', ''); // Les IDs seront récupérés du contexte
}
}
/// Programme une tentative de reconnexion
void _scheduleReconnect(String organizationId, String userId) {
if (_reconnectAttempts >= _maxReconnectAttempts) {
debugPrint('❌ Nombre maximum de tentatives de reconnexion atteint');
_connectionController.add(ConnectionStatus.failed);
return;
}
_reconnectAttempts++;
final delay = _reconnectDelay * _reconnectAttempts;
debugPrint('🔄 Reconnexion programmée dans ${delay.inSeconds}s (tentative $_reconnectAttempts)');
_reconnectTimer = Timer(delay, () {
if (_shouldReconnect) {
_connect(organizationId, userId);
}
});
}
/// Démarre le heartbeat
void _startHeartbeat() {
_heartbeatTimer = Timer.periodic(_heartbeatInterval, (timer) {
if (_isConnected && _channel != null) {
try {
_channel!.sink.add(jsonEncode({
'type': 'ping',
'timestamp': DateTime.now().toIso8601String(),
}));
} catch (e) {
debugPrint('❌ Erreur lors de l\'envoi du heartbeat: $e');
}
}
});
}
/// Envoie une demande de rafraîchissement
void requestRefresh(String organizationId, String userId) {
if (_isConnected && _channel != null) {
try {
_channel!.sink.add(jsonEncode({
'type': 'refresh_request',
'payload': {
'organizationId': organizationId,
'userId': userId,
'timestamp': DateTime.now().toIso8601String(),
},
}));
debugPrint('📤 Demande de rafraîchissement envoyée');
} catch (e) {
debugPrint('❌ Erreur lors de l\'envoi de la demande: $e');
}
}
}
/// S'abonne aux notifications pour un type spécifique
void subscribeToNotifications(List<String> notificationTypes) {
if (_isConnected && _channel != null) {
try {
_channel!.sink.add(jsonEncode({
'type': 'subscribe',
'payload': {
'notificationTypes': notificationTypes,
'timestamp': DateTime.now().toIso8601String(),
},
}));
debugPrint('📋 Abonnement aux notifications: $notificationTypes');
} catch (e) {
debugPrint('❌ Erreur lors de l\'abonnement: $e');
}
}
}
/// Se désabonne des notifications
void unsubscribeFromNotifications(List<String> notificationTypes) {
if (_isConnected && _channel != null) {
try {
_channel!.sink.add(jsonEncode({
'type': 'unsubscribe',
'payload': {
'notificationTypes': notificationTypes,
'timestamp': DateTime.now().toIso8601String(),
},
}));
debugPrint('📋 Désabonnement des notifications: $notificationTypes');
} catch (e) {
debugPrint('❌ Erreur lors du désabonnement: $e');
}
}
}
/// Obtient le statut de la connexion
bool get isConnected => _isConnected;
/// Obtient le nombre de tentatives de reconnexion
int get reconnectAttempts => _reconnectAttempts;
/// Force une reconnexion
Future<void> reconnect(String organizationId, String userId) async {
await disconnect();
_reconnectAttempts = 0;
await _connect(organizationId, userId);
}
/// Déconnecte le service
Future<void> disconnect() async {
_shouldReconnect = false;
_reconnectTimer?.cancel();
_heartbeatTimer?.cancel();
if (_channel != null) {
await _channel!.sink.close();
_channel = null;
}
await _subscription?.cancel();
_subscription = null;
_isConnected = false;
_connectionController.add(ConnectionStatus.disconnected);
debugPrint('🔌 Service de notifications déconnecté');
}
/// Libère les ressources
void dispose() {
disconnect();
_statsController.close();
_activityController.close();
_eventController.close();
_notificationController.close();
_connectionController.close();
}
}
/// Statut de la connexion
enum ConnectionStatus {
disconnected,
connecting,
connected,
error,
failed,
}
/// Notification du dashboard
class DashboardNotification {
final String id;
final String type;
final String title;
final String message;
final NotificationPriority priority;
final DateTime timestamp;
final Map<String, dynamic>? data;
final String? actionUrl;
final bool isRead;
const DashboardNotification({
required this.id,
required this.type,
required this.title,
required this.message,
required this.priority,
required this.timestamp,
this.data,
this.actionUrl,
this.isRead = false,
});
factory DashboardNotification.fromJson(Map<String, dynamic> json) {
return DashboardNotification(
id: json['id'] as String,
type: json['type'] as String,
title: json['title'] as String,
message: json['message'] as String,
priority: NotificationPriority.values.firstWhere(
(p) => p.name == json['priority'],
orElse: () => NotificationPriority.normal,
),
timestamp: DateTime.parse(json['timestamp'] as String),
data: json['data'] as Map<String, dynamic>?,
actionUrl: json['actionUrl'] as String?,
isRead: json['isRead'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
'title': title,
'message': message,
'priority': priority.name,
'timestamp': timestamp.toIso8601String(),
'data': data,
'actionUrl': actionUrl,
'isRead': isRead,
};
}
/// Obtient l'icône pour le type de notification
String get icon {
switch (type) {
case 'new_member':
return '👤';
case 'new_event':
return '📅';
case 'contribution':
return '💰';
case 'urgent':
return '🚨';
case 'system':
return '⚙️';
default:
return '📢';
}
}
/// Obtient la couleur pour la priorité
String get priorityColor {
switch (priority) {
case NotificationPriority.low:
return '#6B7280';
case NotificationPriority.normal:
return '#3B82F6';
case NotificationPriority.high:
return '#F59E0B';
case NotificationPriority.urgent:
return '#EF4444';
}
}
}
/// Priorité des notifications
enum NotificationPriority {
low,
normal,
high,
urgent,
}

View File

@@ -0,0 +1,499 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import '../models/dashboard_stats_model.dart';
import '../../../../core/storage/dashboard_cache_manager.dart';
/// Service de mode hors ligne avec synchronisation pour le Dashboard
class DashboardOfflineService {
static const String _offlineQueueKey = 'dashboard_offline_queue';
static const String _lastSyncKey = 'dashboard_last_sync';
static const String _offlineModeKey = 'dashboard_offline_mode';
final DashboardCacheManager _cacheManager;
final ApiClient _apiClient;
final Connectivity _connectivity = Connectivity();
SharedPreferences? _prefs;
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
Timer? _syncTimer;
final StreamController<OfflineStatus> _statusController =
StreamController<OfflineStatus>.broadcast();
final StreamController<SyncProgress> _syncController =
StreamController<SyncProgress>.broadcast();
final List<OfflineAction> _pendingActions = [];
bool _isOnline = true;
bool _isSyncing = false;
DateTime? _lastSyncTime;
// Streams publics
Stream<OfflineStatus> get statusStream => _statusController.stream;
Stream<SyncProgress> get syncStream => _syncController.stream;
DashboardOfflineService(this._cacheManager, this._apiClient);
/// Initialise le service hors ligne
Future<void> initialize() async {
debugPrint('📱 Initialisation du service hors ligne...');
_prefs = await SharedPreferences.getInstance();
// Charger les actions en attente
await _loadPendingActions();
// Charger la dernière synchronisation
_loadLastSyncTime();
// Vérifier la connectivité initiale
final connectivityResult = await _connectivity.checkConnectivity();
_updateConnectivityStatus(connectivityResult);
// Écouter les changements de connectivité
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(
(List<ConnectivityResult> results) => _updateConnectivityStatus(results),
);
// Démarrer la synchronisation automatique
_startAutoSync();
debugPrint('✅ Service hors ligne initialisé');
}
/// Met à jour le statut de connectivité
void _updateConnectivityStatus(dynamic result) {
final wasOnline = _isOnline;
if (result is List<ConnectivityResult>) {
_isOnline = result.any((r) => r != ConnectivityResult.none);
} else if (result is ConnectivityResult) {
_isOnline = result != ConnectivityResult.none;
} else {
_isOnline = false;
}
debugPrint('🌐 Connectivité: ${_isOnline ? 'En ligne' : 'Hors ligne'}');
_statusController.add(OfflineStatus(
isOnline: _isOnline,
pendingActionsCount: _pendingActions.length,
lastSyncTime: _lastSyncTime,
));
// Si on revient en ligne, synchroniser
if (!wasOnline && _isOnline && _pendingActions.isNotEmpty) {
_syncPendingActions();
}
}
/// Démarre la synchronisation automatique
void _startAutoSync() {
_syncTimer = Timer.periodic(
const Duration(minutes: 5),
(_) {
if (_isOnline && _pendingActions.isNotEmpty) {
_syncPendingActions();
}
},
);
}
/// Ajoute une action à la queue hors ligne
Future<void> queueAction(OfflineAction action) async {
_pendingActions.add(action);
await _savePendingActions();
debugPrint('📝 Action mise en queue: ${action.type} (${_pendingActions.length} en attente)');
_statusController.add(OfflineStatus(
isOnline: _isOnline,
pendingActionsCount: _pendingActions.length,
lastSyncTime: _lastSyncTime,
));
// Si en ligne, essayer de synchroniser immédiatement
if (_isOnline) {
_syncPendingActions();
}
}
/// Synchronise les actions en attente
Future<void> _syncPendingActions() async {
if (_isSyncing || _pendingActions.isEmpty || !_isOnline) {
return;
}
_isSyncing = true;
debugPrint('🔄 Début de la synchronisation (${_pendingActions.length} actions)');
_syncController.add(SyncProgress(
isActive: true,
totalActions: _pendingActions.length,
completedActions: 0,
currentAction: _pendingActions.first.type.toString(),
));
final actionsToSync = List<OfflineAction>.from(_pendingActions);
int completedCount = 0;
for (final action in actionsToSync) {
try {
await _executeAction(action);
_pendingActions.remove(action);
completedCount++;
_syncController.add(SyncProgress(
isActive: true,
totalActions: actionsToSync.length,
completedActions: completedCount,
currentAction: completedCount < actionsToSync.length
? actionsToSync[completedCount].type.toString()
: null,
));
debugPrint('✅ Action synchronisée: ${action.type}');
} catch (e) {
debugPrint('❌ Erreur lors de la synchronisation de ${action.type}: $e');
// Marquer l'action comme échouée si trop de tentatives
action.retryCount++;
if (action.retryCount >= 3) {
_pendingActions.remove(action);
debugPrint('🗑️ Action abandonnée après 3 tentatives: ${action.type}');
}
}
}
await _savePendingActions();
_lastSyncTime = DateTime.now();
await _saveLastSyncTime();
_syncController.add(SyncProgress(
isActive: false,
totalActions: actionsToSync.length,
completedActions: completedCount,
currentAction: null,
));
_statusController.add(OfflineStatus(
isOnline: _isOnline,
pendingActionsCount: _pendingActions.length,
lastSyncTime: _lastSyncTime,
));
_isSyncing = false;
debugPrint('✅ Synchronisation terminée ($completedCount/${actionsToSync.length} réussies)');
}
/// Exécute une action spécifique
Future<void> _executeAction(OfflineAction action) async {
switch (action.type) {
case OfflineActionType.refreshDashboard:
await _syncDashboardData(action);
break;
case OfflineActionType.updatePreferences:
await _syncUserPreferences(action);
break;
case OfflineActionType.markActivityRead:
await _syncActivityRead(action);
break;
case OfflineActionType.joinEvent:
await _syncEventJoin(action);
break;
case OfflineActionType.exportReport:
await _syncReportExport(action);
break;
}
}
/// Synchronise les données du dashboard (rafraîchit le cache)
Future<void> _syncDashboardData(OfflineAction action) async {
final orgId = action.data['organizationId'] as String?;
final userId = action.data['userId'] as String?;
if (orgId == null || userId == null) return;
final response = await _apiClient.get('/api/dashboard/stats', queryParameters: {
'organisationId': orgId,
});
if (response.statusCode == 200 && response.data != null) {
await _cacheManager.setKey(
'dashboard_${orgId}_$userId',
response.data as Map<String, dynamic>,
);
}
}
/// Synchronise les préférences utilisateur
Future<void> _syncUserPreferences(OfflineAction action) async {
final userId = action.data['userId'] as String?;
final preferences = action.data['preferences'] as Map<String, dynamic>?;
if (userId == null || preferences == null) return;
await _apiClient.put('/api/membres/$userId/preferences', data: preferences);
}
/// Synchronise le marquage d'activité comme lue
Future<void> _syncActivityRead(OfflineAction action) async {
final activityId = action.data['activityId'] as String?;
if (activityId == null) return;
await _apiClient.put('/api/notifications/$activityId/read');
}
/// Synchronise l'inscription à un événement
Future<void> _syncEventJoin(OfflineAction action) async {
final eventId = action.data['eventId'] as String?;
final membreId = action.data['membreId'] as String?;
if (eventId == null || membreId == null) return;
await _apiClient.post('/api/evenements/$eventId/inscription', data: {
'membreId': membreId,
});
}
/// Synchronise l'export de rapport
Future<void> _syncReportExport(OfflineAction action) async {
final reportType = action.data['reportType'] as String?;
final params = action.data['params'] as Map<String, dynamic>?;
if (reportType == null) return;
await _apiClient.post('/api/export/$reportType', data: params ?? {});
}
/// Sauvegarde les actions en attente
Future<void> _savePendingActions() async {
if (_prefs == null) return;
final actionsJson = _pendingActions
.map((action) => action.toJson())
.toList();
await _prefs!.setString(_offlineQueueKey, jsonEncode(actionsJson));
}
/// Charge les actions en attente
Future<void> _loadPendingActions() async {
if (_prefs == null) return;
final actionsJsonString = _prefs!.getString(_offlineQueueKey);
if (actionsJsonString != null) {
try {
final actionsJson = jsonDecode(actionsJsonString) as List;
_pendingActions.clear();
_pendingActions.addAll(
actionsJson.map((json) => OfflineAction.fromJson(json)),
);
debugPrint('📋 ${_pendingActions.length} actions chargées depuis le cache');
} catch (e) {
debugPrint('❌ Erreur lors du chargement des actions: $e');
await _prefs!.remove(_offlineQueueKey);
}
}
}
/// Sauvegarde l'heure de dernière synchronisation
Future<void> _saveLastSyncTime() async {
if (_prefs == null || _lastSyncTime == null) return;
await _prefs!.setInt(_lastSyncKey, _lastSyncTime!.millisecondsSinceEpoch);
}
/// Charge l'heure de dernière synchronisation
void _loadLastSyncTime() {
if (_prefs == null) return;
final lastSyncMs = _prefs!.getInt(_lastSyncKey);
if (lastSyncMs != null) {
_lastSyncTime = DateTime.fromMillisecondsSinceEpoch(lastSyncMs);
}
}
/// Force une synchronisation manuelle
Future<void> forceSync() async {
if (!_isOnline) {
throw Exception('Impossible de synchroniser hors ligne');
}
await _syncPendingActions();
}
/// Obtient les données en mode hors ligne
Future<DashboardDataModel?> getOfflineData(
String organizationId,
String userId,
) async {
final m = _cacheManager.getKey<Map<String, dynamic>>('dashboard_${organizationId}_$userId');
return m != null ? DashboardDataModel.fromJson(m) : null;
}
/// Vérifie si des données sont disponibles hors ligne
Future<bool> hasOfflineData(String organizationId, String userId) async {
final data = await getOfflineData(organizationId, userId);
return data != null;
}
/// Obtient les statistiques du mode hors ligne
OfflineStats getStats() {
return OfflineStats(
isOnline: _isOnline,
pendingActionsCount: _pendingActions.length,
lastSyncTime: _lastSyncTime,
isSyncing: _isSyncing,
cacheStats: _cacheManager.getCacheStats(),
);
}
/// Nettoie les anciennes actions
Future<void> cleanupOldActions() async {
final cutoffTime = DateTime.now().subtract(const Duration(days: 7));
_pendingActions.removeWhere((action) =>
action.timestamp.isBefore(cutoffTime));
await _savePendingActions();
}
/// Libère les ressources
void dispose() {
_connectivitySubscription?.cancel();
_syncTimer?.cancel();
_statusController.close();
_syncController.close();
}
}
/// Action hors ligne
class OfflineAction {
final String id;
final OfflineActionType type;
final Map<String, dynamic> data;
final DateTime timestamp;
int retryCount;
OfflineAction({
required this.id,
required this.type,
required this.data,
required this.timestamp,
this.retryCount = 0,
});
factory OfflineAction.fromJson(Map<String, dynamic> json) {
return OfflineAction(
id: json['id'] as String,
type: OfflineActionType.values.firstWhere(
(t) => t.name == json['type'],
),
data: json['data'] as Map<String, dynamic>,
timestamp: DateTime.parse(json['timestamp'] as String),
retryCount: json['retryCount'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type.name,
'data': data,
'timestamp': timestamp.toIso8601String(),
'retryCount': retryCount,
};
}
}
/// Types d'actions hors ligne
enum OfflineActionType {
refreshDashboard,
updatePreferences,
markActivityRead,
joinEvent,
exportReport,
}
/// Statut hors ligne
class OfflineStatus {
final bool isOnline;
final int pendingActionsCount;
final DateTime? lastSyncTime;
const OfflineStatus({
required this.isOnline,
required this.pendingActionsCount,
this.lastSyncTime,
});
String get statusText {
if (isOnline) {
if (pendingActionsCount > 0) {
return 'En ligne - $pendingActionsCount actions en attente';
} else {
return 'En ligne - Synchronisé';
}
} else {
return 'Hors ligne - Mode cache activé';
}
}
}
/// Progression de synchronisation
class SyncProgress {
final bool isActive;
final int totalActions;
final int completedActions;
final String? currentAction;
const SyncProgress({
required this.isActive,
required this.totalActions,
required this.completedActions,
this.currentAction,
});
double get progress {
if (totalActions == 0) return 1.0;
return completedActions / totalActions;
}
String get progressText {
if (!isActive) return 'Synchronisation terminée';
if (currentAction != null) {
return 'Synchronisation: $currentAction ($completedActions/$totalActions)';
}
return 'Synchronisation en cours... ($completedActions/$totalActions)';
}
}
/// Statistiques du mode hors ligne
class OfflineStats {
final bool isOnline;
final int pendingActionsCount;
final DateTime? lastSyncTime;
final bool isSyncing;
final Map<String, dynamic> cacheStats;
const OfflineStats({
required this.isOnline,
required this.pendingActionsCount,
this.lastSyncTime,
required this.isSyncing,
required this.cacheStats,
});
String get lastSyncText {
if (lastSyncTime == null) return 'Jamais synchronisé';
final now = DateTime.now();
final diff = now.difference(lastSyncTime!);
if (diff.inMinutes < 1) return 'Synchronisé à l\'instant';
if (diff.inMinutes < 60) return 'Synchronisé il y a ${diff.inMinutes}min';
if (diff.inHours < 24) return 'Synchronisé il y a ${diff.inHours}h';
return 'Synchronisé il y a ${diff.inDays}j';
}
}

View File

@@ -0,0 +1,528 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../../config/dashboard_config.dart';
/// Moniteur de performances avancé pour le Dashboard
class DashboardPerformanceMonitor {
static const String _channelName = 'dashboard_performance';
static const MethodChannel _channel = MethodChannel(_channelName);
Timer? _monitoringTimer;
Timer? _reportTimer;
final List<PerformanceSnapshot> _snapshots = [];
final StreamController<PerformanceMetrics> _metricsController =
StreamController<PerformanceMetrics>.broadcast();
final StreamController<PerformanceAlert> _alertController =
StreamController<PerformanceAlert>.broadcast();
bool _isMonitoring = false;
DateTime _startTime = DateTime.now();
int _alertsGeneratedCount = 0;
// Seuils d'alerte configurables
final double _memoryThreshold = DashboardConfig.getAlertThreshold('memoryUsage');
final double _cpuThreshold = DashboardConfig.getAlertThreshold('cpuUsage');
final int _networkLatencyThreshold = DashboardConfig.getAlertThreshold('networkLatency').toInt();
final double _frameRateThreshold = DashboardConfig.getAlertThreshold('frameRate');
// Streams publics
Stream<PerformanceMetrics> get metricsStream => _metricsController.stream;
Stream<PerformanceAlert> get alertStream => _alertController.stream;
/// Démarre le monitoring des performances
Future<void> startMonitoring() async {
if (_isMonitoring) return;
debugPrint('🔍 Démarrage du monitoring des performances...');
_isMonitoring = true;
_startTime = DateTime.now();
// Timer pour collecter les métriques
_monitoringTimer = Timer.periodic(
DashboardConfig.performanceCheckInterval,
(_) => _collectMetrics(),
);
// Timer pour générer les rapports
_reportTimer = Timer.periodic(
const Duration(minutes: 5),
(_) => _generateReport(),
);
// Collecte initiale
await _collectMetrics();
debugPrint('✅ Monitoring des performances démarré');
}
/// Arrête le monitoring
void stopMonitoring() {
if (!_isMonitoring) return;
_isMonitoring = false;
_monitoringTimer?.cancel();
_reportTimer?.cancel();
debugPrint('🛑 Monitoring des performances arrêté');
}
/// Collecte les métriques de performance
Future<void> _collectMetrics() async {
try {
final metrics = await _gatherMetrics();
final snapshot = PerformanceSnapshot(
timestamp: DateTime.now(),
metrics: metrics,
);
_snapshots.add(snapshot);
// Garder seulement les 1000 derniers snapshots
if (_snapshots.length > 1000) {
_snapshots.removeAt(0);
}
// Émettre les métriques
_metricsController.add(metrics);
// Vérifier les seuils d'alerte
_checkAlerts(metrics);
} catch (e) {
debugPrint('❌ Erreur lors de la collecte des métriques: $e');
}
}
/// Rassemble toutes les métriques
Future<PerformanceMetrics> _gatherMetrics() async {
final memoryUsage = await _getMemoryUsage();
final cpuUsage = await _getCpuUsage();
final networkLatency = await _getNetworkLatency();
final frameRate = await _getFrameRate();
final batteryLevel = await _getBatteryLevel();
final diskUsage = await _getDiskUsage();
final networkUsage = await _getNetworkUsage();
return PerformanceMetrics(
timestamp: DateTime.now(),
memoryUsage: memoryUsage,
cpuUsage: cpuUsage,
networkLatency: networkLatency,
frameRate: frameRate,
batteryLevel: batteryLevel,
diskUsage: diskUsage,
networkUsage: networkUsage,
uptime: DateTime.now().difference(_startTime),
);
}
/// Obtient l'utilisation mémoire
Future<double> _getMemoryUsage() async {
try {
if (Platform.isAndroid || Platform.isIOS) {
final result = await _channel.invokeMethod('getMemoryUsage');
return (result as num).toDouble();
} else {
// Simulation pour les autres plateformes
return _simulateMemoryUsage();
}
} catch (e) {
return _simulateMemoryUsage();
}
}
/// Obtient l'utilisation CPU
Future<double> _getCpuUsage() async {
try {
if (Platform.isAndroid || Platform.isIOS) {
final result = await _channel.invokeMethod('getCpuUsage');
return (result as num).toDouble();
} else {
return _simulateCpuUsage();
}
} catch (e) {
return _simulateCpuUsage();
}
}
/// Obtient la latence réseau (hôte/port depuis DashboardConfig.apiBaseUrl).
Future<int> _getNetworkLatency() async {
try {
final uri = Uri.parse(DashboardConfig.apiBaseUrl);
final host = uri.host.isNotEmpty ? uri.host : 'localhost';
final port = uri.hasPort ? uri.port : 8085;
final stopwatch = Stopwatch()..start();
final socket = await Socket.connect(host, port).timeout(const Duration(seconds: 5));
stopwatch.stop();
await socket.close();
return stopwatch.elapsedMilliseconds;
} catch (e) {
return _simulateNetworkLatency();
}
}
/// Obtient le frame rate
Future<double> _getFrameRate() async {
try {
if (Platform.isAndroid || Platform.isIOS) {
final result = await _channel.invokeMethod('getFrameRate');
return (result as num).toDouble();
} else {
return _simulateFrameRate();
}
} catch (e) {
return _simulateFrameRate();
}
}
/// Obtient le niveau de batterie
Future<double> _getBatteryLevel() async {
try {
if (Platform.isAndroid || Platform.isIOS) {
final result = await _channel.invokeMethod('getBatteryLevel');
return (result as num).toDouble();
} else {
return _simulateBatteryLevel();
}
} catch (e) {
return _simulateBatteryLevel();
}
}
/// Obtient l'utilisation disque
Future<double> _getDiskUsage() async {
try {
if (Platform.isAndroid || Platform.isIOS) {
final result = await _channel.invokeMethod('getDiskUsage');
return (result as num).toDouble();
} else {
return _simulateDiskUsage();
}
} catch (e) {
return _simulateDiskUsage();
}
}
/// Obtient l'utilisation réseau
Future<NetworkUsage> _getNetworkUsage() async {
try {
if (Platform.isAndroid || Platform.isIOS) {
final result = await _channel.invokeMethod('getNetworkUsage');
return NetworkUsage(
bytesReceived: (result['bytesReceived'] as num).toDouble(),
bytesSent: (result['bytesSent'] as num).toDouble(),
);
} else {
return _simulateNetworkUsage();
}
} catch (e) {
return _simulateNetworkUsage();
}
}
/// Vérifie les seuils d'alerte
void _checkAlerts(PerformanceMetrics metrics) {
// Alerte mémoire
if (metrics.memoryUsage > _memoryThreshold) {
_alertsGeneratedCount++;
_alertController.add(PerformanceAlert(
type: AlertType.memory,
severity: AlertSeverity.warning,
message: 'Utilisation mémoire élevée: ${metrics.memoryUsage.toStringAsFixed(1)}MB',
value: metrics.memoryUsage,
threshold: _memoryThreshold,
timestamp: DateTime.now(),
));
}
// Alerte CPU
if (metrics.cpuUsage > _cpuThreshold) {
_alertsGeneratedCount++;
_alertController.add(PerformanceAlert(
type: AlertType.cpu,
severity: AlertSeverity.warning,
message: 'Utilisation CPU élevée: ${metrics.cpuUsage.toStringAsFixed(1)}%',
value: metrics.cpuUsage,
threshold: _cpuThreshold,
timestamp: DateTime.now(),
));
}
// Alerte latence réseau
if (metrics.networkLatency > _networkLatencyThreshold) {
_alertsGeneratedCount++;
_alertController.add(PerformanceAlert(
type: AlertType.network,
severity: AlertSeverity.error,
message: 'Latence réseau élevée: ${metrics.networkLatency}ms',
value: metrics.networkLatency.toDouble(),
threshold: _networkLatencyThreshold.toDouble(),
timestamp: DateTime.now(),
));
}
// Alerte frame rate
if (metrics.frameRate < _frameRateThreshold) {
_alertsGeneratedCount++;
_alertController.add(PerformanceAlert(
type: AlertType.performance,
severity: AlertSeverity.warning,
message: 'Frame rate faible: ${metrics.frameRate.toStringAsFixed(1)}fps',
value: metrics.frameRate,
threshold: _frameRateThreshold,
timestamp: DateTime.now(),
));
}
}
/// Génère un rapport de performance
void _generateReport() {
if (_snapshots.isEmpty) return;
final recentSnapshots = _snapshots.where((snapshot) =>
DateTime.now().difference(snapshot.timestamp).inMinutes <= 5).toList();
if (recentSnapshots.isEmpty) return;
final report = PerformanceReport.fromSnapshots(recentSnapshots);
debugPrint('📊 RAPPORT DE PERFORMANCE (5 min)');
debugPrint('Mémoire: ${report.averageMemoryUsage.toStringAsFixed(1)}MB (max: ${report.maxMemoryUsage.toStringAsFixed(1)}MB)');
debugPrint('CPU: ${report.averageCpuUsage.toStringAsFixed(1)}% (max: ${report.maxCpuUsage.toStringAsFixed(1)}%)');
debugPrint('Latence: ${report.averageNetworkLatency.toStringAsFixed(0)}ms (max: ${report.maxNetworkLatency.toStringAsFixed(0)}ms)');
debugPrint('FPS: ${report.averageFrameRate.toStringAsFixed(1)}fps (min: ${report.minFrameRate.toStringAsFixed(1)}fps)');
}
/// Obtient les statistiques de performance
PerformanceStats getStats() {
if (_snapshots.isEmpty) {
return PerformanceStats.empty();
}
return PerformanceStats.fromSnapshots(_snapshots, alertsGenerated: _alertsGeneratedCount);
}
/// Méthodes de simulation pour le développement
double _simulateMemoryUsage() {
const base = 200.0;
final variation = 100.0 * (DateTime.now().millisecond / 1000.0);
return base + variation;
}
double _simulateCpuUsage() {
const base = 30.0;
final variation = 40.0 * (DateTime.now().second / 60.0);
return (base + variation).clamp(0.0, 100.0);
}
int _simulateNetworkLatency() {
const base = 150;
final variation = (200 * (DateTime.now().millisecond / 1000.0)).round();
return base + variation;
}
double _simulateFrameRate() {
const base = 58.0;
final variation = 5.0 * (DateTime.now().millisecond / 1000.0);
return (base + variation).clamp(30.0, 60.0);
}
double _simulateBatteryLevel() {
final elapsed = DateTime.now().difference(_startTime).inMinutes;
return (100.0 - elapsed * 0.1).clamp(0.0, 100.0);
}
double _simulateDiskUsage() {
return 45.0 + (10.0 * (DateTime.now().millisecond / 1000.0));
}
NetworkUsage _simulateNetworkUsage() {
const base = 1024.0;
final variation = 512.0 * (DateTime.now().millisecond / 1000.0);
return NetworkUsage(
bytesReceived: base + variation,
bytesSent: (base + variation) * 0.3,
);
}
/// Libère les ressources
void dispose() {
stopMonitoring();
_metricsController.close();
_alertController.close();
_snapshots.clear();
}
}
/// Métriques de performance
class PerformanceMetrics {
final DateTime timestamp;
final double memoryUsage; // MB
final double cpuUsage; // %
final int networkLatency; // ms
final double frameRate; // fps
final double batteryLevel; // %
final double diskUsage; // %
final NetworkUsage networkUsage;
final Duration uptime;
const PerformanceMetrics({
required this.timestamp,
required this.memoryUsage,
required this.cpuUsage,
required this.networkLatency,
required this.frameRate,
required this.batteryLevel,
required this.diskUsage,
required this.networkUsage,
required this.uptime,
});
}
/// Utilisation réseau
class NetworkUsage {
final double bytesReceived;
final double bytesSent;
const NetworkUsage({
required this.bytesReceived,
required this.bytesSent,
});
double get totalBytes => bytesReceived + bytesSent;
}
/// Snapshot de performance
class PerformanceSnapshot {
final DateTime timestamp;
final PerformanceMetrics metrics;
const PerformanceSnapshot({
required this.timestamp,
required this.metrics,
});
}
/// Alerte de performance
class PerformanceAlert {
final AlertType type;
final AlertSeverity severity;
final String message;
final double value;
final double threshold;
final DateTime timestamp;
const PerformanceAlert({
required this.type,
required this.severity,
required this.message,
required this.value,
required this.threshold,
required this.timestamp,
});
}
/// Type d'alerte
enum AlertType { memory, cpu, network, performance, battery, disk }
/// Sévérité d'alerte
enum AlertSeverity { info, warning, error, critical }
/// Rapport de performance
class PerformanceReport {
final DateTime startTime;
final DateTime endTime;
final double averageMemoryUsage;
final double maxMemoryUsage;
final double averageCpuUsage;
final double maxCpuUsage;
final double averageNetworkLatency;
final double maxNetworkLatency;
final double averageFrameRate;
final double minFrameRate;
const PerformanceReport({
required this.startTime,
required this.endTime,
required this.averageMemoryUsage,
required this.maxMemoryUsage,
required this.averageCpuUsage,
required this.maxCpuUsage,
required this.averageNetworkLatency,
required this.maxNetworkLatency,
required this.averageFrameRate,
required this.minFrameRate,
});
factory PerformanceReport.fromSnapshots(List<PerformanceSnapshot> snapshots) {
if (snapshots.isEmpty) {
throw ArgumentError('Cannot create report from empty snapshots');
}
final metrics = snapshots.map((s) => s.metrics).toList();
return PerformanceReport(
startTime: snapshots.first.timestamp,
endTime: snapshots.last.timestamp,
averageMemoryUsage: metrics.map((m) => m.memoryUsage).reduce((a, b) => a + b) / metrics.length,
maxMemoryUsage: metrics.map((m) => m.memoryUsage).reduce((a, b) => a > b ? a : b),
averageCpuUsage: metrics.map((m) => m.cpuUsage).reduce((a, b) => a + b) / metrics.length,
maxCpuUsage: metrics.map((m) => m.cpuUsage).reduce((a, b) => a > b ? a : b),
averageNetworkLatency: metrics.map((m) => m.networkLatency.toDouble()).reduce((a, b) => a + b) / metrics.length,
maxNetworkLatency: metrics.map((m) => m.networkLatency.toDouble()).reduce((a, b) => a > b ? a : b),
averageFrameRate: metrics.map((m) => m.frameRate).reduce((a, b) => a + b) / metrics.length,
minFrameRate: metrics.map((m) => m.frameRate).reduce((a, b) => a < b ? a : b),
);
}
}
/// Statistiques de performance
class PerformanceStats {
final int totalSnapshots;
final Duration totalUptime;
final double averageMemoryUsage;
final double peakMemoryUsage;
final double averageCpuUsage;
final double peakCpuUsage;
final int alertsGenerated;
const PerformanceStats({
required this.totalSnapshots,
required this.totalUptime,
required this.averageMemoryUsage,
required this.peakMemoryUsage,
required this.averageCpuUsage,
required this.peakCpuUsage,
required this.alertsGenerated,
});
factory PerformanceStats.empty() {
return const PerformanceStats(
totalSnapshots: 0,
totalUptime: Duration.zero,
averageMemoryUsage: 0.0,
peakMemoryUsage: 0.0,
averageCpuUsage: 0.0,
peakCpuUsage: 0.0,
alertsGenerated: 0,
);
}
factory PerformanceStats.fromSnapshots(List<PerformanceSnapshot> snapshots, {int alertsGenerated = 0}) {
if (snapshots.isEmpty) return PerformanceStats.empty();
final metrics = snapshots.map((s) => s.metrics).toList();
return PerformanceStats(
totalSnapshots: snapshots.length,
totalUptime: snapshots.last.timestamp.difference(snapshots.first.timestamp),
averageMemoryUsage: metrics.map((m) => m.memoryUsage).reduce((a, b) => a + b) / metrics.length,
peakMemoryUsage: metrics.map((m) => m.memoryUsage).reduce((a, b) => a > b ? a : b),
averageCpuUsage: metrics.map((m) => m.cpuUsage).reduce((a, b) => a + b) / metrics.length,
peakCpuUsage: metrics.map((m) => m.cpuUsage).reduce((a, b) => a > b ? a : b),
alertsGenerated: alertsGenerated,
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:equatable/equatable.dart';
class CompteAdherentEntity extends Equatable {
final String numeroMembre;
final String nomComplet;
final String? organisationNom;
final DateTime? dateAdhesion;
final String statutCompte;
final double soldeCotisations;
final double soldeEpargne;
final double soldeBloque;
final double soldeTotalDisponible;
final double encoursCreditTotal;
final double capaciteEmprunt;
final int nombreCotisationsPayees;
final int nombreCotisationsTotal;
final int nombreCotisationsEnRetard;
final double engagementRate;
final int nombreComptesEpargne;
final DateTime dateCalcul;
const CompteAdherentEntity({
required this.numeroMembre,
required this.nomComplet,
this.organisationNom,
this.dateAdhesion,
required this.statutCompte,
required this.soldeCotisations,
required this.soldeEpargne,
required this.soldeBloque,
required this.soldeTotalDisponible,
required this.encoursCreditTotal,
required this.capaciteEmprunt,
required this.nombreCotisationsPayees,
required this.nombreCotisationsTotal,
required this.nombreCotisationsEnRetard,
required this.engagementRate,
required this.nombreComptesEpargne,
required this.dateCalcul,
});
@override
List<Object?> get props => [
numeroMembre,
nomComplet,
organisationNom,
dateAdhesion,
statutCompte,
soldeCotisations,
soldeEpargne,
soldeBloque,
soldeTotalDisponible,
encoursCreditTotal,
capaciteEmprunt,
nombreCotisationsPayees,
nombreCotisationsTotal,
nombreCotisationsEnRetard,
engagementRate,
nombreComptesEpargne,
dateCalcul,
];
}

View File

@@ -0,0 +1,261 @@
import 'package:equatable/equatable.dart';
import 'compte_adherent_entity.dart';
/// Entité pour les statistiques du dashboard
class DashboardStatsEntity extends Equatable {
final int totalMembers;
final int activeMembers;
final int totalEvents;
final int upcomingEvents;
final int totalContributions;
final double totalContributionAmount;
/// Montant des cotisations seules (sans épargne), pour la carte « Contribution Totale » membre.
final double? contributionsAmountOnly;
final int pendingRequests;
final int completedProjects;
final double monthlyGrowth;
final double engagementRate;
final DateTime lastUpdated;
final int? totalOrganizations;
final Map<String, int>? organizationTypeDistribution;
const DashboardStatsEntity({
required this.totalMembers,
required this.activeMembers,
required this.totalEvents,
required this.upcomingEvents,
required this.totalContributions,
required this.totalContributionAmount,
this.contributionsAmountOnly,
required this.pendingRequests,
required this.completedProjects,
required this.monthlyGrowth,
required this.engagementRate,
required this.lastUpdated,
this.totalOrganizations,
this.organizationTypeDistribution,
});
// Méthodes utilitaires
double get memberActivityRate => totalMembers > 0 ? activeMembers / totalMembers : 0.0;
bool get hasGrowth => monthlyGrowth > 0;
bool get isHighEngagement => engagementRate > 0.7;
String get formattedContributionAmount {
if (totalContributionAmount >= 1000000) {
return '${(totalContributionAmount / 1000000).toStringAsFixed(1)}M';
} else if (totalContributionAmount >= 1000) {
return '${(totalContributionAmount / 1000).toStringAsFixed(1)}K';
}
return totalContributionAmount.toStringAsFixed(0);
}
@override
List<Object?> get props => [
totalMembers,
activeMembers,
totalEvents,
upcomingEvents,
totalContributions,
totalContributionAmount,
contributionsAmountOnly,
pendingRequests,
completedProjects,
monthlyGrowth,
engagementRate,
lastUpdated,
totalOrganizations,
organizationTypeDistribution,
];
}
/// Entité pour les activités récentes
class RecentActivityEntity extends Equatable {
final String id;
final String type;
final String title;
final String description;
final String? userAvatar;
final String userName;
final DateTime timestamp;
final String? actionUrl;
final Map<String, dynamic>? metadata;
const RecentActivityEntity({
required this.id,
required this.type,
required this.title,
required this.description,
this.userAvatar,
required this.userName,
required this.timestamp,
this.actionUrl,
this.metadata,
});
// Méthodes utilitaires
String get timeAgo {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inDays > 0) {
return '${difference.inDays}j';
} else if (difference.inHours > 0) {
return '${difference.inHours}h';
} else if (difference.inMinutes > 0) {
return '${difference.inMinutes}min';
} else {
return 'maintenant';
}
}
bool get isRecent => DateTime.now().difference(timestamp).inHours < 24;
bool get hasAction => actionUrl != null && actionUrl!.isNotEmpty;
@override
List<Object?> get props => [
id,
type,
title,
description,
userAvatar,
userName,
timestamp,
actionUrl,
metadata,
];
}
/// Entité pour les événements à venir
class UpcomingEventEntity extends Equatable {
final String id;
final String title;
final String description;
final DateTime startDate;
final DateTime? endDate;
final String location;
final int maxParticipants;
final int currentParticipants;
final String status;
final String? imageUrl;
final List<String> tags;
const UpcomingEventEntity({
required this.id,
required this.title,
required this.description,
required this.startDate,
this.endDate,
required this.location,
required this.maxParticipants,
required this.currentParticipants,
required this.status,
this.imageUrl,
required this.tags,
});
// Méthodes utilitaires
bool get isAlmostFull => currentParticipants >= (maxParticipants * 0.8);
bool get isFull => currentParticipants >= maxParticipants;
double get fillPercentage => maxParticipants > 0 ? currentParticipants / maxParticipants : 0.0;
int get daysUntilEventInt {
final now = DateTime.now();
final difference = startDate.difference(now);
return difference.inDays;
}
String get daysUntilEvent {
final now = DateTime.now();
final difference = startDate.difference(now);
if (difference.inDays > 0) {
return '${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
} else if (difference.inHours > 0) {
return '${difference.inHours}h';
} else if (difference.inMinutes > 0) {
return '${difference.inMinutes}min';
} else {
return 'En cours';
}
}
String get formattedDate {
final months = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun',
'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc'];
return '${startDate.day} ${months[startDate.month - 1]} ${startDate.year}';
}
bool get hasParticipantInfo => maxParticipants > 0;
bool get isToday {
final now = DateTime.now();
return startDate.year == now.year &&
startDate.month == now.month &&
startDate.day == now.day;
}
bool get isTomorrow {
final tomorrow = DateTime.now().add(const Duration(days: 1));
return startDate.year == tomorrow.year &&
startDate.month == tomorrow.month &&
startDate.day == tomorrow.day;
}
@override
List<Object?> get props => [
id,
title,
description,
startDate,
endDate,
location,
maxParticipants,
currentParticipants,
status,
imageUrl,
tags,
];
}
/// Entité principale du dashboard
class DashboardEntity extends Equatable {
final DashboardStatsEntity stats;
final List<RecentActivityEntity> recentActivities;
final List<UpcomingEventEntity> upcomingEvents;
final Map<String, dynamic> userPreferences;
final String organizationId;
final String userId;
/// Compte adhérent unifié (si disponible)
final CompteAdherentEntity? monCompte;
const DashboardEntity({
required this.stats,
required this.recentActivities,
required this.upcomingEvents,
required this.userPreferences,
required this.organizationId,
required this.userId,
this.monCompte,
});
// Méthodes utilitaires
bool get hasRecentActivity => recentActivities.isNotEmpty;
bool get hasUpcomingEvents => upcomingEvents.isNotEmpty;
int get todayEventsCount => upcomingEvents.where((e) => e.isToday).length;
int get tomorrowEventsCount => upcomingEvents.where((e) => e.isTomorrow).length;
int get recentActivitiesCount => recentActivities.length;
@override
List<Object?> get props => [
stats,
recentActivities,
upcomingEvents,
userPreferences,
organizationId,
userId,
monCompte,
];
}

View File

@@ -0,0 +1,31 @@
import 'package:dartz/dartz.dart';
import '../entities/dashboard_entity.dart';
import '../entities/compte_adherent_entity.dart';
import '../../../../core/error/failures.dart';
abstract class DashboardRepository {
/// Récupère le compte adhérent unifié (soldes, crédits, capacité d'emprunt).
Future<Either<Failure, CompteAdherentEntity>> getCompteAdherent();
Future<Either<Failure, DashboardEntity>> getDashboardData(
String organizationId,
String userId,
);
Future<Either<Failure, DashboardStatsEntity>> getDashboardStats(
String organizationId,
String userId,
);
Future<Either<Failure, List<RecentActivityEntity>>> getRecentActivities(
String organizationId,
String userId, {
int limit = 10,
});
Future<Either<Failure, List<UpcomingEventEntity>>> getUpcomingEvents(
String organizationId,
String userId, {
int limit = 5,
});
}

View File

@@ -0,0 +1,18 @@
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
import '../entities/compte_adherent_entity.dart';
import '../repositories/dashboard_repository.dart';
@injectable
class GetCompteAdherent implements UseCase<CompteAdherentEntity, NoParams> {
final DashboardRepository repository;
GetCompteAdherent(this.repository);
@override
Future<Either<Failure, CompteAdherentEntity>> call(NoParams params) async {
return await repository.getCompteAdherent();
}
}

View File

@@ -0,0 +1,125 @@
import 'package:injectable/injectable.dart';
import 'package:dartz/dartz.dart';
import 'package:equatable/equatable.dart';
import '../entities/dashboard_entity.dart';
import '../repositories/dashboard_repository.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/usecases/usecase.dart';
@injectable
class GetDashboardData implements UseCase<DashboardEntity, GetDashboardDataParams> {
final DashboardRepository repository;
GetDashboardData(this.repository);
@override
Future<Either<Failure, DashboardEntity>> call(GetDashboardDataParams params) async {
return await repository.getDashboardData(
params.organizationId,
params.userId,
);
}
}
class GetDashboardDataParams extends Equatable {
final String organizationId;
final String userId;
const GetDashboardDataParams({
required this.organizationId,
required this.userId,
});
@override
List<Object> get props => [organizationId, userId];
}
@injectable
class GetDashboardStats implements UseCase<DashboardStatsEntity, GetDashboardStatsParams> {
final DashboardRepository repository;
GetDashboardStats(this.repository);
@override
Future<Either<Failure, DashboardStatsEntity>> call(GetDashboardStatsParams params) async {
return await repository.getDashboardStats(
params.organizationId,
params.userId,
);
}
}
class GetDashboardStatsParams extends Equatable {
final String organizationId;
final String userId;
const GetDashboardStatsParams({
required this.organizationId,
required this.userId,
});
@override
List<Object> get props => [organizationId, userId];
}
@injectable
class GetRecentActivities implements UseCase<List<RecentActivityEntity>, GetRecentActivitiesParams> {
final DashboardRepository repository;
GetRecentActivities(this.repository);
@override
Future<Either<Failure, List<RecentActivityEntity>>> call(GetRecentActivitiesParams params) async {
return await repository.getRecentActivities(
params.organizationId,
params.userId,
limit: params.limit,
);
}
}
class GetRecentActivitiesParams extends Equatable {
final String organizationId;
final String userId;
final int limit;
const GetRecentActivitiesParams({
required this.organizationId,
required this.userId,
this.limit = 10,
});
@override
List<Object> get props => [organizationId, userId, limit];
}
@injectable
class GetUpcomingEvents implements UseCase<List<UpcomingEventEntity>, GetUpcomingEventsParams> {
final DashboardRepository repository;
GetUpcomingEvents(this.repository);
@override
Future<Either<Failure, List<UpcomingEventEntity>>> call(GetUpcomingEventsParams params) async {
return await repository.getUpcomingEvents(
params.organizationId,
params.userId,
limit: params.limit,
);
}
}
class GetUpcomingEventsParams extends Equatable {
final String organizationId;
final String userId;
final int limit;
const GetUpcomingEventsParams({
required this.organizationId,
required this.userId,
this.limit = 5,
});
@override
List<Object> get props => [organizationId, userId, limit];
}

View File

@@ -0,0 +1,297 @@
import 'dart:async';
import 'package:injectable/injectable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import '../../domain/entities/dashboard_entity.dart';
import '../../domain/usecases/get_dashboard_data.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/websocket/websocket_service.dart';
import '../../../../core/utils/logger.dart';
part 'dashboard_event.dart';
part 'dashboard_state.dart';
@injectable
class DashboardBloc extends Bloc<DashboardEvent, DashboardState> {
final GetDashboardData getDashboardData;
final GetDashboardStats getDashboardStats;
final GetRecentActivities getRecentActivities;
final GetUpcomingEvents getUpcomingEvents;
final WebSocketService webSocketService;
StreamSubscription<WebSocketEvent>? _webSocketEventSubscription;
StreamSubscription<bool>? _webSocketConnectionSubscription;
DashboardBloc({
required this.getDashboardData,
required this.getDashboardStats,
required this.getRecentActivities,
required this.getUpcomingEvents,
required this.webSocketService,
}) : super(DashboardInitial()) {
on<LoadDashboardData>(_onLoadDashboardData);
on<RefreshDashboardData>(_onRefreshDashboardData);
on<LoadDashboardStats>(_onLoadDashboardStats);
on<LoadRecentActivities>(_onLoadRecentActivities);
on<LoadUpcomingEvents>(_onLoadUpcomingEvents);
on<RefreshDashboardFromWebSocket>(_onRefreshDashboardFromWebSocket);
on<WebSocketConnectionChanged>(_onWebSocketConnectionChanged);
// Initialiser WebSocket et écouter les events
_initializeWebSocket();
}
/// Initialise la connexion WebSocket et écoute les events
void _initializeWebSocket() {
// Connexion au WebSocket
webSocketService.connect();
AppLogger.info('DashboardBloc: WebSocket initialisé');
// Écouter les events WebSocket
_webSocketEventSubscription = webSocketService.eventStream.listen(
(event) {
AppLogger.info('DashboardBloc: Event WebSocket reçu - ${event.eventType}');
// Dispatcher uniquement les events pertinents au dashboard
if (event is DashboardStatsEvent) {
add(RefreshDashboardFromWebSocket(event.data));
} else if (event is FinanceApprovalEvent) {
// Les approbations affectent les stats, rafraîchir
add(RefreshDashboardFromWebSocket(event.data));
} else if (event is MemberEvent) {
// Les changements de membres affectent les stats
add(RefreshDashboardFromWebSocket(event.data));
} else if (event is ContributionEvent) {
// Les cotisations affectent les stats financières
add(RefreshDashboardFromWebSocket(event.data));
}
},
onError: (error) {
AppLogger.error('DashboardBloc: Erreur WebSocket', error: error);
},
);
// Écouter le statut de connexion WebSocket
_webSocketConnectionSubscription = webSocketService.connectionStatusStream.listen(
(isConnected) {
AppLogger.info('DashboardBloc: WebSocket ${isConnected ? "connecté" : "déconnecté"}');
add(WebSocketConnectionChanged(isConnected));
},
);
}
Future<void> _onLoadDashboardData(
LoadDashboardData event,
Emitter<DashboardState> emit,
) async {
emit(DashboardLoading());
final result = await getDashboardData(
GetDashboardDataParams(
organizationId: event.organizationId,
userId: event.userId,
),
);
result.fold(
(failure) => emit(DashboardError(_mapFailureToMessage(failure))),
(dashboardData) => emit(DashboardLoaded(dashboardData)),
);
}
Future<void> _onRefreshDashboardData(
RefreshDashboardData event,
Emitter<DashboardState> emit,
) async {
// Garde l'état actuel pendant le refresh
if (state is DashboardLoaded) {
emit(DashboardRefreshing((state as DashboardLoaded).dashboardData));
} else {
emit(DashboardLoading());
}
final result = await getDashboardData(
GetDashboardDataParams(
organizationId: event.organizationId,
userId: event.userId,
),
);
result.fold(
(failure) => emit(DashboardError(_mapFailureToMessage(failure))),
(dashboardData) => emit(DashboardLoaded(dashboardData)),
);
}
Future<void> _onLoadDashboardStats(
LoadDashboardStats event,
Emitter<DashboardState> emit,
) async {
final result = await getDashboardStats(
GetDashboardStatsParams(
organizationId: event.organizationId,
userId: event.userId,
),
);
result.fold(
(failure) => emit(DashboardError(_mapFailureToMessage(failure))),
(stats) {
if (state is DashboardLoaded) {
final currentData = (state as DashboardLoaded).dashboardData;
final updatedData = DashboardEntity(
stats: stats,
recentActivities: currentData.recentActivities,
upcomingEvents: currentData.upcomingEvents,
userPreferences: currentData.userPreferences,
organizationId: currentData.organizationId,
userId: currentData.userId,
);
emit(DashboardLoaded(updatedData));
}
},
);
}
Future<void> _onLoadRecentActivities(
LoadRecentActivities event,
Emitter<DashboardState> emit,
) async {
final result = await getRecentActivities(
GetRecentActivitiesParams(
organizationId: event.organizationId,
userId: event.userId,
limit: event.limit,
),
);
result.fold(
(failure) => emit(DashboardError(_mapFailureToMessage(failure))),
(activities) {
if (state is DashboardLoaded) {
final currentData = (state as DashboardLoaded).dashboardData;
final updatedData = DashboardEntity(
stats: currentData.stats,
recentActivities: activities,
upcomingEvents: currentData.upcomingEvents,
userPreferences: currentData.userPreferences,
organizationId: currentData.organizationId,
userId: currentData.userId,
);
emit(DashboardLoaded(updatedData));
}
},
);
}
Future<void> _onLoadUpcomingEvents(
LoadUpcomingEvents event,
Emitter<DashboardState> emit,
) async {
final result = await getUpcomingEvents(
GetUpcomingEventsParams(
organizationId: event.organizationId,
userId: event.userId,
limit: event.limit,
),
);
result.fold(
(failure) => emit(DashboardError(_mapFailureToMessage(failure))),
(events) {
if (state is DashboardLoaded) {
final currentData = (state as DashboardLoaded).dashboardData;
final updatedData = DashboardEntity(
stats: currentData.stats,
recentActivities: currentData.recentActivities,
upcomingEvents: events,
userPreferences: currentData.userPreferences,
organizationId: currentData.organizationId,
userId: currentData.userId,
);
emit(DashboardLoaded(updatedData));
}
},
);
}
/// Rafraîchit le dashboard suite à un event WebSocket
Future<void> _onRefreshDashboardFromWebSocket(
RefreshDashboardFromWebSocket event,
Emitter<DashboardState> emit,
) async {
AppLogger.info('DashboardBloc: Rafraîchissement depuis WebSocket');
// Si le dashboard est chargé, on rafraîchit uniquement les stats
// pour éviter de recharger toutes les données
if (state is DashboardLoaded) {
final currentData = (state as DashboardLoaded).dashboardData;
// Rafraîchir les stats depuis le backend
final result = await getDashboardStats(
GetDashboardStatsParams(
organizationId: currentData.organizationId,
userId: currentData.userId,
),
);
result.fold(
(failure) {
AppLogger.error('Erreur rafraîchissement stats WebSocket', error: failure);
// Ne pas émettre d'erreur, garder les données actuelles
},
(stats) {
final updatedData = DashboardEntity(
stats: stats,
recentActivities: currentData.recentActivities,
upcomingEvents: currentData.upcomingEvents,
userPreferences: currentData.userPreferences,
organizationId: currentData.organizationId,
userId: currentData.userId,
);
emit(DashboardLoaded(updatedData));
AppLogger.info('DashboardBloc: Stats rafraîchies depuis WebSocket');
},
);
}
}
/// Gère les changements de statut de connexion WebSocket
void _onWebSocketConnectionChanged(
WebSocketConnectionChanged event,
Emitter<DashboardState> emit,
) {
// Pour l'instant, on log juste le statut
// On pourrait ajouter un indicateur visuel dans l'UI plus tard
if (event.isConnected) {
AppLogger.info('DashboardBloc: WebSocket connecté - Temps réel actif');
} else {
AppLogger.warning('DashboardBloc: WebSocket déconnecté - Reconnexion en cours...');
}
}
String _mapFailureToMessage(Failure failure) {
switch (failure.runtimeType) {
case ServerFailure:
return 'Erreur serveur. Veuillez réessayer.';
case NetworkFailure:
return 'Pas de connexion internet. Vérifiez votre connexion.';
default:
return 'Une erreur inattendue s\'est produite.';
}
}
@override
Future<void> close() {
// Annuler les subscriptions WebSocket
_webSocketEventSubscription?.cancel();
_webSocketConnectionSubscription?.cancel();
// Déconnecter le WebSocket
webSocketService.disconnect();
AppLogger.info('DashboardBloc: Fermé et WebSocket déconnecté');
return super.close();
}
}

View File

@@ -0,0 +1,97 @@
part of 'dashboard_bloc.dart';
abstract class DashboardEvent extends Equatable {
const DashboardEvent();
@override
List<Object> get props => [];
}
class LoadDashboardData extends DashboardEvent {
final String organizationId;
final String userId;
const LoadDashboardData({
required this.organizationId,
required this.userId,
});
@override
List<Object> get props => [organizationId, userId];
}
class RefreshDashboardData extends DashboardEvent {
final String organizationId;
final String userId;
const RefreshDashboardData({
required this.organizationId,
required this.userId,
});
@override
List<Object> get props => [organizationId, userId];
}
class LoadDashboardStats extends DashboardEvent {
final String organizationId;
final String userId;
const LoadDashboardStats({
required this.organizationId,
required this.userId,
});
@override
List<Object> get props => [organizationId, userId];
}
class LoadRecentActivities extends DashboardEvent {
final String organizationId;
final String userId;
final int limit;
const LoadRecentActivities({
required this.organizationId,
required this.userId,
this.limit = 10,
});
@override
List<Object> get props => [organizationId, userId, limit];
}
class LoadUpcomingEvents extends DashboardEvent {
final String organizationId;
final String userId;
final int limit;
const LoadUpcomingEvents({
required this.organizationId,
required this.userId,
this.limit = 5,
});
@override
List<Object> get props => [organizationId, userId, limit];
}
/// Event déclenché par WebSocket pour rafraîchir le dashboard
class RefreshDashboardFromWebSocket extends DashboardEvent {
final Map<String, dynamic> data;
const RefreshDashboardFromWebSocket(this.data);
@override
List<Object> get props => [data];
}
/// Event pour gérer les changements de statut WebSocket
class WebSocketConnectionChanged extends DashboardEvent {
final bool isConnected;
const WebSocketConnectionChanged(this.isConnected);
@override
List<Object> get props => [isConnected];
}

View File

@@ -0,0 +1,39 @@
part of 'dashboard_bloc.dart';
abstract class DashboardState extends Equatable {
const DashboardState();
@override
List<Object> get props => [];
}
class DashboardInitial extends DashboardState {}
class DashboardLoading extends DashboardState {}
class DashboardLoaded extends DashboardState {
final DashboardEntity dashboardData;
const DashboardLoaded(this.dashboardData);
@override
List<Object> get props => [dashboardData];
}
class DashboardRefreshing extends DashboardState {
final DashboardEntity dashboardData;
const DashboardRefreshing(this.dashboardData);
@override
List<Object> get props => [dashboardData];
}
class DashboardError extends DashboardState {
final String message;
const DashboardError(this.message);
@override
List<Object> get props => [message];
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../../data/repositories/finance_repository.dart';
import 'finance_event.dart';
import 'finance_state.dart';
@injectable
class FinanceBloc extends Bloc<FinanceEvent, FinanceState> {
final FinanceRepository _repository;
FinanceBloc(this._repository) : super(FinanceInitial()) {
on<LoadFinanceRequested>(_onLoadFinanceRequested);
on<FinancePaymentInitiated>(_onFinancePaymentInitiated);
}
Future<void> _onLoadFinanceRequested(LoadFinanceRequested event, Emitter<FinanceState> emit) async {
emit(FinanceLoading());
try {
final summary = await _repository.getFinancialSummary();
final transactions = await _repository.getTransactions();
emit(FinanceLoaded(summary: summary, transactions: transactions));
} catch (e) {
emit(FinanceError('Erreur chargement des finances: $e'));
}
}
void _onFinancePaymentInitiated(FinancePaymentInitiated event, Emitter<FinanceState> emit) {
// Intégration paiement: appeler le service Wave ou Orange Money (API paiement) selon le design métier.
// Pour l'instant, la transaction est gérée côté UI (payment_dialog) et le BLoC reste en FinanceLoaded.
if (state is FinanceLoaded) {
// Option: émettre FinancePaymentPending puis FinanceLoaded après confirmation API.
}
}
}

View File

@@ -0,0 +1,18 @@
import 'package:equatable/equatable.dart';
abstract class FinanceEvent extends Equatable {
const FinanceEvent();
@override
List<Object> get props => [];
}
class LoadFinanceRequested extends FinanceEvent {}
class FinancePaymentInitiated extends FinanceEvent {
final String contributionId;
const FinancePaymentInitiated(this.contributionId);
@override
List<Object> get props => [contributionId];
}

View File

@@ -0,0 +1,67 @@
import 'package:equatable/equatable.dart';
class FinanceSummary extends Equatable {
final double totalContributionsPaid;
final double totalContributionsPending;
final double epargneBalance;
const FinanceSummary({
required this.totalContributionsPaid,
required this.totalContributionsPending,
required this.epargneBalance,
});
@override
List<Object?> get props => [totalContributionsPaid, totalContributionsPending, epargneBalance];
}
class FinanceTransaction extends Equatable {
final String id;
final String title;
final String date;
final double amount;
final String status;
const FinanceTransaction({
required this.id,
required this.title,
required this.date,
required this.amount,
required this.status, // "Payé", "En attente"
});
@override
List<Object?> get props => [id, title, date, amount, status];
}
abstract class FinanceState extends Equatable {
const FinanceState();
@override
List<Object?> get props => [];
}
class FinanceInitial extends FinanceState {}
class FinanceLoading extends FinanceState {}
class FinanceLoaded extends FinanceState {
final FinanceSummary summary;
final List<FinanceTransaction> transactions;
const FinanceLoaded({
required this.summary,
required this.transactions,
});
@override
List<Object?> get props => [summary, transactions];
}
class FinanceError extends FinanceState {
final String message;
const FinanceError(this.message);
@override
List<Object?> get props => [message];
}

Some files were not shown because too many files have changed in this diff Show More