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,129 @@
/// BLoC pour la gestion des approbations de transactions
library approval_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../../domain/usecases/get_pending_approvals.dart';
import '../../domain/usecases/approve_transaction.dart';
import '../../domain/usecases/reject_transaction.dart';
import '../../domain/usecases/get_approval_by_id.dart';
import 'approval_event.dart';
import 'approval_state.dart';
@injectable
class ApprovalBloc extends Bloc<ApprovalEvent, ApprovalState> {
final GetPendingApprovals getPendingApprovals;
final GetApprovalById getApprovalById;
final ApproveTransaction approveTransaction;
final RejectTransaction rejectTransaction;
ApprovalBloc({
required this.getPendingApprovals,
required this.getApprovalById,
required this.approveTransaction,
required this.rejectTransaction,
}) : super(const ApprovalInitial()) {
on<LoadPendingApprovals>(_onLoadPendingApprovals);
on<LoadApprovalById>(_onLoadApprovalById);
on<ApproveTransactionEvent>(_onApproveTransaction);
on<RejectTransactionEvent>(_onRejectTransaction);
on<RefreshApprovals>(_onRefreshApprovals);
}
Future<void> _onLoadPendingApprovals(
LoadPendingApprovals event,
Emitter<ApprovalState> emit,
) async {
emit(const ApprovalsLoading());
final result = await getPendingApprovals(
organizationId: event.organizationId,
);
result.fold(
(failure) => emit(ApprovalError(failure.message)),
(approvals) {
if (approvals.isEmpty) {
emit(const ApprovalsEmpty());
} else {
emit(ApprovalsLoaded(
approvals: approvals,
pendingCount: approvals.length,
));
}
},
);
}
Future<void> _onLoadApprovalById(
LoadApprovalById event,
Emitter<ApprovalState> emit,
) async {
emit(const ApprovalsLoading());
final result = await getApprovalById(event.approvalId);
result.fold(
(failure) => emit(ApprovalError(failure.message)),
(approval) => emit(ApprovalDetailLoaded(approval)),
);
}
Future<void> _onApproveTransaction(
ApproveTransactionEvent event,
Emitter<ApprovalState> emit,
) async {
emit(const ApprovalActionInProgress('approve'));
final result = await approveTransaction(
approvalId: event.approvalId,
comment: event.comment,
);
result.fold(
(failure) => emit(ApprovalError(failure.message)),
(approval) => emit(TransactionApproved(approval: approval)),
);
}
Future<void> _onRejectTransaction(
RejectTransactionEvent event,
Emitter<ApprovalState> emit,
) async {
emit(const ApprovalActionInProgress('reject'));
final result = await rejectTransaction(
approvalId: event.approvalId,
reason: event.reason,
);
result.fold(
(failure) => emit(ApprovalError(failure.message)),
(approval) => emit(TransactionRejected(approval: approval)),
);
}
Future<void> _onRefreshApprovals(
RefreshApprovals event,
Emitter<ApprovalState> emit,
) async {
// Keep current state while refreshing
final result = await getPendingApprovals(
organizationId: event.organizationId,
);
result.fold(
(failure) => emit(ApprovalError(failure.message)),
(approvals) {
if (approvals.isEmpty) {
emit(const ApprovalsEmpty());
} else {
emit(ApprovalsLoaded(
approvals: approvals,
pendingCount: approvals.length,
));
}
},
);
}
}

View File

@@ -0,0 +1,69 @@
/// Événements pour le BLoC des approbations
library approval_event;
import 'package:equatable/equatable.dart';
abstract class ApprovalEvent extends Equatable {
const ApprovalEvent();
@override
List<Object?> get props => [];
}
/// Charger les approbations en attente
class LoadPendingApprovals extends ApprovalEvent {
final String? organizationId;
const LoadPendingApprovals({this.organizationId});
@override
List<Object?> get props => [organizationId];
}
/// Charger une approbation spécifique
class LoadApprovalById extends ApprovalEvent {
final String approvalId;
const LoadApprovalById(this.approvalId);
@override
List<Object?> get props => [approvalId];
}
/// Approuver une transaction
class ApproveTransactionEvent extends ApprovalEvent {
final String approvalId;
final String? comment;
const ApproveTransactionEvent({
required this.approvalId,
this.comment,
});
@override
List<Object?> get props => [approvalId, comment];
}
/// Rejeter une transaction
class RejectTransactionEvent extends ApprovalEvent {
final String approvalId;
final String reason;
const RejectTransactionEvent({
required this.approvalId,
required this.reason,
});
@override
List<Object?> get props => [approvalId, reason];
}
/// Rafraîchir les approbations
class RefreshApprovals extends ApprovalEvent {
final String? organizationId;
const RefreshApprovals({this.organizationId});
@override
List<Object?> get props => [organizationId];
}

