Version propre - Dashboard enhanced

This commit is contained in:
DahoudG
2025-09-13 19:05:06 +00:00
parent 3df010add7
commit 73459b3092
70 changed files with 15317 additions and 1498 deletions

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/auth/bloc/auth_bloc.dart';
import '../../../../core/auth/bloc/temp_auth_bloc.dart';
import '../../../../core/auth/bloc/auth_event.dart';
import '../../../../core/auth/models/auth_state.dart';
import '../../../../core/auth/models/login_request.dart';
@@ -100,7 +100,7 @@ class _LoginPageState extends State<LoginPage>
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
body: BlocConsumer<AuthBloc, AuthState>(
body: BlocConsumer<TempAuthBloc, AuthState>(
listener: (context, state) {
setState(() {
_isLoading = state.status == AuthStatus.checking;
@@ -222,7 +222,7 @@ class _LoginPageState extends State<LoginPage>
rememberMe: _rememberMe,
);
context.read<AuthBloc>().add(AuthLoginRequested(loginRequest));
context.read<TempAuthBloc>().add(AuthLoginRequested(loginRequest));
}
void _showErrorSnackBar(String message) {

View File

@@ -59,14 +59,18 @@ class _LoginFormState extends State<LoginForm>
);
_fieldAnimations = List.generate(4, (index) {
// Calcul sécurisé pour éviter end > 1.0
final start = index * 0.15; // Réduit l'espacement
final end = (start + 0.4).clamp(0.0, 1.0); // Assure end <= 1.0
return Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _fieldAnimationController,
curve: Interval(
index * 0.2,
(index * 0.2) + 0.6,
start,
end,
curve: Curves.easeOut,
),
));
@@ -330,13 +334,13 @@ class _LoginFormState extends State<LoginForm>
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: widget.rememberMe
? AppTheme.primaryColor
color: widget.rememberMe
? AppTheme.primaryColor
: AppTheme.textSecondary,
width: 2,
),
color: widget.rememberMe
? AppTheme.primaryColor
color: widget.rememberMe
? AppTheme.primaryColor
: Colors.transparent,
),
child: widget.rememberMe
@@ -348,12 +352,15 @@ class _LoginFormState extends State<LoginForm>
: null,
),
const SizedBox(width: 8),
Text(
'Se souvenir de moi',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
Flexible(
child: Text(
'Se souvenir de moi',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],

View File

@@ -0,0 +1,84 @@
import 'package:injectable/injectable.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../core/services/api_service.dart';
import '../../../cotisations/domain/repositories/cotisation_repository.dart';
/// Implémentation du repository des cotisations
/// Utilise ApiService pour communiquer avec le backend
@LazySingleton(as: CotisationRepository)
class CotisationRepositoryImpl implements CotisationRepository {
final ApiService _apiService;
CotisationRepositoryImpl(this._apiService);
@override
Future<List<CotisationModel>> getCotisations({int page = 0, int size = 20}) async {
return await _apiService.getCotisations(page: page, size: size);
}
@override
Future<CotisationModel> getCotisationById(String id) async {
return await _apiService.getCotisationById(id);
}
@override
Future<CotisationModel> getCotisationByReference(String numeroReference) async {
return await _apiService.getCotisationByReference(numeroReference);
}
@override
Future<CotisationModel> createCotisation(CotisationModel cotisation) async {
return await _apiService.createCotisation(cotisation);
}
@override
Future<CotisationModel> updateCotisation(String id, CotisationModel cotisation) async {
return await _apiService.updateCotisation(id, cotisation);
}
@override
Future<void> deleteCotisation(String id) async {
return await _apiService.deleteCotisation(id);
}
@override
Future<List<CotisationModel>> getCotisationsByMembre(String membreId, {int page = 0, int size = 20}) async {
return await _apiService.getCotisationsByMembre(membreId, page: page, size: size);
}
@override
Future<List<CotisationModel>> getCotisationsByStatut(String statut, {int page = 0, int size = 20}) async {
return await _apiService.getCotisationsByStatut(statut, page: page, size: size);
}
@override
Future<List<CotisationModel>> getCotisationsEnRetard({int page = 0, int size = 20}) async {
return await _apiService.getCotisationsEnRetard(page: page, size: size);
}
@override
Future<List<CotisationModel>> rechercherCotisations({
String? membreId,
String? statut,
String? typeCotisation,
int? annee,
int? mois,
int page = 0,
int size = 20,
}) async {
return await _apiService.rechercherCotisations(
membreId: membreId,
statut: statut,
typeCotisation: typeCotisation,
annee: annee,
mois: mois,
page: page,
size: size,
);
}
@override
Future<Map<String, dynamic>> getCotisationsStats() async {
return await _apiService.getCotisationsStats();
}
}

View File

@@ -0,0 +1,46 @@
import '../../../../core/models/cotisation_model.dart';
/// Interface du repository des cotisations
/// Définit les contrats pour l'accès aux données des cotisations
abstract class CotisationRepository {
/// Récupère la liste de toutes les cotisations avec pagination
Future<List<CotisationModel>> getCotisations({int page = 0, int size = 20});
/// Récupère une cotisation par son ID
Future<CotisationModel> getCotisationById(String id);
/// Récupère une cotisation par son numéro de référence
Future<CotisationModel> getCotisationByReference(String numeroReference);
/// Crée une nouvelle cotisation
Future<CotisationModel> createCotisation(CotisationModel cotisation);
/// Met à jour une cotisation existante
Future<CotisationModel> updateCotisation(String id, CotisationModel cotisation);
/// Supprime une cotisation
Future<void> deleteCotisation(String id);
/// Récupère les cotisations d'un membre
Future<List<CotisationModel>> getCotisationsByMembre(String membreId, {int page = 0, int size = 20});
/// Récupère les cotisations par statut
Future<List<CotisationModel>> getCotisationsByStatut(String statut, {int page = 0, int size = 20});
/// Récupère les cotisations en retard
Future<List<CotisationModel>> getCotisationsEnRetard({int page = 0, int size = 20});
/// Recherche avancée de cotisations
Future<List<CotisationModel>> rechercherCotisations({
String? membreId,
String? statut,
String? typeCotisation,
int? annee,
int? mois,
int page = 0,
int size = 20,
});
/// Récupère les statistiques des cotisations
Future<Map<String, dynamic>> getCotisationsStats();
}

View File

@@ -0,0 +1,509 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../domain/repositories/cotisation_repository.dart';
import 'cotisations_event.dart';
import 'cotisations_state.dart';
/// BLoC pour la gestion des cotisations
/// Gère l'état et les événements liés aux cotisations
@injectable
class CotisationsBloc extends Bloc<CotisationsEvent, CotisationsState> {
final CotisationRepository _cotisationRepository;
CotisationsBloc(this._cotisationRepository) : super(const CotisationsInitial()) {
// Enregistrement des handlers d'événements
on<LoadCotisations>(_onLoadCotisations);
on<LoadCotisationById>(_onLoadCotisationById);
on<LoadCotisationByReference>(_onLoadCotisationByReference);
on<CreateCotisation>(_onCreateCotisation);
on<UpdateCotisation>(_onUpdateCotisation);
on<DeleteCotisation>(_onDeleteCotisation);
on<LoadCotisationsByMembre>(_onLoadCotisationsByMembre);
on<LoadCotisationsByStatut>(_onLoadCotisationsByStatut);
on<LoadCotisationsEnRetard>(_onLoadCotisationsEnRetard);
on<SearchCotisations>(_onSearchCotisations);
on<LoadCotisationsStats>(_onLoadCotisationsStats);
on<RefreshCotisations>(_onRefreshCotisations);
on<ResetCotisationsState>(_onResetCotisationsState);
on<FilterCotisations>(_onFilterCotisations);
on<SortCotisations>(_onSortCotisations);
}
/// Handler pour charger la liste des cotisations
Future<void> _onLoadCotisations(
LoadCotisations event,
Emitter<CotisationsState> emit,
) async {
try {
if (event.refresh || state is CotisationsInitial) {
emit(CotisationsLoading(isRefreshing: event.refresh));
}
final cotisations = await _cotisationRepository.getCotisations(
page: event.page,
size: event.size,
);
List<CotisationModel> allCotisations = [];
// Si c'est un refresh ou la première page, remplacer la liste
if (event.refresh || event.page == 0) {
allCotisations = cotisations;
} else {
// Sinon, ajouter à la liste existante (pagination)
if (state is CotisationsLoaded) {
final currentState = state as CotisationsLoaded;
allCotisations = [...currentState.cotisations, ...cotisations];
} else {
allCotisations = cotisations;
}
}
emit(CotisationsLoaded(
cotisations: allCotisations,
filteredCotisations: allCotisations,
hasReachedMax: cotisations.length < event.size,
currentPage: event.page,
));
} catch (error) {
emit(CotisationsError(
'Erreur lors du chargement des cotisations: ${error.toString()}',
originalError: error,
));
}
}
/// Handler pour charger une cotisation par ID
Future<void> _onLoadCotisationById(
LoadCotisationById event,
Emitter<CotisationsState> emit,
) async {
try {
emit(const CotisationsLoading());
final cotisation = await _cotisationRepository.getCotisationById(event.id);
emit(CotisationDetailLoaded(cotisation));
} catch (error) {
emit(CotisationsError(
'Erreur lors du chargement de la cotisation: ${error.toString()}',
originalError: error,
));
}
}
/// Handler pour charger une cotisation par référence
Future<void> _onLoadCotisationByReference(
LoadCotisationByReference event,
Emitter<CotisationsState> emit,
) async {
try {
emit(const CotisationsLoading());
final cotisation = await _cotisationRepository.getCotisationByReference(event.numeroReference);
emit(CotisationDetailLoaded(cotisation));
} catch (error) {
emit(CotisationsError(
'Erreur lors du chargement de la cotisation: ${error.toString()}',
originalError: error,
));
}
}
/// Handler pour créer une nouvelle cotisation
Future<void> _onCreateCotisation(
CreateCotisation event,
Emitter<CotisationsState> emit,
) async {
try {
emit(const CotisationOperationLoading('create'));
final nouvelleCotisation = await _cotisationRepository.createCotisation(event.cotisation);
emit(CotisationCreated(nouvelleCotisation));
// Recharger la liste des cotisations
add(const LoadCotisations(refresh: true));
} catch (error) {
emit(CotisationsError(
'Erreur lors de la création de la cotisation: ${error.toString()}',
originalError: error,
));
}
}
/// Handler pour mettre à jour une cotisation
Future<void> _onUpdateCotisation(
UpdateCotisation event,
Emitter<CotisationsState> emit,
) async {
try {
emit(CotisationOperationLoading('update', cotisationId: event.id));
final cotisationMiseAJour = await _cotisationRepository.updateCotisation(
event.id,
event.cotisation,
);
emit(CotisationUpdated(cotisationMiseAJour));
// Mettre à jour la liste si elle est chargée
if (state is CotisationsLoaded) {
final currentState = state as CotisationsLoaded;
final updatedList = currentState.cotisations.map((c) {
return c.id == event.id ? cotisationMiseAJour : c;
}).toList();
emit(currentState.copyWith(
cotisations: updatedList,
filteredCotisations: updatedList,
));
}
} catch (error) {
emit(CotisationsError(
'Erreur lors de la mise à jour de la cotisation: ${error.toString()}',
originalError: error,
));
}
}
/// Handler pour supprimer une cotisation
Future<void> _onDeleteCotisation(
DeleteCotisation event,
Emitter<CotisationsState> emit,
) async {
try {
emit(CotisationOperationLoading('delete', cotisationId: event.id));
await _cotisationRepository.deleteCotisation(event.id);
emit(CotisationDeleted(event.id));
// Retirer de la liste si elle est chargée
if (state is CotisationsLoaded) {
final currentState = state as CotisationsLoaded;
final updatedList = currentState.cotisations
.where((c) => c.id != event.id)
.toList();
emit(currentState.copyWith(
cotisations: updatedList,
filteredCotisations: updatedList,
));
}
} catch (error) {
emit(CotisationsError(
'Erreur lors de la suppression de la cotisation: ${error.toString()}',
originalError: error,
));
}
}
/// Handler pour charger les cotisations d'un membre
Future<void> _onLoadCotisationsByMembre(
LoadCotisationsByMembre event,
Emitter<CotisationsState> emit,
) async {
try {
if (event.refresh || event.page == 0) {
emit(CotisationsLoading(isRefreshing: event.refresh));
}
final cotisations = await _cotisationRepository.getCotisationsByMembre(
event.membreId,
page: event.page,
size: event.size,
);
List<CotisationModel> allCotisations = [];
if (event.refresh || event.page == 0) {
allCotisations = cotisations;
} else {
if (state is CotisationsByMembreLoaded) {
final currentState = state as CotisationsByMembreLoaded;
allCotisations = [...currentState.cotisations, ...cotisations];
} else {
allCotisations = cotisations;
}
}
emit(CotisationsByMembreLoaded(
membreId: event.membreId,
cotisations: allCotisations,
hasReachedMax: cotisations.length < event.size,
currentPage: event.page,
));
} catch (error) {
emit(CotisationsError(
'Erreur lors du chargement des cotisations du membre: ${error.toString()}',
originalError: error,
));
}
}
/// Handler pour charger les cotisations par statut
Future<void> _onLoadCotisationsByStatut(
LoadCotisationsByStatut event,
Emitter<CotisationsState> emit,
) async {
try {
if (event.refresh || event.page == 0) {
emit(CotisationsLoading(isRefreshing: event.refresh));
}
final cotisations = await _cotisationRepository.getCotisationsByStatut(
event.statut,
page: event.page,
size: event.size,
);
List<CotisationModel> allCotisations = [];
if (event.refresh || event.page == 0) {
allCotisations = cotisations;
} else {
if (state is CotisationsLoaded) {
final currentState = state as CotisationsLoaded;
allCotisations = [...currentState.cotisations, ...cotisations];
} else {
allCotisations = cotisations;
}
}
emit(CotisationsLoaded(
cotisations: allCotisations,
filteredCotisations: allCotisations,
hasReachedMax: cotisations.length < event.size,
currentPage: event.page,
currentFilter: event.statut,
));
} catch (error) {
emit(CotisationsError(
'Erreur lors du chargement des cotisations par statut: ${error.toString()}',
originalError: error,
));
}
}
/// Handler pour charger les cotisations en retard
Future<void> _onLoadCotisationsEnRetard(
LoadCotisationsEnRetard event,
Emitter<CotisationsState> emit,
) async {
try {
if (event.refresh || event.page == 0) {
emit(CotisationsLoading(isRefreshing: event.refresh));
}
final cotisations = await _cotisationRepository.getCotisationsEnRetard(
page: event.page,
size: event.size,
);
List<CotisationModel> allCotisations = [];
if (event.refresh || event.page == 0) {
allCotisations = cotisations;
} else {
if (state is CotisationsEnRetardLoaded) {
final currentState = state as CotisationsEnRetardLoaded;
allCotisations = [...currentState.cotisations, ...cotisations];
} else {
allCotisations = cotisations;
}
}
emit(CotisationsEnRetardLoaded(
cotisations: allCotisations,
hasReachedMax: cotisations.length < event.size,
currentPage: event.page,
));
} catch (error) {
emit(CotisationsError(
'Erreur lors du chargement des cotisations en retard: ${error.toString()}',
originalError: error,
));
}
}
/// Handler pour la recherche de cotisations
Future<void> _onSearchCotisations(
SearchCotisations event,
Emitter<CotisationsState> emit,
) async {
try {
if (event.refresh || event.page == 0) {
emit(CotisationsLoading(isRefreshing: event.refresh));
}
final cotisations = await _cotisationRepository.rechercherCotisations(
membreId: event.membreId,
statut: event.statut,
typeCotisation: event.typeCotisation,
annee: event.annee,
mois: event.mois,
page: event.page,
size: event.size,
);
final searchCriteria = <String, dynamic>{
if (event.membreId != null) 'membreId': event.membreId,
if (event.statut != null) 'statut': event.statut,
if (event.typeCotisation != null) 'typeCotisation': event.typeCotisation,
if (event.annee != null) 'annee': event.annee,
if (event.mois != null) 'mois': event.mois,
};
List<CotisationModel> allCotisations = [];
if (event.refresh || event.page == 0) {
allCotisations = cotisations;
} else {
if (state is CotisationsSearchResults) {
final currentState = state as CotisationsSearchResults;
allCotisations = [...currentState.cotisations, ...cotisations];
} else {
allCotisations = cotisations;
}
}
emit(CotisationsSearchResults(
cotisations: allCotisations,
searchCriteria: searchCriteria,
hasReachedMax: cotisations.length < event.size,
currentPage: event.page,
));
} catch (error) {
emit(CotisationsError(
'Erreur lors de la recherche de cotisations: ${error.toString()}',
originalError: error,
));
}
}
/// Handler pour charger les statistiques
Future<void> _onLoadCotisationsStats(
LoadCotisationsStats event,
Emitter<CotisationsState> emit,
) async {
try {
emit(const CotisationsLoading());
final statistics = await _cotisationRepository.getCotisationsStats();
emit(CotisationsStatsLoaded(statistics));
} catch (error) {
emit(CotisationsError(
'Erreur lors du chargement des statistiques: ${error.toString()}',
originalError: error,
));
}
}
/// Handler pour rafraîchir les données
Future<void> _onRefreshCotisations(
RefreshCotisations event,
Emitter<CotisationsState> emit,
) async {
add(const LoadCotisations(refresh: true));
}
/// Handler pour réinitialiser l'état
Future<void> _onResetCotisationsState(
ResetCotisationsState event,
Emitter<CotisationsState> emit,
) async {
emit(const CotisationsInitial());
}
/// Handler pour filtrer les cotisations localement
Future<void> _onFilterCotisations(
FilterCotisations event,
Emitter<CotisationsState> emit,
) async {
if (state is CotisationsLoaded) {
final currentState = state as CotisationsLoaded;
List<CotisationModel> filteredList = currentState.cotisations;
// Filtrage par recherche textuelle
if (event.searchQuery != null && event.searchQuery!.isNotEmpty) {
final query = event.searchQuery!.toLowerCase();
filteredList = filteredList.where((cotisation) {
return cotisation.numeroReference.toLowerCase().contains(query) ||
(cotisation.nomMembre?.toLowerCase().contains(query) ?? false) ||
cotisation.typeCotisation.toLowerCase().contains(query) ||
(cotisation.description?.toLowerCase().contains(query) ?? false);
}).toList();
}
// Filtrage par statut
if (event.statutFilter != null && event.statutFilter!.isNotEmpty) {
filteredList = filteredList.where((cotisation) {
return cotisation.statut == event.statutFilter;
}).toList();
}
// Filtrage par type
if (event.typeFilter != null && event.typeFilter!.isNotEmpty) {
filteredList = filteredList.where((cotisation) {
return cotisation.typeCotisation == event.typeFilter;
}).toList();
}
emit(currentState.copyWith(
filteredCotisations: filteredList,
searchQuery: event.searchQuery,
currentFilter: event.statutFilter ?? event.typeFilter,
));
}
}
/// Handler pour trier les cotisations
Future<void> _onSortCotisations(
SortCotisations event,
Emitter<CotisationsState> emit,
) async {
if (state is CotisationsLoaded) {
final currentState = state as CotisationsLoaded;
List<CotisationModel> sortedList = [...currentState.filteredCotisations];
switch (event.sortBy) {
case 'dateEcheance':
sortedList.sort((a, b) => event.ascending
? a.dateEcheance.compareTo(b.dateEcheance)
: b.dateEcheance.compareTo(a.dateEcheance));
break;
case 'montantDu':
sortedList.sort((a, b) => event.ascending
? a.montantDu.compareTo(b.montantDu)
: b.montantDu.compareTo(a.montantDu));
break;
case 'statut':
sortedList.sort((a, b) => event.ascending
? a.statut.compareTo(b.statut)
: b.statut.compareTo(a.statut));
break;
case 'nomMembre':
sortedList.sort((a, b) => event.ascending
? (a.nomMembre ?? '').compareTo(b.nomMembre ?? '')
: (b.nomMembre ?? '').compareTo(a.nomMembre ?? ''));
break;
case 'typeCotisation':
sortedList.sort((a, b) => event.ascending
? a.typeCotisation.compareTo(b.typeCotisation)
: b.typeCotisation.compareTo(a.typeCotisation));
break;
default:
// Tri par défaut par date d'échéance
sortedList.sort((a, b) => b.dateEcheance.compareTo(a.dateEcheance));
}
emit(currentState.copyWith(filteredCotisations: sortedList));
}
}
}

View File

@@ -0,0 +1,206 @@
import 'package:equatable/equatable.dart';
import '../../../../core/models/cotisation_model.dart';
/// Événements du BLoC des cotisations
abstract class CotisationsEvent extends Equatable {
const CotisationsEvent();
@override
List<Object?> get props => [];
}
/// Événement pour charger la liste des cotisations
class LoadCotisations extends CotisationsEvent {
final int page;
final int size;
final bool refresh;
const LoadCotisations({
this.page = 0,
this.size = 20,
this.refresh = false,
});
@override
List<Object?> get props => [page, size, refresh];
}
/// Événement pour charger une cotisation par ID
class LoadCotisationById extends CotisationsEvent {
final String id;
const LoadCotisationById(this.id);
@override
List<Object?> get props => [id];
}
/// Événement pour charger une cotisation par référence
class LoadCotisationByReference extends CotisationsEvent {
final String numeroReference;
const LoadCotisationByReference(this.numeroReference);
@override
List<Object?> get props => [numeroReference];
}
/// Événement pour créer une nouvelle cotisation
class CreateCotisation extends CotisationsEvent {
final CotisationModel cotisation;
const CreateCotisation(this.cotisation);
@override
List<Object?> get props => [cotisation];
}
/// Événement pour mettre à jour une cotisation
class UpdateCotisation extends CotisationsEvent {
final String id;
final CotisationModel cotisation;
const UpdateCotisation(this.id, this.cotisation);
@override
List<Object?> get props => [id, cotisation];
}
/// Événement pour supprimer une cotisation
class DeleteCotisation extends CotisationsEvent {
final String id;
const DeleteCotisation(this.id);
@override
List<Object?> get props => [id];
}
/// Événement pour charger les cotisations d'un membre
class LoadCotisationsByMembre extends CotisationsEvent {
final String membreId;
final int page;
final int size;
final bool refresh;
const LoadCotisationsByMembre(
this.membreId, {
this.page = 0,
this.size = 20,
this.refresh = false,
});
@override
List<Object?> get props => [membreId, page, size, refresh];
}
/// Événement pour charger les cotisations par statut
class LoadCotisationsByStatut extends CotisationsEvent {
final String statut;
final int page;
final int size;
final bool refresh;
const LoadCotisationsByStatut(
this.statut, {
this.page = 0,
this.size = 20,
this.refresh = false,
});
@override
List<Object?> get props => [statut, page, size, refresh];
}
/// Événement pour charger les cotisations en retard
class LoadCotisationsEnRetard extends CotisationsEvent {
final int page;
final int size;
final bool refresh;
const LoadCotisationsEnRetard({
this.page = 0,
this.size = 20,
this.refresh = false,
});
@override
List<Object?> get props => [page, size, refresh];
}
/// Événement pour rechercher des cotisations
class SearchCotisations extends CotisationsEvent {
final String? membreId;
final String? statut;
final String? typeCotisation;
final int? annee;
final int? mois;
final int page;
final int size;
final bool refresh;
const SearchCotisations({
this.membreId,
this.statut,
this.typeCotisation,
this.annee,
this.mois,
this.page = 0,
this.size = 20,
this.refresh = false,
});
@override
List<Object?> get props => [
membreId,
statut,
typeCotisation,
annee,
mois,
page,
size,
refresh,
];
}
/// Événement pour charger les statistiques
class LoadCotisationsStats extends CotisationsEvent {
const LoadCotisationsStats();
}
/// Événement pour rafraîchir les données
class RefreshCotisations extends CotisationsEvent {
const RefreshCotisations();
}
/// Événement pour réinitialiser l'état
class ResetCotisationsState extends CotisationsEvent {
const ResetCotisationsState();
}
/// Événement pour filtrer les cotisations localement
class FilterCotisations extends CotisationsEvent {
final String? searchQuery;
final String? statutFilter;
final String? typeFilter;
const FilterCotisations({
this.searchQuery,
this.statutFilter,
this.typeFilter,
});
@override
List<Object?> get props => [searchQuery, statutFilter, typeFilter];
}
/// Événement pour trier les cotisations
class SortCotisations extends CotisationsEvent {
final String sortBy; // 'dateEcheance', 'montantDu', 'statut', etc.
final bool ascending;
const SortCotisations(this.sortBy, {this.ascending = true});
@override
List<Object?> get props => [sortBy, ascending];
}

View File

@@ -0,0 +1,247 @@
import 'package:equatable/equatable.dart';
import '../../../../core/models/cotisation_model.dart';
/// États du BLoC des cotisations
abstract class CotisationsState extends Equatable {
const CotisationsState();
@override
List<Object?> get props => [];
}
/// État initial
class CotisationsInitial extends CotisationsState {
const CotisationsInitial();
}
/// État de chargement
class CotisationsLoading extends CotisationsState {
final bool isRefreshing;
const CotisationsLoading({this.isRefreshing = false});
@override
List<Object?> get props => [isRefreshing];
}
/// État de succès avec liste des cotisations
class CotisationsLoaded extends CotisationsState {
final List<CotisationModel> cotisations;
final List<CotisationModel> filteredCotisations;
final Map<String, dynamic>? statistics;
final bool hasReachedMax;
final int currentPage;
final String? currentFilter;
final String? searchQuery;
const CotisationsLoaded({
required this.cotisations,
required this.filteredCotisations,
this.statistics,
this.hasReachedMax = false,
this.currentPage = 0,
this.currentFilter,
this.searchQuery,
});
/// Copie avec modifications
CotisationsLoaded copyWith({
List<CotisationModel>? cotisations,
List<CotisationModel>? filteredCotisations,
Map<String, dynamic>? statistics,
bool? hasReachedMax,
int? currentPage,
String? currentFilter,
String? searchQuery,
}) {
return CotisationsLoaded(
cotisations: cotisations ?? this.cotisations,
filteredCotisations: filteredCotisations ?? this.filteredCotisations,
statistics: statistics ?? this.statistics,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
currentPage: currentPage ?? this.currentPage,
currentFilter: currentFilter ?? this.currentFilter,
searchQuery: searchQuery ?? this.searchQuery,
);
}
@override
List<Object?> get props => [
cotisations,
filteredCotisations,
statistics,
hasReachedMax,
currentPage,
currentFilter,
searchQuery,
];
}
/// État de succès pour une cotisation unique
class CotisationDetailLoaded extends CotisationsState {
final CotisationModel cotisation;
const CotisationDetailLoaded(this.cotisation);
@override
List<Object?> get props => [cotisation];
}
/// État de succès pour la création d'une cotisation
class CotisationCreated extends CotisationsState {
final CotisationModel cotisation;
const CotisationCreated(this.cotisation);
@override
List<Object?> get props => [cotisation];
}
/// État de succès pour la mise à jour d'une cotisation
class CotisationUpdated extends CotisationsState {
final CotisationModel cotisation;
const CotisationUpdated(this.cotisation);
@override
List<Object?> get props => [cotisation];
}
/// État de succès pour la suppression d'une cotisation
class CotisationDeleted extends CotisationsState {
final String cotisationId;
const CotisationDeleted(this.cotisationId);
@override
List<Object?> get props => [cotisationId];
}
/// État d'erreur
class CotisationsError extends CotisationsState {
final String message;
final String? errorCode;
final dynamic originalError;
const CotisationsError(
this.message, {
this.errorCode,
this.originalError,
});
@override
List<Object?> get props => [message, errorCode, originalError];
}
/// État de chargement pour une opération spécifique
class CotisationOperationLoading extends CotisationsState {
final String operation; // 'create', 'update', 'delete'
final String? cotisationId;
const CotisationOperationLoading(this.operation, {this.cotisationId});
@override
List<Object?> get props => [operation, cotisationId];
}
/// État de succès pour les statistiques
class CotisationsStatsLoaded extends CotisationsState {
final Map<String, dynamic> statistics;
const CotisationsStatsLoaded(this.statistics);
@override
List<Object?> get props => [statistics];
}
/// État pour les cotisations filtrées par membre
class CotisationsByMembreLoaded extends CotisationsState {
final String membreId;
final List<CotisationModel> cotisations;
final bool hasReachedMax;
final int currentPage;
const CotisationsByMembreLoaded({
required this.membreId,
required this.cotisations,
this.hasReachedMax = false,
this.currentPage = 0,
});
CotisationsByMembreLoaded copyWith({
String? membreId,
List<CotisationModel>? cotisations,
bool? hasReachedMax,
int? currentPage,
}) {
return CotisationsByMembreLoaded(
membreId: membreId ?? this.membreId,
cotisations: cotisations ?? this.cotisations,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
currentPage: currentPage ?? this.currentPage,
);
}
@override
List<Object?> get props => [membreId, cotisations, hasReachedMax, currentPage];
}
/// État pour les cotisations en retard
class CotisationsEnRetardLoaded extends CotisationsState {
final List<CotisationModel> cotisations;
final bool hasReachedMax;
final int currentPage;
const CotisationsEnRetardLoaded({
required this.cotisations,
this.hasReachedMax = false,
this.currentPage = 0,
});
CotisationsEnRetardLoaded copyWith({
List<CotisationModel>? cotisations,
bool? hasReachedMax,
int? currentPage,
}) {
return CotisationsEnRetardLoaded(
cotisations: cotisations ?? this.cotisations,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
currentPage: currentPage ?? this.currentPage,
);
}
@override
List<Object?> get props => [cotisations, hasReachedMax, currentPage];
}
/// État pour les résultats de recherche
class CotisationsSearchResults extends CotisationsState {
final List<CotisationModel> cotisations;
final Map<String, dynamic> searchCriteria;
final bool hasReachedMax;
final int currentPage;
const CotisationsSearchResults({
required this.cotisations,
required this.searchCriteria,
this.hasReachedMax = false,
this.currentPage = 0,
});
CotisationsSearchResults copyWith({
List<CotisationModel>? cotisations,
Map<String, dynamic>? searchCriteria,
bool? hasReachedMax,
int? currentPage,
}) {
return CotisationsSearchResults(
cotisations: cotisations ?? this.cotisations,
searchCriteria: searchCriteria ?? this.searchCriteria,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
currentPage: currentPage ?? this.currentPage,
);
}
@override
List<Object?> get props => [cotisations, searchCriteria, hasReachedMax, currentPage];
}

View File

@@ -0,0 +1,338 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/coming_soon_page.dart';
import '../bloc/cotisations_bloc.dart';
import '../bloc/cotisations_event.dart';
import '../bloc/cotisations_state.dart';
import '../widgets/cotisation_card.dart';
import '../widgets/cotisations_stats_card.dart';
/// Page principale pour la liste des cotisations
class CotisationsListPage extends StatefulWidget {
const CotisationsListPage({super.key});
@override
State<CotisationsListPage> createState() => _CotisationsListPageState();
}
class _CotisationsListPageState extends State<CotisationsListPage> {
late final CotisationsBloc _cotisationsBloc;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_cotisationsBloc = getIt<CotisationsBloc>();
_scrollController.addListener(_onScroll);
// Charger les données initiales
_cotisationsBloc.add(const LoadCotisations());
_cotisationsBloc.add(const LoadCotisationsStats());
}
@override
void dispose() {
_scrollController.dispose();
_cotisationsBloc.close();
super.dispose();
}
void _onScroll() {
if (_isBottom) {
final currentState = _cotisationsBloc.state;
if (currentState is CotisationsLoaded && !currentState.hasReachedMax) {
_cotisationsBloc.add(LoadCotisations(
page: currentState.currentPage + 1,
size: 20,
));
}
}
}
bool get _isBottom {
if (!_scrollController.hasClients) return false;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.9);
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cotisationsBloc,
child: Scaffold(
backgroundColor: AppTheme.backgroundLight,
body: Column(
children: [
// Header personnalisé
_buildHeader(),
// Contenu principal
Expanded(
child: BlocBuilder<CotisationsBloc, CotisationsState>(
builder: (context, state) {
if (state is CotisationsInitial ||
(state is CotisationsLoading && !state.isRefreshing)) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state is CotisationsError) {
return _buildErrorState(state);
}
if (state is CotisationsLoaded) {
return _buildLoadedState(state);
}
// État par défaut - Coming Soon
return const ComingSoonPage(
title: 'Module Cotisations',
description: 'Gestion complète des cotisations avec paiements automatiques',
icon: Icons.payment_rounded,
color: AppTheme.accentColor,
features: [
'Tableau de bord des cotisations',
'Relances automatiques par email/SMS',
'Paiements en ligne sécurisés',
'Génération de reçus automatique',
'Suivi des retards de paiement',
'Rapports financiers détaillés',
],
);
},
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// TODO: Implémenter la création de cotisation
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Création de cotisation - En cours de développement'),
backgroundColor: AppTheme.accentColor,
),
);
},
backgroundColor: AppTheme.accentColor,
child: const Icon(Icons.add, color: Colors.white),
),
),
);
}
Widget _buildHeader() {
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 50, 16, 16),
decoration: const BoxDecoration(
color: AppTheme.accentColor,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Cotisations',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Row(
children: [
IconButton(
icon: const Icon(Icons.search, color: Colors.white),
onPressed: () {
// TODO: Implémenter la recherche
},
),
IconButton(
icon: const Icon(Icons.filter_list, color: Colors.white),
onPressed: () {
// TODO: Implémenter les filtres
},
),
],
),
],
),
const SizedBox(height: 8),
const Text(
'Gérez les cotisations de vos membres',
style: TextStyle(
fontSize: 16,
color: Colors.white70,
),
),
],
),
);
}
Widget _buildLoadedState(CotisationsLoaded state) {
return RefreshIndicator(
onRefresh: () async {
_cotisationsBloc.add(const LoadCotisations(refresh: true));
_cotisationsBloc.add(const LoadCotisationsStats());
},
child: CustomScrollView(
controller: _scrollController,
slivers: [
// Statistiques
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: BlocBuilder<CotisationsBloc, CotisationsState>(
buildWhen: (previous, current) => current is CotisationsStatsLoaded,
builder: (context, statsState) {
if (statsState is CotisationsStatsLoaded) {
return CotisationsStatsCard(statistics: statsState.statistics);
}
return const SizedBox.shrink();
},
),
),
),
// Liste des cotisations
if (state.filteredCotisations.isEmpty)
const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.payment_outlined,
size: 64,
color: AppTheme.textHint,
),
SizedBox(height: 16),
Text(
'Aucune cotisation trouvée',
style: TextStyle(
fontSize: 18,
color: AppTheme.textSecondary,
),
),
SizedBox(height: 8),
Text(
'Commencez par créer une cotisation',
style: TextStyle(
fontSize: 14,
color: AppTheme.textHint,
),
),
],
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= state.filteredCotisations.length) {
return state.hasReachedMax
? const SizedBox.shrink()
: const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(),
),
);
}
final cotisation = state.filteredCotisations[index];
return Padding(
padding: EdgeInsets.fromLTRB(
16,
index == 0 ? 0 : 8,
16,
index == state.filteredCotisations.length - 1 ? 16 : 8,
),
child: CotisationCard(
cotisation: cotisation,
onTap: () {
// TODO: Naviguer vers le détail
},
onPay: () {
// TODO: Implémenter le paiement
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Paiement - En cours de développement'),
backgroundColor: AppTheme.successColor,
),
);
},
),
);
},
childCount: state.filteredCotisations.length +
(state.hasReachedMax ? 0 : 1),
),
),
],
),
);
}
Widget _buildErrorState(CotisationsError state) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: AppTheme.errorColor,
),
const SizedBox(height: 16),
const Text(
'Erreur de chargement',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Text(
state.message,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
_cotisationsBloc.add(const LoadCotisations(refresh: true));
_cotisationsBloc.add(const LoadCotisationsStats());
},
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,316 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../shared/theme/app_theme.dart';
/// Widget card pour afficher une cotisation
class CotisationCard extends StatelessWidget {
final CotisationModel cotisation;
final VoidCallback? onTap;
final VoidCallback? onPay;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
const CotisationCard({
super.key,
required this.cotisation,
this.onTap,
this.onPay,
this.onEdit,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat.currency(
locale: 'fr_FR',
symbol: 'FCFA',
decimalDigits: 0,
);
final dateFormat = DateFormat('dd/MM/yyyy', 'fr_FR');
return Card(
elevation: 2,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: _getStatusColor().withOpacity(0.3),
width: 1,
),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header avec statut et actions
Row(
children: [
// Statut badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getStatusColor().withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
cotisation.libelleStatut,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _getStatusColor(),
),
),
),
const Spacer(),
// Actions
if (cotisation.statut == 'EN_ATTENTE' || cotisation.statut == 'EN_RETARD')
IconButton(
onPressed: onPay,
icon: const Icon(Icons.payment, size: 20),
color: AppTheme.successColor,
tooltip: 'Payer',
),
if (onEdit != null)
IconButton(
onPressed: onEdit,
icon: const Icon(Icons.edit, size: 20),
color: AppTheme.primaryColor,
tooltip: 'Modifier',
),
if (onDelete != null)
IconButton(
onPressed: onDelete,
icon: const Icon(Icons.delete, size: 20),
color: AppTheme.errorColor,
tooltip: 'Supprimer',
),
],
),
const SizedBox(height: 12),
// Informations principales
Row(
children: [
// Icône du type
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Center(
child: Text(
cotisation.iconeTypeCotisation,
style: const TextStyle(fontSize: 20),
),
),
),
const SizedBox(width: 12),
// Détails
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
cotisation.libelleTypeCotisation,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
if (cotisation.nomMembre != null) ...[
const SizedBox(height: 2),
Text(
cotisation.nomMembre!,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
],
if (cotisation.periode != null) ...[
const SizedBox(height: 2),
Text(
cotisation.periode!,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textHint,
),
),
],
],
),
),
// Montant
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
currencyFormat.format(cotisation.montantDu),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
if (cotisation.montantPaye > 0) ...[
const SizedBox(height: 2),
Text(
'Payé: ${currencyFormat.format(cotisation.montantPaye)}',
style: const TextStyle(
fontSize: 12,
color: AppTheme.successColor,
),
),
],
],
),
],
),
const SizedBox(height: 12),
// Barre de progression du paiement
if (cotisation.montantPaye > 0 && !cotisation.isEntierementPayee) ...[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Progression',
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
Text(
'${cotisation.pourcentagePaiement.toStringAsFixed(0)}%',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.textSecondary,
),
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: cotisation.pourcentagePaiement / 100,
backgroundColor: AppTheme.borderColor,
valueColor: AlwaysStoppedAnimation<Color>(
cotisation.pourcentagePaiement >= 100
? AppTheme.successColor
: AppTheme.primaryColor,
),
),
],
),
const SizedBox(height: 12),
],
// Informations d'échéance
Row(
children: [
Icon(
Icons.schedule,
size: 16,
color: cotisation.isEnRetard
? AppTheme.errorColor
: cotisation.echeanceProche
? AppTheme.warningColor
: AppTheme.textHint,
),
const SizedBox(width: 4),
Text(
'Échéance: ${dateFormat.format(cotisation.dateEcheance)}',
style: TextStyle(
fontSize: 12,
color: cotisation.isEnRetard
? AppTheme.errorColor
: cotisation.echeanceProche
? AppTheme.warningColor
: AppTheme.textSecondary,
),
),
if (cotisation.messageUrgence.isNotEmpty) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: cotisation.isEnRetard
? AppTheme.errorColor.withOpacity(0.1)
: AppTheme.warningColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
cotisation.messageUrgence,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: cotisation.isEnRetard
? AppTheme.errorColor
: AppTheme.warningColor,
),
),
),
],
],
),
// Référence
const SizedBox(height: 8),
Row(
children: [
const Icon(
Icons.tag,
size: 16,
color: AppTheme.textHint,
),
const SizedBox(width: 4),
Text(
'Réf: ${cotisation.numeroReference}',
style: const TextStyle(
fontSize: 12,
color: AppTheme.textHint,
),
),
],
),
],
),
),
),
);
}
Color _getStatusColor() {
switch (cotisation.statut) {
case 'PAYEE':
return AppTheme.successColor;
case 'EN_ATTENTE':
return AppTheme.warningColor;
case 'EN_RETARD':
return AppTheme.errorColor;
case 'PARTIELLEMENT_PAYEE':
return AppTheme.infoColor;
case 'ANNULEE':
return AppTheme.textHint;
default:
return AppTheme.textSecondary;
}
}
}

