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,534 @@
/// Page de liste des budgets
library budgets_list_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../domain/entities/budget.dart';
import '../bloc/budget_bloc.dart';
import '../bloc/budget_event.dart';
import '../bloc/budget_state.dart';
class BudgetsListPage extends StatelessWidget {
final String? organizationId;
const BudgetsListPage({
super.key,
this.organizationId,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<BudgetBloc>()
..add(LoadBudgets(organizationId: organizationId)),
child: _BudgetsListView(organizationId: organizationId),
);
}
}
class _BudgetsListView extends StatelessWidget {
final String? organizationId;
const _BudgetsListView({this.organizationId});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorTokens.background,
appBar: UFAppBar(
title: 'BUDGETS',
automaticallyImplyLeading: true,
actions: [
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: () => _showFilterDialog(context),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
context.read<BudgetBloc>().add(
RefreshBudgets(organizationId: organizationId),
);
},
),
],
),
body: BlocConsumer<BudgetBloc, BudgetState>(
listener: (context, state) {
if (state is BudgetCreated) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.success,
),
);
context.read<BudgetBloc>().add(
RefreshBudgets(organizationId: organizationId),
);
} else if (state is BudgetError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.error,
),
);
}
},
builder: (context, state) {
if (state is BudgetsLoading || state is BudgetActionInProgress) {
return const Center(child: CircularProgressIndicator());
}
if (state is BudgetsEmpty) {
return _buildEmptyState(state.message);
}
if (state is BudgetsLoaded) {
return _buildBudgetsList(context, state);
}
if (state is BudgetError) {
return _buildErrorState(context, state.message);
}
return const SizedBox.shrink();
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
// TODO: Navigate to create budget page
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fonctionnalité en cours de développement'),
),
);
},
icon: const Icon(Icons.add),
label: const Text('Nouveau budget'),
backgroundColor: AppColors.primaryGreen,
),
);
}
Widget _buildBudgetsList(BuildContext context, BudgetsLoaded state) {
return RefreshIndicator(
onRefresh: () async {
context.read<BudgetBloc>().add(
RefreshBudgets(
organizationId: organizationId,
status: state.filterStatus,
year: state.filterYear,
),
);
},
child: Column(
children: [
if (state.filterStatus != null || state.filterYear != null)
_buildFilterChips(context, state),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.all(SpacingTokens.md),
itemCount: state.budgets.length,
separatorBuilder: (_, __) => const SizedBox(height: SpacingTokens.md),
itemBuilder: (context, index) {
final budget = state.budgets[index];
return _BudgetCard(budget: budget);
},
),
),
],
),
);
}
Widget _buildFilterChips(BuildContext context, BudgetsLoaded state) {
return Container(
padding: const EdgeInsets.all(SpacingTokens.md),
color: AppColors.lightBackground,
child: Wrap(
spacing: SpacingTokens.sm,
runSpacing: SpacingTokens.sm,
children: [
if (state.filterStatus != null)
Chip(
label: Text(_getStatusLabel(state.filterStatus!)),
onDeleted: () {
context.read<BudgetBloc>().add(
FilterBudgets(
status: null,
year: state.filterYear,
),
);
},
),
if (state.filterYear != null)
Chip(
label: Text('Année ${state.filterYear}'),
onDeleted: () {
context.read<BudgetBloc>().add(
FilterBudgets(
status: state.filterStatus,
year: null,
),
);
},
),
],
),
);
}
Widget _buildEmptyState(String message) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.account_balance_wallet_outlined,
size: 80,
color: AppColors.textSecondaryLight.withOpacity(0.5),
),
const SizedBox(height: SpacingTokens.lg),
Text(
message,
style: AppTypography.headerSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
],
),
);
}
Widget _buildErrorState(BuildContext context, String message) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 80,
color: AppColors.error.withOpacity(0.5),
),
const SizedBox(height: SpacingTokens.lg),
Text(
message,
style: AppTypography.headerSmall.copyWith(
color: AppColors.textSecondaryLight,
),
textAlign: TextAlign.center,
),
const SizedBox(height: SpacingTokens.lg),
UFPrimaryButton(
label: 'Réessayer',
onPressed: () {
context.read<BudgetBloc>().add(
LoadBudgets(organizationId: organizationId),
);
},
icon: Icons.refresh,
),
],
),
);
}
void _showFilterDialog(BuildContext context) {
showDialog(
context: context,
builder: (dialogContext) => BlocProvider.value(
value: context.read<BudgetBloc>(),
child: _FilterDialog(organizationId: organizationId),
),
);
}
String _getStatusLabel(BudgetStatus status) {
switch (status) {
case BudgetStatus.draft:
return 'Brouillon';
case BudgetStatus.active:
return 'Actif';
case BudgetStatus.closed:
return 'Clos';
case BudgetStatus.cancelled:
return 'Annulé';
}
}
}
class _BudgetCard extends StatelessWidget {
final Budget budget;
const _BudgetCard({required this.budget});
String _getPeriodLabel(BudgetPeriod period) {
switch (period) {
case BudgetPeriod.monthly:
return 'Mensuel';
case BudgetPeriod.quarterly:
return 'Trimestriel';
case BudgetPeriod.semiannual:
return 'Semestriel';
case BudgetPeriod.annual:
return 'Annuel';
}
}
Color _getStatusColor(BudgetStatus status) {
switch (status) {
case BudgetStatus.draft:
return AppColors.textSecondaryLight;
case BudgetStatus.active:
return AppColors.brandGreen;
case BudgetStatus.closed:
return AppColors.textSecondaryLight;
case BudgetStatus.cancelled:
return AppColors.error;
}
}
String _getStatusLabel(BudgetStatus status) {
switch (status) {
case BudgetStatus.draft:
return 'Brouillon';
case BudgetStatus.active:
return 'Actif';
case BudgetStatus.closed:
return 'Clos';
case BudgetStatus.cancelled:
return 'Annulé';
}
}
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat.currency(symbol: budget.currency);
return Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
border: Border.all(color: AppColors.lightBorder),
boxShadow: const [
BoxShadow(
color: Color(0x0A000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
budget.name,
style: AppTypography.actionText,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: _getStatusColor(budget.status).withOpacity(0.1),
borderRadius: BorderRadius.circular(SpacingTokens.radiusSm),
),
child: Text(
_getStatusLabel(budget.status),
style: AppTypography.badgeText.copyWith(
color: _getStatusColor(budget.status),
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: SpacingTokens.sm),
Text(
'${_getPeriodLabel(budget.period)} ${budget.year}',
style: AppTypography.subtitleSmall,
),
const SizedBox(height: SpacingTokens.md),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Prévu',
style: AppTypography.subtitleSmall,
),
const SizedBox(height: 4),
Text(
currencyFormat.format(budget.totalPlanned),
style: AppTypography.headerSmall.copyWith(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
),
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Réalisé',
style: AppTypography.subtitleSmall,
),
const SizedBox(height: 4),
Text(
currencyFormat.format(budget.totalRealized),
style: AppTypography.headerSmall.copyWith(
color: budget.isOverBudget
? AppColors.error
: AppColors.brandGreen,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: SpacingTokens.sm),
LinearProgressIndicator(
value: budget.realizationRate / 100,
backgroundColor: AppColors.lightBorder,
color: budget.isOverBudget
? AppColors.error
: AppColors.brandGreen,
),
const SizedBox(height: SpacingTokens.xs),
Text(
'${budget.realizationRate.toStringAsFixed(1)}% réalisé',
style: AppTypography.subtitleSmall,
),
],
),
);
}
}
class _FilterDialog extends StatefulWidget {
final String? organizationId;
const _FilterDialog({this.organizationId});
@override
State<_FilterDialog> createState() => _FilterDialogState();
}
class _FilterDialogState extends State<_FilterDialog> {
BudgetStatus? _selectedStatus;
int? _selectedYear;
final _currentYear = DateTime.now().year;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Filtrer les budgets'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Statut',
style: AppTypography.actionText,
),
const SizedBox(height: SpacingTokens.sm),
Wrap(
spacing: SpacingTokens.sm,
children: [
for (final status in BudgetStatus.values)
ChoiceChip(
label: Text(_getStatusLabel(status)),
selected: _selectedStatus == status,
onSelected: (selected) {
setState(() {
_selectedStatus = selected ? status : null;
});
},
),
],
),
const SizedBox(height: SpacingTokens.md),
Text(
'Année',
style: AppTypography.actionText,
),
const SizedBox(height: SpacingTokens.sm),
Wrap(
spacing: SpacingTokens.sm,
children: [
for (int year = _currentYear; year >= _currentYear - 5; year--)
ChoiceChip(
label: Text(year.toString()),
selected: _selectedYear == year,
onSelected: (selected) {
setState(() {
_selectedYear = selected ? year : null;
});
},
),
],
),
],
),
),
actions: [
TextButton(
onPressed: () {
setState(() {
_selectedStatus = null;
_selectedYear = null;
});
},
child: const Text('Réinitialiser'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
context.read<BudgetBloc>().add(
FilterBudgets(
status: _selectedStatus,
year: _selectedYear,
),
);
Navigator.of(context).pop();
},
child: const Text('Appliquer'),
),
],
);
}
String _getStatusLabel(BudgetStatus status) {
switch (status) {
case BudgetStatus.draft:
return 'Brouillon';
case BudgetStatus.active:
return 'Actif';
case BudgetStatus.closed:
return 'Clos';
case BudgetStatus.cancelled:
return 'Annulé';
}
}
}