View File

@@ -0,0 +1,106 @@
/// États pour le BLoC des approbations
library approval_state;
import 'package:equatable/equatable.dart';
import '../../domain/entities/transaction_approval.dart';
abstract class ApprovalState extends Equatable {
const ApprovalState();
@override
List<Object?> get props => [];
}
/// État initial
class ApprovalInitial extends ApprovalState {
const ApprovalInitial();
}
/// Chargement en cours
class ApprovalsLoading extends ApprovalState {
const ApprovalsLoading();
}
/// Approbations chargées
class ApprovalsLoaded extends ApprovalState {
final List<TransactionApproval> approvals;
final int pendingCount;
const ApprovalsLoaded({
required this.approvals,
required this.pendingCount,
});
@override
List<Object?> get props => [approvals, pendingCount];
}
/// Approbation spécifique chargée
class ApprovalDetailLoaded extends ApprovalState {
final TransactionApproval approval;
const ApprovalDetailLoaded(this.approval);
@override
List<Object?> get props => [approval];
}
/// Transaction approuvée avec succès
class TransactionApproved extends ApprovalState {
final TransactionApproval approval;
final String message;
const TransactionApproved({
required this.approval,
this.message = 'Transaction approuvée avec succès',
});
@override
List<Object?> get props => [approval, message];
}
/// Transaction rejetée avec succès
class TransactionRejected extends ApprovalState {
final TransactionApproval approval;
final String message;
const TransactionRejected({
required this.approval,
this.message = 'Transaction rejetée avec succès',
});
@override
List<Object?> get props => [approval, message];
}
/// Action en cours (approve/reject)
class ApprovalActionInProgress extends ApprovalState {
final String actionType; // 'approve' or 'reject'
const ApprovalActionInProgress(this.actionType);
@override
List<Object?> get props => [actionType];
}
/// Erreur
class ApprovalError extends ApprovalState {
final String message;
const ApprovalError(this.message);
@override
List<Object?> get props => [message];
}
/// Liste vide
class ApprovalsEmpty extends ApprovalState {
final String message;
const ApprovalsEmpty({
this.message = 'Aucune approbation en attente',
});
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,187 @@
/// BLoC pour la gestion des budgets
library budget_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../../domain/usecases/get_budgets.dart';
import '../../domain/usecases/get_budget_by_id.dart';
import '../../domain/usecases/create_budget.dart';
import '../../domain/usecases/get_budget_tracking.dart';
import 'budget_event.dart';
import 'budget_state.dart';
@injectable
class BudgetBloc extends Bloc<BudgetEvent, BudgetState> {
final GetBudgets getBudgets;
final GetBudgetById getBudgetById;
final CreateBudget createBudget;
final GetBudgetTracking getBudgetTracking;
BudgetBloc({
required this.getBudgets,
required this.getBudgetById,
required this.createBudget,
required this.getBudgetTracking,
}) : super(const BudgetInitial()) {
on<LoadBudgets>(_onLoadBudgets);
on<LoadBudgetById>(_onLoadBudgetById);
on<CreateBudgetEvent>(_onCreateBudget);
on<LoadBudgetTracking>(_onLoadBudgetTracking);
on<RefreshBudgets>(_onRefreshBudgets);
on<FilterBudgets>(_onFilterBudgets);
}
Future<void> _onLoadBudgets(
LoadBudgets event,
Emitter<BudgetState> emit,
) async {
emit(const BudgetsLoading());
final result = await getBudgets(
organizationId: event.organizationId,
status: event.status,
year: event.year,
);
result.fold(
(failure) => emit(BudgetError(failure.message)),
(budgets) {
if (budgets.isEmpty) {
emit(const BudgetsEmpty());
} else {
emit(BudgetsLoaded(
budgets: budgets,
filterStatus: event.status,
filterYear: event.year,
));
}
},
);
}
Future<void> _onLoadBudgetById(
LoadBudgetById event,
Emitter<BudgetState> emit,
) async {
emit(const BudgetsLoading());
final result = await getBudgetById(event.budgetId);
result.fold(
(failure) => emit(BudgetError(failure.message)),
(budget) => emit(BudgetDetailLoaded(budget)),
);
}
Future<void> _onCreateBudget(
CreateBudgetEvent event,
Emitter<BudgetState> emit,
) async {
emit(const BudgetActionInProgress('create'));
final result = await createBudget(
name: event.name,
description: event.description,
organizationId: event.organizationId,
period: event.period,
year: event.year,
month: event.month,
lines: event.lines,
);
result.fold(
(failure) => emit(BudgetError(failure.message)),
(budget) => emit(BudgetCreated(budget: budget)),
);
}
Future<void> _onLoadBudgetTracking(
LoadBudgetTracking event,
Emitter<BudgetState> emit,
) async {
emit(const BudgetsLoading());
// Load budget first
final budgetResult = await getBudgetById(event.budgetId);
await budgetResult.fold(
(failure) async => emit(BudgetError(failure.message)),
(budget) async {
// Then load tracking
final trackingResult = await getBudgetTracking(budgetId: event.budgetId);
trackingResult.fold(
(failure) => emit(BudgetError(failure.message)),
(tracking) => emit(BudgetTrackingLoaded(
budget: budget,
tracking: tracking,
)),
);
},
);
}
Future<void> _onRefreshBudgets(
RefreshBudgets event,
Emitter<BudgetState> emit,
) async {
final result = await getBudgets(
organizationId: event.organizationId,
status: event.status,
year: event.year,
);
result.fold(
(failure) => emit(BudgetError(failure.message)),
(budgets) {
if (budgets.isEmpty) {
emit(const BudgetsEmpty());
} else {
emit(BudgetsLoaded(
budgets: budgets,
filterStatus: event.status,
filterYear: event.year,
));
}
},
);
}
Future<void> _onFilterBudgets(
FilterBudgets event,
Emitter<BudgetState> emit,
) async {
// Keep current organization if in loaded state
String? organizationId;
if (state is BudgetsLoaded) {
final currentState = state as BudgetsLoaded;
// Extract org ID from first budget if available
if (currentState.budgets.isNotEmpty) {
organizationId = currentState.budgets.first.organizationId;
}
}
emit(const BudgetsLoading());
final result = await getBudgets(
organizationId: organizationId,
status: event.status,
year: event.year,
);
result.fold(
(failure) => emit(BudgetError(failure.message)),
(budgets) {
if (budgets.isEmpty) {
emit(const BudgetsEmpty());
} else {
emit(BudgetsLoaded(
budgets: budgets,
filterStatus: event.status,
filterYear: event.year,
));
}
},
);
}
}

View File

@@ -0,0 +1,110 @@
/// Événements pour le BLoC des budgets
library budget_event;
import 'package:equatable/equatable.dart';
import '../../domain/entities/budget.dart';
abstract class BudgetEvent extends Equatable {
const BudgetEvent();
@override
List<Object?> get props => [];
}
/// Charger les budgets
class LoadBudgets extends BudgetEvent {
final String? organizationId;
final BudgetStatus? status;
final int? year;
const LoadBudgets({
this.organizationId,
this.status,
this.year,
});
@override
List<Object?> get props => [organizationId, status, year];
}
/// Charger un budget spécifique
class LoadBudgetById extends BudgetEvent {
final String budgetId;
const LoadBudgetById(this.budgetId);
@override
List<Object?> get props => [budgetId];
}
/// Créer un budget
class CreateBudgetEvent extends BudgetEvent {
final String name;
final String? description;
final String organizationId;
final BudgetPeriod period;
final int year;
final int? month;
final List<BudgetLine> lines;
const CreateBudgetEvent({
required this.name,
this.description,
required this.organizationId,
required this.period,
required this.year,
this.month,
required this.lines,
});
@override
List<Object?> get props => [
name,
description,
organizationId,
period,
year,
month,
lines,
];
}
/// Charger le suivi budgétaire
class LoadBudgetTracking extends BudgetEvent {
final String budgetId;
const LoadBudgetTracking(this.budgetId);
@override
List<Object?> get props => [budgetId];
}
/// Rafraîchir les budgets
class RefreshBudgets extends BudgetEvent {
final String? organizationId;
final BudgetStatus? status;
final int? year;
const RefreshBudgets({
this.organizationId,
this.status,
this.year,
});
@override
List<Object?> get props => [organizationId, status, year];
}
/// Filtrer les budgets
class FilterBudgets extends BudgetEvent {
final BudgetStatus? status;
final int? year;
const FilterBudgets({
this.status,
this.year,
});
@override
List<Object?> get props => [status, year];
}

View File

@@ -0,0 +1,108 @@
/// États pour le BLoC des budgets
library budget_state;
import 'package:equatable/equatable.dart';
import '../../domain/entities/budget.dart';
abstract class BudgetState extends Equatable {
const BudgetState();
@override
List<Object?> get props => [];
}
/// État initial
class BudgetInitial extends BudgetState {
const BudgetInitial();
}
/// Chargement en cours
class BudgetsLoading extends BudgetState {
const BudgetsLoading();
}
/// Budgets chargés
class BudgetsLoaded extends BudgetState {
final List<Budget> budgets;
final BudgetStatus? filterStatus;
final int? filterYear;
const BudgetsLoaded({
required this.budgets,
this.filterStatus,
this.filterYear,
});
@override
List<Object?> get props => [budgets, filterStatus, filterYear];
}
/// Budget spécifique chargé
class BudgetDetailLoaded extends BudgetState {
final Budget budget;
const BudgetDetailLoaded(this.budget);
@override
List<Object?> get props => [budget];
}
/// Suivi budgétaire chargé
class BudgetTrackingLoaded extends BudgetState {
final Budget budget;
final Map<String, dynamic> tracking;
const BudgetTrackingLoaded({
required this.budget,
required this.tracking,
});
@override
List<Object?> get props => [budget, tracking];
}
/// Budget créé avec succès
class BudgetCreated extends BudgetState {
final Budget budget;
final String message;
const BudgetCreated({
required this.budget,
this.message = 'Budget créé avec succès',
});
@override
List<Object?> get props => [budget, message];
}
/// Action en cours (create, update)
class BudgetActionInProgress extends BudgetState {
final String actionType;
const BudgetActionInProgress(this.actionType);
@override
List<Object?> get props => [actionType];
}
/// Erreur
class BudgetError extends BudgetState {
final String message;
const BudgetError(this.message);
@override
List<Object?> get props => [message];
}
/// Liste vide
class BudgetsEmpty extends BudgetState {
final String message;
const BudgetsEmpty({
this.message = 'Aucun budget trouvé',
});
@override
List<Object?> get props => [message];
}

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,
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,177 @@
/// Dialog pour approuver une transaction
library approve_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../core/validation/validators.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';
class ApproveDialog extends StatefulWidget {
final TransactionApproval approval;
const ApproveDialog({
super.key,
required this.approval,
});
@override
State<ApproveDialog> createState() => _ApproveDialogState();
}
class _ApproveDialogState extends State<ApproveDialog> {
final _commentController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void dispose() {
_commentController.dispose();
super.dispose();
}
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';
}
}
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat.currency(symbol: widget.approval.currency);
return AlertDialog(
title: const Text('Approuver la transaction'),
content: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Confirmez-vous l\'approbation de cette transaction ?',
style: AppTypography.bodyTextSmall,
),
const SizedBox(height: SpacingTokens.md),
Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: AppColors.lightBackground,
borderRadius: BorderRadius.circular(SpacingTokens.radiusSm),
border: Border.all(color: AppColors.lightBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(
'Type',
_getTransactionTypeLabel(widget.approval.transactionType),
),
const SizedBox(height: SpacingTokens.sm),
_buildInfoRow(
'Montant',
currencyFormat.format(widget.approval.amount),
valueStyle: AppTypography.actionText.copyWith(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: SpacingTokens.sm),
_buildInfoRow(
'Demandeur',
widget.approval.requesterName,
),
const SizedBox(height: SpacingTokens.sm),
_buildInfoRow(
'Date',
DateFormat('dd/MM/yyyy HH:mm').format(widget.approval.createdAt),
),
],
),
),
const SizedBox(height: SpacingTokens.md),
TextFormField(
controller: _commentController,
decoration: const InputDecoration(
labelText: 'Commentaire (optionnel)',
hintText: 'Ajouter un commentaire...',
border: OutlineInputBorder(),
helperText: 'Maximum 500 caractères',
),
maxLines: 3,
maxLength: 500,
validator: FinanceValidators.approvalComment(),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton.icon(
onPressed: () {
if (_formKey.currentState!.validate()) {
context.read<ApprovalBloc>().add(
ApproveTransactionEvent(
approvalId: widget.approval.id,
comment: _commentController.text.trim().isEmpty
? null
: _commentController.text.trim(),
),
);
Navigator.of(context).pop();
}
},
icon: const Icon(Icons.check),
label: const Text('Approuver'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.success,
foregroundColor: Colors.white,
),
),
],
);
}
Widget _buildInfoRow(String label, String value, {TextStyle? valueStyle}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 90,
child: Text(
'$label :',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Text(
value,
style: valueStyle ?? AppTypography.bodyTextSmall,
),
),
],
);
}
}