View File

@@ -0,0 +1,283 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../shared/theme/app_theme.dart';
/// Widget pour afficher les statistiques des cotisations
class CotisationsStatsCard extends StatelessWidget {
final Map<String, dynamic> statistics;
const CotisationsStatsCard({
super.key,
required this.statistics,
});
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat.currency(
locale: 'fr_FR',
symbol: 'FCFA',
decimalDigits: 0,
);
final totalCotisations = statistics['totalCotisations'] as int? ?? 0;
final cotisationsPayees = statistics['cotisationsPayees'] as int? ?? 0;
final cotisationsEnRetard = statistics['cotisationsEnRetard'] as int? ?? 0;
final tauxPaiement = statistics['tauxPaiement'] as double? ?? 0.0;
return Card(
elevation: 2,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: AppTheme.accentColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
Icons.analytics,
size: 18,
color: AppTheme.accentColor,
),
),
const SizedBox(width: 12),
const Text(
'Statistiques des cotisations',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 16),
// Grille des statistiques
Row(
children: [
// Total des cotisations
Expanded(
child: _buildStatItem(
icon: Icons.receipt_long,
label: 'Total',
value: totalCotisations.toString(),
color: AppTheme.primaryColor,
),
),
const SizedBox(width: 12),
// Cotisations payées
Expanded(
child: _buildStatItem(
icon: Icons.check_circle,
label: 'Payées',
value: cotisationsPayees.toString(),
color: AppTheme.successColor,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
// Cotisations en retard
Expanded(
child: _buildStatItem(
icon: Icons.warning,
label: 'En retard',
value: cotisationsEnRetard.toString(),
color: AppTheme.errorColor,
),
),
const SizedBox(width: 12),
// Taux de paiement
Expanded(
child: _buildStatItem(
icon: Icons.trending_up,
label: 'Taux paiement',
value: '${tauxPaiement.toStringAsFixed(1)}%',
color: tauxPaiement >= 80
? AppTheme.successColor
: tauxPaiement >= 60
? AppTheme.warningColor
: AppTheme.errorColor,
),
),
],
),
const SizedBox(height: 16),
// Barre de progression globale
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Progression globale',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
Text(
'${tauxPaiement.toStringAsFixed(1)}%',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: tauxPaiement / 100,
backgroundColor: AppTheme.borderColor,
valueColor: AlwaysStoppedAnimation<Color>(
tauxPaiement >= 80
? AppTheme.successColor
: tauxPaiement >= 60
? AppTheme.warningColor
: AppTheme.errorColor,
),
),
],
),
// Montants si disponibles
if (statistics.containsKey('montantTotal') ||
statistics.containsKey('montantPaye')) ...[
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
Row(
children: [
if (statistics.containsKey('montantTotal')) ...[
Expanded(
child: _buildMoneyStatItem(
label: 'Montant total',
value: currencyFormat.format(
(statistics['montantTotal'] as num?)?.toDouble() ?? 0.0
),
color: AppTheme.textPrimary,
),
),
],
if (statistics.containsKey('montantTotal') &&
statistics.containsKey('montantPaye'))
const SizedBox(width: 12),
if (statistics.containsKey('montantPaye')) ...[
Expanded(
child: _buildMoneyStatItem(
label: 'Montant payé',
value: currencyFormat.format(
(statistics['montantPaye'] as num?)?.toDouble() ?? 0.0
),
color: AppTheme.successColor,
),
),
],
],
),
],
],
),
),
);
}
Widget _buildStatItem({
required IconData icon,
required String label,
required String value,
required Color color,
}) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Icon(
icon,
size: 24,
color: color,
),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildMoneyStatItem({
required String label,
required String value,
required Color color,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
],
);
}
}

View File

@@ -1,7 +1,24 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../../../shared/theme/app_theme.dart';
// Imports des nouveaux widgets refactorisés
import '../widgets/welcome/welcome_section_widget.dart';
import '../widgets/kpi/kpi_cards_widget.dart';
import '../widgets/actions/quick_actions_widget.dart';
import '../widgets/activities/recent_activities_widget.dart';
import '../widgets/charts/charts_analytics_widget.dart';
/// Page principale du tableau de bord UnionFlow
///
/// Affiche une vue d'ensemble complète de l'association avec :
/// - Section d'accueil personnalisée
/// - Indicateurs clés de performance (KPI)
/// - Actions rapides et gestion
/// - Flux d'activités en temps réel
/// - Analyses et tendances graphiques
///
/// Architecture modulaire avec widgets réutilisables pour une
/// maintenabilité optimale et une évolutivité facilitée.
class DashboardPage extends StatelessWidget {
const DashboardPage({super.key});
@@ -16,11 +33,15 @@ class DashboardPage extends StatelessWidget {
actions: [
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () {},
onPressed: () {
// TODO: Implémenter la navigation vers les notifications
},
),
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () {},
onPressed: () {
// TODO: Implémenter la navigation vers les paramètres
},
),
],
),
@@ -30,646 +51,32 @@ class DashboardPage extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Message de bienvenue
_buildWelcomeSection(context),
// 1. ACCUEIL & CONTEXTE - Message de bienvenue personnalisé
const WelcomeSectionWidget(),
const SizedBox(height: 24),
// Cartes KPI principales
_buildKPICards(context),
// 2. VISION GLOBALE - Indicateurs clés de performance (KPI)
// Vue d'ensemble immédiate de la santé de l'association
const KPICardsWidget(),
const SizedBox(height: 24),
// Graphiques et statistiques
_buildChartsSection(context),
// 3. ACTIONS PRIORITAIRES - Actions rapides et gestion
// Accès direct aux tâches critiques quotidiennes
const QuickActionsWidget(),
const SizedBox(height: 24),
// Actions rapides
_buildQuickActions(context),
// 4. SUIVI TEMPS RÉEL - Flux d'activités en direct
// Monitoring des événements récents et alertes
const RecentActivitiesWidget(),
const SizedBox(height: 24),
// Activités récentes
_buildRecentActivities(context),
// 5. ANALYSES APPROFONDIES - Graphiques et tendances
// Analyses détaillées pour la prise de décision stratégique
const ChartsAnalyticsWidget(),
],
),
),
),
);
}
Widget _buildWelcomeSection(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppTheme.primaryColor, AppTheme.primaryLight],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Bonjour !',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Voici un aperçu de votre association',
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 16,
),
),
],
),
),
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(30),
),
child: const Icon(
Icons.dashboard,
color: Colors.white,
size: 30,
),
),
],
),
);
}
Widget _buildKPICards(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Indicateurs clés',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildKPICard(
context,
'Membres',
'1,247',
'+5.2%',
Icons.people,
AppTheme.primaryColor,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildKPICard(
context,
'Revenus',
'€45,890',
'+12.8%',
Icons.euro,
AppTheme.successColor,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildKPICard(
context,
'Événements',
'23',
'+3',
Icons.event,
AppTheme.accentColor,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildKPICard(
context,
'Cotisations',
'89.5%',
'+2.1%',
Icons.payments,
AppTheme.infoColor,
),
),
],
),
],
);
}
Widget _buildKPICard(
BuildContext context,
String title,
String value,
String change,
IconData icon,
Color color,
) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 20,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.successColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
change,
style: const TextStyle(
color: AppTheme.successColor,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
Text(
value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
title,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
],
),
);
}
Widget _buildChartsSection(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Analyses',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildLineChart(context),
),
const SizedBox(width: 12),
Expanded(
child: _buildPieChart(context),
),
],
),
],
);
}
Widget _buildLineChart(BuildContext context) {
return Container(
height: 200,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Évolution des membres',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
Expanded(
child: LineChart(
LineChartData(
gridData: const FlGridData(show: false),
titlesData: const FlTitlesData(show: false),
borderData: FlBorderData(show: false),
lineBarsData: [
LineChartBarData(
spots: const [
FlSpot(0, 1000),
FlSpot(1, 1050),
FlSpot(2, 1100),
FlSpot(3, 1180),
FlSpot(4, 1247),
],
color: AppTheme.primaryColor,
barWidth: 3,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
color: AppTheme.primaryColor.withOpacity(0.1),
),
),
],
),
),
),
],
),
);
}
Widget _buildPieChart(BuildContext context) {
return Container(
height: 200,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Répartition des membres',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
Expanded(
child: PieChart(
PieChartData(
sectionsSpace: 0,
centerSpaceRadius: 40,
sections: [
PieChartSectionData(
color: AppTheme.primaryColor,
value: 45,
title: '45%',
radius: 50,
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
PieChartSectionData(
color: AppTheme.secondaryColor,
value: 30,
title: '30%',
radius: 50,
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
PieChartSectionData(
color: AppTheme.accentColor,
value: 25,
title: '25%',
radius: 50,
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
),
),
],
),
);
}
Widget _buildQuickActions(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Actions rapides',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildActionCard(
context,
'Nouveau membre',
'Ajouter un membre',
Icons.person_add,
AppTheme.primaryColor,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionCard(
context,
'Créer événement',
'Organiser un événement',
Icons.event_available,
AppTheme.secondaryColor,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildActionCard(
context,
'Suivi cotisations',
'Gérer les cotisations',
Icons.payment,
AppTheme.accentColor,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionCard(
context,
'Rapports',
'Générer des rapports',
Icons.analytics,
AppTheme.infoColor,
),
),
],
),
],
);
}
Widget _buildActionCard(
BuildContext context,
String title,
String subtitle,
IconData icon,
Color color,
) {
return InkWell(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$title - En cours de développement'),
backgroundColor: color,
),
);
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.2)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: color,
size: 24,
),
),
const SizedBox(height: 12),
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
Widget _buildRecentActivities(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Activités récentes',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
TextButton(
onPressed: () {},
child: const Text('Voir tout'),
),
],
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
_buildActivityItem(
'Nouveau membre inscrit',
'Marie Dupont a rejoint l\'association',
Icons.person_add,
AppTheme.successColor,
'Il y a 2h',
),
const Divider(height: 1),
_buildActivityItem(
'Cotisation reçue',
'Jean Martin a payé sa cotisation annuelle',
Icons.payment,
AppTheme.primaryColor,
'Il y a 4h',
),
const Divider(height: 1),
_buildActivityItem(
'Événement créé',
'Assemblée générale 2024 programmée',
Icons.event,
AppTheme.accentColor,
'Hier',
),
],
),
),
],
);
}
Widget _buildActivityItem(
String title,
String description,
IconData icon,
Color color,
String time,
) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
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(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 2),
Text(
description,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
Text(
time,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textHint,
),
),
],
),
);
}
}
}

