Initial commit: unionflow-mobile-apps

Application Flutter complète (sans build artifacts).

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

View File

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

View File

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

View File

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