View File

@@ -0,0 +1,513 @@
/// Dialog pour créer un budget
library create_budget_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/validation/validators.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/widgets/validated_text_field.dart';
import '../../domain/entities/budget.dart';
import '../bloc/budget_bloc.dart';
import '../bloc/budget_event.dart';
class CreateBudgetDialog extends StatefulWidget {
final String organizationId;
const CreateBudgetDialog({
super.key,
required this.organizationId,
});
@override
State<CreateBudgetDialog> createState() => _CreateBudgetDialogState();
}
class _CreateBudgetDialogState extends State<CreateBudgetDialog> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final _yearController = TextEditingController(
text: DateTime.now().year.toString(),
);
BudgetPeriod _selectedPeriod = BudgetPeriod.annual;
int? _selectedMonth;
// Budget lines
final List<_BudgetLineData> _budgetLines = [];
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
_yearController.dispose();
super.dispose();
}
void _addBudgetLine() {
setState(() {
_budgetLines.add(_BudgetLineData());
});
}
void _removeBudgetLine(int index) {
setState(() {
_budgetLines.removeAt(index);
});
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
// Validate at least one budget line
if (_budgetLines.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez ajouter au moins une ligne budgétaire'),
backgroundColor: Colors.red,
),
);
return;
}
// Build budget lines
final lines = _budgetLines.map((line) {
return BudgetLine(
id: DateTime.now().millisecondsSinceEpoch.toString(),
category: line.category!,
name: line.nameController.text.trim(),
description: line.descriptionController.text.trim(),
amountPlanned: double.parse(line.amountController.text.trim()),
amountRealized: 0.0,
);
}).toList();
// Dispatch create budget event
context.read<BudgetBloc>().add(
CreateBudgetEvent(
name: _nameController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
organizationId: widget.organizationId,
period: _selectedPeriod,
year: int.parse(_yearController.text.trim()),
month: _selectedMonth,
lines: lines,
),
);
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return Dialog(
child: Container(
constraints: const BoxConstraints(maxWidth: 600),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: AppColors.primaryGreen,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(SpacingTokens.radiusMd),
topRight: Radius.circular(SpacingTokens.radiusMd),
),
),
child: Row(
children: [
const Icon(Icons.account_balance, color: Colors.white),
const SizedBox(width: SpacingTokens.sm),
const Text(
'Créer un budget',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Content
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(SpacingTokens.md),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Budget name
ValidatedTextField(
controller: _nameController,
labelText: 'Nom du budget *',
hintText: 'Ex: Budget annuel 2026',
validator: FinanceValidators.budgetName(),
textInputAction: TextInputAction.next,
),
const SizedBox(height: SpacingTokens.md),
// Description
ValidatedTextField(
controller: _descriptionController,
labelText: 'Description',
hintText: 'Description du budget...',
validator: FinanceValidators.budgetDescription(),
maxLines: 3,
textInputAction: TextInputAction.next,
),
const SizedBox(height: SpacingTokens.md),
// Period and Year
Row(
children: [
Expanded(
child: ValidatedDropdownField<BudgetPeriod>(
value: _selectedPeriod,
labelText: 'Période *',
items: BudgetPeriod.values.map((period) {
return DropdownMenuItem(
value: period,
child: Text(_getPeriodLabel(period)),
);
}).toList(),
validator: (value) {
if (value == null) {
return 'Période requise';
}
return null;
},
onChanged: (value) {
setState(() {
_selectedPeriod = value!;
if (_selectedPeriod != BudgetPeriod.monthly) {
_selectedMonth = null;
}
});
},
),
),
const SizedBox(width: SpacingTokens.sm),
Expanded(
child: ValidatedTextField(
controller: _yearController,
labelText: 'Année *',
hintText: 'Ex: 2026',
validator: FinanceValidators.fiscalYear(),
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
),
),
],
),
// Month (if monthly period)
if (_selectedPeriod == BudgetPeriod.monthly) ...[
const SizedBox(height: SpacingTokens.md),
ValidatedDropdownField<int>(
value: _selectedMonth,
labelText: 'Mois *',
items: List.generate(12, (index) {
final month = index + 1;
return DropdownMenuItem(
value: month,
child: Text(_getMonthLabel(month)),
);
}),
validator: (value) {
if (_selectedPeriod == BudgetPeriod.monthly &&
value == null) {
return 'Mois requis pour budget mensuel';
}
return null;
},
onChanged: (value) {
setState(() {
_selectedMonth = value;
});
},
),
],
const SizedBox(height: SpacingTokens.lg),
// Budget lines section
Row(
children: [
Text(
'Lignes budgétaires',
style: AppTypography.headerSmall.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
ElevatedButton.icon(
onPressed: _addBudgetLine,
icon: const Icon(Icons.add, size: 18),
label: const Text('Ajouter'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: SpacingTokens.md,
vertical: SpacingTokens.sm,
),
),
),
],
),
const SizedBox(height: SpacingTokens.sm),
// Budget lines list
if (_budgetLines.isEmpty)
Container(
padding: const EdgeInsets.all(SpacingTokens.lg),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius:
BorderRadius.circular(SpacingTokens.radiusSm),
border: Border.all(color: Colors.grey.shade300),
),
child: const Center(
child: Text(
'Aucune ligne budgétaire.\nCliquez sur "Ajouter" pour commencer.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
),
)
else
..._budgetLines.asMap().entries.map((entry) {
final index = entry.key;
final line = entry.value;
return _BudgetLineWidget(
key: ValueKey(line.id),
lineData: line,
onRemove: () => _removeBudgetLine(index),
);
}),
],
),
),
),
),
// Actions
Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(
top: BorderSide(color: Colors.grey.shade300),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
const SizedBox(width: SpacingTokens.sm),
ElevatedButton.icon(
onPressed: _submitForm,
icon: const Icon(Icons.check),
label: const Text('Créer le budget'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.success,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: SpacingTokens.lg,
vertical: SpacingTokens.md,
),
),
),
],
),
),
],
),
),
);
}
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';
}
}
String _getMonthLabel(int month) {
const months = [
'Janvier',
'Février',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Août',
'Septembre',
'Octobre',
'Novembre',
'Décembre'
];
return months[month - 1];
}
}
/// Budget line data holder
class _BudgetLineData {
final String id;
final nameController = TextEditingController();
final descriptionController = TextEditingController();
final amountController = TextEditingController();
BudgetCategory? category;
_BudgetLineData() : id = DateTime.now().millisecondsSinceEpoch.toString();
void dispose() {
nameController.dispose();
descriptionController.dispose();
amountController.dispose();
}
}
/// Budget line widget
class _BudgetLineWidget extends StatefulWidget {
final _BudgetLineData lineData;
final VoidCallback onRemove;
const _BudgetLineWidget({
super.key,
required this.lineData,
required this.onRemove,
});
@override
State<_BudgetLineWidget> createState() => _BudgetLineWidgetState();
}
class _BudgetLineWidgetState extends State<_BudgetLineWidget> {
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: SpacingTokens.md),
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(SpacingTokens.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.receipt_long, color: AppColors.primaryGreen),
const SizedBox(width: SpacingTokens.sm),
const Text(
'Ligne budgétaire',
style: TextStyle(fontWeight: FontWeight.bold),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: widget.onRemove,
tooltip: 'Supprimer',
),
],
),
const SizedBox(height: SpacingTokens.md),
// Category
ValidatedDropdownField<BudgetCategory>(
value: widget.lineData.category,
labelText: 'Catégorie *',
items: BudgetCategory.values.map((category) {
return DropdownMenuItem(
value: category,
child: Text(_getCategoryLabel(category)),
);
}).toList(),
validator: (value) {
if (value == null) {
return 'Catégorie requise';
}
return null;
},
onChanged: (value) {
setState(() {
widget.lineData.category = value;
});
},
),
const SizedBox(height: SpacingTokens.sm),
// Name
ValidatedTextField(
controller: widget.lineData.nameController,
labelText: 'Nom *',
hintText: 'Ex: Cotisations mensuelles',
validator: FinanceValidators.budgetLineName(),
textInputAction: TextInputAction.next,
),
const SizedBox(height: SpacingTokens.sm),
// Amount
ValidatedAmountField(
controller: widget.lineData.amountController,
labelText: 'Montant prévu *',
hintText: '0.00',
validator: FinanceValidators.amount(min: 0.01),
),
const SizedBox(height: SpacingTokens.sm),
// Description
ValidatedTextField(
controller: widget.lineData.descriptionController,
labelText: 'Description',
hintText: 'Description de la ligne...',
validator: FinanceValidators.budgetDescription(),
maxLines: 2,
),
],
),
),
);
}
String _getCategoryLabel(BudgetCategory category) {
switch (category) {
case BudgetCategory.contributions:
return 'Cotisations';
case BudgetCategory.savings:
return 'Épargne';
case BudgetCategory.solidarity:
return 'Solidarité';
case BudgetCategory.events:
return 'Événements';
case BudgetCategory.operational:
return 'Opérationnel';
case BudgetCategory.investments:
return 'Investissements';
case BudgetCategory.other:
return 'Autre';
}
}
}