View File

@@ -1,485 +0,0 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../widgets/clickable_kpi_card.dart';
import '../widgets/chart_card.dart';
import '../widgets/activity_feed.dart';
import '../widgets/quick_actions_grid.dart';
import '../widgets/navigation_cards.dart';
class EnhancedDashboard extends StatefulWidget {
final Function(int)? onNavigateToTab;
const EnhancedDashboard({
super.key,
this.onNavigateToTab,
});
@override
State<EnhancedDashboard> createState() => _EnhancedDashboardState();
}
class _EnhancedDashboardState extends State<EnhancedDashboard> {
final PageController _pageController = PageController();
int _currentPage = 0;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
body: CustomScrollView(
slivers: [
_buildAppBar(),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildWelcomeCard(),
const SizedBox(height: 24),
_buildKPISection(),
const SizedBox(height: 24),
_buildChartsSection(),
const SizedBox(height: 24),
NavigationCards(
onNavigateToTab: widget.onNavigateToTab,
),
const SizedBox(height: 24),
const QuickActionsGrid(),
const SizedBox(height: 24),
const ActivityFeed(),
const SizedBox(height: 24),
],
),
),
),
],
),
);
}
Widget _buildAppBar() {
return SliverAppBar(
expandedHeight: 120,
floating: false,
pinned: true,
backgroundColor: AppTheme.primaryColor,
flexibleSpace: FlexibleSpaceBar(
title: const Text(
'Tableau de bord',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppTheme.primaryColor, AppTheme.primaryDark],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
),
actions: [
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () => _showNotifications(),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => _refreshData(),
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: _handleMenuSelection,
itemBuilder: (context) => [
const PopupMenuItem(
value: 'settings',
child: Row(
children: [
Icon(Icons.settings),
SizedBox(width: 8),
Text('Paramètres'),
],
),
),
const PopupMenuItem(
value: 'export',
child: Row(
children: [
Icon(Icons.download),
SizedBox(width: 8),
Text('Exporter'),
],
),
),
const PopupMenuItem(
value: 'help',
child: Row(
children: [
Icon(Icons.help),
SizedBox(width: 8),
Text('Aide'),
],
),
),
],
),
],
);
}
Widget _buildWelcomeCard() {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppTheme.primaryColor, AppTheme.primaryLight],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: AppTheme.primaryColor.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Bonjour !',
style: TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Découvrez les dernières statistiques de votre association',
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 16,
),
),
const SizedBox(height: 16),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.trending_up,
color: Colors.white,
size: 16,
),
const SizedBox(width: 4),
Text(
'+12% ce mois',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
],
),
),
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(40),
),
child: const Icon(
Icons.dashboard_rounded,
color: Colors.white,
size: 40,
),
),
],
),
);
}
Widget _buildKPISection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Indicateurs clés',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
TextButton.icon(
onPressed: () {},
icon: const Icon(Icons.analytics, size: 16),
label: const Text('Analyse détaillée'),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 180,
child: PageView(
controller: _pageController,
onPageChanged: (index) {
setState(() {
_currentPage = index;
});
},
children: [
_buildKPIPage1(),
_buildKPIPage2(),
],
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildPageIndicator(0),
const SizedBox(width: 8),
_buildPageIndicator(1),
],
),
],
);
}
Widget _buildKPIPage1() {
return Row(
children: [
Expanded(
child: ClickableKPICard(
title: 'Membres actifs',
value: '1,247',
change: '+5.2%',
icon: Icons.people,
color: AppTheme.secondaryColor,
actionText: 'Gérer',
onTap: () => widget.onNavigateToTab?.call(1),
),
),
const SizedBox(width: 12),
Expanded(
child: ClickableKPICard(
title: 'Revenus mensuel',
value: '€45,890',
change: '+12.8%',
icon: Icons.euro,
color: AppTheme.successColor,
actionText: 'Finances',
onTap: () => _showFinancesMessage(),
),
),
],
);
}
Widget _buildKPIPage2() {
return Row(
children: [
Expanded(
child: ClickableKPICard(
title: 'Événements',
value: '23',
change: '+3',
icon: Icons.event,
color: AppTheme.warningColor,
actionText: 'Planifier',
onTap: () => widget.onNavigateToTab?.call(3),
),
),
const SizedBox(width: 12),
Expanded(
child: ClickableKPICard(
title: 'Taux cotisation',
value: '89.5%',
change: '+2.1%',
icon: Icons.payments,
color: AppTheme.accentColor,
actionText: 'Gérer',
onTap: () => widget.onNavigateToTab?.call(2),
),
),
],
);
}
Widget _buildPageIndicator(int index) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: _currentPage == index ? 20 : 8,
height: 8,
decoration: BoxDecoration(
color: _currentPage == index
? AppTheme.primaryColor
: AppTheme.primaryColor.withOpacity(0.3),
borderRadius: BorderRadius.circular(4),
),
);
}
Widget _buildChartsSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Analyses et tendances',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
ChartCard(
title: 'Évolution des membres',
subtitle: 'Croissance sur 6 mois',
chart: const MembershipChart(),
onTap: () => widget.onNavigateToTab?.call(1),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ChartCard(
title: 'Répartition',
subtitle: 'Par catégorie',
chart: const CategoryChart(),
onTap: () => widget.onNavigateToTab?.call(1),
),
),
const SizedBox(width: 12),
Expanded(
child: ChartCard(
title: 'Revenus',
subtitle: 'Évolution mensuelle',
chart: const RevenueChart(),
onTap: () => _showFinancesMessage(),
),
),
],
),
],
);
}
void _showNotifications() {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Notifications',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.warning, color: AppTheme.warningColor),
title: const Text('3 cotisations en retard'),
subtitle: const Text('Nécessite votre attention'),
onTap: () {},
),
ListTile(
leading: const Icon(Icons.event, color: AppTheme.accentColor),
title: const Text('Assemblée générale'),
subtitle: const Text('Dans 5 jours'),
onTap: () {},
),
ListTile(
leading: const Icon(Icons.check_circle, color: AppTheme.successColor),
title: const Text('Rapport mensuel'),
subtitle: const Text('Prêt à être envoyé'),
onTap: () {},
),
],
),
),
);
}
void _refreshData() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Données actualisées'),
backgroundColor: AppTheme.successColor,
behavior: SnackBarBehavior.floating,
),
);
}
void _handleMenuSelection(String value) {
switch (value) {
case 'settings':
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Paramètres - En développement')),
);
break;
case 'export':
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Export - En développement')),
);
break;
case 'help':
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aide - En développement')),
);
break;
}
}
void _showFinancesMessage() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Module Finances - Prochainement disponible'),
backgroundColor: AppTheme.successColor,
behavior: SnackBarBehavior.floating,
),
);
}
}

View File

@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
/// Widget de carte d'action rapide réutilisable
///
/// Affiche une action cliquable avec:
/// - Icône colorée dans un conteneur arrondi
/// - Titre principal
/// - Sous-titre descriptif
/// - Interaction tactile avec feedback visuel
/// - Callback personnalisable pour l'action
class ActionCardWidget extends StatelessWidget {
/// Titre de l'action
final String title;
/// Description de l'action
final String subtitle;
/// Icône représentative
final IconData icon;
/// Couleur thématique de l'action
final Color color;
/// Callback exécuté lors du tap
final VoidCallback? onTap;
const ActionCardWidget({
super.key,
required this.title,
required this.subtitle,
required this.icon,
required this.color,
this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap ?? () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$title - En cours de développement'),
backgroundColor: color,
),
);
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.2)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: color,
size: 24,
),
),
const SizedBox(height: 12),
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
import 'action_card_widget.dart';
/// Widget de section des actions rapides et de gestion
///
/// Affiche une grille d'actions rapides organisées par catégories:
/// - Actions principales (nouveau membre, créer événement)
/// - Gestion financière (encaisser cotisation, relances)
/// - Communication (messages, convocations)
/// - Rapports et conformité (OHADA, exports)
/// - Urgences et support (alertes, assistance)
class QuickActionsWidget extends StatelessWidget {
const QuickActionsWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Actions rapides & Gestion',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
// Première ligne - Actions principales
Row(
children: [
Expanded(
child: ActionCardWidget(
title: 'Nouveau membre',
subtitle: 'Inscription rapide',
icon: Icons.person_add,
color: AppTheme.primaryColor,
),
),
const SizedBox(width: 12),
Expanded(
child: ActionCardWidget(
title: 'Créer événement',
subtitle: 'Organiser activité',
icon: Icons.event_available,
color: AppTheme.secondaryColor,
),
),
],
),
const SizedBox(height: 12),
// Deuxième ligne - Gestion financière
Row(
children: [
Expanded(
child: ActionCardWidget(
title: 'Encaisser cotisation',
subtitle: 'Paiement immédiat',
icon: Icons.payment,
color: AppTheme.successColor,
),
),
const SizedBox(width: 12),
Expanded(
child: ActionCardWidget(
title: 'Relances impayés',
subtitle: 'Notifications SMS',
icon: Icons.notifications_active,
color: AppTheme.warningColor,
),
),
],
),
const SizedBox(height: 12),
// Troisième ligne - Communication
Row(
children: [
Expanded(
child: ActionCardWidget(
title: 'Message groupe',
subtitle: 'Diffusion WhatsApp',
icon: Icons.message,
color: const Color(0xFF25D366),
),
),
const SizedBox(width: 12),
Expanded(
child: ActionCardWidget(
title: 'Convoquer AG',
subtitle: 'Assemblée générale',
icon: Icons.groups,
color: const Color(0xFF9C27B0),
),
),
],
),
const SizedBox(height: 12),
// Quatrième ligne - Rapports et conformité
Row(
children: [
Expanded(
child: ActionCardWidget(
title: 'Rapport OHADA',
subtitle: 'Conformité légale',
icon: Icons.gavel,
color: const Color(0xFF795548),
),
),
const SizedBox(width: 12),
Expanded(
child: ActionCardWidget(
title: 'Export données',
subtitle: 'Sauvegarde Excel',
icon: Icons.file_download,
color: AppTheme.infoColor,
),
),
],
),
const SizedBox(height: 12),
// Cinquième ligne - Urgences et support
Row(
children: [
Expanded(
child: ActionCardWidget(
title: 'Alerte urgente',
subtitle: 'Notification critique',
icon: Icons.emergency,
color: AppTheme.errorColor,
),
),
const SizedBox(width: 12),
Expanded(
child: ActionCardWidget(
title: 'Support technique',
subtitle: 'Assistance UnionFlow',
icon: Icons.support_agent,
color: const Color(0xFF607D8B),
),
),
],
),
],
);
}
}

View File

@@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
/// Widget d'élément d'activité récente réutilisable
///
/// Affiche une activité avec:
/// - Icône colorée avec indicateur "nouveau" optionnel
/// - Titre et description
/// - Horodatage avec mise en évidence pour les nouveaux éléments
/// - Badge "NOUVEAU" pour les activités récentes
/// - Indicateur visuel pour les nouvelles activités
class ActivityItemWidget extends StatelessWidget {
/// Titre de l'activité
final String title;
/// Description détaillée de l'activité
final String description;
/// Icône représentative
final IconData icon;
/// Couleur thématique
final Color color;
/// Horodatage de l'activité
final String time;
/// Indique si l'activité est nouvelle
final bool isNew;
const ActivityItemWidget({
super.key,
required this.title,
required this.description,
required this.icon,
required this.color,
required this.time,
this.isNew = false,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Stack(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 16,
),
),
if (isNew)
Positioned(
top: -2,
right: -2,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppTheme.errorColor,
shape: BoxShape.circle,
),
),
),
],
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: isNew ? FontWeight.w700 : FontWeight.w600,
color: isNew ? AppTheme.textPrimary : AppTheme.textPrimary,
),
),
),
if (isNew)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.errorColor,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'NOUVEAU',
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
],
),
const SizedBox(height: 2),
Text(
description,
style: TextStyle(
fontSize: 12,
color: isNew ? AppTheme.textPrimary : AppTheme.textSecondary,
fontWeight: isNew ? FontWeight.w500 : FontWeight.normal,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
time,
style: TextStyle(
fontSize: 12,
color: isNew ? AppTheme.primaryColor : AppTheme.textHint,
fontWeight: isNew ? FontWeight.w600 : FontWeight.normal,
),
),
if (isNew)
const SizedBox(height: 2),
if (isNew)
const Icon(
Icons.fiber_new,
size: 12,
color: AppTheme.errorColor,
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
import 'activity_item_widget.dart';
/// Widget de section des activités récentes en temps réel
///
/// Affiche un flux d'activités en temps réel avec:
/// - En-tête avec indicateur "Live" et bouton "Tout voir"
/// - Liste d'activités avec indicateurs visuels pour les nouveaux éléments
/// - Séparateurs entre les éléments
/// - Horodatage précis pour chaque activité
/// - Icônes et couleurs thématiques par type d'activité
class RecentActivitiesWidget extends StatelessWidget {
const RecentActivitiesWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Flux d\'activités en temps réel',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppTheme.successColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 4,
height: 4,
decoration: const BoxDecoration(
color: AppTheme.successColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 3),
const Text(
'Live',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
color: AppTheme.successColor,
),
),
],
),
),
const SizedBox(width: 6),
TextButton(
onPressed: () {},
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text(
'Tout',
style: TextStyle(fontSize: 12),
),
),
],
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
ActivityItemWidget(
title: 'Paiement Mobile Money reçu',
description: 'Kouassi Yao - 25,000 FCFA via Orange Money',
icon: Icons.phone_android,
color: const Color(0xFFFF9800),
time: 'Il y a 3 min',
isNew: true,
),
const Divider(height: 1),
ActivityItemWidget(
title: 'Nouveau membre validé',
description: 'Adjoua Marie inscrite depuis Abidjan',
icon: Icons.person_add,
color: AppTheme.successColor,
time: 'Il y a 15 min',
isNew: true,
),
const Divider(height: 1),
ActivityItemWidget(
title: 'Relance automatique envoyée',
description: '12 SMS de rappel cotisations expédiés',
icon: Icons.sms,
color: AppTheme.infoColor,
time: 'Il y a 1h',
),
const Divider(height: 1),
ActivityItemWidget(
title: 'Rapport OHADA généré',
description: 'Bilan financier T4 2024 exporté',
icon: Icons.description,
color: const Color(0xFF795548),
time: 'Il y a 2h',
),
const Divider(height: 1),
ActivityItemWidget(
title: 'Événement: Forte participation',
description: 'AG Extraordinaire - 89% de présence',
icon: Icons.trending_up,
color: AppTheme.successColor,
time: 'Il y a 3h',
),
const Divider(height: 1),
ActivityItemWidget(
title: 'Alerte: Cotisations en retard',
description: '23 membres avec +30 jours de retard',
icon: Icons.warning,
color: AppTheme.warningColor,
time: 'Il y a 4h',
),
const Divider(height: 1),
ActivityItemWidget(
title: 'Synchronisation réussie',
description: 'Données sauvegardées sur le cloud',
icon: Icons.cloud_done,
color: AppTheme.successColor,
time: 'Il y a 6h',
),
const Divider(height: 1),
ActivityItemWidget(
title: 'Message diffusé',
description: 'Info COVID-19 envoyée à 1,247 membres',
icon: Icons.campaign,
color: const Color(0xFF9C27B0),
time: 'Hier 18:30',
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,432 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
import '../common/section_header_widget.dart';
/// Widget de section des analyses et tendances avec graphiques
///
/// Affiche tous les graphiques d'analyse en une seule colonne:
/// - Évolution des membres actifs (ligne)
/// - Répartition des cotisations (camembert)
/// - Revenus par source (barres)
/// - Cotisations par mois (barres)
/// - Engagement des membres (radar)
/// - Tendances géographiques (carte)
/// - Analyse comparative (barres groupées)
///
/// Chaque graphique est optimisé pour l'affichage mobile
/// avec des détails enrichis et des légendes complètes.
class ChartsAnalyticsWidget extends StatelessWidget {
const ChartsAnalyticsWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionHeaderWidget(title: 'Analyses & Tendances'),
const SizedBox(height: 16),
// Graphiques d'analyse - Une seule colonne pour exploiter toute la largeur
_buildLineChart(context),
const SizedBox(height: 16),
_buildPieChart(context),
const SizedBox(height: 16),
_buildRevenueChart(context),
const SizedBox(height: 16),
_buildCotisationsChart(context),
const SizedBox(height: 16),
_buildEngagementChart(context),
const SizedBox(height: 16),
_buildTrendsChart(context),
const SizedBox(height: 16),
_buildGeographicChart(context),
],
);
}
/// Graphique d'évolution des membres actifs (ligne)
Widget _buildLineChart(BuildContext context) {
return Container(
height: 280,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête enrichi avec icône et métriques
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: const Icon(
Icons.trending_up,
color: AppTheme.primaryColor,
size: 16,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Évolution des membres actifs',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 2),
const Text(
'Croissance sur 5 mois • +24.7% (+247 membres)',
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.successColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.trending_up,
color: AppTheme.successColor,
size: 12,
),
SizedBox(width: 4),
Text(
'+24.7%',
style: TextStyle(
color: AppTheme.successColor,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Placeholder pour le graphique
Expanded(
child: Container(
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppTheme.primaryColor.withOpacity(0.1),
width: 1,
),
),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.show_chart,
color: AppTheme.primaryColor,
size: 48,
),
SizedBox(height: 8),
Text(
'Graphique d\'évolution des membres',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 12,
),
),
],
),
),
),
),
],
),
);
}
/// Graphique de répartition des cotisations (camembert)
Widget _buildPieChart(BuildContext context) {
return Container(
height: 280,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête enrichi
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppTheme.accentColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: const Icon(
Icons.pie_chart,
color: AppTheme.accentColor,
size: 16,
),
),
const SizedBox(width: 8),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Répartition des cotisations',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
SizedBox(height: 2),
Text(
'Par statut de paiement • 1,247 membres total',
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Placeholder pour le graphique camembert
Expanded(
child: Container(
decoration: BoxDecoration(
color: AppTheme.accentColor.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppTheme.accentColor.withOpacity(0.1),
width: 1,
),
),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.donut_small,
color: AppTheme.accentColor,
size: 48,
),
SizedBox(height: 8),
Text(
'Graphique camembert des cotisations',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 12,
),
),
],
),
),
),
),
],
),
);
}
/// Placeholder pour les autres graphiques
Widget _buildRevenueChart(BuildContext context) {
return _buildPlaceholderChart(
'Revenus par source',
'Analyse mensuelle • 2,845,000 FCFA total',
Icons.bar_chart,
AppTheme.successColor,
'Graphique des revenus par source',
);
}
Widget _buildCotisationsChart(BuildContext context) {
return _buildPlaceholderChart(
'Cotisations par mois',
'Évolution sur 12 mois • Tendance positive',
Icons.assessment,
AppTheme.infoColor,
'Graphique des cotisations mensuelles',
);
}
Widget _buildEngagementChart(BuildContext context) {
return _buildPlaceholderChart(
'Engagement des membres',
'Analyse multi-critères • Score global 85/100',
Icons.radar,
const Color(0xFF9C27B0),
'Graphique radar d\'engagement',
);
}
Widget _buildTrendsChart(BuildContext context) {
return _buildPlaceholderChart(
'Tendances comparatives',
'Comparaison avec période précédente',
Icons.compare_arrows,
AppTheme.warningColor,
'Graphique de tendances comparatives',
);
}
Widget _buildGeographicChart(BuildContext context) {
return _buildPlaceholderChart(
'Répartition géographique',
'Membres par région • Côte d\'Ivoire',
Icons.map,
const Color(0xFF795548),
'Carte géographique des membres',
);
}
/// Widget placeholder générique pour les graphiques
Widget _buildPlaceholderChart(
String title,
String subtitle,
IconData icon,
Color color,
String description,
) {
return Container(
height: 280,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
icon,
color: color,
size: 16,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
const SizedBox(height: 16),
Expanded(
child: Container(
decoration: BoxDecoration(
color: color.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: color.withOpacity(0.1),
width: 1,
),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
color: color,
size: 48,
),
const SizedBox(height: 8),
Text(
description,
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 12,
),
),
],
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
/// Widget d'en-tête de section réutilisable
///
/// Affiche un titre de section avec style cohérent
/// utilisé dans toutes les sections du dashboard.
class SectionHeaderWidget extends StatelessWidget {
/// Titre de la section
final String title;
/// Style de texte personnalisé (optionnel)
final TextStyle? textStyle;
const SectionHeaderWidget({
super.key,
required this.title,
this.textStyle,
});
@override
Widget build(BuildContext context) {
return Text(
title,
style: textStyle ?? Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
);
}
}

View File

@@ -0,0 +1,289 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
/// Widget de carte KPI réutilisable avec détails enrichis
///
/// Affiche un indicateur de performance clé avec:
/// - Icône et badge de tendance coloré
/// - Valeur principale avec objectif optionnel
/// - Titre avec période
/// - Description détaillée
/// - Points de détail sous forme de puces
/// - Horodatage de dernière mise à jour
class KPICardWidget extends StatelessWidget {
/// Titre de l'indicateur
final String title;
/// Valeur principale affichée
final String value;
/// Changement/tendance (ex: "+5.2%", "-3.1%")
final String change;
/// Icône représentative
final IconData icon;
/// Couleur thématique de la carte
final Color color;
/// Description détaillée optionnelle
final String? subtitle;
/// Période de référence (ex: "30j", "Mois")
final String? period;
/// Objectif cible optionnel
final String? target;
/// Horodatage de dernière mise à jour
final String? lastUpdate;
/// Liste de détails supplémentaires (max 3)
final List<String>? details;
const KPICardWidget({
super.key,
required this.title,
required this.value,
required this.change,
required this.icon,
required this.color,
this.subtitle,
this.period,
this.target,
this.lastUpdate,
this.details,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête avec icône et badge de tendance
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 20,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getChangeColor(change).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getChangeIcon(change),
color: _getChangeColor(change),
size: 12,
),
const SizedBox(width: 4),
Text(
change,
style: TextStyle(
color: _getChangeColor(change),
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
const SizedBox(height: 12),
// Valeur principale
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
),
if (target != null)
Text(
'/ $target',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
],
),
const SizedBox(height: 4),
// Titre et période
Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
),
if (period != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
period!,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
color: color,
),
),
),
],
),
// Description détaillée
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(
subtitle!,
style: const TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
height: 1.3,
),
),
],
// Détails supplémentaires sous forme de puces
if (details != null && details!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.05),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: color.withOpacity(0.1),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: details!.take(3).map((detail) => Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin: const EdgeInsets.only(top: 4),
width: 4,
height: 4,
decoration: BoxDecoration(
color: color.withOpacity(0.6),
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
detail,
style: TextStyle(
fontSize: 10,
color: AppTheme.textSecondary.withOpacity(0.8),
height: 1.2,
),
),
),
],
),
)).toList(),
),
),
],
// Dernière mise à jour
if (lastUpdate != null) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.access_time,
size: 10,
color: AppTheme.textSecondary.withOpacity(0.5),
),
const SizedBox(width: 4),
Text(
'Mis à jour: $lastUpdate',
style: TextStyle(
fontSize: 9,
color: AppTheme.textSecondary.withOpacity(0.5),
fontStyle: FontStyle.italic,
),
),
],
),
],
],
),
);
}
/// Détermine la couleur du badge de changement selon la valeur
Color _getChangeColor(String change) {
if (change.startsWith('+')) {
return AppTheme.successColor;
} else if (change.startsWith('-')) {
return AppTheme.errorColor;
} else {
return AppTheme.textSecondary;
}
}
/// Détermine l'icône du badge de changement selon la valeur
IconData _getChangeIcon(String change) {
if (change.startsWith('+')) {
return Icons.trending_up;
} else if (change.startsWith('-')) {
return Icons.trending_down;
} else {
return Icons.trending_flat;
}
}
}