View File

@@ -0,0 +1,388 @@
/// Page des approbations en attente
library pending_approvals_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection_container.dart';
import 'package:intl/intl.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../domain/entities/transaction_approval.dart';
import '../bloc/approval_bloc.dart';
import '../bloc/approval_event.dart';
import '../bloc/approval_state.dart';
import '../widgets/approve_dialog.dart';
import '../widgets/reject_dialog.dart';
class PendingApprovalsPage extends StatelessWidget {
final String? organizationId;
const PendingApprovalsPage({
super.key,
this.organizationId,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<ApprovalBloc>()
..add(LoadPendingApprovals(organizationId: organizationId)),
child: _PendingApprovalsView(organizationId: organizationId),
);
}
}
class _PendingApprovalsView extends StatelessWidget {
final String? organizationId;
const _PendingApprovalsView({this.organizationId});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorTokens.background,
appBar: UFAppBar(
title: 'APPROBATIONS EN ATTENTE',
automaticallyImplyLeading: true,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
context.read<ApprovalBloc>().add(
RefreshApprovals(organizationId: organizationId),
);
},
),
],
),
body: BlocConsumer<ApprovalBloc, ApprovalState>(
listener: (context, state) {
if (state is TransactionApproved) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.success,
),
);
context.read<ApprovalBloc>().add(
RefreshApprovals(organizationId: organizationId),
);
} else if (state is TransactionRejected) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.warning,
),
);
context.read<ApprovalBloc>().add(
RefreshApprovals(organizationId: organizationId),
);
} else if (state is ApprovalError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.error,
),
);
}
},
builder: (context, state) {
if (state is ApprovalsLoading || state is ApprovalActionInProgress) {
return const Center(child: CircularProgressIndicator());
}
if (state is ApprovalsEmpty) {
return _buildEmptyState();
}
if (state is ApprovalsLoaded) {
return _buildApprovalsList(context, state.approvals);
}
if (state is ApprovalError) {
return _buildErrorState(context, state.message);
}
return const SizedBox.shrink();
},
),
);
}
Widget _buildApprovalsList(
BuildContext context,
List<TransactionApproval> approvals,
) {
return RefreshIndicator(
onRefresh: () async {
context.read<ApprovalBloc>().add(
RefreshApprovals(organizationId: organizationId),
);
},
child: ListView.separated(
padding: const EdgeInsets.all(SpacingTokens.md),
itemCount: approvals.length,
separatorBuilder: (_, __) => const SizedBox(height: SpacingTokens.md),
itemBuilder: (context, index) {
final approval = approvals[index];
return _ApprovalCard(
approval: approval,
onApprove: () => _showApproveDialog(context, approval),
onReject: () => _showRejectDialog(context, approval),
);
},
),
);
}
void _showApproveDialog(BuildContext context, TransactionApproval approval) {
showDialog(
context: context,
builder: (dialogContext) => BlocProvider.value(
value: context.read<ApprovalBloc>(),
child: ApproveDialog(approval: approval),
),
);
}
void _showRejectDialog(BuildContext context, TransactionApproval approval) {
showDialog(
context: context,
builder: (dialogContext) => BlocProvider.value(
value: context.read<ApprovalBloc>(),
child: RejectDialog(approval: approval),
),
);
}
Widget _buildErrorState(BuildContext context, String message) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 80,
color: AppColors.error.withOpacity(0.5),
),
const SizedBox(height: SpacingTokens.lg),
Text(
message,
style: AppTypography.headerSmall.copyWith(
color: AppColors.textSecondaryLight,
),
textAlign: TextAlign.center,
),
const SizedBox(height: SpacingTokens.lg),
UFPrimaryButton(
label: 'Réessayer',
onPressed: () {
context.read<ApprovalBloc>().add(
LoadPendingApprovals(organizationId: organizationId),
);
},
icon: Icons.refresh,
),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check_circle_outline,
size: 80,
color: AppColors.success.withOpacity(0.5),
),
const SizedBox(height: SpacingTokens.lg),
Text(
'Aucune approbation en attente',
style: AppTypography.headerSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
const SizedBox(height: SpacingTokens.sm),
Text(
'Toutes les transactions sont approuvées',
style: AppTypography.bodyTextSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
],
),
);
}
}
class _ApprovalCard extends StatelessWidget {
final TransactionApproval approval;
final VoidCallback onApprove;
final VoidCallback onReject;
const _ApprovalCard({
required this.approval,
required this.onApprove,
required this.onReject,
});
String _getTransactionTypeLabel(TransactionType type) {
switch (type) {
case TransactionType.contribution:
return 'Cotisation';
case TransactionType.deposit:
return 'Dépôt';
case TransactionType.withdrawal:
return 'Retrait';
case TransactionType.transfer:
return 'Transfert';
case TransactionType.solidarity:
return 'Solidarité';
case TransactionType.event:
return 'Événement';
case TransactionType.other:
return 'Autre';
}
}
Color _getLevelColor(ApprovalLevel level) {
switch (level) {
case ApprovalLevel.none:
return AppColors.textSecondaryLight;
case ApprovalLevel.level1:
return AppColors.brandGreen;
case ApprovalLevel.level2:
return AppColors.warning;
case ApprovalLevel.level3:
return AppColors.error;
}
}
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat.currency(symbol: approval.currency);
return Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
border: Border.all(color: AppColors.lightBorder),
boxShadow: const [
BoxShadow(
color: Color(0x0A000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
_getTransactionTypeLabel(approval.transactionType),
style: AppTypography.actionText,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: _getLevelColor(approval.requiredLevel).withOpacity(0.1),
borderRadius: BorderRadius.circular(SpacingTokens.radiusSm),
),
child: Text(
'Niveau ${approval.requiredApprovals}',
style: AppTypography.badgeText.copyWith(
color: _getLevelColor(approval.requiredLevel),
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: SpacingTokens.sm),
Text(
currencyFormat.format(approval.amount),
style: AppTypography.headerSmall.copyWith(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: SpacingTokens.sm),
Row(
children: [
Icon(
Icons.person_outline,
size: 16,
color: AppColors.textSecondaryLight,
),
const SizedBox(width: 4),
Expanded(
child: Text(
'Demandé par ${approval.requesterName}',
style: AppTypography.subtitleSmall,
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.access_time,
size: 16,
color: AppColors.textSecondaryLight,
),
const SizedBox(width: 4),
Text(
DateFormat('dd/MM/yyyy HH:mm').format(approval.createdAt),
style: AppTypography.subtitleSmall,
),
],
),
if (approval.approvers.isNotEmpty) ...[
const SizedBox(height: SpacingTokens.sm),
const Divider(),
const SizedBox(height: SpacingTokens.sm),
Text(
'Approbations : ${approval.approvalCount}/${approval.requiredApprovals}',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: SpacingTokens.md),
Row(
children: [
Expanded(
child: UFSecondaryButton(
label: 'Rejeter',
onPressed: onReject,
icon: Icons.close,
),
),
const SizedBox(width: SpacingTokens.sm),
Expanded(
child: UFPrimaryButton(
label: 'Approuver',
onPressed: onApprove,
icon: Icons.check,
),
),
],
),
],
),
);
}
}