View File

@@ -0,0 +1,173 @@
/// Dialog pour rejeter une transaction
library reject_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../core/validation/validators.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';
class RejectDialog extends StatefulWidget {
final TransactionApproval approval;
const RejectDialog({
super.key,
required this.approval,
});
@override
State<RejectDialog> createState() => _RejectDialogState();
}
class _RejectDialogState extends State<RejectDialog> {
final _reasonController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void dispose() {
_reasonController.dispose();
super.dispose();
}
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';
}
}
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat.currency(symbol: widget.approval.currency);
return AlertDialog(
title: const Text('Rejeter la transaction'),
content: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Vous êtes sur le point de rejeter cette transaction.',
style: AppTypography.bodyTextSmall.copyWith(
color: AppColors.error,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: SpacingTokens.md),
Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: AppColors.lightBackground,
borderRadius: BorderRadius.circular(SpacingTokens.radiusSm),
border: Border.all(color: AppColors.lightBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(
'Type',
_getTransactionTypeLabel(widget.approval.transactionType),
),
const SizedBox(height: SpacingTokens.sm),
_buildInfoRow(
'Montant',
currencyFormat.format(widget.approval.amount),
valueStyle: AppTypography.actionText.copyWith(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: SpacingTokens.sm),
_buildInfoRow(
'Demandeur',
widget.approval.requesterName,
),
],
),
),
const SizedBox(height: SpacingTokens.md),
TextFormField(
controller: _reasonController,
decoration: const InputDecoration(
labelText: 'Raison du rejet *',
hintText: 'Expliquez la raison du rejet...',
border: OutlineInputBorder(),
helperText: 'Minimum 10 caractères, maximum 500',
),
maxLines: 4,
maxLength: 500,
validator: FinanceValidators.rejectionReason(),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton.icon(
onPressed: () {
if (_formKey.currentState!.validate()) {
context.read<ApprovalBloc>().add(
RejectTransactionEvent(
approvalId: widget.approval.id,
reason: _reasonController.text.trim(),
),
);
Navigator.of(context).pop();
}
},
icon: const Icon(Icons.close),
label: const Text('Rejeter'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.error,
foregroundColor: Colors.white,
),
),
],
);
}
Widget _buildInfoRow(String label, String value, {TextStyle? valueStyle}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 90,
child: Text(
'$label :',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Text(
value,
style: valueStyle ?? AppTypography.bodyTextSmall,
),
),
],
);
}
}