View File

@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
import 'kpi_card_widget.dart';
/// Widget de section des cartes KPI principales
///
/// Affiche les 8 indicateurs clés de performance principaux
/// en une seule colonne pour optimiser l'utilisation de l'espace écran.
/// Chaque KPI contient des détails enrichis et des informations contextuelles.
class KPICardsWidget extends StatelessWidget {
const KPICardsWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Indicateurs clés de performance',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
// Indicateurs principaux - Une seule colonne pour exploiter toute la largeur
KPICardWidget(
title: 'Membres Actifs',
value: '1,247',
change: '+5.2%',
icon: Icons.people,
color: AppTheme.primaryColor,
subtitle: 'Base de cotisants actifs avec droits de vote et participation aux décisions',
period: '30j',
target: '1,300',
lastUpdate: 'il y a 2h',
details: const [
'892 membres à jour de cotisation (71.5%)',
'355 nouveaux membres cette année',
'23 membres en période d\'essai de 3 mois',
],
),
const SizedBox(height: 12),
KPICardWidget(
title: 'Revenus Totaux',
value: '2,845,000 FCFA',
change: '+12.8%',
icon: Icons.account_balance_wallet,
color: AppTheme.successColor,
subtitle: 'Ensemble des revenus générés incluant cotisations, événements et subventions',
period: 'Mois',
target: '3,200,000 FCFA',
lastUpdate: 'il y a 1h',
details: const [
'1,950,000 FCFA de cotisations mensuelles (68.5%)',
'645,000 FCFA d\'activités et événements (22.7%)',
'250,000 FCFA de dons et subventions (8.8%)',
],
),
const SizedBox(height: 12),
KPICardWidget(
title: 'Événements Actifs',
value: '23',
change: '+3',
icon: Icons.event,
color: AppTheme.accentColor,
subtitle: 'Événements planifiés, formations professionnelles et activités sociales',
period: 'Mois',
target: '25',
lastUpdate: 'il y a 3h',
details: const [
'8 formations professionnelles et techniques',
'9 événements sociaux et culturels',
'6 assemblées générales et réunions',
],
),
const SizedBox(height: 12),
KPICardWidget(
title: 'Taux de Participation',
value: '78.3%',
change: '+2.1%',
icon: Icons.groups,
color: const Color(0xFF2196F3), // Blue
subtitle: 'Pourcentage de membres participant activement aux événements et décisions',
period: 'Trim.',
target: '85%',
lastUpdate: 'il y a 4h',
details: const [
'158 membres en retard de paiement',
'45,000 FCFA de frais de relance économisés',
'Amélioration de 12% par rapport au trimestre précédent',
],
),
const SizedBox(height: 12),
KPICardWidget(
title: 'Nouveaux Membres (30j)',
value: '47',
change: '+18.5%',
icon: Icons.person_add,
color: const Color(0xFF9C27B0), // Purple
subtitle: 'Nouvelles adhésions validées par le comité d\'admission',
period: '30j',
target: '50',
lastUpdate: 'il y a 30min',
details: const [
'28 adhésions individuelles (59.6%)',
'12 adhésions familiales (25.5%)',
'7 adhésions d\'entreprises partenaires (14.9%)',
],
),
const SizedBox(height: 12),
KPICardWidget(
title: 'Montant en Attente',
value: '785,000 FCFA',
change: '-5.2%',
icon: Icons.schedule,
color: AppTheme.warningColor,
subtitle: 'Montants promis en attente d\'encaissement ou de validation administrative',
period: 'Total',
lastUpdate: 'il y a 1h',
details: const [
'450,000 FCFA de promesses de dons (57.3%)',
'235,000 FCFA de cotisations promises (29.9%)',
'100,000 FCFA de subventions en cours (12.8%)',
],
),
const SizedBox(height: 12),
KPICardWidget(
title: 'Cotisations en Retard',
value: '156',
change: '+8.3%',
icon: Icons.access_time,
color: AppTheme.errorColor,
subtitle: 'Membres en situation d\'impayé nécessitant un suivi personnalisé',
period: '+30j',
lastUpdate: 'il y a 2h',
details: const [
'89 retards de 1-3 mois (57.1%)',
'45 retards de 3-6 mois (28.8%)',
'22 retards de plus de 6 mois (14.1%)',
],
),
const SizedBox(height: 12),
KPICardWidget(
title: 'Score Global de Performance',
value: '85/100',
change: '+3 pts',
icon: Icons.assessment,
color: const Color(0xFF00BCD4), // Cyan
subtitle: 'Évaluation globale basée sur 15 indicateurs de santé organisationnelle',
period: 'Mois',
target: '90/100',
lastUpdate: 'il y a 6h',
details: const [
'Finances: 92/100 (Excellent)',
'Participation: 78/100 (Bon)',
'Gouvernance: 85/100 (Très bon)',
],
),
],
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import '../../../../../shared/theme/app_theme.dart';
/// Widget de section d'accueil personnalisé pour le dashboard
///
/// Affiche un message de bienvenue avec un gradient coloré et une icône.
/// Conçu pour donner une impression chaleureuse et professionnelle à l'utilisateur.
class WelcomeSectionWidget extends StatelessWidget {
/// Titre principal affiché (par défaut "Bonjour !")
final String title;
/// Sous-titre descriptif (par défaut "Voici un aperçu de votre association")
final String subtitle;
/// Icône affichée à droite (par défaut Icons.dashboard)
final IconData icon;
/// Couleurs du gradient (par défaut primaryColor vers primaryLight)
final List<Color>? gradientColors;
const WelcomeSectionWidget({
super.key,
this.title = 'Bonjour !',
this.subtitle = 'Voici un aperçu de votre association',
this.icon = Icons.dashboard,
this.gradientColors,
});
@override
Widget build(BuildContext context) {
final colors = gradientColors ?? [AppTheme.primaryColor, AppTheme.primaryLight];
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: colors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
subtitle,
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 16,
),
),
],
),
),
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(30),
),
child: Icon(
icon,
color: Colors.white,
size: 30,
),
),
],
),
);
}
}

View File

@@ -69,6 +69,9 @@ class MembreDetailLoaded extends MembresState {
List<Object?> get props => [membre];
}
/// Alias pour MembreDetailLoaded pour compatibilité
typedef MembreLoaded = MembreDetailLoaded;
/// État de succès pour les statistiques
class MembresStatsLoaded extends MembresState {
const MembresStatsLoaded(this.stats);

View File

@@ -0,0 +1,937 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/custom_text_field.dart';
import '../../../../shared/widgets/buttons/buttons.dart';
import '../bloc/membres_bloc.dart';
import '../bloc/membres_event.dart';
import '../bloc/membres_state.dart';
/// Page de création d'un nouveau membre
class MembreCreatePage extends StatefulWidget {
const MembreCreatePage({super.key});
@override
State<MembreCreatePage> createState() => _MembreCreatePageState();
}
class _MembreCreatePageState extends State<MembreCreatePage>
with SingleTickerProviderStateMixin {
late MembresBloc _membresBloc;
late TabController _tabController;
final _formKey = GlobalKey<FormState>();
// Controllers pour les champs du formulaire
final _nomController = TextEditingController();
final _prenomController = TextEditingController();
final _emailController = TextEditingController();
final _telephoneController = TextEditingController();
final _adresseController = TextEditingController();
final _villeController = TextEditingController();
final _codePostalController = TextEditingController();
final _paysController = TextEditingController();
final _professionController = TextEditingController();
final _numeroMembreController = TextEditingController();
// Variables d'état
DateTime? _dateNaissance;
DateTime _dateAdhesion = DateTime.now();
bool _actif = true;
bool _isLoading = false;
int _currentStep = 0;
@override
void initState() {
super.initState();
_membresBloc = getIt<MembresBloc>();
_tabController = TabController(length: 3, vsync: this);
// Générer un numéro de membre automatique
_generateMemberNumber();
// Initialiser les valeurs par défaut
_paysController.text = 'Côte d\'Ivoire';
}
@override
void dispose() {
_tabController.dispose();
_nomController.dispose();
_prenomController.dispose();
_emailController.dispose();
_telephoneController.dispose();
_adresseController.dispose();
_villeController.dispose();
_codePostalController.dispose();
_paysController.dispose();
_professionController.dispose();
_numeroMembreController.dispose();
super.dispose();
}
void _generateMemberNumber() {
final now = DateTime.now();
final year = now.year.toString().substring(2);
final month = now.month.toString().padLeft(2, '0');
final random = (DateTime.now().millisecondsSinceEpoch % 1000).toString().padLeft(3, '0');
_numeroMembreController.text = 'MBR$year$month$random';
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _membresBloc,
child: Scaffold(
backgroundColor: AppTheme.backgroundLight,
appBar: _buildAppBar(),
body: BlocConsumer<MembresBloc, MembresState>(
listener: (context, state) {
if (state is MembreCreated) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Membre créé avec succès !'),
backgroundColor: AppTheme.successColor,
),
);
Navigator.of(context).pop(true); // Retourner true pour indiquer le succès
} else if (state is MembresError) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppTheme.errorColor,
),
);
}
},
builder: (context, state) {
return Column(
children: [
_buildProgressIndicator(),
Expanded(
child: _buildFormContent(),
),
_buildBottomActions(),
],
);
},
),
),
);
}
PreferredSizeWidget _buildAppBar() {
return AppBar(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
title: const Text(
'Nouveau membre',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
actions: [
IconButton(
icon: const Icon(Icons.help_outline),
onPressed: _showHelp,
tooltip: 'Aide',
),
],
);
}
Widget _buildProgressIndicator() {
return Container(
padding: const EdgeInsets.all(16),
color: Colors.white,
child: Column(
children: [
Row(
children: [
_buildStepIndicator(0, 'Informations\npersonnelles', Icons.person),
_buildStepConnector(0),
_buildStepIndicator(1, 'Contact &\nAdresse', Icons.contact_mail),
_buildStepConnector(1),
_buildStepIndicator(2, 'Finalisation', Icons.check_circle),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: (_currentStep + 1) / 3,
backgroundColor: AppTheme.backgroundLight,
valueColor: const AlwaysStoppedAnimation<Color>(AppTheme.primaryColor),
),
],
),
);
}
Widget _buildStepIndicator(int step, String label, IconData icon) {
final isActive = step == _currentStep;
final isCompleted = step < _currentStep;
Color color;
if (isCompleted) {
color = AppTheme.successColor;
} else if (isActive) {
color = AppTheme.primaryColor;
} else {
color = AppTheme.textHint;
}
return Expanded(
child: Column(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isCompleted ? AppTheme.successColor :
isActive ? AppTheme.primaryColor : AppTheme.backgroundLight,
shape: BoxShape.circle,
border: Border.all(color: color, width: 2),
),
child: Icon(
isCompleted ? Icons.check : icon,
color: isCompleted || isActive ? Colors.white : color,
size: 20,
),
),
const SizedBox(height: 8),
Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
color: color,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
);
}
Widget _buildStepConnector(int step) {
final isCompleted = step < _currentStep;
return Expanded(
child: Container(
height: 2,
margin: const EdgeInsets.only(bottom: 32),
color: isCompleted ? AppTheme.successColor : AppTheme.backgroundLight,
),
);
}
Widget _buildFormContent() {
return Form(
key: _formKey,
child: PageView(
controller: PageController(initialPage: _currentStep),
onPageChanged: (index) {
setState(() {
_currentStep = index;
});
},
children: [
_buildPersonalInfoStep(),
_buildContactStep(),
_buildFinalizationStep(),
],
),
);
}
Widget _buildPersonalInfoStep() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Informations personnelles',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
'Renseignez les informations de base du nouveau membre',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 24),
// Numéro de membre (généré automatiquement)
CustomTextField(
controller: _numeroMembreController,
label: 'Numéro de membre',
prefixIcon: Icons.badge,
enabled: false,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Le numéro de membre est requis';
}
return null;
},
),
const SizedBox(height: 16),
// Nom et Prénom
Row(
children: [
Expanded(
child: CustomTextField(
controller: _prenomController,
label: 'Prénom *',
hintText: 'Jean',
prefixIcon: Icons.person_outline,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Le prénom est requis';
}
if (value.trim().length < 2) {
return 'Le prénom doit contenir au moins 2 caractères';
}
return null;
},
),
),
const SizedBox(width: 16),
Expanded(
child: CustomTextField(
controller: _nomController,
label: 'Nom *',
hintText: 'Dupont',
prefixIcon: Icons.person_outline,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Le nom est requis';
}
if (value.trim().length < 2) {
return 'Le nom doit contenir au moins 2 caractères';
}
return null;
},
),
),
],
),
const SizedBox(height: 16),
// Date de naissance
InkWell(
onTap: _selectDateNaissance,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
border: Border.all(color: AppTheme.borderColor),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.cake_outlined, color: AppTheme.textSecondary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Date de naissance',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
Text(
_dateNaissance != null
? DateFormat('dd/MM/yyyy').format(_dateNaissance!)
: 'Sélectionner une date',
style: TextStyle(
fontSize: 16,
color: _dateNaissance != null
? AppTheme.textPrimary
: AppTheme.textHint,
),
),
],
),
),
const Icon(Icons.calendar_today, color: AppTheme.textSecondary),
],
),
),
),
const SizedBox(height: 16),
// Profession
CustomTextField(
controller: _professionController,
label: 'Profession',
hintText: 'Enseignant, Commerçant, etc.',
prefixIcon: Icons.work_outline,
textInputAction: TextInputAction.next,
),
],
),
);
}
Widget _buildContactStep() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Contact & Adresse',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
'Informations de contact et adresse du membre',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 24),
// Email
CustomTextField(
controller: _emailController,
label: 'Email *',
hintText: 'exemple@email.com',
prefixIcon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'L\'email est requis';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return 'Format d\'email invalide';
}
return null;
},
),
const SizedBox(height: 16),
// Téléphone
CustomTextField(
controller: _telephoneController,
label: 'Téléphone *',
hintText: '+225 XX XX XX XX XX',
prefixIcon: Icons.phone_outlined,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9+\-\s\(\)]')),
],
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Le téléphone est requis';
}
if (value.trim().length < 8) {
return 'Numéro de téléphone invalide';
}
return null;
},
),
const SizedBox(height: 24),
// Section Adresse
const Text(
'Adresse',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
// Adresse
CustomTextField(
controller: _adresseController,
label: 'Adresse',
hintText: 'Rue, quartier, etc.',
prefixIcon: Icons.location_on_outlined,
textInputAction: TextInputAction.next,
maxLines: 2,
),
const SizedBox(height: 16),
// Ville et Code postal
Row(
children: [
Expanded(
flex: 2,
child: CustomTextField(
controller: _villeController,
label: 'Ville',
hintText: 'Abidjan',
prefixIcon: Icons.location_city_outlined,
textInputAction: TextInputAction.next,
),
),
const SizedBox(width: 16),
Expanded(
child: CustomTextField(
controller: _codePostalController,
label: 'Code postal',
hintText: '00225',
prefixIcon: Icons.markunread_mailbox_outlined,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
),
),
],
),
const SizedBox(height: 16),
// Pays
CustomTextField(
controller: _paysController,
label: 'Pays',
prefixIcon: Icons.flag_outlined,
textInputAction: TextInputAction.done,
),
],
),
);
}
Widget _buildFinalizationStep() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Finalisation',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
'Vérifiez les informations et finalisez la création',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 24),
// Résumé des informations
_buildSummaryCard(),
const SizedBox(height: 24),
// Date d'adhésion
InkWell(
onTap: _selectDateAdhesion,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
border: Border.all(color: AppTheme.borderColor),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.calendar_today_outlined, color: AppTheme.textSecondary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Date d\'adhésion',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
Text(
DateFormat('dd/MM/yyyy').format(_dateAdhesion),
style: const TextStyle(
fontSize: 16,
color: AppTheme.textPrimary,
),
),
],
),
),
const Icon(Icons.edit, color: AppTheme.textSecondary),
],
),
),
),
const SizedBox(height: 16),
// Statut actif
SwitchListTile(
title: const Text('Membre actif'),
subtitle: const Text('Le membre peut accéder aux services'),
value: _actif,
onChanged: (value) {
setState(() {
_actif = value;
});
},
activeColor: AppTheme.primaryColor,
),
],
),
);
}
Widget _buildSummaryCard() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.summarize, color: AppTheme.primaryColor),
const SizedBox(width: 8),
const Text(
'Résumé des informations',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 16),
_buildSummaryRow('Nom complet', '${_prenomController.text} ${_nomController.text}'),
_buildSummaryRow('Email', _emailController.text),
_buildSummaryRow('Téléphone', _telephoneController.text),
if (_dateNaissance != null)
_buildSummaryRow('Date de naissance', DateFormat('dd/MM/yyyy').format(_dateNaissance!)),
if (_professionController.text.isNotEmpty)
_buildSummaryRow('Profession', _professionController.text),
if (_adresseController.text.isNotEmpty)
_buildSummaryRow('Adresse', _adresseController.text),
],
),
),
);
}
Widget _buildSummaryRow(String label, String value) {
if (value.trim().isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
),
),
),
],
),
);
}
Widget _buildBottomActions() {
return Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 4,
offset: Offset(0, -2),
),
],
),
child: Row(
children: [
if (_currentStep > 0)
Expanded(
child: OutlinedButton(
onPressed: _previousStep,
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.primaryColor,
side: const BorderSide(color: AppTheme.primaryColor),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Précédent'),
),
),
if (_currentStep > 0) const SizedBox(width: 16),
Expanded(
flex: _currentStep == 0 ? 1 : 1,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleNextOrSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(_currentStep == 2 ? 'Créer le membre' : 'Suivant'),
),
),
],
),
);
}
void _previousStep() {
if (_currentStep > 0) {
setState(() {
_currentStep--;
});
}
}
void _handleNextOrSubmit() {
if (_currentStep < 2) {
if (_validateCurrentStep()) {
setState(() {
_currentStep++;
});
}
} else {
_submitForm();
}
}
bool _validateCurrentStep() {
switch (_currentStep) {
case 0:
return _validatePersonalInfo();
case 1:
return _validateContactInfo();
case 2:
return true; // Pas de validation spécifique pour la finalisation
default:
return false;
}
}
bool _validatePersonalInfo() {
bool isValid = true;
if (_prenomController.text.trim().isEmpty) {
_showFieldError('Le prénom est requis');
isValid = false;
}
if (_nomController.text.trim().isEmpty) {
_showFieldError('Le nom est requis');
isValid = false;
}
return isValid;
}
bool _validateContactInfo() {
bool isValid = true;
if (_emailController.text.trim().isEmpty) {
_showFieldError('L\'email est requis');
isValid = false;
} else if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(_emailController.text)) {
_showFieldError('Format d\'email invalide');
isValid = false;
}
if (_telephoneController.text.trim().isEmpty) {
_showFieldError('Le téléphone est requis');
isValid = false;
}
return isValid;
}
void _showFieldError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 2),
),
);
}
void _submitForm() {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
});
// Créer le modèle membre
final membre = MembreModel(
id: '', // Sera généré par le backend
numeroMembre: _numeroMembreController.text.trim(),
nom: _nomController.text.trim(),
prenom: _prenomController.text.trim(),
email: _emailController.text.trim(),
telephone: _telephoneController.text.trim(),
dateNaissance: _dateNaissance,
adresse: _adresseController.text.trim().isNotEmpty ? _adresseController.text.trim() : null,
ville: _villeController.text.trim().isNotEmpty ? _villeController.text.trim() : null,
codePostal: _codePostalController.text.trim().isNotEmpty ? _codePostalController.text.trim() : null,
pays: _paysController.text.trim().isNotEmpty ? _paysController.text.trim() : null,
profession: _professionController.text.trim().isNotEmpty ? _professionController.text.trim() : null,
dateAdhesion: _dateAdhesion,
actif: _actif,
statut: 'ACTIF',
version: 1,
dateCreation: DateTime.now(),
);
// Envoyer l'événement de création
_membresBloc.add(CreateMembre(membre));
}
Future<void> _selectDateNaissance() async {
final date = await showDatePicker(
context: context,
initialDate: _dateNaissance ?? DateTime.now().subtract(const Duration(days: 365 * 25)),
firstDate: DateTime(1900),
lastDate: DateTime.now(),
locale: const Locale('fr', 'FR'),
);
if (date != null) {
setState(() {
_dateNaissance = date;
});
}
}
Future<void> _selectDateAdhesion() async {
final date = await showDatePicker(
context: context,
initialDate: _dateAdhesion,
firstDate: DateTime(2000),
lastDate: DateTime.now().add(const Duration(days: 365)),
locale: const Locale('fr', 'FR'),
);
if (date != null) {
setState(() {
_dateAdhesion = date;
});
}
}
void _showHelp() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Aide - Création de membre'),
content: const SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Étapes de création :',
style: TextStyle(fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text('1. Informations personnelles : Nom, prénom, date de naissance'),
Text('2. Contact & Adresse : Email, téléphone, adresse'),
Text('3. Finalisation : Vérification et validation'),
SizedBox(height: 16),
Text(
'Champs obligatoires :',
style: TextStyle(fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text('• Nom et prénom'),
Text('• Email (format valide)'),
Text('• Téléphone'),
SizedBox(height: 16),
Text(
'Le numéro de membre est généré automatiquement selon le format : MBR + Année + Mois + Numéro séquentiel',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
),
);
}
}

View File

