Initial commit: unionflow-mobile-apps
Application Flutter complète (sans build artifacts). Signed-off-by: lions dev Team
This commit is contained in:
522
lib/features/about/presentation/pages/about_page.dart
Normal file
522
lib/features/about/presentation/pages/about_page.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
143
lib/features/adhesions/bloc/adhesions_bloc.dart
Normal file
143
lib/features/adhesions/bloc/adhesions_bloc.dart
Normal 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,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
90
lib/features/adhesions/bloc/adhesions_event.dart
Normal file
90
lib/features/adhesions/bloc/adhesions_event.dart
Normal 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();
|
||||
}
|
||||
42
lib/features/adhesions/bloc/adhesions_state.dart
Normal file
42
lib/features/adhesions/bloc/adhesions_state.dart
Normal 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];
|
||||
}
|
||||
139
lib/features/adhesions/data/models/adhesion_model.dart
Normal file
139
lib/features/adhesions/data/models/adhesion_model.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
69
lib/features/adhesions/data/models/adhesion_model.g.dart
Normal file
69
lib/features/adhesions/data/models/adhesion_model.g.dart
Normal 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(),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
314
lib/features/adhesions/presentation/pages/adhesions_page.dart
Normal file
314
lib/features/adhesions/presentation/pages/adhesions_page.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
91
lib/features/admin/bloc/admin_users_bloc.dart
Normal file
91
lib/features/admin/bloc/admin_users_bloc.dart
Normal 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
29
lib/features/admin/bloc/admin_users_event.dart
Normal file
29
lib/features/admin/bloc/admin_users_event.dart
Normal 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 {}
|
||||
45
lib/features/admin/bloc/admin_users_state.dart
Normal file
45
lib/features/admin/bloc/admin_users_state.dart
Normal 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 {}
|
||||
66
lib/features/admin/data/models/admin_user_model.dart
Normal file
66
lib/features/admin/data/models/admin_user_model.dart
Normal 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?,
|
||||
);
|
||||
}
|
||||
105
lib/features/admin/data/repositories/admin_user_repository.dart
Normal file
105
lib/features/admin/data/repositories/admin_user_repository.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
213
lib/features/admin/presentation/pages/user_management_page.dart
Normal file
213
lib/features/admin/presentation/pages/user_management_page.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
212
lib/features/authentication/data/models/permission_matrix.dart
Normal file
212
lib/features/authentication/data/models/permission_matrix.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
359
lib/features/authentication/data/models/user.dart
Normal file
359
lib/features/authentication/data/models/user.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
359
lib/features/authentication/data/models/user_role.dart
Normal file
359
lib/features/authentication/data/models/user_role.dart
Normal 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,
|
||||
];
|
||||
139
lib/features/authentication/presentation/bloc/auth_bloc.dart
Normal file
139
lib/features/authentication/presentation/bloc/auth_bloc.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
169
lib/features/authentication/presentation/pages/login_page.dart
Normal file
169
lib/features/authentication/presentation/pages/login_page.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
63
lib/features/backup/data/models/backup_config_model.dart
Normal file
63
lib/features/backup/data/models/backup_config_model.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
45
lib/features/backup/data/models/backup_config_model.g.dart
Normal file
45
lib/features/backup/data/models/backup_config_model.g.dart
Normal 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,
|
||||
};
|
||||
69
lib/features/backup/data/models/backup_model.dart
Normal file
69
lib/features/backup/data/models/backup_model.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
48
lib/features/backup/data/models/backup_model.g.dart
Normal file
48
lib/features/backup/data/models/backup_model.g.dart
Normal 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,
|
||||
};
|
||||
131
lib/features/backup/data/repositories/backup_repository.dart
Normal file
131
lib/features/backup/data/repositories/backup_repository.dart
Normal 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}');
|
||||
}
|
||||
}
|
||||
166
lib/features/backup/presentation/bloc/backup_bloc.dart
Normal file
166
lib/features/backup/presentation/bloc/backup_bloc.dart
Normal 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()}'));
|
||||
}
|
||||
}
|
||||
}
|
||||
773
lib/features/backup/presentation/pages/backup_page.dart
Normal file
773
lib/features/backup/presentation/pages/backup_page.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
192
lib/features/communication/README.md
Normal file
192
lib/features/communication/README.md
Normal 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)
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
83
lib/features/communication/data/models/message_model.dart
Normal file
83
lib/features/communication/data/models/message_model.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
84
lib/features/communication/data/models/message_model.g.dart
Normal file
84
lib/features/communication/data/models/message_model.g.dart
Normal 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',
|
||||
};
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
127
lib/features/communication/domain/entities/conversation.dart
Normal file
127
lib/features/communication/domain/entities/conversation.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
173
lib/features/communication/domain/entities/message.dart
Normal file
173
lib/features/communication/domain/entities/message.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
154
lib/features/communication/domain/entities/message_template.dart
Normal file
154
lib/features/communication/domain/entities/message_template.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
31
lib/features/communication/domain/usecases/get_messages.dart
Normal file
31
lib/features/communication/domain/usecases/get_messages.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
34
lib/features/communication/domain/usecases/send_message.dart
Normal file
34
lib/features/communication/domain/usecases/send_message.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
105
lib/features/communication/presentation/bloc/messaging_bloc.dart
Normal file
105
lib/features/communication/presentation/bloc/messaging_bloc.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
367
lib/features/contributions/bloc/contributions_bloc.dart
Normal file
367
lib/features/contributions/bloc/contributions_bloc.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
225
lib/features/contributions/bloc/contributions_event.dart
Normal file
225
lib/features/contributions/bloc/contributions_event.dart
Normal 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];
|
||||
}
|
||||
|
||||
174
lib/features/contributions/bloc/contributions_state.dart
Normal file
174
lib/features/contributions/bloc/contributions_state.dart
Normal 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];
|
||||
}
|
||||
|
||||
335
lib/features/contributions/data/models/contribution_model.dart
Normal file
335
lib/features/contributions/data/models/contribution_model.dart
Normal 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)';
|
||||
}
|
||||
|
||||
112
lib/features/contributions/data/models/contribution_model.g.dart
Normal file
112
lib/features/contributions/data/models/contribution_model.g.dart
Normal 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',
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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: 'Dû',
|
||||
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, 'Dû', _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 l’axe 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 l’axe X : "Jan 25", "Avr 25" — peu de caractères.
|
||||
String _formatAxisPeriod(int month, int year) {
|
||||
final shortYear = year % 100;
|
||||
return '${_monthShort(month)} $shortYear';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
// L’UI est rafraîchie par le BLoC après RecordPayment ; pas besoin de copyWith local.
|
||||
context.read<ContributionsBloc>().add(RecordPayment(
|
||||
contributionId: widget.cotisation.id!,
|
||||
montant: montant,
|
||||
methodePaiement: _selectedMethode,
|
||||
datePaiement: _datePaiement,
|
||||
reference: _referenceController.text.isNotEmpty ? _referenceController.text : null,
|
||||
notes: _notesController.text.isNotEmpty ? _notesController.text : null,
|
||||
));
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Paiement enregistré avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Initie le paiement Wave : appel API Checkout, ouverture de l'app Wave, retour via deep link.
|
||||
Future<void> _submitWavePayment() async {
|
||||
if (widget.cotisation.id == null || widget.cotisation.id!.isEmpty) return;
|
||||
final phone = _wavePhoneController.text.replaceAll(RegExp(r'\D'), '');
|
||||
if (phone.length < 9) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
305
lib/features/dashboard/config/dashboard_config.dart
Normal file
305
lib/features/dashboard/config/dashboard_config.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
222
lib/features/dashboard/data/models/dashboard_stats_model.dart
Normal file
222
lib/features/dashboard/data/models/dashboard_stats_model.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
130
lib/features/dashboard/data/models/dashboard_stats_model.g.dart
Normal file
130
lib/features/dashboard/data/models/dashboard_stats_model.g.dart
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
261
lib/features/dashboard/domain/entities/dashboard_entity.dart
Normal file
261
lib/features/dashboard/domain/entities/dashboard_entity.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
125
lib/features/dashboard/domain/usecases/get_dashboard_data.dart
Normal file
125
lib/features/dashboard/domain/usecases/get_dashboard_data.dart
Normal 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];
|
||||
}
|
||||
297
lib/features/dashboard/presentation/bloc/dashboard_bloc.dart
Normal file
297
lib/features/dashboard/presentation/bloc/dashboard_bloc.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
35
lib/features/dashboard/presentation/bloc/finance_bloc.dart
Normal file
35
lib/features/dashboard/presentation/bloc/finance_bloc.dart
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
18
lib/features/dashboard/presentation/bloc/finance_event.dart
Normal file
18
lib/features/dashboard/presentation/bloc/finance_event.dart
Normal 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];
|
||||
}
|
||||
67
lib/features/dashboard/presentation/bloc/finance_state.dart
Normal file
67
lib/features/dashboard/presentation/bloc/finance_state.dart
Normal 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
Reference in New Issue
Block a user