@@ -0,0 +1,474 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../bloc/membres_bloc.dart';
import '../bloc/membres_event.dart';
import '../bloc/membres_state.dart';
import '../widgets/membre_info_section.dart';
import '../widgets/membre_stats_section.dart';
import '../widgets/membre_cotisations_section.dart';
import '../widgets/membre_actions_section.dart';
import '../widgets/membre_delete_dialog.dart';
import 'membre_edit_page.dart';
/// Page de détails complète d'un membre
class MembreDetailsPage extends StatefulWidget {
const MembreDetailsPage({
super.key,
required this.membreId,
this.membre,
});
final String membreId;
final MembreModel? membre;
@override
State<MembreDetailsPage> createState() => _MembreDetailsPageState();
}
class _MembreDetailsPageState extends State<MembreDetailsPage>
with SingleTickerProviderStateMixin {
late MembresBloc _membresBloc;
late TabController _tabController;
MembreModel? _currentMembre;
List<CotisationModel> _cotisations = [];
bool _isLoadingCotisations = false;
@override
void initState() {
super.initState();
_membresBloc = getIt<MembresBloc>();
_tabController = TabController(length: 3, vsync: this);
_currentMembre = widget.membre;
// Charger les détails du membre si pas fourni
if (_currentMembre == null) {
_membresBloc.add(LoadMembreById(widget.membreId));
}
// Charger les cotisations du membre
_loadMemberCotisations();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _loadMemberCotisations() async {
setState(() {
_isLoadingCotisations = true;
});
try {
// TODO: Implémenter le chargement des cotisations via le repository
// final cotisations = await getIt<CotisationRepository>()
// .getCotisationsByMembre(widget.membreId);
// setState(() {
// _cotisations = cotisations;
// });
// Simulation temporaire
await Future.delayed(const Duration(seconds: 1));
setState(() {
_cotisations = _generateMockCotisations();
});
} catch (e) {
// Gérer l'erreur
debugPrint('Erreur lors du chargement des cotisations: $e');
} finally {
setState(() {
_isLoadingCotisations = false;
});
}
}
List<CotisationModel> _generateMockCotisations() {
// Données de test temporaires
return [
CotisationModel(
id: '1',
numeroReference: 'COT-2025-001',
membreId: widget.membreId,
typeCotisation: 'MENSUELLE',
periode: 'Janvier 2025',
montantDu: 25000,
montantPaye: 25000,
codeDevise: 'XOF',
statut: 'PAYEE',
dateEcheance: DateTime(2025, 1, 31),
datePaiement: DateTime(2025, 1, 15),
annee: 2025,
recurrente: true,
nombreRappels: 0,
dateCreation: DateTime(2025, 1, 1),
),
CotisationModel(
id: '2',
numeroReference: 'COT-2025-002',
membreId: widget.membreId,
typeCotisation: 'MENSUELLE',
periode: 'Février 2025',
montantDu: 25000,
montantPaye: 0,
codeDevise: 'XOF',
statut: 'EN_ATTENTE',
dateEcheance: DateTime(2025, 2, 28),
annee: 2025,
recurrente: true,
nombreRappels: 1,
dateCreation: DateTime(2025, 2, 1),
),
];
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _membresBloc,
child: Scaffold(
backgroundColor: AppTheme.backgroundLight,
body: BlocConsumer<MembresBloc, MembresState>(
listener: (context, state) {
if (state is MembreLoaded) {
setState(() {
_currentMembre = state.membre;
});
} else if (state is MembresError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppTheme.errorColor,
),
);
}
},
builder: (context, state) {
if (state is MembresLoading && _currentMembre == null) {
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Chargement des détails...'),
],
),
),
);
}
if (state is MembresError && _currentMembre == null) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size: 64, color: AppTheme.errorColor),
SizedBox(height: 16),
Text(state.message),
SizedBox(height: 16),
ElevatedButton(
onPressed: () => _membresBloc.add(LoadMembreById(widget.membreId)),
child: Text('Réessayer'),
),
],
),
),
);
}
if (_currentMembre == null) {
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_off, size: 64),
SizedBox(height: 16),
Text('Membre non trouvé'),
],
),
),
);
}
return _buildContent();
},
),
),
);
}
Widget _buildContent() {
return NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
_buildAppBar(innerBoxIsScrolled),
_buildMemberHeader(),
_buildTabBar(),
];
},
body: TabBarView(
controller: _tabController,
children: [
_buildInfoTab(),
_buildCotisationsTab(),
_buildStatsTab(),
],
),
);
}
Widget _buildAppBar(bool innerBoxIsScrolled) {
return SliverAppBar(
expandedHeight: 0,
floating: true,
pinned: true,
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
title: Text(
_currentMembre?.nomComplet ?? 'Détails du membre',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
actions: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: _editMember,
tooltip: 'Modifier',
),
PopupMenuButton<String>(
onSelected: _handleMenuAction,
itemBuilder: (context) => [
const PopupMenuItem(
value: 'call',
child: ListTile(
leading: Icon(Icons.phone),
title: Text('Appeler'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
value: 'message',
child: ListTile(
leading: Icon(Icons.message),
title: Text('Message'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
value: 'export',
child: ListTile(
leading: Icon(Icons.download),
title: Text('Exporter'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
value: 'delete',
child: ListTile(
leading: Icon(Icons.delete, color: Colors.red),
title: Text('Supprimer', style: TextStyle(color: Colors.red)),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
);
}
Widget _buildMemberHeader() {
return SliverToBoxAdapter(
child: Container(
color: AppTheme.primaryColor,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: MembreInfoSection(
membre: _currentMembre!,
showActions: false,
),
),
);
}
Widget _buildTabBar() {
return SliverPersistentHeader(
pinned: true,
delegate: _TabBarDelegate(
TabBar(
controller: _tabController,
labelColor: AppTheme.primaryColor,
unselectedLabelColor: AppTheme.textSecondary,
indicatorColor: AppTheme.primaryColor,
indicatorWeight: 3,
tabs: const [
Tab(text: 'Informations'),
Tab(text: 'Cotisations'),
Tab(text: 'Statistiques'),
],
),
),
);
}
Widget _buildInfoTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
MembreInfoSection(
membre: _currentMembre!,
showActions: true,
onEdit: _editMember,
onCall: _callMember,
onMessage: _messageMember,
),
const SizedBox(height: 16),
MembreActionsSection(
membre: _currentMembre!,
onEdit: _editMember,
onDelete: _deleteMember,
onExport: _exportMember,
),
],
),
);
}
Widget _buildCotisationsTab() {
return MembreCotisationsSection(
membre: _currentMembre!,
cotisations: _cotisations,
isLoading: _isLoadingCotisations,
onRefresh: _loadMemberCotisations,
);
}
Widget _buildStatsTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: MembreStatsSection(
membre: _currentMembre!,
cotisations: _cotisations,
),
);
}
void _editMember() async {
if (widget.membre == null) return;
final result = await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => MembreEditPage(membre: widget.membre!),
),
);
// Si le membre a été modifié avec succès, recharger les données
if (result == true) {
_loadMemberCotisations();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Membre modifié avec succès !'),
backgroundColor: AppTheme.successColor,
),
);
}
}
void _callMember() {
// TODO: Implémenter l'appel
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Appel - À implémenter')),
);
}
void _messageMember() {
// TODO: Implémenter l'envoi de message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Message - À implémenter')),
);
}
void _deleteMember() async {
if (widget.membre == null) return;
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => MembreDeleteDialog(membre: widget.membre!),
);
// Si le membre a été supprimé/désactivé avec succès
if (result == true && mounted) {
// Retourner à la liste des membres
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Membre traité avec succès !'),
backgroundColor: AppTheme.successColor,
),
);
}
}
void _exportMember() {
// TODO: Implémenter l'export
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Export - À implémenter')),
);
}
void _handleMenuAction(String action) {
switch (action) {
case 'call':
_callMember();
break;
case 'message':
_messageMember();
break;
case 'export':
_exportMember();
break;
case 'delete':
_deleteMember();
break;
}
}
}
class _TabBarDelegate extends SliverPersistentHeaderDelegate {
const _TabBarDelegate(this.tabBar);
final TabBar tabBar;
@override
double get minExtent => tabBar.preferredSize.height;
@override
double get maxExtent => tabBar.preferredSize.height;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(
color: Colors.white,
child: tabBar,
);
}
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
return false;
}
}

View File

@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../../../shared/theme/app_theme.dart';
import '../bloc/membres_bloc.dart';
import '../bloc/membres_event.dart';
import '../bloc/membres_state.dart';
class MembresDashboardPage extends StatefulWidget {
const MembresDashboardPage({super.key});
@override
State<MembresDashboardPage> createState() => _MembresDashboardPageState();
}
class _MembresDashboardPageState extends State<MembresDashboardPage> {
late MembresBloc _membresBloc;
@override
void initState() {
super.initState();
_membresBloc = getIt<MembresBloc>();
_loadData();
}
void _loadData() {
_membresBloc.add(const LoadMembres());
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _membresBloc,
child: Scaffold(
backgroundColor: AppTheme.backgroundLight,
appBar: AppBar(
title: const Text(
'Dashboard Membres',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 20,
),
),
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadData,
tooltip: 'Actualiser',
),
],
),
body: BlocBuilder<MembresBloc, MembresState>(
builder: (context, state) {
if (state is MembresLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state is MembresError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: AppTheme.errorColor,
),
const SizedBox(height: 16),
Text(
'Erreur de chargement',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Text(
state.message,
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _loadData,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
),
],
),
);
}
return _buildDashboard();
},
),
floatingActionButton: FloatingActionButton(
onPressed: _loadData,
backgroundColor: AppTheme.primaryColor,
tooltip: 'Actualiser les données',
child: const Icon(Icons.refresh, color: Colors.white),
),
),
);
}
Widget _buildDashboard() {
return Container(
padding: const EdgeInsets.all(16),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.dashboard,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'Dashboard Vide',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
SizedBox(height: 8),
Text(
'Prêt à être reconstruit pièce par pièce',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/coming_soon_page.dart';
import '../bloc/membres_bloc.dart';
@@ -9,6 +10,12 @@ import '../bloc/membres_event.dart';
import '../bloc/membres_state.dart';
import '../widgets/membre_card.dart';
import '../widgets/membres_search_bar.dart';
import '../widgets/membre_delete_dialog.dart';
import '../widgets/membres_advanced_search.dart';
import '../widgets/membres_export_dialog.dart';
import 'membre_details_page.dart';
import 'membre_create_page.dart';
import 'membres_dashboard_page.dart';
/// Page de liste des membres avec fonctionnalités avancées
@@ -23,6 +30,7 @@ class _MembresListPageState extends State<MembresListPage> {
final RefreshController _refreshController = RefreshController();
final TextEditingController _searchController = TextEditingController();
late MembresBloc _membresBloc;
List<MembreModel> _membres = [];
@override
void initState() {
@@ -56,6 +64,16 @@ class _MembresListPageState extends State<MembresListPage> {
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () => _showAdvancedSearch(),
tooltip: 'Recherche avancée',
),
IconButton(
icon: const Icon(Icons.file_download),
onPressed: () => _showExportDialog(),
tooltip: 'Exporter',
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () => _showAddMemberDialog(),
@@ -97,7 +115,14 @@ class _MembresListPageState extends State<MembresListPage> {
} else if (state is MembresErrorWithData) {
_showErrorSnackBar(state.message);
}
// Mettre à jour la liste des membres
if (state is MembresLoaded) {
_membres = state.membres;
} else if (state is MembresErrorWithData) {
_membres = state.membres;
}
// Arrêter le refresh
if (state is! MembresRefreshing && state is! MembresLoading) {
_refreshController.refreshCompleted();
@@ -288,30 +313,28 @@ class _MembresListPageState extends State<MembresListPage> {
/// Affiche les détails d'un membre
void _showMemberDetails(membre) {
// TODO: Implémenter la page de détails
showDialog(
context: context,
builder: (context) => const ComingSoonPage(
title: 'Détails du membre',
description: 'La page de détails du membre sera bientôt disponible.',
icon: Icons.person,
color: AppTheme.primaryColor,
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => MembreDetailsPage(
membreId: membre.id,
membre: membre,
),
),
);
}
/// Affiche le dialog d'ajout de membre
void _showAddMemberDialog() {
// TODO: Implémenter le formulaire d'ajout
showDialog(
context: context,
builder: (context) => const ComingSoonPage(
title: 'Ajouter un membre',
description: 'Le formulaire d\'ajout de membre sera bientôt disponible.',
icon: Icons.person_add,
color: AppTheme.successColor,
/// Affiche le formulaire d'ajout de membre
void _showAddMemberDialog() async {
final result = await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const MembreCreatePage(),
),
);
// Si un membre a été créé avec succès, recharger la liste
if (result == true) {
_membresBloc.add(const RefreshMembres());
}
}
/// Affiche le dialog d'édition de membre
@@ -329,29 +352,59 @@ class _MembresListPageState extends State<MembresListPage> {
}
/// Affiche la confirmation de suppression
void _showDeleteConfirmation(membre) {
// TODO: Implémenter la confirmation de suppression
showDialog(
void _showDeleteConfirmation(membre) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => const ComingSoonPage(
title: 'Supprimer le membre',
description: 'La confirmation de suppression sera bientôt disponible.',
icon: Icons.delete,
color: AppTheme.errorColor,
),
barrierDismissible: false,
builder: (context) => MembreDeleteDialog(membre: membre),
);
// Si le membre a été supprimé/désactivé avec succès, recharger la liste
if (result == true) {
_membresBloc.add(const RefreshMembres());
}
}
/// Affiche les statistiques
void _showStatsDialog() {
// TODO: Implémenter les statistiques
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const MembresDashboardPage(),
),
);
}
/// Affiche la recherche avancée
void _showAdvancedSearch() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.9,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (context, scrollController) => MembresAdvancedSearch(
onSearch: (filters) {
// TODO: Implémenter la recherche avec filtres
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Recherche avec ${filters.length} filtres - À implémenter'),
backgroundColor: AppTheme.infoColor,
),
);
},
),
),
);
}
/// Affiche le dialog d'export
void _showExportDialog() {
showDialog(
context: context,
builder: (context) => const ComingSoonPage(
title: 'Statistiques',
description: 'Les statistiques des membres seront bientôt disponibles.',
icon: Icons.analytics,
color: AppTheme.infoColor,
builder: (context) => MembresExportDialog(
membres: _membres,
),
);
}

View File

@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Container professionnel pour les graphiques du dashboard avec animations
class DashboardChartCard extends StatefulWidget {
const DashboardChartCard({
super.key,
required this.title,
required this.child,
this.subtitle,
this.actions,
this.height,
this.isLoading = false,
this.onRefresh,
this.showBorder = true,
});
final String title;
final Widget child;
final String? subtitle;
final List<Widget>? actions;
final double? height;
final bool isLoading;
final VoidCallback? onRefresh;
final bool showBorder;
@override
State<DashboardChartCard> createState() => _DashboardChartCardState();
}
class _DashboardChartCardState extends State<DashboardChartCard>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _slideAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: DesignSystem.animationMedium,
vsync: this,
);
_slideAnimation = Tween<double>(
begin: 30.0,
end: 0.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurveEnter,
));
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, _slideAnimation.value),
child: FadeTransition(
opacity: _fadeAnimation,
child: _buildCard(),
),
);
},
);
}
Widget _buildCard() {
return Container(
height: widget.height,
padding: EdgeInsets.all(DesignSystem.spacingLg),
decoration: BoxDecoration(
color: AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
boxShadow: DesignSystem.shadowCard,
border: widget.showBorder ? Border.all(
color: AppTheme.borderColor.withOpacity(0.5),
width: 1,
) : null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedBox(height: DesignSystem.spacingLg),
Expanded(
child: widget.isLoading ? _buildLoadingState() : widget.child,
),
],
),
);
}
Widget _buildHeader() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: DesignSystem.headlineMedium.copyWith(
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
Text(
widget.subtitle!,
style: DesignSystem.bodyMedium.copyWith(
color: AppTheme.textSecondary,
),
),
],
],
),
),
if (widget.actions != null || widget.onRefresh != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.onRefresh != null)
_buildRefreshButton(),
if (widget.actions != null) ...widget.actions!,
],
),
],
);
}
Widget _buildRefreshButton() {
return Container(
margin: EdgeInsets.only(right: DesignSystem.spacingSm),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onRefresh,
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
child: Container(
padding: EdgeInsets.all(DesignSystem.spacingSm),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: const Icon(
Icons.refresh,
size: 18,
color: AppTheme.primaryColor,
),
),
),
),
);
}
Widget _buildLoadingState() {
return Column(
children: [
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.primaryColor.withOpacity(0.7),
),
),
),
SizedBox(height: DesignSystem.spacingMd),
Text(
'Chargement des données...',
style: DesignSystem.bodyMedium.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
),
],
);
}
}

View File

@@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Card statistique professionnelle avec design basé sur le nombre d'or
class DashboardStatCard extends StatefulWidget {
const DashboardStatCard({
super.key,
required this.title,
required this.value,
required this.icon,
required this.color,
this.trend,
this.subtitle,
this.onTap,
this.isLoading = false,
});
final String title;
final String value;
final IconData icon;
final Color color;
final String? trend;
final String? subtitle;
final VoidCallback? onTap;
final bool isLoading;
@override
State<DashboardStatCard> createState() => _DashboardStatCardState();
}
class _DashboardStatCardState extends State<DashboardStatCard>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _fadeAnimation;
bool _isHovered = false;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: DesignSystem.animationMedium,
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurveEnter,
));
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: FadeTransition(
opacity: _fadeAnimation,
child: _buildCard(context),
),
);
},
);
}
Widget _buildCard(BuildContext context) {
return MouseRegion(
onEnter: (_) => _setHovered(true),
onExit: (_) => _setHovered(false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: DesignSystem.animationFast,
curve: DesignSystem.animationCurve,
padding: EdgeInsets.all(DesignSystem.spacingLg),
decoration: BoxDecoration(
color: AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
boxShadow: _isHovered ? DesignSystem.shadowCardHover : DesignSystem.shadowCard,
border: Border.all(
color: widget.color.withOpacity(0.1),
width: 1,
),
),
child: widget.isLoading ? _buildLoadingState() : _buildContent(),
),
),
);
}
Widget _buildLoadingState() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildShimmer(40, 40, isCircular: true),
if (widget.trend != null) _buildShimmer(60, 24, radius: 12),
],
),
SizedBox(height: DesignSystem.spacingMd),
_buildShimmer(80, 32),
SizedBox(height: DesignSystem.spacingSm),
_buildShimmer(120, 16),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
_buildShimmer(100, 14),
],
],
);
}
Widget _buildShimmer(double width, double height, {double? radius, bool isCircular = false}) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: AppTheme.textHint.withOpacity(0.1),
borderRadius: isCircular
? BorderRadius.circular(height / 2)
: BorderRadius.circular(radius ?? DesignSystem.radiusSm),
),
);
}
Widget _buildContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedBox(height: DesignSystem.goldenHeight(DesignSystem.spacingLg)),
_buildValue(),
SizedBox(height: DesignSystem.spacingSm),
_buildTitle(),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
_buildSubtitle(),
],
],
);
}
Widget _buildHeader() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildIconContainer(),
if (widget.trend != null) _buildTrendBadge(),
],
);
}
Widget _buildIconContainer() {
return Container(
width: DesignSystem.goldenWidth(32),
height: 32,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
widget.color.withOpacity(0.15),
widget.color.withOpacity(0.05),
],
),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
border: Border.all(
color: widget.color.withOpacity(0.2),
width: 1,
),
),
child: Icon(
widget.icon,
color: widget.color,
size: 20,
),
);
}
Widget _buildTrendBadge() {
return Container(
padding: EdgeInsets.symmetric(
horizontal: DesignSystem.spacingSm,
vertical: DesignSystem.spacingXs,
),
decoration: BoxDecoration(
color: _getTrendColor().withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusXl),
border: Border.all(
color: _getTrendColor().withOpacity(0.2),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getTrendIcon(),
color: _getTrendColor(),
size: 14,
),
SizedBox(width: DesignSystem.spacing2xs),
Text(
widget.trend!,
style: DesignSystem.labelSmall.copyWith(
color: _getTrendColor(),
fontWeight: FontWeight.w600,
),
),
],
),
);
}
Widget _buildValue() {
return Text(
widget.value,
style: DesignSystem.displayMedium.copyWith(
color: widget.color,
fontWeight: FontWeight.w800,
fontSize: 28,
),
);
}
Widget _buildTitle() {
return Text(
widget.title,
style: DesignSystem.labelLarge.copyWith(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
);
}
Widget _buildSubtitle() {
return Text(
widget.subtitle!,
style: DesignSystem.labelMedium.copyWith(
color: AppTheme.textHint,
),
);
}
void _setHovered(bool hovered) {
if (mounted) {
setState(() {
_isHovered = hovered;
});
}
}
Color _getTrendColor() {
if (widget.trend == null) return AppTheme.textSecondary;
if (widget.trend!.startsWith('+')) {
return AppTheme.successColor;
} else if (widget.trend!.startsWith('-')) {
return AppTheme.errorColor;
} else {
return AppTheme.warningColor;
}
}
IconData _getTrendIcon() {
if (widget.trend == null) return Icons.trending_flat;
if (widget.trend!.startsWith('+')) {
return Icons.trending_up;
} else if (widget.trend!.startsWith('-')) {
return Icons.trending_down;
} else {
return Icons.trending_flat;
}
}
}

View File

@@ -0,0 +1,456 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../pages/membre_edit_page.dart';
/// Section des actions disponibles pour un membre
class MembreActionsSection extends StatelessWidget {
const MembreActionsSection({
super.key,
required this.membre,
this.onEdit,
this.onDelete,
this.onExport,
this.onCall,
this.onMessage,
this.onEmail,
});
final MembreModel membre;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
final VoidCallback? onExport;
final VoidCallback? onCall;
final VoidCallback? onMessage;
final VoidCallback? onEmail;
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.settings,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Actions',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 16),
_buildActionGrid(context),
],
),
),
);
}
Widget _buildActionGrid(BuildContext context) {
return Column(
children: [
Row(
children: [
Expanded(
child: _buildActionButton(
context,
'Modifier',
Icons.edit,
AppTheme.primaryColor,
onEdit ?? () => _showNotImplemented(context, 'Modification'),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionButton(
context,
'Appeler',
Icons.phone,
AppTheme.successColor,
onCall ?? () => _callMember(context),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildActionButton(
context,
'Message',
Icons.message,
AppTheme.infoColor,
onMessage ?? () => _messageMember(context),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionButton(
context,
'Email',
Icons.email,
AppTheme.warningColor,
onEmail ?? () => _emailMember(context),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildActionButton(
context,
'Exporter',
Icons.download,
AppTheme.textSecondary,
onExport ?? () => _exportMember(context),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionButton(
context,
'Supprimer',
Icons.delete,
AppTheme.errorColor,
onDelete ?? () => _deleteMember(context),
),
),
],
),
const SizedBox(height: 20),
_buildQuickInfoSection(context),
],
);
}
Widget _buildActionButton(
BuildContext context,
String label,
IconData icon,
Color color,
VoidCallback onPressed,
) {
return Material(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Column(
children: [
Icon(icon, color: color, size: 24),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
Widget _buildQuickInfoSection(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.backgroundLight,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Informations rapides',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
_buildQuickInfoRow(
'Numéro de membre',
membre.numeroMembre,
Icons.badge,
() => _copyToClipboard(context, membre.numeroMembre, 'Numéro de membre'),
),
_buildQuickInfoRow(
'Téléphone',
membre.telephone,
Icons.phone,
() => _copyToClipboard(context, membre.telephone, 'Téléphone'),
),
_buildQuickInfoRow(
'Email',
membre.email,
Icons.email,
() => _copyToClipboard(context, membre.email, 'Email'),
),
],
),
);
}
Widget _buildQuickInfoRow(
String label,
String value,
IconData icon,
VoidCallback onTap,
) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
children: [
Icon(icon, size: 16, color: AppTheme.textSecondary),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 10,
color: AppTheme.textSecondary,
),
),
Text(
value,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
],
),
),
const Icon(
Icons.copy,
size: 14,
color: AppTheme.textHint,
),
],
),
),
),
);
}
void _callMember(BuildContext context) {
// TODO: Implémenter l'appel téléphonique
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Appeler le membre'),
content: Text('Voulez-vous appeler ${membre.prenom} ${membre.nom} au ${membre.telephone} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_showNotImplemented(context, 'Appel téléphonique');
},
child: const Text('Appeler'),
),
],
),
);
}
void _messageMember(BuildContext context) {
// TODO: Implémenter l'envoi de SMS
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Envoyer un message'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Envoyer un SMS à ${membre.prenom} ${membre.nom} ?'),
const SizedBox(height: 16),
const TextField(
decoration: InputDecoration(
labelText: 'Message',
border: OutlineInputBorder(),
),
maxLines: 3,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_showNotImplemented(context, 'Envoi de SMS');
},
child: const Text('Envoyer'),
),
],
),
);
}
void _emailMember(BuildContext context) {
// TODO: Implémenter l'envoi d'email
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Envoyer un email'),
content: Text('Ouvrir l\'application email pour envoyer un message à ${membre.email} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_showNotImplemented(context, 'Envoi d\'email');
},
child: const Text('Ouvrir'),
),
],
),
);
}
void _exportMember(BuildContext context) {
// TODO: Implémenter l'export des données du membre
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Exporter les données'),
content: Text('Exporter les données de ${membre.prenom} ${membre.nom} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_showNotImplemented(context, 'Export des données');
},
child: const Text('Exporter'),
),
],
),
);
}
void _deleteMember(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer le membre'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning,
color: AppTheme.errorColor,
size: 48,
),
const SizedBox(height: 16),
Text(
'Êtes-vous sûr de vouloir supprimer ${membre.prenom} ${membre.nom} ?',
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Cette action est irréversible.',
style: TextStyle(
color: AppTheme.errorColor,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_showNotImplemented(context, 'Suppression du membre');
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.errorColor,
foregroundColor: Colors.white,
),
child: const Text('Supprimer'),
),
],
),
);
}
void _copyToClipboard(BuildContext context, String text, String label) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$label copié dans le presse-papiers'),
duration: const Duration(seconds: 2),
backgroundColor: AppTheme.successColor,
),
);
}
void _showNotImplemented(BuildContext context, String feature) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$feature - Fonctionnalité à implémenter'),
backgroundColor: AppTheme.infoColor,
),
);
}
}

View File

@@ -0,0 +1,431 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../shared/theme/app_theme.dart';
/// Section des cotisations d'un membre
class MembreCotisationsSection extends StatelessWidget {
const MembreCotisationsSection({
super.key,
required this.membre,
required this.cotisations,
required this.isLoading,
this.onRefresh,
});
final MembreModel membre;
final List<CotisationModel> cotisations;
final bool isLoading;
final VoidCallback? onRefresh;
@override
Widget build(BuildContext context) {
if (isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Chargement des cotisations...'),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
onRefresh?.call();
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSummaryCard(),
const SizedBox(height: 16),
_buildCotisationsList(),
],
),
),
);
}
Widget _buildSummaryCard() {
final totalDu = cotisations.fold<double>(
0,
(sum, cotisation) => sum + cotisation.montantDu,
);
final totalPaye = cotisations.fold<double>(
0,
(sum, cotisation) => sum + cotisation.montantPaye,
);
final totalRestant = totalDu - totalPaye;
final cotisationsPayees = cotisations.where((c) => c.statut == 'PAYEE').length;
final cotisationsEnRetard = cotisations.where((c) => c.isEnRetard).length;
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.account_balance_wallet,
color: AppTheme.primaryColor,
size: 24,
),
const SizedBox(width: 8),
const Text(
'Résumé des cotisations',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: _buildSummaryItem(
'Total dû',
_formatAmount(totalDu),
AppTheme.infoColor,
Icons.receipt_long,
),
),
Expanded(
child: _buildSummaryItem(
'Payé',
_formatAmount(totalPaye),
AppTheme.successColor,
Icons.check_circle,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildSummaryItem(
'Restant',
_formatAmount(totalRestant),
totalRestant > 0 ? AppTheme.warningColor : AppTheme.successColor,
Icons.pending,
),
),
Expanded(
child: _buildSummaryItem(
'En retard',
'$cotisationsEnRetard',
cotisationsEnRetard > 0 ? AppTheme.errorColor : AppTheme.successColor,
Icons.warning,
),
),
],
),
const SizedBox(height: 16),
LinearProgressIndicator(
value: totalDu > 0 ? totalPaye / totalDu : 0,
backgroundColor: AppTheme.backgroundLight,
valueColor: AlwaysStoppedAnimation<Color>(
totalPaye == totalDu ? AppTheme.successColor : AppTheme.primaryColor,
),
),
const SizedBox(height: 8),
Text(
'$cotisationsPayees/${cotisations.length} cotisations payées',
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
);
}
Widget _buildSummaryItem(String label, String value, Color color, IconData icon) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Column(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
);
}
Widget _buildCotisationsList() {
if (cotisations.isEmpty) {
return Card(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(
Icons.receipt_long_outlined,
size: 48,
color: AppTheme.textHint,
),
const SizedBox(height: 16),
const Text(
'Aucune cotisation',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
'Ce membre n\'a pas encore de cotisations enregistrées.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
],
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.list_alt,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Historique des cotisations',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 12),
...cotisations.map((cotisation) => _buildCotisationCard(cotisation)),
],
);
}
Widget _buildCotisationCard(CotisationModel cotisation) {
return Card(
elevation: 1,
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
cotisation.periode ?? 'Période non définie',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
cotisation.typeCotisation,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
_buildStatusBadge(cotisation),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildCotisationDetail(
'Montant dû',
_formatAmount(cotisation.montantDu),
Icons.receipt,
),
),
Expanded(
child: _buildCotisationDetail(
'Montant payé',
_formatAmount(cotisation.montantPaye),
Icons.payment,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _buildCotisationDetail(
'Échéance',
DateFormat('dd/MM/yyyy').format(cotisation.dateEcheance),
Icons.schedule,
),
),
if (cotisation.datePaiement != null)
Expanded(
child: _buildCotisationDetail(
'Payé le',
DateFormat('dd/MM/yyyy').format(cotisation.datePaiement!),
Icons.check_circle,
),
),
],
),
],
),
),
);
}
Widget _buildStatusBadge(CotisationModel cotisation) {
Color color;
String label;
switch (cotisation.statut) {
case 'PAYEE':
color = AppTheme.successColor;
label = 'Payée';
break;
case 'EN_ATTENTE':
color = AppTheme.warningColor;
label = 'En attente';
break;
case 'EN_RETARD':
color = AppTheme.errorColor;
label = 'En retard';
break;
case 'PARTIELLEMENT_PAYEE':
color = AppTheme.infoColor;
label = 'Partielle';
break;
default:
color = AppTheme.textSecondary;
label = cotisation.statut;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
Widget _buildCotisationDetail(String label, String value, IconData icon) {
return Row(
children: [
Icon(icon, size: 14, color: AppTheme.textSecondary),
const SizedBox(width: 4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 10,
color: AppTheme.textSecondary,
),
),
Text(
value,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
],
),
),
],
);
}
String _formatAmount(double amount) {
return NumberFormat.currency(
locale: 'fr_FR',
symbol: 'FCFA',
decimalDigits: 0,
).format(amount);
}
}

View File

@@ -0,0 +1,495 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../bloc/membres_bloc.dart';
import '../bloc/membres_event.dart';
import '../bloc/membres_state.dart';
/// Dialog de confirmation de suppression/désactivation d'un membre
class MembreDeleteDialog extends StatefulWidget {
const MembreDeleteDialog({
super.key,
required this.membre,
});
final MembreModel membre;
@override
State<MembreDeleteDialog> createState() => _MembreDeleteDialogState();
}
class _MembreDeleteDialogState extends State<MembreDeleteDialog> {
late MembresBloc _membresBloc;
bool _isLoading = false;
bool _softDelete = true; // Par défaut, désactivation plutôt que suppression
bool _hasActiveCotisations = false;
bool _hasUnpaidCotisations = false;
int _totalCotisations = 0;
double _unpaidAmount = 0.0;
@override
void initState() {
super.initState();
_membresBloc = getIt<MembresBloc>();
_checkMemberDependencies();
}
void _checkMemberDependencies() {
// TODO: Implémenter la vérification des dépendances via le repository
// Pour l'instant, simulation avec des données fictives
setState(() {
_hasActiveCotisations = true;
_hasUnpaidCotisations = true;
_totalCotisations = 5;
_unpaidAmount = 75000.0;
});
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _membresBloc,
child: BlocConsumer<MembresBloc, MembresState>(
listener: (context, state) {
if (state is MembreDeleted) {
setState(() {
_isLoading = false;
});
Navigator.of(context).pop(true);
} else if (state is MembreUpdated) {
setState(() {
_isLoading = false;
});
Navigator.of(context).pop(true);
} else if (state is MembresError) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppTheme.errorColor,
),
);
}
},
builder: (context, state) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Icon(
_softDelete ? Icons.person_off : Icons.delete_forever,
color: _softDelete ? AppTheme.warningColor : AppTheme.errorColor,
),
const SizedBox(width: 12),
Expanded(
child: Text(
_softDelete ? 'Désactiver le membre' : 'Supprimer le membre',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Informations du membre
_buildMemberInfo(),
const SizedBox(height: 20),
// Vérifications des dépendances
if (_hasActiveCotisations || _hasUnpaidCotisations)
_buildDependenciesWarning(),
const SizedBox(height: 16),
// Options de suppression
_buildDeleteOptions(),
const SizedBox(height: 20),
// Message de confirmation
_buildConfirmationMessage(),
],
),
),
actions: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: _isLoading ? null : _handleDelete,
style: ElevatedButton.styleFrom(
backgroundColor: _softDelete ? AppTheme.warningColor : AppTheme.errorColor,
foregroundColor: Colors.white,
),
child: _isLoading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(_softDelete ? 'Désactiver' : 'Supprimer'),
),
],
);
},
),
);
}
Widget _buildMemberInfo() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.backgroundLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: AppTheme.primaryColor,
child: Text(
'${widget.membre.prenom[0]}${widget.membre.nom[0]}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${widget.membre.prenom} ${widget.membre.nom}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
Text(
widget.membre.numeroMembre,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: widget.membre.actif ? AppTheme.successColor : AppTheme.errorColor,
borderRadius: BorderRadius.circular(12),
),
child: Text(
widget.membre.actif ? 'Actif' : 'Inactif',
style: const TextStyle(
fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 8),
Text(
widget.membre.email,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
);
}
Widget _buildDependenciesWarning() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.warningColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.warningColor.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.warning_amber,
color: AppTheme.warningColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Attention - Dépendances détectées',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.warningColor,
),
),
],
),
const SizedBox(height: 8),
if (_hasActiveCotisations) ...[
Text(
'$_totalCotisations cotisations associées à ce membre',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
if (_hasUnpaidCotisations) ...[
Text(
'${_unpaidAmount.toStringAsFixed(0)} XOF de cotisations impayées',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
],
const SizedBox(height: 8),
const Text(
'La désactivation est recommandée pour préserver l\'historique.',
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
fontStyle: FontStyle.italic,
),
),
],
),
);
}
Widget _buildDeleteOptions() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Options de suppression :',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
// Option désactivation
InkWell(
onTap: () {
setState(() {
_softDelete = true;
});
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _softDelete ? AppTheme.warningColor.withOpacity(0.1) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _softDelete ? AppTheme.warningColor : AppTheme.borderColor,
width: _softDelete ? 2 : 1,
),
),
child: Row(
children: [
Radio<bool>(
value: true,
groupValue: _softDelete,
onChanged: (value) {
setState(() {
_softDelete = value!;
});
},
activeColor: AppTheme.warningColor,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Désactiver le membre (Recommandé)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
const Text(
'Le membre sera marqué comme inactif mais ses données et historique seront préservés.',
style: TextStyle(
fontSize: 11,
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
),
),
const SizedBox(height: 8),
// Option suppression définitive
InkWell(
onTap: () {
setState(() {
_softDelete = false;
});
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: !_softDelete ? AppTheme.errorColor.withOpacity(0.1) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: !_softDelete ? AppTheme.errorColor : AppTheme.borderColor,
width: !_softDelete ? 2 : 1,
),
),
child: Row(
children: [
Radio<bool>(
value: false,
groupValue: _softDelete,
onChanged: (value) {
setState(() {
_softDelete = value!;
});
},
activeColor: AppTheme.errorColor,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Supprimer définitivement',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
const Text(
'ATTENTION : Cette action est irréversible. Toutes les données du membre seront perdues.',
style: TextStyle(
fontSize: 11,
color: AppTheme.errorColor,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
),
),
],
);
}
Widget _buildConfirmationMessage() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _softDelete
? AppTheme.warningColor.withOpacity(0.1)
: AppTheme.errorColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _softDelete
? AppTheme.warningColor.withOpacity(0.3)
: AppTheme.errorColor.withOpacity(0.3),
),
),
child: Text(
_softDelete
? 'Le membre "${widget.membre.prenom} ${widget.membre.nom}" sera désactivé et ne pourra plus accéder aux services, mais son historique sera préservé.'
: 'Le membre "${widget.membre.prenom} ${widget.membre.nom}" sera définitivement supprimé avec toutes ses données. Cette action ne peut pas être annulée.',
style: TextStyle(
fontSize: 12,
color: _softDelete ? AppTheme.warningColor : AppTheme.errorColor,
fontWeight: FontWeight.w500,
),
),
);
}
void _handleDelete() {
setState(() {
_isLoading = true;
});
if (_softDelete) {
// Désactivation du membre
final membreDesactive = widget.membre.copyWith(
actif: false,
version: widget.membre.version + 1,
dateModification: DateTime.now(),
);
final memberId = widget.membre.id;
if (memberId != null && memberId.isNotEmpty) {
_membresBloc.add(UpdateMembre(memberId, membreDesactive));
} else {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Erreur : ID du membre manquant'),
backgroundColor: AppTheme.errorColor,
),
);
}
} else {
// Suppression définitive
final memberId = widget.membre.id;
if (memberId != null && memberId.isNotEmpty) {
_membresBloc.add(DeleteMembre(memberId));
} else {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Erreur : ID du membre manquant'),
backgroundColor: AppTheme.errorColor,
),
);
}
}
}
}

View File

@@ -0,0 +1,373 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../shared/theme/app_theme.dart';
/// Section d'informations détaillées d'un membre
class MembreInfoSection extends StatelessWidget {
const MembreInfoSection({
super.key,
required this.membre,
this.showActions = false,
this.onEdit,
this.onCall,
this.onMessage,
});
final MembreModel membre;
final bool showActions;
final VoidCallback? onEdit;
final VoidCallback? onCall;
final VoidCallback? onMessage;
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
const SizedBox(height: 20),
_buildPersonalInfo(),
const SizedBox(height: 16),
_buildContactInfo(),
const SizedBox(height: 16),
_buildMembershipInfo(),
if (showActions) ...[
const SizedBox(height: 20),
_buildActionButtons(),
],
],
),
),
);
}
Widget _buildHeader() {
return Row(
children: [
_buildAvatar(),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
membre.nomComplet,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
membre.numeroMembre,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
_buildStatusBadge(),
],
),
),
],
);
}
Widget _buildAvatar() {
return Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(40),
border: Border.all(
color: AppTheme.primaryColor.withOpacity(0.3),
width: 2,
),
),
child: Icon(
Icons.person,
size: 40,
color: AppTheme.primaryColor,
),
);
}
Widget _buildStatusBadge() {
final isActive = membre.actif;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isActive ? AppTheme.successColor : AppTheme.errorColor,
borderRadius: BorderRadius.circular(20),
),
child: Text(
isActive ? 'Actif' : 'Inactif',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildPersonalInfo() {
return _buildSection(
title: 'Informations personnelles',
icon: Icons.person_outline,
children: [
_buildInfoRow(
icon: Icons.cake_outlined,
label: 'Date de naissance',
value: membre.dateNaissance != null
? DateFormat('dd/MM/yyyy').format(membre.dateNaissance!)
: 'Non renseignée',
),
_buildInfoRow(
icon: Icons.work_outline,
label: 'Profession',
value: membre.profession ?? 'Non renseignée',
),
_buildInfoRow(
icon: Icons.location_on_outlined,
label: 'Adresse',
value: _buildFullAddress(),
),
],
);
}
Widget _buildContactInfo() {
return _buildSection(
title: 'Contact',
icon: Icons.contact_phone_outlined,
children: [
_buildInfoRow(
icon: Icons.email_outlined,
label: 'Email',
value: membre.email,
isSelectable: true,
),
_buildInfoRow(
icon: Icons.phone_outlined,
label: 'Téléphone',
value: membre.telephone,
isSelectable: true,
),
],
);
}
Widget _buildMembershipInfo() {
return _buildSection(
title: 'Adhésion',
icon: Icons.card_membership_outlined,
children: [
_buildInfoRow(
icon: Icons.calendar_today_outlined,
label: 'Date d\'adhésion',
value: DateFormat('dd/MM/yyyy').format(membre.dateAdhesion),
),
_buildInfoRow(
icon: Icons.access_time_outlined,
label: 'Membre depuis',
value: _calculateMembershipDuration(),
),
_buildInfoRow(
icon: Icons.update_outlined,
label: 'Dernière modification',
value: membre.dateModification != null
? DateFormat('dd/MM/yyyy à HH:mm').format(membre.dateModification!)
: 'Jamais modifié',
),
],
);
}
Widget _buildSection({
required String title,
required IconData icon,
required List<Widget> children,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
size: 20,
color: AppTheme.primaryColor,
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 12),
...children,
],
);
}
Widget _buildInfoRow({
required IconData icon,
required String label,
required String value,
bool isSelectable = false,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 16,
color: AppTheme.textSecondary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
isSelectable
? SelectableText(
value,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
),
)
: Text(
value,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textPrimary,
),
),
],
),
),
],
),
);
}
Widget _buildActionButtons() {
return Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit, size: 18),
label: const Text('Modifier'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: onCall,
icon: const Icon(Icons.phone, size: 18),
label: const Text('Appeler'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.primaryColor,
side: const BorderSide(color: AppTheme.primaryColor),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: onMessage,
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.infoColor,
side: const BorderSide(color: AppTheme.infoColor),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Icon(Icons.message, size: 18),
),
],
);
}
String _buildFullAddress() {
final parts = <String>[];
if (membre.adresse != null && membre.adresse!.isNotEmpty) {
parts.add(membre.adresse!);
}
if (membre.ville != null && membre.ville!.isNotEmpty) {
parts.add(membre.ville!);
}
if (membre.codePostal != null && membre.codePostal!.isNotEmpty) {
parts.add(membre.codePostal!);
}
if (membre.pays != null && membre.pays!.isNotEmpty) {
parts.add(membre.pays!);
}
return parts.isNotEmpty ? parts.join(', ') : 'Non renseignée';
}
String _calculateMembershipDuration() {
final now = DateTime.now();
final adhesion = membre.dateAdhesion;
final difference = now.difference(adhesion);
final years = (difference.inDays / 365).floor();
final months = ((difference.inDays % 365) / 30).floor();
if (years > 0) {
return months > 0 ? '$years an${years > 1 ? 's' : ''} et $months mois' : '$years an${years > 1 ? 's' : ''}';
} else if (months > 0) {
return '$months mois';
} else {
return '${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
}
}
}

View File

@@ -0,0 +1,592 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../../../../core/models/membre_model.dart';
import '../../../../core/models/cotisation_model.dart';
import '../../../../shared/theme/app_theme.dart';
/// Section des statistiques d'un membre
class MembreStatsSection extends StatelessWidget {
const MembreStatsSection({
super.key,
required this.membre,
required this.cotisations,
});
final MembreModel membre;
final List<CotisationModel> cotisations;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildOverviewCard(),
const SizedBox(height: 16),
_buildPaymentChart(),
const SizedBox(height: 16),
_buildStatusChart(),
const SizedBox(height: 16),
_buildTimelineCard(),
],
),
);
}
Widget _buildOverviewCard() {
final totalCotisations = cotisations.length;
final cotisationsPayees = cotisations.where((c) => c.statut == 'PAYEE').length;
final cotisationsEnRetard = cotisations.where((c) => c.isEnRetard).length;
final tauxPaiement = totalCotisations > 0 ? (cotisationsPayees / totalCotisations * 100) : 0.0;
final totalMontantDu = cotisations.fold<double>(0, (sum, c) => sum + c.montantDu);
final totalMontantPaye = cotisations.fold<double>(0, (sum, c) => sum + c.montantPaye);
final membershipDuration = DateTime.now().difference(membre.dateAdhesion).inDays;
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.analytics,
color: AppTheme.primaryColor,
size: 24,
),
const SizedBox(width: 8),
const Text(
'Vue d\'ensemble',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: _buildStatItem(
'Cotisations',
'$totalCotisations',
AppTheme.primaryColor,
Icons.receipt_long,
),
),
Expanded(
child: _buildStatItem(
'Taux de paiement',
'${tauxPaiement.toStringAsFixed(1)}%',
tauxPaiement >= 80 ? AppTheme.successColor :
tauxPaiement >= 50 ? AppTheme.warningColor : AppTheme.errorColor,
Icons.trending_up,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildStatItem(
'En retard',
'$cotisationsEnRetard',
cotisationsEnRetard > 0 ? AppTheme.errorColor : AppTheme.successColor,
Icons.warning,
),
),
Expanded(
child: _buildStatItem(
'Ancienneté',
'${(membershipDuration / 365).floor()} an${(membershipDuration / 365).floor() > 1 ? 's' : ''}',
AppTheme.infoColor,
Icons.schedule,
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.backgroundLight,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Total payé',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
Text(
_formatAmount(totalMontantPaye),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.successColor,
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Text(
'Restant à payer',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
Text(
_formatAmount(totalMontantDu - totalMontantPaye),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: totalMontantDu > totalMontantPaye ? AppTheme.warningColor : AppTheme.successColor,
),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildStatItem(String label, String value, Color color, IconData icon) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Column(
children: [
Icon(icon, color: color, size: 20),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildPaymentChart() {
if (cotisations.isEmpty) {
return _buildEmptyChart('Aucune donnée de paiement');
}
final paymentData = _getPaymentChartData();
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.pie_chart,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Répartition des paiements',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 20),
SizedBox(
height: 200,
child: PieChart(
PieChartData(
sections: paymentData,
centerSpaceRadius: 40,
sectionsSpace: 2,
),
),
),
const SizedBox(height: 16),
_buildChartLegend(),
],
),
),
);
}
Widget _buildStatusChart() {
if (cotisations.isEmpty) {
return _buildEmptyChart('Aucune donnée de statut');
}
final statusData = _getStatusChartData();
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.bar_chart,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Évolution des montants',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 20),
SizedBox(
height: 200,
child: BarChart(
BarChartData(
barGroups: statusData,
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 60,
getTitlesWidget: (value, meta) {
return Text(
_formatAmount(value),
style: const TextStyle(
fontSize: 10,
color: AppTheme.textSecondary,
),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index >= 0 && index < cotisations.length) {
return Text(
(cotisations[index].periode ?? 'N/A').substring(0, 3),
style: const TextStyle(
fontSize: 10,
color: AppTheme.textSecondary,
),
);
}
return const Text('');
},
),
),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: false),
gridData: const FlGridData(show: false),
),
),
),
],
),
),
);
}
Widget _buildTimelineCard() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.timeline,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Chronologie',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 16),
_buildTimelineItem(
'Adhésion',
DateFormat('dd/MM/yyyy').format(membre.dateAdhesion),
AppTheme.primaryColor,
Icons.person_add,
true,
),
if (cotisations.isNotEmpty) ...[
_buildTimelineItem(
'Première cotisation',
DateFormat('dd/MM/yyyy').format(
cotisations.map((c) => c.dateCreation).reduce((a, b) => a.isBefore(b) ? a : b),
),
AppTheme.infoColor,
Icons.payment,
true,
),
_buildTimelineItem(
'Dernière cotisation',
DateFormat('dd/MM/yyyy').format(
cotisations.map((c) => c.dateCreation).reduce((a, b) => a.isAfter(b) ? a : b),
),
AppTheme.successColor,
Icons.receipt,
false,
),
],
],
),
),
);
}
Widget _buildTimelineItem(String title, String date, Color color, IconData icon, bool showLine) {
return Row(
children: [
Column(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(icon, color: Colors.white, size: 16),
),
if (showLine)
Container(
width: 2,
height: 24,
color: color.withOpacity(0.3),
),
],
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
Text(
date,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
],
);
}
Widget _buildEmptyChart(String message) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(40),
child: Column(
children: [
Icon(
Icons.bar_chart,
size: 48,
color: AppTheme.textHint,
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
],
),
),
);
}
Widget _buildChartLegend() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildLegendItem('Payé', AppTheme.successColor),
_buildLegendItem('En attente', AppTheme.warningColor),
_buildLegendItem('En retard', AppTheme.errorColor),
],
);
}
Widget _buildLegendItem(String label, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
);
}
List<PieChartSectionData> _getPaymentChartData() {
final payees = cotisations.where((c) => c.statut == 'PAYEE').length;
final enAttente = cotisations.where((c) => c.statut == 'EN_ATTENTE').length;
final enRetard = cotisations.where((c) => c.isEnRetard).length;
final total = cotisations.length;
return [
if (payees > 0)
PieChartSectionData(
color: AppTheme.successColor,
value: payees.toDouble(),
title: '${(payees / total * 100).toStringAsFixed(1)}%',
radius: 50,
),
if (enAttente > 0)
PieChartSectionData(
color: AppTheme.warningColor,
value: enAttente.toDouble(),
title: '${(enAttente / total * 100).toStringAsFixed(1)}%',
radius: 50,
),
if (enRetard > 0)
PieChartSectionData(
color: AppTheme.errorColor,
value: enRetard.toDouble(),
title: '${(enRetard / total * 100).toStringAsFixed(1)}%',
radius: 50,
),
];
}
List<BarChartGroupData> _getStatusChartData() {
return cotisations.asMap().entries.map((entry) {
final index = entry.key;
final cotisation = entry.value;
return BarChartGroupData(
x: index,
barRods: [
BarChartRodData(
toY: cotisation.montantDu,
color: AppTheme.infoColor.withOpacity(0.7),
width: 8,
),
BarChartRodData(
toY: cotisation.montantPaye,
color: AppTheme.successColor,
width: 8,
),
],
);
}).toList();
}
String _formatAmount(double amount) {
return NumberFormat.currency(
locale: 'fr_FR',
symbol: 'FCFA',
decimalDigits: 0,
).format(amount);
}
}

View File

@@ -0,0 +1,626 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/custom_text_field.dart';
/// Widget de recherche avancée pour les membres
class MembresAdvancedSearch extends StatefulWidget {
const MembresAdvancedSearch({
super.key,
required this.onSearch,
this.initialFilters,
});
final Function(Map<String, dynamic>) onSearch;
final Map<String, dynamic>? initialFilters;
@override
State<MembresAdvancedSearch> createState() => _MembresAdvancedSearchState();
}
class _MembresAdvancedSearchState extends State<MembresAdvancedSearch> {
final _formKey = GlobalKey<FormState>();
// Contrôleurs de texte
final _nomController = TextEditingController();
final _prenomController = TextEditingController();
final _emailController = TextEditingController();
final _telephoneController = TextEditingController();
final _numeroMembreController = TextEditingController();
final _professionController = TextEditingController();
final _villeController = TextEditingController();
// Filtres de statut
bool? _actifFilter;
// Filtres de date
DateTime? _dateAdhesionDebut;
DateTime? _dateAdhesionFin;
DateTime? _dateNaissanceDebut;
DateTime? _dateNaissanceFin;
// Filtres d'âge
int? _ageMin;
int? _ageMax;
@override
void initState() {
super.initState();
_initializeFilters();
}
void _initializeFilters() {
if (widget.initialFilters != null) {
final filters = widget.initialFilters!;
_nomController.text = filters['nom'] ?? '';
_prenomController.text = filters['prenom'] ?? '';
_emailController.text = filters['email'] ?? '';
_telephoneController.text = filters['telephone'] ?? '';
_numeroMembreController.text = filters['numeroMembre'] ?? '';
_professionController.text = filters['profession'] ?? '';
_villeController.text = filters['ville'] ?? '';
_actifFilter = filters['actif'];
_dateAdhesionDebut = filters['dateAdhesionDebut'];
_dateAdhesionFin = filters['dateAdhesionFin'];
_dateNaissanceDebut = filters['dateNaissanceDebut'];
_dateNaissanceFin = filters['dateNaissanceFin'];
_ageMin = filters['ageMin'];
_ageMax = filters['ageMax'];
}
}
@override
void dispose() {
_nomController.dispose();
_prenomController.dispose();
_emailController.dispose();
_telephoneController.dispose();
_numeroMembreController.dispose();
_professionController.dispose();
_villeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête
_buildHeader(),
const SizedBox(height: 20),
// Contenu scrollable
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Informations personnelles
_buildSection(
'Informations personnelles',
Icons.person,
[
Row(
children: [
Expanded(
child: CustomTextField(
controller: _nomController,
label: 'Nom',
prefixIcon: Icons.person_outline,
),
),
const SizedBox(width: 12),
Expanded(
child: CustomTextField(
controller: _prenomController,
label: 'Prénom',
prefixIcon: Icons.person_outline,
),
),
],
),
const SizedBox(height: 12),
CustomTextField(
controller: _numeroMembreController,
label: 'Numéro de membre',
prefixIcon: Icons.badge,
),
const SizedBox(height: 12),
CustomTextField(
controller: _professionController,
label: 'Profession',
prefixIcon: Icons.work,
),
],
),
const SizedBox(height: 20),
// Contact et localisation
_buildSection(
'Contact et localisation',
Icons.contact_phone,
[
CustomTextField(
controller: _emailController,
label: 'Email',
prefixIcon: Icons.email,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 12),
CustomTextField(
controller: _telephoneController,
label: 'Téléphone',
prefixIcon: Icons.phone,
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
CustomTextField(
controller: _villeController,
label: 'Ville',
prefixIcon: Icons.location_city,
),
],
),
const SizedBox(height: 20),
// Statut et dates
_buildSection(
'Statut et dates',
Icons.calendar_today,
[
_buildStatusFilter(),
const SizedBox(height: 16),
_buildDateRangeFilter(
'Période d\'adhésion',
_dateAdhesionDebut,
_dateAdhesionFin,
(debut, fin) {
setState(() {
_dateAdhesionDebut = debut;
_dateAdhesionFin = fin;
});
},
),
const SizedBox(height: 16),
_buildDateRangeFilter(
'Période de naissance',
_dateNaissanceDebut,
_dateNaissanceFin,
(debut, fin) {
setState(() {
_dateNaissanceDebut = debut;
_dateNaissanceFin = fin;
});
},
),
const SizedBox(height: 16),
_buildAgeRangeFilter(),
],
),
],
),
),
),
const SizedBox(height: 20),
// Boutons d'action
_buildActionButtons(),
],
),
),
);
}
Widget _buildHeader() {
return Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.search,
color: AppTheme.primaryColor,
size: 24,
),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Recherche avancée',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
color: AppTheme.textSecondary,
),
],
);
}
Widget _buildSection(String title, IconData icon, List<Widget> children) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 12),
...children,
],
);
}
Widget _buildStatusFilter() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Statut du membre',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: RadioListTile<bool?>(
title: const Text('Tous', style: TextStyle(fontSize: 14)),
value: null,
groupValue: _actifFilter,
onChanged: (value) {
setState(() {
_actifFilter = value;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
),
Expanded(
child: RadioListTile<bool?>(
title: const Text('Actifs', style: TextStyle(fontSize: 14)),
value: true,
groupValue: _actifFilter,
onChanged: (value) {
setState(() {
_actifFilter = value;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
),
Expanded(
child: RadioListTile<bool?>(
title: const Text('Inactifs', style: TextStyle(fontSize: 14)),
value: false,
groupValue: _actifFilter,
onChanged: (value) {
setState(() {
_actifFilter = value;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
),
],
),
],
);
}
Widget _buildDateRangeFilter(
String title,
DateTime? dateDebut,
DateTime? dateFin,
Function(DateTime?, DateTime?) onChanged,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: InkWell(
onTap: () => _selectDate(context, dateDebut, (date) {
onChanged(date, dateFin);
}),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
decoration: BoxDecoration(
border: Border.all(color: AppTheme.borderColor),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.calendar_today,
color: AppTheme.textSecondary,
size: 16,
),
const SizedBox(width: 8),
Text(
dateDebut != null
? DateFormat('dd/MM/yyyy').format(dateDebut)
: 'Date début',
style: TextStyle(
fontSize: 14,
color: dateDebut != null
? AppTheme.textPrimary
: AppTheme.textSecondary,
),
),
],
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: InkWell(
onTap: () => _selectDate(context, dateFin, (date) {
onChanged(dateDebut, date);
}),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
decoration: BoxDecoration(
border: Border.all(color: AppTheme.borderColor),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.calendar_today,
color: AppTheme.textSecondary,
size: 16,
),
const SizedBox(width: 8),
Text(
dateFin != null
? DateFormat('dd/MM/yyyy').format(dateFin)
: 'Date fin',
style: TextStyle(
fontSize: 14,
color: dateFin != null
? AppTheme.textPrimary
: AppTheme.textSecondary,
),
),
],
),
),
),
),
],
),
],
);
}
Widget _buildAgeRangeFilter() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Tranche d\'âge',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextFormField(
initialValue: _ageMin?.toString(),
decoration: InputDecoration(
labelText: 'Âge minimum',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
),
keyboardType: TextInputType.number,
onChanged: (value) {
_ageMin = int.tryParse(value);
},
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
initialValue: _ageMax?.toString(),
decoration: InputDecoration(
labelText: 'Âge maximum',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
),
keyboardType: TextInputType.number,
onChanged: (value) {
_ageMax = int.tryParse(value);
},
),
),
],
),
],
);
}
Widget _buildActionButtons() {
return Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _clearFilters,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
side: BorderSide(color: AppTheme.borderColor),
),
child: const Text('Effacer'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: ElevatedButton(
onPressed: _performSearch,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text('Rechercher'),
),
),
],
);
}
Future<void> _selectDate(
BuildContext context,
DateTime? initialDate,
Function(DateTime?) onDateSelected,
) async {
final date = await showDatePicker(
context: context,
initialDate: initialDate ?? DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime.now(),
);
if (date != null) {
onDateSelected(date);
}
}
void _clearFilters() {
setState(() {
_nomController.clear();
_prenomController.clear();
_emailController.clear();
_telephoneController.clear();
_numeroMembreController.clear();
_professionController.clear();
_villeController.clear();
_actifFilter = null;
_dateAdhesionDebut = null;
_dateAdhesionFin = null;
_dateNaissanceDebut = null;
_dateNaissanceFin = null;
_ageMin = null;
_ageMax = null;
});
}
void _performSearch() {
final filters = <String, dynamic>{};
// Ajout des filtres texte
if (_nomController.text.isNotEmpty) {
filters['nom'] = _nomController.text;
}
if (_prenomController.text.isNotEmpty) {
filters['prenom'] = _prenomController.text;
}
if (_emailController.text.isNotEmpty) {
filters['email'] = _emailController.text;
}
if (_telephoneController.text.isNotEmpty) {
filters['telephone'] = _telephoneController.text;
}
if (_numeroMembreController.text.isNotEmpty) {
filters['numeroMembre'] = _numeroMembreController.text;
}
if (_professionController.text.isNotEmpty) {
filters['profession'] = _professionController.text;
}
if (_villeController.text.isNotEmpty) {
filters['ville'] = _villeController.text;
}
// Ajout des filtres de statut
if (_actifFilter != null) {
filters['actif'] = _actifFilter;
}
// Ajout des filtres de date
if (_dateAdhesionDebut != null) {
filters['dateAdhesionDebut'] = _dateAdhesionDebut;
}
if (_dateAdhesionFin != null) {
filters['dateAdhesionFin'] = _dateAdhesionFin;
}
if (_dateNaissanceDebut != null) {
filters['dateNaissanceDebut'] = _dateNaissanceDebut;
}
if (_dateNaissanceFin != null) {
filters['dateNaissanceFin'] = _dateNaissanceFin;
}
// Ajout des filtres d'âge
if (_ageMin != null) {
filters['ageMin'] = _ageMin;
}
if (_ageMax != null) {
filters['ageMax'] = _ageMax;
}
widget.onSearch(filters);
Navigator.of(context).pop();
}
}

View File

@@ -0,0 +1,433 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../core/models/membre_model.dart';
/// Dialog d'export des données des membres
class MembresExportDialog extends StatefulWidget {
const MembresExportDialog({
super.key,
required this.membres,
this.selectedMembers,
});
final List<MembreModel> membres;
final List<MembreModel>? selectedMembers;
@override
State<MembresExportDialog> createState() => _MembresExportDialogState();
}
class _MembresExportDialogState extends State<MembresExportDialog> {
String _selectedFormat = 'excel';
bool _includeInactiveMembers = true;
bool _includePersonalInfo = true;
bool _includeContactInfo = true;
bool _includeAdhesionInfo = true;
bool _includeStatistics = false;
final List<String> _availableFormats = [
'excel',
'csv',
'pdf',
'json',
];
@override
Widget build(BuildContext context) {
final membersToExport = widget.selectedMembers ?? widget.membres;
final activeMembers = membersToExport.where((m) => m.actif).length;
final inactiveMembers = membersToExport.length - activeMembers;
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.file_download,
color: AppTheme.primaryColor,
size: 24,
),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Exporter les données',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Résumé des données à exporter
_buildDataSummary(membersToExport.length, activeMembers, inactiveMembers),
const SizedBox(height: 20),
// Sélection du format
_buildFormatSelection(),
const SizedBox(height: 20),
// Options d'export
_buildExportOptions(),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton.icon(
onPressed: () => _performExport(membersToExport),
icon: const Icon(Icons.download),
label: const Text('Exporter'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
),
],
);
}
Widget _buildDataSummary(int total, int active, int inactive) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.backgroundLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
color: AppTheme.primaryColor,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Données à exporter',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildSummaryItem(
'Total',
total.toString(),
AppTheme.primaryColor,
Icons.people,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildSummaryItem(
'Actifs',
active.toString(),
AppTheme.successColor,
Icons.person,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildSummaryItem(
'Inactifs',
inactive.toString(),
AppTheme.errorColor,
Icons.person_off,
),
),
],
),
],
),
);
}
Widget _buildSummaryItem(String label, String value, Color color, IconData icon) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Icon(
icon,
color: color,
size: 20,
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
);
}
Widget _buildFormatSelection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Format d\'export',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _availableFormats.map((format) {
final isSelected = _selectedFormat == format;
return InkWell(
onTap: () {
setState(() {
_selectedFormat = format;
});
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isSelected ? AppTheme.primaryColor : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? AppTheme.primaryColor : AppTheme.borderColor,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getFormatIcon(format),
color: isSelected ? Colors.white : AppTheme.textSecondary,
size: 20,
),
const SizedBox(width: 8),
Text(
_getFormatLabel(format),
style: TextStyle(
color: isSelected ? Colors.white : AppTheme.textPrimary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
);
}).toList(),
),
],
);
}
Widget _buildExportOptions() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Options d\'export',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 12),
// Inclusion des membres inactifs
CheckboxListTile(
title: const Text('Inclure les membres inactifs'),
subtitle: const Text('Exporter aussi les membres désactivés'),
value: _includeInactiveMembers,
onChanged: (value) {
setState(() {
_includeInactiveMembers = value ?? true;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
const Divider(),
// Sections de données à inclure
const Text(
'Sections à inclure',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Informations personnelles'),
subtitle: const Text('Nom, prénom, date de naissance, etc.'),
value: _includePersonalInfo,
onChanged: (value) {
setState(() {
_includePersonalInfo = value ?? true;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
title: const Text('Informations de contact'),
subtitle: const Text('Email, téléphone, adresse'),
value: _includeContactInfo,
onChanged: (value) {
setState(() {
_includeContactInfo = value ?? true;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
title: const Text('Informations d\'adhésion'),
subtitle: const Text('Date d\'adhésion, statut, numéro de membre'),
value: _includeAdhesionInfo,
onChanged: (value) {
setState(() {
_includeAdhesionInfo = value ?? true;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
title: const Text('Statistiques'),
subtitle: const Text('Données de cotisations et statistiques'),
value: _includeStatistics,
onChanged: (value) {
setState(() {
_includeStatistics = value ?? false;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
),
],
);
}
IconData _getFormatIcon(String format) {
switch (format) {
case 'excel':
return Icons.table_chart;
case 'csv':
return Icons.text_snippet;
case 'pdf':
return Icons.picture_as_pdf;
case 'json':
return Icons.code;
default:
return Icons.file_download;
}
}
String _getFormatLabel(String format) {
switch (format) {
case 'excel':
return 'Excel (.xlsx)';
case 'csv':
return 'CSV (.csv)';
case 'pdf':
return 'PDF (.pdf)';
case 'json':
return 'JSON (.json)';
default:
return format.toUpperCase();
}
}
void _performExport(List<MembreModel> membersToExport) {
// Filtrer les membres selon les options
List<MembreModel> filteredMembers = membersToExport;
if (!_includeInactiveMembers) {
filteredMembers = filteredMembers.where((m) => m.actif).toList();
}
// Créer les options d'export
final exportOptions = {
'format': _selectedFormat,
'includePersonalInfo': _includePersonalInfo,
'includeContactInfo': _includeContactInfo,
'includeAdhesionInfo': _includeAdhesionInfo,
'includeStatistics': _includeStatistics,
'includeInactiveMembers': _includeInactiveMembers,
};
// TODO: Implémenter l'export réel selon le format
_showExportResult(filteredMembers.length, _selectedFormat);
}
void _showExportResult(int count, String format) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Export $format de $count membres - À implémenter',
),
backgroundColor: AppTheme.infoColor,
action: SnackBarAction(
label: 'Voir',
onPressed: () {
// TODO: Ouvrir le fichier exporté
},
),
),
);
}
}

View File

@@ -0,0 +1,340 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Floating Action Button moderne avec animations et design professionnel
class ModernFloatingActionButton extends StatefulWidget {
const ModernFloatingActionButton({
super.key,
required this.onPressed,
required this.icon,
this.label,
this.backgroundColor,
this.foregroundColor,
this.heroTag,
this.tooltip,
this.mini = false,
this.extended = false,
});
final VoidCallback? onPressed;
final IconData icon;
final String? label;
final Color? backgroundColor;
final Color? foregroundColor;
final Object? heroTag;
final String? tooltip;
final bool mini;
final bool extended;
@override
State<ModernFloatingActionButton> createState() => _ModernFloatingActionButtonState();
}
class _ModernFloatingActionButtonState extends State<ModernFloatingActionButton>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _rotationAnimation;
bool _isPressed = false;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: DesignSystem.animationFast,
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
_rotationAnimation = Tween<double>(
begin: 0.0,
end: 0.1,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
setState(() => _isPressed = true);
_animationController.forward();
}
void _handleTapUp(TapUpDetails details) {
setState(() => _isPressed = false);
_animationController.reverse();
}
void _handleTapCancel() {
setState(() => _isPressed = false);
_animationController.reverse();
}
@override
Widget build(BuildContext context) {
if (widget.extended && widget.label != null) {
return _buildExtendedFAB();
}
return _buildRegularFAB();
}
Widget _buildRegularFAB() {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Transform.rotate(
angle: _rotationAnimation.value,
child: GestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
onTap: widget.onPressed,
child: Container(
width: widget.mini ? 40 : 56,
height: widget.mini ? 40 : 56,
decoration: BoxDecoration(
gradient: DesignSystem.primaryGradient,
borderRadius: BorderRadius.circular(
widget.mini ? 20 : 28,
),
boxShadow: [
BoxShadow(
color: (widget.backgroundColor ?? AppTheme.primaryColor).withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
...DesignSystem.shadowCard,
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(
widget.mini ? 20 : 28,
),
onTap: widget.onPressed,
child: Center(
child: Icon(
widget.icon,
color: widget.foregroundColor ?? Colors.white,
size: widget.mini ? 20 : 24,
),
),
),
),
),
),
),
);
},
);
}
Widget _buildExtendedFAB() {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: GestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
onTap: widget.onPressed,
child: Container(
height: 48,
padding: EdgeInsets.symmetric(
horizontal: DesignSystem.spacingLg,
vertical: DesignSystem.spacingSm,
),
decoration: BoxDecoration(
gradient: DesignSystem.primaryGradient,
borderRadius: BorderRadius.circular(DesignSystem.radiusXl),
boxShadow: [
BoxShadow(
color: (widget.backgroundColor ?? AppTheme.primaryColor).withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
...DesignSystem.shadowCard,
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(DesignSystem.radiusXl),
onTap: widget.onPressed,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
widget.icon,
color: widget.foregroundColor ?? Colors.white,
size: 20,
),
SizedBox(width: DesignSystem.spacingSm),
Text(
widget.label!,
style: DesignSystem.labelLarge.copyWith(
color: widget.foregroundColor ?? Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
),
);
},
);
}
}
/// Widget de FAB avec menu contextuel
class ModernFABWithMenu extends StatefulWidget {
const ModernFABWithMenu({
super.key,
required this.mainAction,
required this.menuItems,
this.heroTag,
});
final ModernFABAction mainAction;
final List<ModernFABAction> menuItems;
final Object? heroTag;
@override
State<ModernFABWithMenu> createState() => _ModernFABWithMenuState();
}
class _ModernFABWithMenuState extends State<ModernFABWithMenu>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _rotationAnimation;
bool _isOpen = false;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: DesignSystem.animationMedium,
vsync: this,
);
_rotationAnimation = Tween<double>(
begin: 0.0,
end: 0.75,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _toggleMenu() {
setState(() {
_isOpen = !_isOpen;
if (_isOpen) {
_animationController.forward();
} else {
_animationController.reverse();
}
});
}
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.bottomRight,
children: [
// Menu items
...widget.menuItems.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
final offset = (index + 1) * 70.0 * _animationController.value;
return Transform.translate(
offset: Offset(0, -offset),
child: Opacity(
opacity: _animationController.value,
child: ModernFloatingActionButton(
onPressed: () {
_toggleMenu();
item.onPressed?.call();
},
icon: item.icon,
mini: true,
backgroundColor: item.backgroundColor,
foregroundColor: item.foregroundColor,
heroTag: '${widget.heroTag}_$index',
),
),
);
},
);
}).toList(),
// Main FAB
AnimatedBuilder(
animation: _rotationAnimation,
builder: (context, child) {
return Transform.rotate(
angle: _rotationAnimation.value * 2 * 3.14159,
child: ModernFloatingActionButton(
onPressed: _toggleMenu,
icon: _isOpen ? Icons.close : widget.mainAction.icon,
backgroundColor: widget.mainAction.backgroundColor,
foregroundColor: widget.mainAction.foregroundColor,
heroTag: widget.heroTag,
),
);
},
),
],
);
}
}
/// Modèle pour une action de FAB
class ModernFABAction {
const ModernFABAction({
required this.icon,
this.onPressed,
this.backgroundColor,
this.foregroundColor,
this.label,
});
final IconData icon;
final VoidCallback? onPressed;
final Color? backgroundColor;
final Color? foregroundColor;
final String? label;
}

View File

@@ -0,0 +1,205 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// TabBar moderne avec animations et design professionnel
class ModernTabBar extends StatefulWidget implements PreferredSizeWidget {
const ModernTabBar({
super.key,
required this.controller,
required this.tabs,
this.onTap,
});
final TabController controller;
final List<ModernTab> tabs;
final ValueChanged<int>? onTap;
@override
State<ModernTabBar> createState() => _ModernTabBarState();
@override
Size get preferredSize => Size.fromHeight(DesignSystem.goldenWidth(60));
}
class _ModernTabBarState extends State<ModernTabBar>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: DesignSystem.animationFast,
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
widget.controller.addListener(_onTabChanged);
}
@override
void dispose() {
widget.controller.removeListener(_onTabChanged);
_animationController.dispose();
super.dispose();
}
void _onTabChanged() {
if (mounted) {
_animationController.forward().then((_) {
_animationController.reverse();
});
}
}
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.symmetric(
horizontal: DesignSystem.spacingLg,
vertical: DesignSystem.spacingSm,
),
decoration: BoxDecoration(
color: AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
boxShadow: DesignSystem.shadowCard,
border: Border.all(
color: AppTheme.borderColor.withOpacity(0.1),
width: 1,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
child: TabBar(
controller: widget.controller,
onTap: widget.onTap,
indicator: BoxDecoration(
gradient: DesignSystem.primaryGradient,
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: EdgeInsets.all(DesignSystem.spacingXs),
labelColor: Colors.white,
unselectedLabelColor: AppTheme.textSecondary,
labelStyle: DesignSystem.labelLarge.copyWith(
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: DesignSystem.labelLarge.copyWith(
fontWeight: FontWeight.w500,
),
dividerColor: Colors.transparent,
tabs: widget.tabs.asMap().entries.map((entry) {
final index = entry.key;
final tab = entry.value;
final isSelected = widget.controller.index == index;
return AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(
scale: isSelected ? _scaleAnimation.value : 1.0,
child: _buildTab(tab, isSelected),
);
},
);
}).toList(),
),
),
);
}
Widget _buildTab(ModernTab tab, bool isSelected) {
return Container(
height: DesignSystem.goldenWidth(50),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: DesignSystem.animationFast,
child: Icon(
tab.icon,
size: isSelected ? 20 : 18,
color: isSelected ? Colors.white : AppTheme.textSecondary,
),
),
if (tab.label != null) ...[
SizedBox(width: DesignSystem.spacingXs),
AnimatedDefaultTextStyle(
duration: DesignSystem.animationFast,
style: (isSelected ? DesignSystem.labelLarge : DesignSystem.labelMedium).copyWith(
color: isSelected ? Colors.white : AppTheme.textSecondary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
),
child: Text(tab.label!),
),
],
if (tab.badge != null) ...[
SizedBox(width: DesignSystem.spacingXs),
_buildBadge(tab.badge!, isSelected),
],
],
),
);
}
Widget _buildBadge(String badge, bool isSelected) {
return AnimatedContainer(
duration: DesignSystem.animationFast,
padding: EdgeInsets.symmetric(
horizontal: DesignSystem.spacingXs,
vertical: 2,
),
decoration: BoxDecoration(
color: isSelected
? Colors.white.withOpacity(0.2)
: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: Text(
badge,
style: DesignSystem.labelSmall.copyWith(
color: isSelected ? Colors.white : AppTheme.primaryColor,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
);
}
}
/// Modèle pour un onglet moderne
class ModernTab {
const ModernTab({
required this.icon,
this.label,
this.badge,
});
final IconData icon;
final String? label;
final String? badge;
}
/// Extension pour créer facilement des onglets modernes
extension ModernTabExtension on Tab {
static ModernTab modern({
required IconData icon,
String? label,
String? badge,
}) {
return ModernTab(
icon: icon,
label: label,
badge: badge,
);
}
}

View File

@@ -0,0 +1,269 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Graphique en barres professionnel avec animations et interactions
class ProfessionalBarChart extends StatefulWidget {
const ProfessionalBarChart({
super.key,
required this.data,
required this.title,
this.subtitle,
this.showGrid = true,
this.showValues = true,
this.animationDuration = const Duration(milliseconds: 1500),
this.barColor,
this.gradientColors,
});
final List<BarDataPoint> data;
final String title;
final String? subtitle;
final bool showGrid;
final bool showValues;
final Duration animationDuration;
final Color? barColor;
final List<Color>? gradientColors;
@override
State<ProfessionalBarChart> createState() => _ProfessionalBarChartState();
}
class _ProfessionalBarChartState extends State<ProfessionalBarChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
int _touchedIndex = -1;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
_animation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedBox(height: DesignSystem.spacingLg),
Expanded(
child: _buildChart(),
),
],
);
}
Widget _buildHeader() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: DesignSystem.titleLarge.copyWith(
fontWeight: FontWeight.w700,
),
),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
Text(
widget.subtitle!,
style: DesignSystem.bodyMedium.copyWith(
color: AppTheme.textSecondary,
),
),
],
],
);
}
Widget _buildChart() {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: _getMaxY() * 1.2,
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
tooltipBgColor: AppTheme.textPrimary.withOpacity(0.9),
tooltipRoundedRadius: DesignSystem.radiusSm,
tooltipPadding: EdgeInsets.all(DesignSystem.spacingSm),
getTooltipItem: (group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'${widget.data[groupIndex].label}\n${rod.toY.toInt()}',
DesignSystem.labelMedium.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
);
},
),
touchCallback: (FlTouchEvent event, barTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions ||
barTouchResponse == null ||
barTouchResponse.spot == null) {
_touchedIndex = -1;
return;
}
_touchedIndex = barTouchResponse.spot!.touchedBarGroupIndex;
});
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: _buildBottomTitles,
reservedSize: 42,
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: _buildLeftTitles,
reservedSize: 40,
),
),
),
borderData: FlBorderData(show: false),
gridData: FlGridData(
show: widget.showGrid,
drawVerticalLine: false,
horizontalInterval: _getMaxY() / 5,
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppTheme.borderColor.withOpacity(0.3),
strokeWidth: 1,
);
},
),
barGroups: _buildBarGroups(),
),
);
},
);
}
List<BarChartGroupData> _buildBarGroups() {
return widget.data.asMap().entries.map((entry) {
final index = entry.key;
final data = entry.value;
final isTouched = index == _touchedIndex;
return BarChartGroupData(
x: index,
barRods: [
BarChartRodData(
toY: data.value * _animation.value,
color: _getBarColor(index, isTouched),
width: isTouched ? 24 : 20,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(DesignSystem.radiusXs),
topRight: Radius.circular(DesignSystem.radiusXs),
),
gradient: widget.gradientColors != null ? LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: widget.gradientColors!,
) : null,
),
],
showingTooltipIndicators: isTouched ? [0] : [],
);
}).toList();
}
Color _getBarColor(int index, bool isTouched) {
if (widget.barColor != null) {
return isTouched
? widget.barColor!
: widget.barColor!.withOpacity(0.8);
}
final colors = DesignSystem.chartColors;
final color = colors[index % colors.length];
return isTouched ? color : color.withOpacity(0.8);
}
Widget _buildBottomTitles(double value, TitleMeta meta) {
if (value.toInt() >= widget.data.length) return const SizedBox.shrink();
final data = widget.data[value.toInt()];
return SideTitleWidget(
axisSide: meta.axisSide,
child: Padding(
padding: EdgeInsets.only(top: DesignSystem.spacingXs),
child: Text(
data.label,
style: DesignSystem.labelSmall.copyWith(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
);
}
Widget _buildLeftTitles(double value, TitleMeta meta) {
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(
value.toInt().toString(),
style: DesignSystem.labelSmall.copyWith(
color: AppTheme.textSecondary,
),
),
);
}
double _getMaxY() {
if (widget.data.isEmpty) return 10;
return widget.data.map((e) => e.value).reduce((a, b) => a > b ? a : b);
}
}
/// Modèle de données pour le graphique en barres
class BarDataPoint {
const BarDataPoint({
required this.label,
required this.value,
this.color,
});
final String label;
final double value;
final Color? color;
}

View File

@@ -0,0 +1,282 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Graphique linéaire professionnel avec animations et interactions
class ProfessionalLineChart extends StatefulWidget {
const ProfessionalLineChart({
super.key,
required this.data,
required this.title,
this.subtitle,
this.showGrid = true,
this.showDots = true,
this.showArea = false,
this.animationDuration = const Duration(milliseconds: 1500),
this.lineColor,
this.gradientColors,
});
final List<LineDataPoint> data;
final String title;
final String? subtitle;
final bool showGrid;
final bool showDots;
final bool showArea;
final Duration animationDuration;
final Color? lineColor;
final List<Color>? gradientColors;
@override
State<ProfessionalLineChart> createState() => _ProfessionalLineChartState();
}
class _ProfessionalLineChartState extends State<ProfessionalLineChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
List<int> _showingTooltipOnSpots = [];
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
_animation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedBox(height: DesignSystem.spacingLg),
Expanded(
child: _buildChart(),
),
],
);
}
Widget _buildHeader() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: DesignSystem.titleLarge.copyWith(
fontWeight: FontWeight.w700,
),
),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
Text(
widget.subtitle!,
style: DesignSystem.bodyMedium.copyWith(
color: AppTheme.textSecondary,
),
),
],
],
);
}
Widget _buildChart() {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return LineChart(
LineChartData(
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
tooltipBgColor: AppTheme.textPrimary.withOpacity(0.9),
tooltipRoundedRadius: DesignSystem.radiusSm,
tooltipPadding: EdgeInsets.all(DesignSystem.spacingSm),
getTooltipItems: (List<LineBarSpot> touchedBarSpots) {
return touchedBarSpots.map((barSpot) {
final data = widget.data[barSpot.x.toInt()];
return LineTooltipItem(
'${data.label}\n${barSpot.y.toInt()}',
DesignSystem.labelMedium.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
);
}).toList();
},
),
handleBuiltInTouches: true,
getTouchedSpotIndicator: (LineChartBarData barData, List<int> spotIndexes) {
return spotIndexes.map((index) {
return TouchedSpotIndicatorData(
FlLine(
color: widget.lineColor ?? AppTheme.primaryColor,
strokeWidth: 2,
dashArray: [3, 3],
),
FlDotData(
getDotPainter: (spot, percent, barData, index) =>
FlDotCirclePainter(
radius: 6,
color: widget.lineColor ?? AppTheme.primaryColor,
strokeWidth: 2,
strokeColor: Colors.white,
),
),
);
}).toList();
},
),
gridData: FlGridData(
show: widget.showGrid,
drawVerticalLine: false,
horizontalInterval: _getMaxY() / 5,
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppTheme.borderColor.withOpacity(0.3),
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: _buildBottomTitles,
reservedSize: 42,
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: _buildLeftTitles,
reservedSize: 40,
),
),
),
borderData: FlBorderData(show: false),
minX: 0,
maxX: widget.data.length.toDouble() - 1,
minY: 0,
maxY: _getMaxY() * 1.2,
lineBarsData: [
_buildLineBarData(),
],
),
);
},
);
}
LineChartBarData _buildLineBarData() {
final spots = widget.data.asMap().entries.map((entry) {
final index = entry.key;
final data = entry.value;
return FlSpot(index.toDouble(), data.value * _animation.value);
}).toList();
return LineChartBarData(
spots: spots,
isCurved: true,
curveSmoothness: 0.3,
color: widget.lineColor ?? AppTheme.primaryColor,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: widget.showDots,
getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter(
radius: 4,
color: widget.lineColor ?? AppTheme.primaryColor,
strokeWidth: 2,
strokeColor: Colors.white,
),
),
belowBarData: widget.showArea ? BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: widget.gradientColors ?? [
(widget.lineColor ?? AppTheme.primaryColor).withOpacity(0.3),
(widget.lineColor ?? AppTheme.primaryColor).withOpacity(0.05),
],
),
) : BarAreaData(show: false),
);
}
Widget _buildBottomTitles(double value, TitleMeta meta) {
if (value.toInt() >= widget.data.length) return const SizedBox.shrink();
final data = widget.data[value.toInt()];
return SideTitleWidget(
axisSide: meta.axisSide,
child: Padding(
padding: EdgeInsets.only(top: DesignSystem.spacingXs),
child: Text(
data.label,
style: DesignSystem.labelSmall.copyWith(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
);
}
Widget _buildLeftTitles(double value, TitleMeta meta) {
return SideTitleWidget(
axisSide: meta.axisSide,
child: Text(
value.toInt().toString(),
style: DesignSystem.labelSmall.copyWith(
color: AppTheme.textSecondary,
),
),
);
}
double _getMaxY() {
if (widget.data.isEmpty) return 10;
return widget.data.map((e) => e.value).reduce((a, b) => a > b ? a : b);
}
}
/// Modèle de données pour le graphique linéaire
class LineDataPoint {
const LineDataPoint({
required this.label,
required this.value,
});
final String label;
final double value;
}

View File

@@ -0,0 +1,307 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Graphique en secteurs professionnel avec animations et légendes
class ProfessionalPieChart extends StatefulWidget {
const ProfessionalPieChart({
super.key,
required this.data,
required this.title,
this.subtitle,
this.centerText,
this.showLegend = true,
this.showPercentages = true,
this.animationDuration = const Duration(milliseconds: 1500),
});
final List<ChartDataPoint> data;
final String title;
final String? subtitle;
final String? centerText;
final bool showLegend;
final bool showPercentages;
final Duration animationDuration;
@override
State<ProfessionalPieChart> createState() => _ProfessionalPieChartState();
}
class _ProfessionalPieChartState extends State<ProfessionalPieChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _animation;
int _touchedIndex = -1;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
_animation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedBox(height: DesignSystem.spacingLg),
Expanded(
child: Row(
children: [
Expanded(
flex: 3,
child: _buildChart(),
),
if (widget.showLegend) ...[
SizedBox(width: DesignSystem.spacingLg),
Expanded(
flex: 2,
child: _buildLegend(),
),
],
],
),
),
],
);
}
Widget _buildHeader() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: DesignSystem.titleLarge.copyWith(
fontWeight: FontWeight.w700,
),
),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
Text(
widget.subtitle!,
style: DesignSystem.bodyMedium.copyWith(
color: AppTheme.textSecondary,
),
),
],
],
);
}
Widget _buildChart() {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
height: 140, // Hauteur encore plus réduite
padding: const EdgeInsets.all(4), // Padding minimal pour contenir le graphique
child: PieChart(
PieChartData(
pieTouchData: PieTouchData(
touchCallback: (FlTouchEvent event, pieTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions ||
pieTouchResponse == null ||
pieTouchResponse.touchedSection == null) {
_touchedIndex = -1;
return;
}
_touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex;
});
},
),
borderData: FlBorderData(show: false),
sectionsSpace: 1, // Espace réduit entre sections
centerSpaceRadius: widget.centerText != null ? 45 : 30, // Rayon central réduit
sections: _buildSections(),
),
),
);
},
);
}
List<PieChartSectionData> _buildSections() {
final total = widget.data.fold<double>(0, (sum, item) => sum + item.value);
return widget.data.asMap().entries.map((entry) {
final index = entry.key;
final data = entry.value;
final isTouched = index == _touchedIndex;
final percentage = (data.value / total * 100);
return PieChartSectionData(
color: data.color,
value: data.value * _animation.value,
title: widget.showPercentages ? '${percentage.toStringAsFixed(1)}%' : '',
radius: isTouched ? 70 : 60,
titleStyle: DesignSystem.labelMedium.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
shadows: [
Shadow(
color: Colors.black.withOpacity(0.3),
offset: const Offset(1, 1),
blurRadius: 2,
),
],
),
titlePositionPercentageOffset: 0.6,
badgeWidget: isTouched ? _buildBadge(data) : null,
badgePositionPercentageOffset: 1.3,
);
}).toList();
}
Widget _buildBadge(ChartDataPoint data) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: DesignSystem.spacingSm,
vertical: DesignSystem.spacingXs,
),
decoration: BoxDecoration(
color: data.color,
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
boxShadow: DesignSystem.shadowCard,
),
child: Text(
data.value.toInt().toString(),
style: DesignSystem.labelMedium.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
);
}
Widget _buildLegend() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.centerText != null) ...[
_buildCenterInfo(),
SizedBox(height: DesignSystem.spacingLg),
],
...widget.data.asMap().entries.map((entry) {
final index = entry.key;
final data = entry.value;
final isSelected = index == _touchedIndex;
return AnimatedContainer(
duration: DesignSystem.animationFast,
margin: EdgeInsets.only(bottom: DesignSystem.spacingSm),
padding: EdgeInsets.all(DesignSystem.spacingSm),
decoration: BoxDecoration(
color: isSelected ? data.color.withOpacity(0.1) : Colors.transparent,
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
border: isSelected ? Border.all(
color: data.color.withOpacity(0.3),
width: 1,
) : null,
),
child: Row(
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: data.color,
borderRadius: BorderRadius.circular(DesignSystem.radiusXs),
),
),
SizedBox(width: DesignSystem.spacingSm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.label,
style: DesignSystem.labelLarge.copyWith(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
),
),
Text(
data.value.toInt().toString(),
style: DesignSystem.labelMedium.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
);
}).toList(),
],
);
}
Widget _buildCenterInfo() {
return Container(
padding: EdgeInsets.all(DesignSystem.spacingMd),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
border: Border.all(
color: AppTheme.primaryColor.withOpacity(0.2),
width: 1,
),
),
child: Column(
children: [
Text(
'Total',
style: DesignSystem.labelMedium.copyWith(
color: AppTheme.textSecondary,
),
),
SizedBox(height: DesignSystem.spacingXs),
Text(
widget.centerText!,
style: DesignSystem.headlineMedium.copyWith(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
/// Modèle de données pour le graphique en secteurs
class ChartDataPoint {
const ChartDataPoint({
required this.label,
required this.value,
required this.color,
});
final String label;
final double value;
final Color color;
}

View File

@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Grille de statistiques compacte pour mobile
class StatsGridCard extends StatefulWidget {
const StatsGridCard({
super.key,
required this.stats,
this.crossAxisCount = 2,
this.childAspectRatio = 1.2,
});
final Map<String, dynamic> stats;
final int crossAxisCount;
final double childAspectRatio;
@override
State<StatsGridCard> createState() => _StatsGridCardState();
}
class _StatsGridCardState extends State<StatsGridCard>
with TickerProviderStateMixin {
late List<AnimationController> _animationControllers;
late List<Animation<double>> _scaleAnimations;
late List<Animation<Offset>> _slideAnimations;
@override
void initState() {
super.initState();
_initializeAnimations();
}
void _initializeAnimations() {
const itemCount = 4; // Nombre de statistiques
_animationControllers = List.generate(
itemCount,
(index) => AnimationController(
duration: Duration(
milliseconds: DesignSystem.animationMedium.inMilliseconds + (index * 100),
),
vsync: this,
),
);
_scaleAnimations = _animationControllers.map((controller) {
return Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: controller,
curve: DesignSystem.animationCurveEnter,
));
}).toList();
_slideAnimations = _animationControllers.map((controller) {
return Tween<Offset>(
begin: const Offset(0, 0.5),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller,
curve: DesignSystem.animationCurveEnter,
));
}).toList();
// Démarrer les animations en cascade
for (int i = 0; i < _animationControllers.length; i++) {
Future.delayed(Duration(milliseconds: i * 100), () {
if (mounted) {
_animationControllers[i].forward();
}
});
}
}
@override
void dispose() {
for (final controller in _animationControllers) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final statsItems = [
_StatItem(
title: 'Total Membres',
value: widget.stats['totalMembres'].toString(),
icon: Icons.people,
color: AppTheme.primaryColor,
trend: '+${widget.stats['nouveauxCeMois']}',
trendPositive: true,
),
_StatItem(
title: 'Membres Actifs',
value: widget.stats['membresActifs'].toString(),
icon: Icons.person,
color: AppTheme.successColor,
trend: '${widget.stats['tauxActivite']}%',
trendPositive: widget.stats['tauxActivite'] >= 70,
),
_StatItem(
title: 'Nouveaux ce mois',
value: widget.stats['nouveauxCeMois'].toString(),
icon: Icons.person_add,
color: AppTheme.infoColor,
trend: 'Ce mois',
trendPositive: widget.stats['nouveauxCeMois'] > 0,
),
_StatItem(
title: 'Taux d\'activité',
value: '${widget.stats['tauxActivite']}%',
icon: Icons.trending_up,
color: AppTheme.warningColor,
trend: widget.stats['tauxActivite'] >= 70 ? 'Excellent' : 'Moyen',
trendPositive: widget.stats['tauxActivite'] >= 70,
),
];
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: widget.crossAxisCount,
childAspectRatio: widget.childAspectRatio,
crossAxisSpacing: DesignSystem.spacingMd,
mainAxisSpacing: DesignSystem.spacingMd,
),
itemCount: statsItems.length,
itemBuilder: (context, index) {
return AnimatedBuilder(
animation: _animationControllers[index],
builder: (context, child) {
return SlideTransition(
position: _slideAnimations[index],
child: ScaleTransition(
scale: _scaleAnimations[index],
child: _buildStatCard(statsItems[index]),
),
);
},
);
},
);
}
Widget _buildStatCard(_StatItem item) {
return Container(
padding: EdgeInsets.all(DesignSystem.spacingMd),
decoration: BoxDecoration(
color: AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
boxShadow: DesignSystem.shadowCard,
border: Border.all(
color: item.color.withOpacity(0.1),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: EdgeInsets.all(DesignSystem.spacingSm),
decoration: BoxDecoration(
color: item.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
child: Icon(
item.icon,
color: item.color,
size: 20,
),
),
Container(
padding: EdgeInsets.symmetric(
horizontal: DesignSystem.spacingXs,
vertical: 2,
),
decoration: BoxDecoration(
color: item.trendPositive
? AppTheme.successColor.withOpacity(0.1)
: AppTheme.errorColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
),
child: Text(
item.trend,
style: DesignSystem.labelSmall.copyWith(
color: item.trendPositive
? AppTheme.successColor
: AppTheme.errorColor,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
),
],
),
SizedBox(height: DesignSystem.spacingSm),
Text(
item.value,
style: DesignSystem.headlineMedium.copyWith(
fontWeight: FontWeight.w800,
color: AppTheme.textPrimary,
),
),
SizedBox(height: DesignSystem.spacingXs),
Text(
item.title,
style: DesignSystem.labelMedium.copyWith(
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
/// Modèle pour un élément de statistique
class _StatItem {
const _StatItem({
required this.title,
required this.value,
required this.icon,
required this.color,
required this.trend,
required this.trendPositive,
});
final String title;
final String value;
final IconData icon;
final Color color;
final String trend;
final bool trendPositive;
}

View File

@@ -0,0 +1,281 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/theme/design_system.dart';
/// Card de vue d'ensemble des statistiques avec design professionnel
class StatsOverviewCard extends StatefulWidget {
const StatsOverviewCard({
super.key,
required this.stats,
this.onTap,
});
final Map<String, dynamic> stats;
final VoidCallback? onTap;
@override
State<StatsOverviewCard> createState() => _StatsOverviewCardState();
}
class _StatsOverviewCardState extends State<StatsOverviewCard>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: DesignSystem.animationMedium,
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurve,
));
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _animationController,
curve: DesignSystem.animationCurveEnter,
));
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: _buildCard(),
),
);
},
);
}
Widget _buildCard() {
return Container(
padding: EdgeInsets.all(DesignSystem.spacingLg),
decoration: BoxDecoration(
gradient: DesignSystem.primaryGradient,
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
boxShadow: DesignSystem.shadowCard,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedBox(height: DesignSystem.spacingLg),
_buildMainStats(),
SizedBox(height: DesignSystem.spacingLg),
_buildSecondaryStats(),
SizedBox(height: DesignSystem.spacingMd),
_buildProgressIndicator(),
],
),
);
}
Widget _buildHeader() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Vue d\'ensemble',
style: DesignSystem.titleLarge.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: DesignSystem.spacingXs),
Text(
'Statistiques générales',
style: DesignSystem.bodyMedium.copyWith(
color: Colors.white.withOpacity(0.9),
),
),
],
),
Container(
padding: EdgeInsets.all(DesignSystem.spacingSm),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
),
child: const Icon(
Icons.analytics,
color: Colors.white,
size: 24,
),
),
],
);
}
Widget _buildMainStats() {
return Row(
children: [
Expanded(
child: _buildStatItem(
'Total Membres',
widget.stats['totalMembres'].toString(),
Icons.people,
Colors.white,
),
),
SizedBox(width: DesignSystem.spacingLg),
Expanded(
child: _buildStatItem(
'Membres Actifs',
widget.stats['membresActifs'].toString(),
Icons.person,
Colors.white,
),
),
],
);
}
Widget _buildSecondaryStats() {
return Row(
children: [
Expanded(
child: _buildStatItem(
'Nouveaux ce mois',
widget.stats['nouveauxCeMois'].toString(),
Icons.person_add,
Colors.white.withOpacity(0.9),
isSecondary: true,
),
),
SizedBox(width: DesignSystem.spacingLg),
Expanded(
child: _buildStatItem(
'Taux d\'activité',
'${widget.stats['tauxActivite']}%',
Icons.trending_up,
Colors.white.withOpacity(0.9),
isSecondary: true,
),
),
],
);
}
Widget _buildStatItem(
String label,
String value,
IconData icon,
Color color, {
bool isSecondary = false,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
color: color,
size: isSecondary ? 16 : 20,
),
SizedBox(width: DesignSystem.spacingXs),
Text(
label,
style: (isSecondary ? DesignSystem.labelMedium : DesignSystem.labelLarge).copyWith(
color: color,
fontWeight: FontWeight.w500,
),
),
],
),
SizedBox(height: DesignSystem.spacingXs),
Text(
value,
style: (isSecondary ? DesignSystem.headlineMedium : DesignSystem.displayMedium).copyWith(
color: color,
fontWeight: FontWeight.w800,
fontSize: isSecondary ? 20 : 32,
),
),
],
);
}
Widget _buildProgressIndicator() {
final tauxActivite = widget.stats['tauxActivite'] as int;
final progress = tauxActivite / 100.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Engagement communautaire',
style: DesignSystem.labelMedium.copyWith(
color: Colors.white.withOpacity(0.9),
fontWeight: FontWeight.w500,
),
),
Text(
'$tauxActivite%',
style: DesignSystem.labelMedium.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
],
),
SizedBox(height: DesignSystem.spacingXs),
Container(
height: 6,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(DesignSystem.radiusXs),
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: progress,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(DesignSystem.radiusXs),
boxShadow: [
BoxShadow(
color: Colors.white.withOpacity(0.3),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
),
),
),
],
);
}
}

View File

@@ -3,8 +3,9 @@ import 'package:flutter/services.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/coming_soon_page.dart';
import '../../../../shared/widgets/buttons/buttons.dart';
import '../../../dashboard/presentation/pages/enhanced_dashboard.dart';
import '../../../dashboard/presentation/pages/dashboard_page.dart';
import '../../../members/presentation/pages/membres_list_page.dart';
import '../../../cotisations/presentation/pages/cotisations_list_page.dart';
import '../widgets/custom_bottom_nav_bar.dart';
class MainNavigation extends StatefulWidget {
@@ -85,9 +86,7 @@ class _MainNavigationState extends State<MainNavigation>
body: IndexedStack(
index: _currentIndex,
children: [
EnhancedDashboard(
onNavigateToTab: _onTabTapped,
),
const DashboardPage(),
_buildMembresPage(),
_buildCotisationsPage(),
_buildEventsPage(),
@@ -209,20 +208,7 @@ class _MainNavigationState extends State<MainNavigation>
}
Widget _buildCotisationsPage() {
return const ComingSoonPage(
title: 'Module Cotisations',
description: 'Suivi et gestion des cotisations avec paiements automatiques',
icon: Icons.payment_rounded,
color: AppTheme.accentColor,
features: [
'Tableau de bord des cotisations',
'Relances automatiques par email/SMS',
'Paiements en ligne sécurisés',
'Génération de reçus automatique',
'Suivi des retards de paiement',
'Rapports financiers détaillés',
],
);
return const CotisationsListPage();
}
Widget _buildEventsPage() {

View File

@@ -88,19 +88,19 @@ class _SplashScreenState extends State<SplashScreen>
),
);
// Séquence d'animations
// Séquence d'animations avec vérification mounted
await Future.delayed(const Duration(milliseconds: 300));
_logoController.forward();
if (mounted) _logoController.forward();
await Future.delayed(const Duration(milliseconds: 500));
_textController.forward();
if (mounted) _textController.forward();
await Future.delayed(const Duration(milliseconds: 300));
_progressController.forward();
if (mounted) _progressController.forward();
// Attendre la fin de toutes les animations + temps de chargement
await Future.delayed(const Duration(milliseconds: 2000));
// Le splash screen sera remplacé automatiquement par l'AppWrapper
// basé sur l'état d'authentification
}