Authentification stable - WIP
This commit is contained in:
@@ -1,322 +0,0 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../../domain/repositories/membre_repository.dart';
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../../../../core/models/membre_model.dart';
|
||||
import 'membres_event.dart';
|
||||
import 'membres_state.dart';
|
||||
|
||||
/// BLoC pour la gestion des membres
|
||||
@injectable
|
||||
class MembresBloc extends Bloc<MembresEvent, MembresState> {
|
||||
final MembreRepository _membreRepository;
|
||||
|
||||
MembresBloc(this._membreRepository) : super(const MembresInitial()) {
|
||||
// Enregistrement des handlers d'événements
|
||||
on<LoadMembres>(_onLoadMembres);
|
||||
on<RefreshMembres>(_onRefreshMembres);
|
||||
on<SearchMembres>(_onSearchMembres);
|
||||
on<AdvancedSearchMembres>(_onAdvancedSearchMembres);
|
||||
on<LoadMembreById>(_onLoadMembreById);
|
||||
on<CreateMembre>(_onCreateMembre);
|
||||
on<UpdateMembre>(_onUpdateMembre);
|
||||
on<DeleteMembre>(_onDeleteMembre);
|
||||
on<LoadMembresStats>(_onLoadMembresStats);
|
||||
on<ClearMembresError>(_onClearMembresError);
|
||||
on<ResetMembresState>(_onResetMembresState);
|
||||
}
|
||||
|
||||
/// Handler pour charger la liste des membres
|
||||
Future<void> _onLoadMembres(
|
||||
LoadMembres event,
|
||||
Emitter<MembresState> emit,
|
||||
) async {
|
||||
emit(const MembresLoading());
|
||||
|
||||
try {
|
||||
final membres = await _membreRepository.getMembres();
|
||||
emit(MembresLoaded(membres: membres));
|
||||
} catch (e) {
|
||||
final failure = _mapExceptionToFailure(e);
|
||||
emit(MembresError(failure: failure));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour rafraîchir la liste des membres
|
||||
Future<void> _onRefreshMembres(
|
||||
RefreshMembres event,
|
||||
Emitter<MembresState> emit,
|
||||
) async {
|
||||
// Conserver les données actuelles pendant le refresh
|
||||
final currentState = state;
|
||||
List<MembreModel> currentMembres = [];
|
||||
|
||||
if (currentState is MembresLoaded) {
|
||||
currentMembres = currentState.membres;
|
||||
emit(MembresRefreshing(currentMembres));
|
||||
} else {
|
||||
emit(const MembresLoading());
|
||||
}
|
||||
|
||||
try {
|
||||
final membres = await _membreRepository.getMembres();
|
||||
emit(MembresLoaded(membres: membres));
|
||||
} catch (e) {
|
||||
final failure = _mapExceptionToFailure(e);
|
||||
|
||||
// Si on avait des données, les conserver avec l'erreur
|
||||
if (currentMembres.isNotEmpty) {
|
||||
emit(MembresErrorWithData(
|
||||
failure: failure,
|
||||
membres: currentMembres,
|
||||
));
|
||||
} else {
|
||||
emit(MembresError(failure: failure));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour rechercher des membres
|
||||
Future<void> _onSearchMembres(
|
||||
SearchMembres event,
|
||||
Emitter<MembresState> emit,
|
||||
) async {
|
||||
if (event.query.trim().isEmpty) {
|
||||
// Si la recherche est vide, recharger tous les membres
|
||||
add(const LoadMembres());
|
||||
return;
|
||||
}
|
||||
|
||||
emit(const MembresLoading());
|
||||
|
||||
try {
|
||||
final membres = await _membreRepository.searchMembres(event.query);
|
||||
emit(MembresLoaded(
|
||||
membres: membres,
|
||||
isSearchResult: true,
|
||||
searchQuery: event.query,
|
||||
));
|
||||
} catch (e) {
|
||||
final failure = _mapExceptionToFailure(e);
|
||||
emit(MembresError(failure: failure));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour recherche avancée des membres avec filtres multiples
|
||||
Future<void> _onAdvancedSearchMembres(
|
||||
AdvancedSearchMembres event,
|
||||
Emitter<MembresState> emit,
|
||||
) async {
|
||||
// Si aucun filtre n'est appliqué, recharger tous les membres
|
||||
if (event.filters.isEmpty || _areFiltersEmpty(event.filters)) {
|
||||
add(const LoadMembres());
|
||||
return;
|
||||
}
|
||||
|
||||
emit(const MembresLoading());
|
||||
|
||||
try {
|
||||
final membres = await _membreRepository.advancedSearchMembres(event.filters);
|
||||
emit(MembresLoaded(
|
||||
membres: membres,
|
||||
isSearchResult: true,
|
||||
searchQuery: _buildSearchQueryFromFilters(event.filters),
|
||||
));
|
||||
} catch (e) {
|
||||
final failure = _mapExceptionToFailure(e);
|
||||
emit(MembresError(failure: failure));
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si tous les filtres sont vides
|
||||
bool _areFiltersEmpty(Map<String, dynamic> filters) {
|
||||
return filters.values.every((value) {
|
||||
if (value == null) return true;
|
||||
if (value is String) return value.trim().isEmpty;
|
||||
if (value is List) return value.isEmpty;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/// Construit une chaîne de recherche à partir des filtres pour l'affichage
|
||||
String _buildSearchQueryFromFilters(Map<String, dynamic> filters) {
|
||||
final activeFilters = <String>[];
|
||||
|
||||
filters.forEach((key, value) {
|
||||
if (value != null && value.toString().isNotEmpty) {
|
||||
switch (key) {
|
||||
case 'nom':
|
||||
activeFilters.add('Nom: $value');
|
||||
break;
|
||||
case 'prenom':
|
||||
activeFilters.add('Prénom: $value');
|
||||
break;
|
||||
case 'email':
|
||||
activeFilters.add('Email: $value');
|
||||
break;
|
||||
case 'telephone':
|
||||
activeFilters.add('Téléphone: $value');
|
||||
break;
|
||||
case 'actif':
|
||||
activeFilters.add('Statut: ${value == true ? "Actif" : "Inactif"}');
|
||||
break;
|
||||
case 'profession':
|
||||
activeFilters.add('Profession: $value');
|
||||
break;
|
||||
case 'ville':
|
||||
activeFilters.add('Ville: $value');
|
||||
break;
|
||||
case 'ageMin':
|
||||
activeFilters.add('Âge min: $value');
|
||||
break;
|
||||
case 'ageMax':
|
||||
activeFilters.add('Âge max: $value');
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return activeFilters.join(', ');
|
||||
}
|
||||
|
||||
/// Handler pour charger un membre par ID
|
||||
Future<void> _onLoadMembreById(
|
||||
LoadMembreById event,
|
||||
Emitter<MembresState> emit,
|
||||
) async {
|
||||
emit(const MembresLoading());
|
||||
|
||||
try {
|
||||
final membre = await _membreRepository.getMembreById(event.id);
|
||||
emit(MembreDetailLoaded(membre));
|
||||
} catch (e) {
|
||||
final failure = _mapExceptionToFailure(e);
|
||||
emit(MembresError(failure: failure));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour créer un membre
|
||||
Future<void> _onCreateMembre(
|
||||
CreateMembre event,
|
||||
Emitter<MembresState> emit,
|
||||
) async {
|
||||
emit(const MembresLoading());
|
||||
|
||||
try {
|
||||
final nouveauMembre = await _membreRepository.createMembre(event.membre);
|
||||
emit(MembreCreated(nouveauMembre));
|
||||
|
||||
// Recharger la liste après création
|
||||
add(const LoadMembres());
|
||||
} catch (e) {
|
||||
final failure = _mapExceptionToFailure(e);
|
||||
emit(MembresError(failure: failure));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour mettre à jour un membre
|
||||
Future<void> _onUpdateMembre(
|
||||
UpdateMembre event,
|
||||
Emitter<MembresState> emit,
|
||||
) async {
|
||||
emit(const MembresLoading());
|
||||
|
||||
try {
|
||||
final membreMisAJour = await _membreRepository.updateMembre(
|
||||
event.id,
|
||||
event.membre,
|
||||
);
|
||||
emit(MembreUpdated(membreMisAJour));
|
||||
|
||||
// Recharger la liste après mise à jour
|
||||
add(const LoadMembres());
|
||||
} catch (e) {
|
||||
final failure = _mapExceptionToFailure(e);
|
||||
emit(MembresError(failure: failure));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour supprimer un membre
|
||||
Future<void> _onDeleteMembre(
|
||||
DeleteMembre event,
|
||||
Emitter<MembresState> emit,
|
||||
) async {
|
||||
emit(const MembresLoading());
|
||||
|
||||
try {
|
||||
await _membreRepository.deleteMembre(event.id);
|
||||
emit(MembreDeleted(event.id));
|
||||
|
||||
// Recharger la liste après suppression
|
||||
add(const LoadMembres());
|
||||
} catch (e) {
|
||||
final failure = _mapExceptionToFailure(e);
|
||||
emit(MembresError(failure: failure));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour charger les statistiques
|
||||
Future<void> _onLoadMembresStats(
|
||||
LoadMembresStats event,
|
||||
Emitter<MembresState> emit,
|
||||
) async {
|
||||
emit(const MembresLoading());
|
||||
|
||||
try {
|
||||
final stats = await _membreRepository.getMembresStats();
|
||||
emit(MembresStatsLoaded(stats));
|
||||
} catch (e) {
|
||||
final failure = _mapExceptionToFailure(e);
|
||||
emit(MembresError(failure: failure));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour effacer les erreurs
|
||||
void _onClearMembresError(
|
||||
ClearMembresError event,
|
||||
Emitter<MembresState> emit,
|
||||
) {
|
||||
final currentState = state;
|
||||
|
||||
if (currentState is MembresError && currentState.previousState != null) {
|
||||
emit(currentState.previousState!);
|
||||
} else if (currentState is MembresErrorWithData) {
|
||||
emit(MembresLoaded(
|
||||
membres: currentState.membres,
|
||||
isSearchResult: currentState.isSearchResult,
|
||||
searchQuery: currentState.searchQuery,
|
||||
));
|
||||
} else {
|
||||
emit(const MembresInitial());
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour réinitialiser l'état
|
||||
void _onResetMembresState(
|
||||
ResetMembresState event,
|
||||
Emitter<MembresState> emit,
|
||||
) {
|
||||
emit(const MembresInitial());
|
||||
}
|
||||
|
||||
/// Convertit une exception en Failure approprié
|
||||
Failure _mapExceptionToFailure(dynamic exception) {
|
||||
if (exception is Failure) {
|
||||
return exception;
|
||||
}
|
||||
|
||||
final message = exception.toString();
|
||||
|
||||
if (message.contains('connexion') || message.contains('network')) {
|
||||
return NetworkFailure(message: message);
|
||||
} else if (message.contains('401') || message.contains('unauthorized')) {
|
||||
return const AuthFailure(message: 'Session expirée. Veuillez vous reconnecter.');
|
||||
} else if (message.contains('400') || message.contains('validation')) {
|
||||
return ValidationFailure(message: message);
|
||||
} else if (message.contains('500') || message.contains('server')) {
|
||||
return ServerFailure(message: message);
|
||||
}
|
||||
|
||||
return ServerFailure(message: message);
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../../core/models/membre_model.dart';
|
||||
|
||||
/// Événements pour le BLoC des membres
|
||||
abstract class MembresEvent extends Equatable {
|
||||
const MembresEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Événement pour charger la liste des membres
|
||||
class LoadMembres extends MembresEvent {
|
||||
const LoadMembres();
|
||||
}
|
||||
|
||||
/// Événement pour rafraîchir la liste des membres
|
||||
class RefreshMembres extends MembresEvent {
|
||||
const RefreshMembres();
|
||||
}
|
||||
|
||||
/// Événement pour rechercher des membres
|
||||
class SearchMembres extends MembresEvent {
|
||||
const SearchMembres(this.query);
|
||||
|
||||
final String query;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [query];
|
||||
}
|
||||
|
||||
/// Événement pour recherche avancée des membres avec filtres multiples
|
||||
class AdvancedSearchMembres extends MembresEvent {
|
||||
const AdvancedSearchMembres(this.filters);
|
||||
|
||||
final Map<String, dynamic> filters;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [filters];
|
||||
}
|
||||
|
||||
/// Événement pour charger un membre spécifique
|
||||
class LoadMembreById extends MembresEvent {
|
||||
const LoadMembreById(this.id);
|
||||
|
||||
final String id;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Événement pour créer un nouveau membre
|
||||
class CreateMembre extends MembresEvent {
|
||||
const CreateMembre(this.membre);
|
||||
|
||||
final MembreModel membre;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membre];
|
||||
}
|
||||
|
||||
/// Événement pour mettre à jour un membre
|
||||
class UpdateMembre extends MembresEvent {
|
||||
const UpdateMembre(this.id, this.membre);
|
||||
|
||||
final String id;
|
||||
final MembreModel membre;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, membre];
|
||||
}
|
||||
|
||||
/// Événement pour supprimer un membre
|
||||
class DeleteMembre extends MembresEvent {
|
||||
const DeleteMembre(this.id);
|
||||
|
||||
final String id;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Événement pour charger les statistiques des membres
|
||||
class LoadMembresStats extends MembresEvent {
|
||||
const LoadMembresStats();
|
||||
}
|
||||
|
||||
/// Événement pour effacer les erreurs
|
||||
class ClearMembresError extends MembresEvent {
|
||||
const ClearMembresError();
|
||||
}
|
||||
|
||||
/// Événement pour réinitialiser l'état
|
||||
class ResetMembresState extends MembresEvent {
|
||||
const ResetMembresState();
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../../core/models/membre_model.dart';
|
||||
import '../../../../core/errors/failures.dart';
|
||||
|
||||
/// États pour le BLoC des membres
|
||||
abstract class MembresState extends Equatable {
|
||||
const MembresState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// État initial
|
||||
class MembresInitial extends MembresState {
|
||||
const MembresInitial();
|
||||
}
|
||||
|
||||
/// État de chargement
|
||||
class MembresLoading extends MembresState {
|
||||
const MembresLoading();
|
||||
}
|
||||
|
||||
/// État de chargement avec données existantes (pour le refresh)
|
||||
class MembresRefreshing extends MembresState {
|
||||
const MembresRefreshing(this.currentMembres);
|
||||
|
||||
final List<MembreModel> currentMembres;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [currentMembres];
|
||||
}
|
||||
|
||||
/// État de succès avec liste des membres
|
||||
class MembresLoaded extends MembresState {
|
||||
const MembresLoaded({
|
||||
required this.membres,
|
||||
this.isSearchResult = false,
|
||||
this.searchQuery,
|
||||
});
|
||||
|
||||
final List<MembreModel> membres;
|
||||
final bool isSearchResult;
|
||||
final String? searchQuery;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membres, isSearchResult, searchQuery];
|
||||
|
||||
/// Copie avec modifications
|
||||
MembresLoaded copyWith({
|
||||
List<MembreModel>? membres,
|
||||
bool? isSearchResult,
|
||||
String? searchQuery,
|
||||
}) {
|
||||
return MembresLoaded(
|
||||
membres: membres ?? this.membres,
|
||||
isSearchResult: isSearchResult ?? this.isSearchResult,
|
||||
searchQuery: searchQuery ?? this.searchQuery,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// État de succès pour un membre spécifique
|
||||
class MembreDetailLoaded extends MembresState {
|
||||
const MembreDetailLoaded(this.membre);
|
||||
|
||||
final MembreModel membre;
|
||||
|
||||
@override
|
||||
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);
|
||||
|
||||
final Map<String, dynamic> stats;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [stats];
|
||||
}
|
||||
|
||||
/// État de succès pour la création d'un membre
|
||||
class MembreCreated extends MembresState {
|
||||
const MembreCreated(this.membre);
|
||||
|
||||
final MembreModel membre;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membre];
|
||||
}
|
||||
|
||||
/// État de succès pour la mise à jour d'un membre
|
||||
class MembreUpdated extends MembresState {
|
||||
const MembreUpdated(this.membre);
|
||||
|
||||
final MembreModel membre;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membre];
|
||||
}
|
||||
|
||||
/// État de succès pour la suppression d'un membre
|
||||
class MembreDeleted extends MembresState {
|
||||
const MembreDeleted(this.membreId);
|
||||
|
||||
final String membreId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membreId];
|
||||
}
|
||||
|
||||
/// État d'erreur
|
||||
class MembresError extends MembresState {
|
||||
const MembresError({
|
||||
required this.failure,
|
||||
this.previousState,
|
||||
});
|
||||
|
||||
final Failure failure;
|
||||
final MembresState? previousState;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [failure, previousState];
|
||||
|
||||
/// Message d'erreur formaté
|
||||
String get message => failure.message;
|
||||
|
||||
/// Code d'erreur
|
||||
String? get code => failure.code;
|
||||
|
||||
/// Indique si c'est une erreur réseau
|
||||
bool get isNetworkError => failure is NetworkFailure;
|
||||
|
||||
/// Indique si c'est une erreur serveur
|
||||
bool get isServerError => failure is ServerFailure;
|
||||
|
||||
/// Indique si c'est une erreur d'authentification
|
||||
bool get isAuthError => failure is AuthFailure;
|
||||
|
||||
/// Indique si c'est une erreur de validation
|
||||
bool get isValidationError => failure is ValidationFailure;
|
||||
}
|
||||
|
||||
/// État d'erreur avec données existantes (pour les erreurs non critiques)
|
||||
class MembresErrorWithData extends MembresState {
|
||||
const MembresErrorWithData({
|
||||
required this.failure,
|
||||
required this.membres,
|
||||
this.isSearchResult = false,
|
||||
this.searchQuery,
|
||||
});
|
||||
|
||||
final Failure failure;
|
||||
final List<MembreModel> membres;
|
||||
final bool isSearchResult;
|
||||
final String? searchQuery;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [failure, membres, isSearchResult, searchQuery];
|
||||
|
||||
/// Message d'erreur formaté
|
||||
String get message => failure.message;
|
||||
}
|
||||
@@ -1,627 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../core/utils/responsive_utils.dart';
|
||||
import '../widgets/sophisticated_member_card.dart';
|
||||
import '../widgets/members_search_bar.dart';
|
||||
import '../widgets/members_filter_sheet.dart';
|
||||
|
||||
class MembersListPage extends StatefulWidget {
|
||||
const MembersListPage({super.key});
|
||||
|
||||
@override
|
||||
State<MembersListPage> createState() => _MembersListPageState();
|
||||
}
|
||||
|
||||
class _MembersListPageState extends State<MembersListPage>
|
||||
with TickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
String _searchQuery = '';
|
||||
String _selectedFilter = 'Tous';
|
||||
bool _isSearchActive = false;
|
||||
|
||||
final List<Map<String, dynamic>> _members = [
|
||||
{
|
||||
'id': '1',
|
||||
'firstName': 'Jean',
|
||||
'lastName': 'Dupont',
|
||||
'email': 'jean.dupont@email.com',
|
||||
'phone': '+33 6 12 34 56 78',
|
||||
'role': 'Président',
|
||||
'status': 'Actif',
|
||||
'joinDate': '2022-01-15',
|
||||
'lastActivity': '2024-08-15',
|
||||
'cotisationStatus': 'À jour',
|
||||
'avatar': null,
|
||||
'category': 'Bureau',
|
||||
},
|
||||
{
|
||||
'id': '2',
|
||||
'firstName': 'Marie',
|
||||
'lastName': 'Martin',
|
||||
'email': 'marie.martin@email.com',
|
||||
'phone': '+33 6 98 76 54 32',
|
||||
'role': 'Secrétaire',
|
||||
'status': 'Actif',
|
||||
'joinDate': '2022-03-20',
|
||||
'lastActivity': '2024-08-14',
|
||||
'cotisationStatus': 'À jour',
|
||||
'avatar': null,
|
||||
'category': 'Bureau',
|
||||
},
|
||||
{
|
||||
'id': '3',
|
||||
'firstName': 'Pierre',
|
||||
'lastName': 'Dubois',
|
||||
'email': 'pierre.dubois@email.com',
|
||||
'phone': '+33 6 55 44 33 22',
|
||||
'role': 'Trésorier',
|
||||
'status': 'Actif',
|
||||
'joinDate': '2022-02-10',
|
||||
'lastActivity': '2024-08-13',
|
||||
'cotisationStatus': 'En retard',
|
||||
'avatar': null,
|
||||
'category': 'Bureau',
|
||||
},
|
||||
{
|
||||
'id': '4',
|
||||
'firstName': 'Sophie',
|
||||
'lastName': 'Leroy',
|
||||
'email': 'sophie.leroy@email.com',
|
||||
'phone': '+33 6 11 22 33 44',
|
||||
'role': 'Membre',
|
||||
'status': 'Actif',
|
||||
'joinDate': '2023-05-12',
|
||||
'lastActivity': '2024-08-12',
|
||||
'cotisationStatus': 'À jour',
|
||||
'avatar': null,
|
||||
'category': 'Membres',
|
||||
},
|
||||
{
|
||||
'id': '5',
|
||||
'firstName': 'Thomas',
|
||||
'lastName': 'Roux',
|
||||
'email': 'thomas.roux@email.com',
|
||||
'phone': '+33 6 77 88 99 00',
|
||||
'role': 'Membre',
|
||||
'status': 'Inactif',
|
||||
'joinDate': '2021-09-08',
|
||||
'lastActivity': '2024-07-20',
|
||||
'cotisationStatus': 'En retard',
|
||||
'avatar': null,
|
||||
'category': 'Membres',
|
||||
},
|
||||
{
|
||||
'id': '6',
|
||||
'firstName': 'Emma',
|
||||
'lastName': 'Moreau',
|
||||
'email': 'emma.moreau@email.com',
|
||||
'phone': '+33 6 66 77 88 99',
|
||||
'role': 'Responsable événements',
|
||||
'status': 'Actif',
|
||||
'joinDate': '2023-01-25',
|
||||
'lastActivity': '2024-08-16',
|
||||
'cotisationStatus': 'À jour',
|
||||
'avatar': null,
|
||||
'category': 'Responsables',
|
||||
},
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_searchController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _filteredMembers {
|
||||
return _members.where((member) {
|
||||
final matchesSearch = _searchQuery.isEmpty ||
|
||||
member['firstName'].toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||
member['lastName'].toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||
member['email'].toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||
member['role'].toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
|
||||
final matchesFilter = _selectedFilter == 'Tous' ||
|
||||
(_selectedFilter == 'Actifs' && member['status'] == 'Actif') ||
|
||||
(_selectedFilter == 'Inactifs' && member['status'] == 'Inactif') ||
|
||||
(_selectedFilter == 'Bureau' && member['category'] == 'Bureau') ||
|
||||
(_selectedFilter == 'En retard' && member['cotisationStatus'] == 'En retard');
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ResponsiveUtils.init(context);
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.backgroundLight,
|
||||
body: NestedScrollView(
|
||||
controller: _scrollController,
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) {
|
||||
return [
|
||||
_buildAppBar(innerBoxIsScrolled),
|
||||
_buildTabBar(),
|
||||
];
|
||||
},
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildMembersList(),
|
||||
_buildMembersList(filter: 'Bureau'),
|
||||
_buildMembersList(filter: 'Responsables'),
|
||||
_buildMembersList(filter: 'Membres'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(bool innerBoxIsScrolled) {
|
||||
return SliverAppBar(
|
||||
expandedHeight: _isSearchActive ? 250 : 180,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: AppTheme.secondaryColor,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
title: AnimatedOpacity(
|
||||
opacity: innerBoxIsScrolled ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: const Text(
|
||||
'Membres',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
background: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [AppTheme.secondaryColor, AppTheme.secondaryColor.withOpacity(0.8)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Titre principal quand l'AppBar est étendu
|
||||
if (!innerBoxIsScrolled)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 60),
|
||||
child: Text(
|
||||
'Membres',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Contenu principal
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: ResponsiveUtils.paddingOnly(
|
||||
left: 4,
|
||||
top: 2,
|
||||
right: 4,
|
||||
bottom: 2,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isSearchActive) ...[
|
||||
Flexible(
|
||||
child: MembersSearchBar(
|
||||
controller: _searchController,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
onClear: () {
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
_searchController.clear();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2.hp),
|
||||
],
|
||||
Flexible(
|
||||
child: _buildStatsRow(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_isSearchActive ? Icons.search_off : Icons.search),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isSearchActive = !_isSearchActive;
|
||||
if (!_isSearchActive) {
|
||||
_searchController.clear();
|
||||
_searchQuery = '';
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.filter_list),
|
||||
onPressed: _showFilterSheet,
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: _handleMenuSelection,
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'export',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.download),
|
||||
SizedBox(width: 8),
|
||||
Text('Exporter'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'import',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.upload),
|
||||
SizedBox(width: 8),
|
||||
Text('Importer'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'stats',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.analytics),
|
||||
SizedBox(width: 8),
|
||||
Text('Statistiques'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabBar() {
|
||||
return SliverPersistentHeader(
|
||||
delegate: _TabBarDelegate(
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppTheme.secondaryColor,
|
||||
unselectedLabelColor: AppTheme.textSecondary,
|
||||
indicatorColor: AppTheme.secondaryColor,
|
||||
indicatorWeight: 3,
|
||||
labelStyle: const TextStyle(fontWeight: FontWeight.w600),
|
||||
tabs: const [
|
||||
Tab(text: 'Tous'),
|
||||
Tab(text: 'Bureau'),
|
||||
Tab(text: 'Responsables'),
|
||||
Tab(text: 'Membres'),
|
||||
],
|
||||
),
|
||||
),
|
||||
pinned: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatsRow() {
|
||||
final activeCount = _members.where((m) => m['status'] == 'Actif').length;
|
||||
final latePayments = _members.where((m) => m['cotisationStatus'] == 'En retard').length;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
_buildStatCard(
|
||||
title: 'Total',
|
||||
value: '${_members.length}',
|
||||
icon: Icons.people,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildStatCard(
|
||||
title: 'Actifs',
|
||||
value: '$activeCount',
|
||||
icon: Icons.check_circle,
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildStatCard(
|
||||
title: 'En retard',
|
||||
value: '$latePayments',
|
||||
icon: Icons.warning,
|
||||
color: AppTheme.warningColor,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatCard({
|
||||
required String title,
|
||||
required String value,
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
}) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: ResponsiveUtils.iconSize(4),
|
||||
),
|
||||
SizedBox(width: 1.5.wp),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: ResponsiveUtils.adaptive(
|
||||
small: 3.5.fs,
|
||||
medium: 3.2.fs,
|
||||
large: 3.fs,
|
||||
),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: ResponsiveUtils.adaptive(
|
||||
small: 2.8.fs,
|
||||
medium: 2.6.fs,
|
||||
large: 2.4.fs,
|
||||
),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMembersList({String? filter}) {
|
||||
List<Map<String, dynamic>> members = _filteredMembers;
|
||||
|
||||
if (filter != null) {
|
||||
members = members.where((member) => member['category'] == filter).toList();
|
||||
}
|
||||
|
||||
if (members.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refreshMembers,
|
||||
color: AppTheme.secondaryColor,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: members.length,
|
||||
itemBuilder: (context, index) {
|
||||
final member = members[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: SophisticatedMemberCard(
|
||||
member: member,
|
||||
onTap: () => _showMemberDetails(member),
|
||||
onEdit: () => _editMember(member),
|
||||
compact: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.people_outline,
|
||||
size: 80,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Aucun membre trouvé',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Modifiez vos critères de recherche ou ajoutez de nouveaux membres',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _addMember,
|
||||
icon: const Icon(Icons.person_add),
|
||||
label: const Text('Ajouter un membre'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.secondaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
void _showFilterSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => MembersFilterSheet(
|
||||
selectedFilter: _selectedFilter,
|
||||
onFilterChanged: (filter) {
|
||||
setState(() {
|
||||
_selectedFilter = filter;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleMenuSelection(String value) {
|
||||
switch (value) {
|
||||
case 'export':
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Export des membres - En développement'),
|
||||
backgroundColor: AppTheme.secondaryColor,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case 'import':
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Import des membres - En développement'),
|
||||
backgroundColor: AppTheme.secondaryColor,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case 'stats':
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Statistiques détaillées - En développement'),
|
||||
backgroundColor: AppTheme.secondaryColor,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshMembers() async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Liste des membres actualisée'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMemberDetails(Map<String, dynamic> member) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Détails de ${member['firstName']} ${member['lastName']} - En développement'),
|
||||
backgroundColor: AppTheme.secondaryColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _editMember(Map<String, dynamic> member) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Édition de ${member['firstName']} ${member['lastName']} - En développement'),
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _addMember() {
|
||||
HapticFeedback.lightImpact();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Ajouter un membre - En développement'),
|
||||
backgroundColor: AppTheme.secondaryColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
final TabBar tabBar;
|
||||
|
||||
_TabBarDelegate(this.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(_TabBarDelegate oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,995 +0,0 @@
|
||||
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 '../../../../core/error/error_handler.dart';
|
||||
import '../../../../core/validation/form_validator.dart';
|
||||
import '../../../../core/feedback/user_feedback.dart';
|
||||
import '../../../../core/animations/loading_animations.dart';
|
||||
import '../../../../core/animations/page_transitions.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) {
|
||||
// Fermer l'indicateur de chargement
|
||||
UserFeedback.hideLoading(context);
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// Afficher le message de succès avec feedback haptique
|
||||
UserFeedback.showSuccess(
|
||||
context,
|
||||
'Membre créé avec succès !',
|
||||
onAction: () => Navigator.of(context).pop(true),
|
||||
actionLabel: 'Voir la liste',
|
||||
);
|
||||
|
||||
// Retourner à la liste après un délai
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
});
|
||||
|
||||
} else if (state is MembresError) {
|
||||
// Fermer l'indicateur de chargement
|
||||
UserFeedback.hideLoading(context);
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// Gérer l'erreur avec le nouveau système
|
||||
ErrorHandler.handleError(
|
||||
context,
|
||||
state.failure,
|
||||
onRetry: () => _submitForm(),
|
||||
);
|
||||
}
|
||||
},
|
||||
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() {
|
||||
final errors = <String>[];
|
||||
|
||||
// Validation du prénom
|
||||
final prenomError = FormValidator.name(_prenomController.text, fieldName: 'Le prénom');
|
||||
if (prenomError != null) errors.add(prenomError);
|
||||
|
||||
// Validation du nom
|
||||
final nomError = FormValidator.name(_nomController.text, fieldName: 'Le nom');
|
||||
if (nomError != null) errors.add(nomError);
|
||||
|
||||
// Validation de la date de naissance
|
||||
if (_dateNaissance != null) {
|
||||
final dateError = FormValidator.birthDate(_dateNaissance!, minAge: 16);
|
||||
if (dateError != null) errors.add(dateError);
|
||||
}
|
||||
|
||||
if (errors.isNotEmpty) {
|
||||
UserFeedback.showWarning(context, errors.first);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _validateContactInfo() {
|
||||
final errors = <String>[];
|
||||
|
||||
// Validation de l'email
|
||||
final emailError = FormValidator.email(_emailController.text);
|
||||
if (emailError != null) errors.add(emailError);
|
||||
|
||||
// Validation du téléphone
|
||||
final phoneError = FormValidator.phone(_telephoneController.text);
|
||||
if (phoneError != null) errors.add(phoneError);
|
||||
|
||||
// Validation de l'adresse (optionnelle)
|
||||
final addressError = FormValidator.address(_adresseController.text);
|
||||
if (addressError != null) errors.add(addressError);
|
||||
|
||||
// Validation de la profession (optionnelle)
|
||||
final professionError = FormValidator.profession(_professionController.text);
|
||||
if (professionError != null) errors.add(professionError);
|
||||
|
||||
if (errors.isNotEmpty) {
|
||||
UserFeedback.showWarning(context, errors.first);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
void _submitForm() {
|
||||
// Validation finale complète
|
||||
if (!_validateAllSteps()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
UserFeedback.showWarning(context, 'Veuillez corriger les erreurs dans le formulaire');
|
||||
return;
|
||||
}
|
||||
|
||||
// Afficher l'indicateur de chargement
|
||||
UserFeedback.showLoading(context, message: 'Création du membre en cours...');
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Créer le modèle membre avec validation des données
|
||||
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));
|
||||
} catch (e) {
|
||||
UserFeedback.hideLoading(context);
|
||||
ErrorHandler.handleError(context, e, customMessage: 'Erreur lors de la préparation des données');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool _validateAllSteps() {
|
||||
// Valider toutes les étapes
|
||||
if (!_validatePersonalInfo()) return false;
|
||||
if (!_validateContactInfo()) return false;
|
||||
|
||||
// Validation supplémentaire pour les champs obligatoires
|
||||
if (_dateNaissance == null) {
|
||||
UserFeedback.showWarning(context, 'La date de naissance est requise');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,474 +0,0 @@
|
||||
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: [
|
||||
const Icon(Icons.error, size: 64, color: AppTheme.errorColor),
|
||||
const SizedBox(height: 16),
|
||||
Text(state.message),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => _membresBloc.add(LoadMembreById(widget.membreId)),
|
||||
child: const 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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,225 +0,0 @@
|
||||
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';
|
||||
import '../widgets/dashboard/welcome_section_widget.dart';
|
||||
import '../widgets/dashboard/members_kpi_section_widget.dart';
|
||||
import '../widgets/dashboard/members_quick_actions_widget.dart';
|
||||
import '../widgets/dashboard/members_analytics_widget.dart';
|
||||
import '../widgets/dashboard/members_enhanced_list_widget.dart';
|
||||
import '../widgets/dashboard/members_recent_activities_widget.dart';
|
||||
import '../widgets/dashboard/members_advanced_filters_widget.dart';
|
||||
import '../widgets/dashboard/members_smart_search_widget.dart';
|
||||
import '../widgets/dashboard/members_notifications_widget.dart';
|
||||
import 'membre_edit_page.dart';
|
||||
|
||||
// Import de l'architecture unifiée pour amélioration progressive
|
||||
import '../../../../shared/widgets/common/unified_page_layout.dart';
|
||||
|
||||
// Imports des optimisations de performance
|
||||
import '../../../../core/performance/performance_optimizer.dart';
|
||||
import '../../../../shared/widgets/performance/optimized_list_view.dart';
|
||||
|
||||
class MembresDashboardPage extends StatefulWidget {
|
||||
const MembresDashboardPage({super.key});
|
||||
|
||||
@override
|
||||
State<MembresDashboardPage> createState() => _MembresDashboardPageState();
|
||||
}
|
||||
|
||||
class _MembresDashboardPageState extends State<MembresDashboardPage> {
|
||||
late MembresBloc _membresBloc;
|
||||
Map<String, dynamic> _currentFilters = {};
|
||||
String _currentSearchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_membresBloc = getIt<MembresBloc>();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void _loadData() {
|
||||
_membresBloc.add(const LoadMembres());
|
||||
}
|
||||
|
||||
void _onFiltersChanged(Map<String, dynamic> filters) {
|
||||
setState(() {
|
||||
_currentFilters = filters;
|
||||
});
|
||||
// TODO: Appliquer les filtres aux données
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
setState(() {
|
||||
_currentSearchQuery = query;
|
||||
});
|
||||
// TODO: Appliquer la recherche
|
||||
if (query.isNotEmpty) {
|
||||
_membresBloc.add(SearchMembres(query));
|
||||
} else {
|
||||
_loadData();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSuggestionSelected(Map<String, dynamic> suggestion) {
|
||||
switch (suggestion['type']) {
|
||||
case 'quick_filter':
|
||||
_onFiltersChanged(suggestion['filter']);
|
||||
break;
|
||||
case 'member':
|
||||
// TODO: Naviguer vers les détails du membre
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _membresBloc,
|
||||
child: BlocBuilder<MembresBloc, MembresState>(
|
||||
builder: (context, state) {
|
||||
// Utilisation de UnifiedPageLayout pour améliorer la cohérence
|
||||
// tout en conservant TOUS les widgets spécialisés existants
|
||||
return UnifiedPageLayout(
|
||||
title: 'Dashboard Membres',
|
||||
icon: Icons.people,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadData,
|
||||
tooltip: 'Actualiser',
|
||||
),
|
||||
],
|
||||
isLoading: state is MembresLoading,
|
||||
errorMessage: state is MembresError ? state.message : null,
|
||||
onRefresh: _loadData,
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _loadData,
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
tooltip: 'Actualiser les données',
|
||||
child: const Icon(Icons.refresh, color: Colors.white),
|
||||
),
|
||||
body: _buildDashboard(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDashboard() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section d'accueil
|
||||
const MembersWelcomeSectionWidget(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Notifications en temps réel
|
||||
const MembersNotificationsWidget(),
|
||||
|
||||
// Recherche intelligente
|
||||
MembersSmartSearchWidget(
|
||||
onSearch: _onSearchChanged,
|
||||
onSuggestionSelected: _onSuggestionSelected,
|
||||
recentSearches: const [], // TODO: Implémenter l'historique
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Filtres avancés
|
||||
MembersAdvancedFiltersWidget(
|
||||
onFiltersChanged: _onFiltersChanged,
|
||||
initialFilters: _currentFilters,
|
||||
),
|
||||
|
||||
// KPI Cards
|
||||
const MembersKPISectionWidget(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Actions rapides
|
||||
const MembersQuickActionsWidget(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Graphiques et analyses
|
||||
const MembersAnalyticsWidget(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Activités récentes
|
||||
const MembersRecentActivitiesWidget(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Liste des membres améliorée
|
||||
BlocBuilder<MembresBloc, MembresState>(
|
||||
builder: (context, state) {
|
||||
if (state is MembresLoaded) {
|
||||
return MembersEnhancedListWidget(
|
||||
members: state.membres,
|
||||
onMemberTap: (member) {
|
||||
// TODO: Naviguer vers les détails du membre
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Détails de ${member.nomComplet}'),
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
),
|
||||
);
|
||||
},
|
||||
onMemberCall: (member) {
|
||||
// TODO: Appeler le membre
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Appel de ${member.nomComplet}'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
},
|
||||
onMemberMessage: (member) {
|
||||
// TODO: Envoyer un message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Message à ${member.nomComplet}'),
|
||||
backgroundColor: AppTheme.infoColor,
|
||||
),
|
||||
);
|
||||
},
|
||||
onMemberEdit: (member) async {
|
||||
final result = await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MembreEditPage(membre: member),
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
// Recharger les données si le membre a été modifié
|
||||
_membresBloc.add(const LoadMembres());
|
||||
}
|
||||
},
|
||||
searchQuery: _currentSearchQuery,
|
||||
filters: _currentFilters,
|
||||
);
|
||||
} else if (state is MembresLoading) {
|
||||
return MembersEnhancedListWidget(
|
||||
members: const [],
|
||||
onMemberTap: (member) {},
|
||||
isLoading: true,
|
||||
searchQuery: '',
|
||||
filters: const {},
|
||||
);
|
||||
} else {
|
||||
return const Center(
|
||||
child: Text('Erreur lors du chargement des membres'),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../shared/widgets/unified_components.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../core/models/membre_model.dart';
|
||||
import '../bloc/membres_bloc.dart';
|
||||
import '../bloc/membres_event.dart';
|
||||
import '../bloc/membres_state.dart';
|
||||
|
||||
/// Dashboard des membres UnionFlow - Version Unifiée
|
||||
///
|
||||
/// Utilise l'architecture unifiée pour une expérience cohérente :
|
||||
/// - Composants standardisés réutilisables
|
||||
/// - Interface homogène avec les autres onglets
|
||||
/// - Performance optimisée avec animations fluides
|
||||
/// - Maintenabilité maximale
|
||||
class MembresDashboardPageUnified extends StatefulWidget {
|
||||
const MembresDashboardPageUnified({super.key});
|
||||
|
||||
@override
|
||||
State<MembresDashboardPageUnified> createState() => _MembresDashboardPageUnifiedState();
|
||||
}
|
||||
|
||||
class _MembresDashboardPageUnifiedState extends State<MembresDashboardPageUnified> {
|
||||
late MembresBloc _membresBloc;
|
||||
Map<String, dynamic> _currentFilters = {};
|
||||
String _currentSearchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_membresBloc = getIt<MembresBloc>();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void _loadData() {
|
||||
_membresBloc.add(const LoadMembres());
|
||||
}
|
||||
|
||||
void _onFiltersChanged(Map<String, dynamic> filters) {
|
||||
setState(() {
|
||||
_currentFilters = filters;
|
||||
});
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
setState(() {
|
||||
_currentSearchQuery = query;
|
||||
});
|
||||
if (query.isNotEmpty) {
|
||||
_membresBloc.add(SearchMembres(query));
|
||||
} else {
|
||||
_loadData();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _membresBloc,
|
||||
child: BlocBuilder<MembresBloc, MembresState>(
|
||||
builder: (context, state) {
|
||||
return UnifiedPageLayout(
|
||||
title: 'Membres',
|
||||
subtitle: 'Gestion des membres de l\'association',
|
||||
icon: Icons.people,
|
||||
iconColor: AppTheme.primaryColor,
|
||||
isLoading: state is MembresLoading,
|
||||
errorMessage: state is MembresError ? state.message : null,
|
||||
onRefresh: _loadData,
|
||||
actions: _buildActions(),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildSearchSection(),
|
||||
const SizedBox(height: AppTheme.spacingLarge),
|
||||
_buildKPISection(state),
|
||||
const SizedBox(height: AppTheme.spacingLarge),
|
||||
_buildQuickActionsSection(),
|
||||
const SizedBox(height: AppTheme.spacingLarge),
|
||||
_buildFiltersSection(),
|
||||
const SizedBox(height: AppTheme.spacingLarge),
|
||||
Expanded(child: _buildMembersList(state)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Actions de la barre d'outils
|
||||
List<Widget> _buildActions() {
|
||||
return [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_add),
|
||||
onPressed: () {
|
||||
// TODO: Navigation vers ajout membre
|
||||
},
|
||||
tooltip: 'Ajouter un membre',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.import_export),
|
||||
onPressed: () {
|
||||
// TODO: Import/Export des membres
|
||||
},
|
||||
tooltip: 'Import/Export',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.analytics),
|
||||
onPressed: () {
|
||||
// TODO: Navigation vers analyses détaillées
|
||||
},
|
||||
tooltip: 'Analyses',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Section de recherche intelligente
|
||||
Widget _buildSearchSection() {
|
||||
return UnifiedCard.outlined(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingMedium),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher un membre...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppTheme.backgroundLight,
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
),
|
||||
if (_currentSearchQuery.isNotEmpty) ...[
|
||||
const SizedBox(height: AppTheme.spacingSmall),
|
||||
Text(
|
||||
'Recherche: "$_currentSearchQuery"',
|
||||
style: AppTheme.bodySmall.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Section des KPI des membres
|
||||
Widget _buildKPISection(MembresState state) {
|
||||
final membres = state is MembresLoaded ? state.membres : <MembreModel>[];
|
||||
final totalMembres = membres.length;
|
||||
final membresActifs = membres.where((m) => m.statut == StatutMembre.actif).length;
|
||||
final nouveauxMembres = membres.where((m) {
|
||||
final now = DateTime.now();
|
||||
final monthAgo = DateTime(now.year, now.month - 1, now.day);
|
||||
return m.dateInscription.isAfter(monthAgo);
|
||||
}).length;
|
||||
final cotisationsAJour = membres.where((m) => m.cotisationAJour).length;
|
||||
|
||||
final kpis = [
|
||||
UnifiedKPIData(
|
||||
title: 'Total',
|
||||
value: totalMembres.toString(),
|
||||
icon: Icons.people,
|
||||
color: AppTheme.primaryColor,
|
||||
trend: UnifiedKPITrend(
|
||||
direction: nouveauxMembres > 0 ? UnifiedKPITrendDirection.up : UnifiedKPITrendDirection.stable,
|
||||
value: '+$nouveauxMembres',
|
||||
label: 'ce mois',
|
||||
),
|
||||
),
|
||||
UnifiedKPIData(
|
||||
title: 'Actifs',
|
||||
value: membresActifs.toString(),
|
||||
icon: Icons.verified_user,
|
||||
color: AppTheme.successColor,
|
||||
trend: UnifiedKPITrend(
|
||||
direction: UnifiedKPITrendDirection.stable,
|
||||
value: '${((membresActifs / totalMembres) * 100).toInt()}%',
|
||||
label: 'du total',
|
||||
),
|
||||
),
|
||||
UnifiedKPIData(
|
||||
title: 'Nouveaux',
|
||||
value: nouveauxMembres.toString(),
|
||||
icon: Icons.person_add,
|
||||
color: AppTheme.accentColor,
|
||||
trend: UnifiedKPITrend(
|
||||
direction: UnifiedKPITrendDirection.up,
|
||||
value: 'Ce mois',
|
||||
label: 'inscriptions',
|
||||
),
|
||||
),
|
||||
UnifiedKPIData(
|
||||
title: 'À jour',
|
||||
value: '${((cotisationsAJour / totalMembres) * 100).toInt()}%',
|
||||
icon: Icons.account_balance_wallet,
|
||||
color: AppTheme.warningColor,
|
||||
trend: UnifiedKPITrend(
|
||||
direction: UnifiedKPITrendDirection.stable,
|
||||
value: '$cotisationsAJour/$totalMembres',
|
||||
label: 'cotisations',
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return UnifiedKPISection(
|
||||
title: 'Statistiques des membres',
|
||||
kpis: kpis,
|
||||
);
|
||||
}
|
||||
|
||||
/// Section des actions rapides
|
||||
Widget _buildQuickActionsSection() {
|
||||
final actions = [
|
||||
UnifiedQuickAction(
|
||||
id: 'add_member',
|
||||
title: 'Nouveau\nMembre',
|
||||
icon: Icons.person_add,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
UnifiedQuickAction(
|
||||
id: 'bulk_import',
|
||||
title: 'Import\nGroupé',
|
||||
icon: Icons.upload_file,
|
||||
color: AppTheme.accentColor,
|
||||
),
|
||||
UnifiedQuickAction(
|
||||
id: 'send_message',
|
||||
title: 'Message\nGroupé',
|
||||
icon: Icons.send,
|
||||
color: AppTheme.infoColor,
|
||||
),
|
||||
UnifiedQuickAction(
|
||||
id: 'export_data',
|
||||
title: 'Exporter\nDonnées',
|
||||
icon: Icons.download,
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
UnifiedQuickAction(
|
||||
id: 'cotisations_reminder',
|
||||
title: 'Rappel\nCotisations',
|
||||
icon: Icons.notification_important,
|
||||
color: AppTheme.warningColor,
|
||||
badgeCount: 12,
|
||||
),
|
||||
UnifiedQuickAction(
|
||||
id: 'member_reports',
|
||||
title: 'Rapports\nMembres',
|
||||
icon: Icons.analytics,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
];
|
||||
|
||||
return UnifiedQuickActionsSection(
|
||||
title: 'Actions rapides',
|
||||
actions: actions,
|
||||
onActionTap: _handleQuickAction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Section des filtres
|
||||
Widget _buildFiltersSection() {
|
||||
return UnifiedCard.outlined(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.filter_list,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingSmall),
|
||||
Text(
|
||||
'Filtres rapides',
|
||||
style: AppTheme.titleSmall.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingMedium),
|
||||
Wrap(
|
||||
spacing: AppTheme.spacingSmall,
|
||||
runSpacing: AppTheme.spacingSmall,
|
||||
children: [
|
||||
_buildFilterChip('Tous', _currentFilters.isEmpty),
|
||||
_buildFilterChip('Actifs', _currentFilters['statut'] == 'actif'),
|
||||
_buildFilterChip('Inactifs', _currentFilters['statut'] == 'inactif'),
|
||||
_buildFilterChip('Nouveaux', _currentFilters['type'] == 'nouveaux'),
|
||||
_buildFilterChip('Cotisations en retard', _currentFilters['cotisation'] == 'retard'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un chip de filtre
|
||||
Widget _buildFilterChip(String label, bool isSelected) {
|
||||
return FilterChip(
|
||||
label: Text(label),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
Map<String, dynamic> newFilters = {};
|
||||
if (selected) {
|
||||
switch (label) {
|
||||
case 'Actifs':
|
||||
newFilters['statut'] = 'actif';
|
||||
break;
|
||||
case 'Inactifs':
|
||||
newFilters['statut'] = 'inactif';
|
||||
break;
|
||||
case 'Nouveaux':
|
||||
newFilters['type'] = 'nouveaux';
|
||||
break;
|
||||
case 'Cotisations en retard':
|
||||
newFilters['cotisation'] = 'retard';
|
||||
break;
|
||||
}
|
||||
}
|
||||
_onFiltersChanged(newFilters);
|
||||
},
|
||||
selectedColor: AppTheme.primaryColor.withOpacity(0.2),
|
||||
checkmarkColor: AppTheme.primaryColor,
|
||||
);
|
||||
}
|
||||
|
||||
/// Liste des membres avec composant unifié
|
||||
Widget _buildMembersList(MembresState state) {
|
||||
if (state is MembresLoaded) {
|
||||
return UnifiedListWidget<MembreModel>(
|
||||
items: state.membres,
|
||||
itemBuilder: (context, membre, index) => _buildMemberCard(membre),
|
||||
isLoading: false,
|
||||
hasReachedMax: true,
|
||||
enableAnimations: true,
|
||||
emptyMessage: 'Aucun membre trouvé',
|
||||
emptyIcon: Icons.people_outline,
|
||||
);
|
||||
}
|
||||
|
||||
return const Center(
|
||||
child: Text('Chargement des membres...'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit une carte de membre
|
||||
Widget _buildMemberCard(MembreModel membre) {
|
||||
return UnifiedCard.listItem(
|
||||
onTap: () {
|
||||
// TODO: Navigation vers détails du membre
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingMedium),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: AppTheme.primaryColor.withOpacity(0.1),
|
||||
child: Text(
|
||||
membre.prenom.isNotEmpty ? membre.prenom[0].toUpperCase() : 'M',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingMedium),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${membre.prenom} ${membre.nom}',
|
||||
style: AppTheme.bodyLarge.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingXSmall),
|
||||
Text(
|
||||
membre.email,
|
||||
style: AppTheme.bodySmall.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingSmall,
|
||||
vertical: AppTheme.spacingXSmall,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusColor(membre.statut).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusSmall),
|
||||
),
|
||||
child: Text(
|
||||
_getStatusLabel(membre.statut),
|
||||
style: AppTheme.bodySmall.copyWith(
|
||||
color: _getStatusColor(membre.statut),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingXSmall),
|
||||
Icon(
|
||||
membre.cotisationAJour ? Icons.check_circle : Icons.warning,
|
||||
color: membre.cotisationAJour ? AppTheme.successColor : AppTheme.warningColor,
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtient la couleur du statut
|
||||
Color _getStatusColor(StatutMembre statut) {
|
||||
switch (statut) {
|
||||
case StatutMembre.actif:
|
||||
return AppTheme.successColor;
|
||||
case StatutMembre.inactif:
|
||||
return AppTheme.errorColor;
|
||||
case StatutMembre.suspendu:
|
||||
return AppTheme.warningColor;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient le libellé du statut
|
||||
String _getStatusLabel(StatutMembre statut) {
|
||||
switch (statut) {
|
||||
case StatutMembre.actif:
|
||||
return 'Actif';
|
||||
case StatutMembre.inactif:
|
||||
return 'Inactif';
|
||||
case StatutMembre.suspendu:
|
||||
return 'Suspendu';
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère les actions rapides
|
||||
void _handleQuickAction(UnifiedQuickAction action) {
|
||||
switch (action.id) {
|
||||
case 'add_member':
|
||||
// TODO: Navigation vers ajout membre
|
||||
break;
|
||||
case 'bulk_import':
|
||||
// TODO: Import groupé
|
||||
break;
|
||||
case 'send_message':
|
||||
// TODO: Message groupé
|
||||
break;
|
||||
case 'export_data':
|
||||
// TODO: Export des données
|
||||
break;
|
||||
case 'cotisations_reminder':
|
||||
// TODO: Rappel cotisations
|
||||
break;
|
||||
case 'member_reports':
|
||||
// TODO: Rapports membres
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_membresBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,792 +0,0 @@
|
||||
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 '../../../../core/auth/services/permission_service.dart';
|
||||
import '../../../../core/services/communication_service.dart';
|
||||
import '../../../../core/services/export_import_service.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../shared/widgets/permission_widget.dart';
|
||||
import '../bloc/membres_bloc.dart';
|
||||
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 '../widgets/membres_stats_overview.dart';
|
||||
import '../widgets/membres_view_controls.dart';
|
||||
import '../widgets/membre_enhanced_card.dart';
|
||||
import 'membre_details_page.dart';
|
||||
import 'membre_create_page.dart';
|
||||
import 'membre_edit_page.dart';
|
||||
import '../widgets/error_demo_widget.dart';
|
||||
|
||||
|
||||
/// Page de liste des membres avec fonctionnalités avancées
|
||||
class MembresListPage extends StatefulWidget {
|
||||
const MembresListPage({super.key});
|
||||
|
||||
@override
|
||||
State<MembresListPage> createState() => _MembresListPageState();
|
||||
}
|
||||
|
||||
class _MembresListPageState extends State<MembresListPage> with PermissionMixin {
|
||||
final RefreshController _refreshController = RefreshController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
late MembresBloc _membresBloc;
|
||||
List<MembreModel> _membres = [];
|
||||
|
||||
// Nouvelles variables pour les améliorations
|
||||
String _viewMode = 'card'; // 'card', 'list', 'grid'
|
||||
String _sortBy = 'name'; // 'name', 'date', 'age', 'status'
|
||||
bool _sortAscending = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_membresBloc = getIt<MembresBloc>();
|
||||
_membresBloc.add(const LoadMembres());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _membresBloc,
|
||||
child: Scaffold(
|
||||
backgroundColor: AppTheme.backgroundLight,
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'Membres',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
// Recherche avancée - Accessible à tous les utilisateurs connectés
|
||||
PermissionIconButton(
|
||||
permission: () => permissionService.isAuthenticated,
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () => _showAdvancedSearch(),
|
||||
tooltip: 'Recherche avancée',
|
||||
),
|
||||
|
||||
// Export - Réservé aux gestionnaires et admins
|
||||
PermissionIconButton(
|
||||
permission: () => permissionService.canExportMembers,
|
||||
icon: const Icon(Icons.file_download),
|
||||
onPressed: () => _showExportDialog(),
|
||||
tooltip: 'Exporter',
|
||||
disabledMessage: 'Seuls les gestionnaires peuvent exporter les données',
|
||||
),
|
||||
|
||||
// Import - Réservé aux gestionnaires et admins
|
||||
PermissionIconButton(
|
||||
permission: () => permissionService.canCreateMembers,
|
||||
icon: const Icon(Icons.file_upload),
|
||||
onPressed: () => _showImportDialog(),
|
||||
tooltip: 'Importer',
|
||||
disabledMessage: 'Seuls les gestionnaires peuvent importer des données',
|
||||
),
|
||||
|
||||
// Statistiques - Réservé aux gestionnaires et admins
|
||||
PermissionIconButton(
|
||||
permission: () => permissionService.canViewMemberStats,
|
||||
icon: const Icon(Icons.analytics_outlined),
|
||||
onPressed: () => _showStatsDialog(),
|
||||
tooltip: 'Statistiques',
|
||||
disabledMessage: 'Seuls les gestionnaires peuvent voir les statistiques',
|
||||
),
|
||||
|
||||
// Démonstration des nouvelles fonctionnalités (développement uniquement)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report),
|
||||
onPressed: () => _showErrorDemo(),
|
||||
tooltip: 'Démo Gestion d\'Erreurs',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Barre de recherche
|
||||
Container(
|
||||
color: AppTheme.primaryColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: MembresSearchBar(
|
||||
controller: _searchController,
|
||||
onSearch: (query) {
|
||||
_membresBloc.add(SearchMembres(query));
|
||||
},
|
||||
onClear: () {
|
||||
_searchController.clear();
|
||||
_membresBloc.add(const LoadMembres());
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des membres
|
||||
Expanded(
|
||||
child: BlocConsumer<MembresBloc, MembresState>(
|
||||
listener: (context, state) {
|
||||
if (state is MembresError) {
|
||||
_showErrorSnackBar(state.message);
|
||||
} 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();
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is MembresLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primaryColor),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is MembresError) {
|
||||
return _buildErrorWidget(state);
|
||||
}
|
||||
|
||||
if (state is MembresLoaded || state is MembresErrorWithData) {
|
||||
final membres = state is MembresLoaded
|
||||
? state.membres
|
||||
: (state as MembresErrorWithData).membres;
|
||||
|
||||
final isSearchResult = state is MembresLoaded
|
||||
? state.isSearchResult
|
||||
: (state as MembresErrorWithData).isSearchResult;
|
||||
|
||||
return SmartRefresher(
|
||||
controller: _refreshController,
|
||||
onRefresh: () => _membresBloc.add(const RefreshMembres()),
|
||||
header: const WaterDropHeader(
|
||||
waterDropColor: AppTheme.primaryColor,
|
||||
),
|
||||
child: membres.isEmpty
|
||||
? _buildEmptyWidget(isSearchResult)
|
||||
: _buildScrollableContent(membres),
|
||||
);
|
||||
}
|
||||
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Aucune donnée disponible',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: PermissionFAB(
|
||||
permission: () => permissionService.canCreateMembers,
|
||||
onPressed: () => _showAddMemberDialog(),
|
||||
tooltip: 'Ajouter un membre',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Widget d'erreur avec bouton de retry
|
||||
Widget _buildErrorWidget(MembresError state) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
state.isNetworkError ? Icons.wifi_off : Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppTheme.errorColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
state.isNetworkError
|
||||
? 'Problème de connexion'
|
||||
: 'Une erreur est survenue',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
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: () => _membresBloc.add(const LoadMembres()),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Réessayer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Widget vide (aucun membre trouvé)
|
||||
Widget _buildEmptyWidget(bool isSearchResult) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isSearchResult ? Icons.search_off : Icons.people_outline,
|
||||
size: 64,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
isSearchResult
|
||||
? 'Aucun membre trouvé'
|
||||
: 'Aucun membre enregistré',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isSearchResult
|
||||
? 'Essayez avec d\'autres termes de recherche'
|
||||
: 'Utilisez le bouton + en bas pour ajouter votre premier membre',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche une snackbar d'erreur
|
||||
void _showErrorSnackBar(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
action: SnackBarAction(
|
||||
label: 'Fermer',
|
||||
textColor: Colors.white,
|
||||
onPressed: () => ScaffoldMessenger.of(context).hideCurrentSnackBar(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche les détails d'un membre
|
||||
void _showMemberDetails(membre) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MembreDetailsPage(
|
||||
membreId: membre.id,
|
||||
membre: membre,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit le contenu scrollable avec statistiques, contrôles et liste
|
||||
Widget _buildScrollableContent(List<MembreModel> membres) {
|
||||
final sortedMembers = _getSortedMembers(membres);
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Widget de statistiques
|
||||
SliverToBoxAdapter(
|
||||
child: MembresStatsOverview(
|
||||
membres: membres,
|
||||
searchQuery: _searchController.text,
|
||||
),
|
||||
),
|
||||
|
||||
// Contrôles d'affichage
|
||||
SliverToBoxAdapter(
|
||||
child: MembresViewControls(
|
||||
viewMode: _viewMode,
|
||||
sortBy: _sortBy,
|
||||
sortAscending: _sortAscending,
|
||||
totalCount: membres.length,
|
||||
onViewModeChanged: (mode) {
|
||||
setState(() {
|
||||
_viewMode = mode;
|
||||
});
|
||||
},
|
||||
onSortChanged: (sortBy) {
|
||||
setState(() {
|
||||
_sortBy = sortBy;
|
||||
});
|
||||
},
|
||||
onSortDirectionChanged: () {
|
||||
setState(() {
|
||||
_sortAscending = !_sortAscending;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des membres en mode sliver
|
||||
_buildSliverMembersList(sortedMembers),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit la liste des membres en mode sliver pour le scroll
|
||||
Widget _buildSliverMembersList(List<MembreModel> membres) {
|
||||
if (_viewMode == 'grid') {
|
||||
return SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.8,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: MembreEnhancedCard(
|
||||
membre: membres[index],
|
||||
viewMode: _viewMode,
|
||||
onTap: () => _showMemberDetails(membres[index]),
|
||||
onEdit: permissionService.canEditMembers
|
||||
? () => _showEditMemberDialog(membres[index])
|
||||
: null,
|
||||
onDelete: permissionService.canDeleteMembers
|
||||
? () => _showDeleteConfirmation(membres[index])
|
||||
: null,
|
||||
onCall: permissionService.canCallMembers
|
||||
? () => _callMember(membres[index])
|
||||
: null,
|
||||
onMessage: permissionService.canMessageMembers
|
||||
? () => _messageMember(membres[index])
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: membres.length,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: MembreEnhancedCard(
|
||||
membre: membres[index],
|
||||
viewMode: _viewMode,
|
||||
onTap: () => _showMemberDetails(membres[index]),
|
||||
onEdit: permissionService.canEditMembers
|
||||
? () => _showEditMemberDialog(membres[index])
|
||||
: null,
|
||||
onDelete: permissionService.canDeleteMembers
|
||||
? () => _showDeleteConfirmation(membres[index])
|
||||
: null,
|
||||
onCall: permissionService.canCallMembers
|
||||
? () => _callMember(membres[index])
|
||||
: null,
|
||||
onMessage: permissionService.canMessageMembers
|
||||
? () => _messageMember(membres[index])
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: membres.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Trie les membres selon les critères sélectionnés
|
||||
List<MembreModel> _getSortedMembers(List<MembreModel> membres) {
|
||||
final sortedMembers = List<MembreModel>.from(membres);
|
||||
|
||||
sortedMembers.sort((a, b) {
|
||||
int comparison = 0;
|
||||
|
||||
switch (_sortBy) {
|
||||
case 'name':
|
||||
comparison = a.nomComplet.compareTo(b.nomComplet);
|
||||
break;
|
||||
case 'date':
|
||||
comparison = a.dateAdhesion.compareTo(b.dateAdhesion);
|
||||
break;
|
||||
case 'age':
|
||||
comparison = a.age.compareTo(b.age);
|
||||
break;
|
||||
case 'status':
|
||||
comparison = a.statut.compareTo(b.statut);
|
||||
break;
|
||||
}
|
||||
|
||||
return _sortAscending ? comparison : -comparison;
|
||||
});
|
||||
|
||||
return sortedMembers;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Actions sur les membres
|
||||
Future<void> _callMember(MembreModel membre) async {
|
||||
// Vérifier les permissions
|
||||
if (!permissionService.canCallMembers) {
|
||||
showPermissionError(context, 'Vous n\'avez pas les permissions pour appeler les membres');
|
||||
return;
|
||||
}
|
||||
|
||||
// Log de l'action pour audit
|
||||
permissionService.logAction('Tentative d\'appel membre', details: {
|
||||
'membreId': membre.id,
|
||||
'membreNom': membre.nomComplet,
|
||||
'telephone': membre.telephone,
|
||||
});
|
||||
|
||||
// Utiliser le service de communication pour effectuer l'appel
|
||||
final communicationService = CommunicationService();
|
||||
await communicationService.callMember(context, membre);
|
||||
}
|
||||
|
||||
Future<void> _messageMember(MembreModel membre) async {
|
||||
// Vérifier les permissions
|
||||
if (!permissionService.canMessageMembers) {
|
||||
showPermissionError(context, 'Vous n\'avez pas les permissions pour envoyer des messages aux membres');
|
||||
return;
|
||||
}
|
||||
|
||||
// Log de l'action pour audit
|
||||
permissionService.logAction('Tentative d\'envoi SMS membre', details: {
|
||||
'membreId': membre.id,
|
||||
'membreNom': membre.nomComplet,
|
||||
'telephone': membre.telephone,
|
||||
});
|
||||
|
||||
// Utiliser le service de communication pour envoyer un SMS
|
||||
final communicationService = CommunicationService();
|
||||
await communicationService.sendSMS(context, membre);
|
||||
}
|
||||
|
||||
/// Affiche le formulaire d'ajout de membre
|
||||
void _showAddMemberDialog() async {
|
||||
// Vérifier les permissions avant d'ouvrir le formulaire
|
||||
if (!permissionService.canCreateMembers) {
|
||||
showPermissionError(context, 'Vous n\'avez pas les permissions pour créer de nouveaux membres');
|
||||
return;
|
||||
}
|
||||
|
||||
permissionService.logAction('Ouverture formulaire création membre');
|
||||
|
||||
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
|
||||
void _showEditMemberDialog(membre) async {
|
||||
// Vérifier les permissions avant d'ouvrir le formulaire
|
||||
if (!permissionService.canEditMembers) {
|
||||
showPermissionError(context, 'Vous n\'avez pas les permissions pour modifier les membres');
|
||||
return;
|
||||
}
|
||||
|
||||
permissionService.logAction('Ouverture formulaire édition membre', details: {'membreId': membre.id});
|
||||
|
||||
final result = await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MembreEditPage(membre: membre),
|
||||
),
|
||||
);
|
||||
|
||||
// Si le membre a été modifié avec succès, recharger la liste
|
||||
if (result == true) {
|
||||
_membresBloc.add(const RefreshMembres());
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche la confirmation de suppression
|
||||
void _showDeleteConfirmation(membre) async {
|
||||
// Vérifier les permissions avant d'ouvrir le dialog
|
||||
if (!permissionService.canDeleteMembers) {
|
||||
showPermissionError(context, 'Vous n\'avez pas les permissions pour supprimer des membres');
|
||||
return;
|
||||
}
|
||||
|
||||
permissionService.logAction('Ouverture dialog suppression membre', details: {'membreId': membre.id});
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
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() {
|
||||
// Vérifier les permissions avant d'afficher les statistiques
|
||||
if (!permissionService.canViewMemberStats) {
|
||||
showPermissionError(context, 'Vous n\'avez pas les permissions pour voir les statistiques');
|
||||
return;
|
||||
}
|
||||
|
||||
permissionService.logAction('Consultation statistiques membres');
|
||||
|
||||
// TODO: Créer une page de statistiques détaillées
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Statistiques détaillées - En développement'),
|
||||
backgroundColor: AppTheme.infoColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
// Fermer le modal
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// Lancer la recherche avancée
|
||||
context.read<MembresBloc>().add(AdvancedSearchMembres(filters));
|
||||
|
||||
// Log de l'action pour audit
|
||||
permissionService.logAction('Recherche avancée membres', details: {
|
||||
'filtres': filters.keys.where((key) => filters[key] != null && filters[key].toString().isNotEmpty).toList(),
|
||||
'nombreFiltres': filters.values.where((value) => value != null && value.toString().isNotEmpty).length,
|
||||
});
|
||||
|
||||
// Afficher un message de confirmation
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Recherche lancée avec ${filters.values.where((value) => value != null && value.toString().isNotEmpty).length} filtres'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche le dialog d'export
|
||||
void _showExportDialog() {
|
||||
// Vérifier les permissions avant d'ouvrir le dialog d'export
|
||||
if (!permissionService.canExportMembers) {
|
||||
showPermissionError(context, 'Vous n\'avez pas les permissions pour exporter les données');
|
||||
return;
|
||||
}
|
||||
|
||||
permissionService.logAction('Ouverture dialog export membres', details: {'nombreMembres': _membres.length});
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => MembresExportDialog(
|
||||
membres: _membres,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche le dialog d'import
|
||||
Future<void> _showImportDialog() async {
|
||||
// Vérifier les permissions avant d'ouvrir le dialog d'import
|
||||
if (!permissionService.canCreateMembers) {
|
||||
showPermissionError(context, 'Vous n\'avez pas les permissions pour importer des données');
|
||||
return;
|
||||
}
|
||||
|
||||
permissionService.logAction('Tentative import membres');
|
||||
|
||||
// Afficher un dialog de confirmation
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => 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: const Icon(
|
||||
Icons.file_upload,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Importer des membres',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Sélectionnez un fichier Excel (.xlsx), CSV (.csv) ou JSON (.json) contenant les données des membres à importer.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Formats supportés :',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('• Excel (.xlsx)'),
|
||||
Text('• CSV (.csv)'),
|
||||
Text('• JSON (.json)'),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'⚠️ Les données existantes ne seront pas supprimées. Les nouveaux membres seront ajoutés.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.warningColor,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
icon: const Icon(Icons.file_upload),
|
||||
label: const Text('Sélectionner fichier'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
// Effectuer l'import
|
||||
final exportService = ExportImportService();
|
||||
final importedMembers = await exportService.importMembers(context);
|
||||
|
||||
if (importedMembers != null && importedMembers.isNotEmpty && mounted) {
|
||||
// Log de l'action réussie
|
||||
permissionService.logAction('Import membres réussi', details: {
|
||||
'nombreMembres': importedMembers.length,
|
||||
});
|
||||
|
||||
// TODO: Intégrer les membres importés avec l'API
|
||||
// Pour l'instant, on affiche juste un message de succès
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.info, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${importedMembers.length} membres importés avec succès. Intégration avec l\'API en cours de développement.',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppTheme.infoColor,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche la page de démonstration des nouvelles fonctionnalités
|
||||
void _showErrorDemo() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ErrorDemoWidget(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget de carte d'action réutilisable pour les membres
|
||||
class MembersActionCardWidget extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
final String? badge;
|
||||
|
||||
const MembersActionCardWidget({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
this.badge,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Icône avec badge optionnel
|
||||
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: 18,
|
||||
),
|
||||
),
|
||||
if (badge != null)
|
||||
Positioned(
|
||||
right: -2,
|
||||
top: -2,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.errorColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 16,
|
||||
minHeight: 16,
|
||||
),
|
||||
child: Text(
|
||||
badge!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Titre
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
|
||||
// Sous-titre
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget d'élément d'activité réutilisable pour les membres
|
||||
class MembersActivityItemWidget extends StatelessWidget {
|
||||
final String title;
|
||||
final String description;
|
||||
final String time;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String? memberName;
|
||||
final String? memberAvatar;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const MembersActivityItemWidget({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.time,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
this.memberName,
|
||||
this.memberAvatar,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Icône d'activité
|
||||
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),
|
||||
|
||||
// Contenu principal
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
|
||||
// Description
|
||||
Text(
|
||||
description,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
// Nom du membre si fourni
|
||||
if (memberName != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
// Avatar du membre
|
||||
if (memberAvatar != null)
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
size: 10,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: 10,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
memberName!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Temps et indicateur
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
time,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget de filtres avancés pour le dashboard des membres
|
||||
class MembersAdvancedFiltersWidget extends StatefulWidget {
|
||||
final Function(Map<String, dynamic>) onFiltersChanged;
|
||||
final Map<String, dynamic> initialFilters;
|
||||
|
||||
const MembersAdvancedFiltersWidget({
|
||||
super.key,
|
||||
required this.onFiltersChanged,
|
||||
this.initialFilters = const {},
|
||||
});
|
||||
|
||||
@override
|
||||
State<MembersAdvancedFiltersWidget> createState() => _MembersAdvancedFiltersWidgetState();
|
||||
}
|
||||
|
||||
class _MembersAdvancedFiltersWidgetState extends State<MembersAdvancedFiltersWidget>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
Map<String, dynamic> _filters = {};
|
||||
bool _isExpanded = false;
|
||||
|
||||
// Options de filtres
|
||||
final List<String> _statusOptions = ['Tous', 'Actif', 'Inactif', 'Suspendu'];
|
||||
final List<String> _ageRanges = ['Tous', '18-30', '31-45', '46-60', '60+'];
|
||||
final List<String> _genderOptions = ['Tous', 'Homme', 'Femme'];
|
||||
final List<String> _roleOptions = ['Tous', 'Membre', 'Responsable', 'Bureau'];
|
||||
final List<String> _timeRanges = ['7 jours', '30 jours', '3 mois', '6 mois', '1 an'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_filters = Map.from(widget.initialFilters);
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleExpanded() {
|
||||
setState(() {
|
||||
_isExpanded = !_isExpanded;
|
||||
if (_isExpanded) {
|
||||
_animationController.forward();
|
||||
} else {
|
||||
_animationController.reverse();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _updateFilter(String key, dynamic value) {
|
||||
setState(() {
|
||||
_filters[key] = value;
|
||||
});
|
||||
widget.onFiltersChanged(_filters);
|
||||
}
|
||||
|
||||
void _resetFilters() {
|
||||
setState(() {
|
||||
_filters.clear();
|
||||
});
|
||||
widget.onFiltersChanged(_filters);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 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(
|
||||
children: [
|
||||
// En-tête des filtres
|
||||
InkWell(
|
||||
onTap: _toggleExpanded,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.tune,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Filtres Avancés',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_filters.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'${_filters.length}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AnimatedRotation(
|
||||
turns: _isExpanded ? 0.5 : 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Contenu des filtres
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
height: _isExpanded ? null : 0,
|
||||
child: _isExpanded
|
||||
? FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: _buildFiltersContent(),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFiltersContent() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Période
|
||||
_buildFilterSection(
|
||||
'Période',
|
||||
Icons.date_range,
|
||||
_buildChipFilter('timeRange', _timeRanges),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Statut
|
||||
_buildFilterSection(
|
||||
'Statut',
|
||||
Icons.verified_user,
|
||||
_buildChipFilter('status', _statusOptions),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Tranche d'âge
|
||||
_buildFilterSection(
|
||||
'Âge',
|
||||
Icons.cake,
|
||||
_buildChipFilter('ageRange', _ageRanges),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Genre
|
||||
_buildFilterSection(
|
||||
'Genre',
|
||||
Icons.people_outline,
|
||||
_buildChipFilter('gender', _genderOptions),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Rôle
|
||||
_buildFilterSection(
|
||||
'Rôle',
|
||||
Icons.admin_panel_settings,
|
||||
_buildChipFilter('role', _roleOptions),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Boutons d'action
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _resetFilters,
|
||||
icon: const Icon(Icons.clear_all, size: 16),
|
||||
label: const Text('Réinitialiser'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.textSecondary,
|
||||
side: BorderSide(color: AppTheme.textSecondary.withOpacity(0.3)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _toggleExpanded(),
|
||||
icon: const Icon(Icons.check, size: 16),
|
||||
label: const Text('Appliquer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterSection(String title, IconData icon, Widget content) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: AppTheme.textSecondary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
content,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChipFilter(String filterKey, List<String> options) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: options.map((option) {
|
||||
final isSelected = _filters[filterKey] == option;
|
||||
return FilterChip(
|
||||
label: Text(
|
||||
option,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isSelected ? Colors.white : AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
_updateFilter(filterKey, option);
|
||||
} else {
|
||||
_updateFilter(filterKey, null);
|
||||
}
|
||||
},
|
||||
backgroundColor: Colors.grey[100],
|
||||
selectedColor: AppTheme.primaryColor,
|
||||
checkmarkColor: Colors.white,
|
||||
side: BorderSide(
|
||||
color: isSelected ? AppTheme.primaryColor : Colors.grey[300]!,
|
||||
width: 1,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,564 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import '../../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget de section d'analyses pour les membres
|
||||
class MembersAnalyticsWidget extends StatelessWidget {
|
||||
const MembersAnalyticsWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre de section
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.analytics,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Analyses & Tendances',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Grille de graphiques
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 1,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 1.4,
|
||||
children: [
|
||||
// Évolution des inscriptions
|
||||
_buildMemberGrowthChart(),
|
||||
|
||||
// Répartition par âge
|
||||
_buildAgeDistributionChart(),
|
||||
|
||||
// Activité mensuelle
|
||||
_buildMonthlyActivityChart(),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Graphique d'évolution des inscriptions
|
||||
Widget _buildMemberGrowthChart() {
|
||||
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
|
||||
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),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Évolution des Inscriptions',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'Croissance sur 6 mois • +24.7%',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Graphique linéaire
|
||||
Expanded(
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: 50,
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
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,
|
||||
reservedSize: 30,
|
||||
interval: 1,
|
||||
getTitlesWidget: (double value, TitleMeta meta) {
|
||||
const months = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin'];
|
||||
if (value.toInt() >= 0 && value.toInt() < months.length) {
|
||||
return SideTitleWidget(
|
||||
axisSide: meta.axisSide,
|
||||
child: Text(
|
||||
months[value.toInt()],
|
||||
style: const TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Text('');
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
interval: 50,
|
||||
reservedSize: 40,
|
||||
getTitlesWidget: (double value, TitleMeta meta) {
|
||||
return SideTitleWidget(
|
||||
axisSide: meta.axisSide,
|
||||
child: Text(
|
||||
'${value.toInt()}',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
minX: 0,
|
||||
maxX: 5,
|
||||
minY: 0,
|
||||
maxY: 300,
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: const [
|
||||
FlSpot(0, 180), // Janvier: 180 nouveaux
|
||||
FlSpot(1, 195), // Février: 195 nouveaux
|
||||
FlSpot(2, 210), // Mars: 210 nouveaux
|
||||
FlSpot(3, 235), // Avril: 235 nouveaux
|
||||
FlSpot(4, 265), // Mai: 265 nouveaux
|
||||
FlSpot(5, 285), // Juin: 285 nouveaux
|
||||
],
|
||||
isCurved: true,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppTheme.primaryColor,
|
||||
AppTheme.primaryColor.withOpacity(0.7),
|
||||
],
|
||||
),
|
||||
barWidth: 3,
|
||||
isStrokeCapRound: true,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, barData, index) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 4,
|
||||
color: AppTheme.primaryColor,
|
||||
strokeWidth: 2,
|
||||
strokeColor: Colors.white,
|
||||
);
|
||||
},
|
||||
),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppTheme.primaryColor.withOpacity(0.2),
|
||||
AppTheme.primaryColor.withOpacity(0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Graphique de répartition par âge
|
||||
Widget _buildAgeDistributionChart() {
|
||||
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
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.successColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.cake,
|
||||
color: AppTheme.successColor,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Répartition par Âge',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'Distribution par tranches d\'âge',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Graphique en camembert
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// Graphique
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
sectionsSpace: 2,
|
||||
centerSpaceRadius: 40,
|
||||
sections: [
|
||||
PieChartSectionData(
|
||||
color: AppTheme.primaryColor,
|
||||
value: 42,
|
||||
title: '42%',
|
||||
radius: 50,
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
PieChartSectionData(
|
||||
color: AppTheme.successColor,
|
||||
value: 38,
|
||||
title: '38%',
|
||||
radius: 50,
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
PieChartSectionData(
|
||||
color: AppTheme.warningColor,
|
||||
value: 15,
|
||||
title: '15%',
|
||||
radius: 50,
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
PieChartSectionData(
|
||||
color: AppTheme.errorColor,
|
||||
value: 5,
|
||||
title: '5%',
|
||||
radius: 50,
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Légende
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildAgeLegend('18-30 ans', '524', AppTheme.primaryColor),
|
||||
const SizedBox(height: 8),
|
||||
_buildAgeLegend('31-45 ans', '474', AppTheme.successColor),
|
||||
const SizedBox(height: 8),
|
||||
_buildAgeLegend('46-60 ans', '187', AppTheme.warningColor),
|
||||
const SizedBox(height: 8),
|
||||
_buildAgeLegend('60+ ans', '62', AppTheme.errorColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Widget de légende pour les âges
|
||||
Widget _buildAgeLegend(String label, String count, Color color) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
count,
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Graphique d'activité mensuelle
|
||||
Widget _buildMonthlyActivityChart() {
|
||||
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
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.infoColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.timeline,
|
||||
color: AppTheme.infoColor,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Activité Mensuelle',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'Connexions et interactions',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Graphique en barres
|
||||
Expanded(
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: 1200,
|
||||
barTouchData: BarTouchData(enabled: false),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
getTitlesWidget: (double value, TitleMeta meta) {
|
||||
const months = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin'];
|
||||
if (value.toInt() >= 0 && value.toInt() < months.length) {
|
||||
return SideTitleWidget(
|
||||
axisSide: meta.axisSide,
|
||||
child: Text(
|
||||
months[value.toInt()],
|
||||
style: const TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Text('');
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
interval: 200,
|
||||
getTitlesWidget: (double value, TitleMeta meta) {
|
||||
return SideTitleWidget(
|
||||
axisSide: meta.axisSide,
|
||||
child: Text(
|
||||
'${value.toInt()}',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
barGroups: [
|
||||
BarChartGroupData(x: 0, barRods: [BarChartRodData(toY: 850, color: AppTheme.infoColor, width: 16)]),
|
||||
BarChartGroupData(x: 1, barRods: [BarChartRodData(toY: 920, color: AppTheme.infoColor, width: 16)]),
|
||||
BarChartGroupData(x: 2, barRods: [BarChartRodData(toY: 1050, color: AppTheme.infoColor, width: 16)]),
|
||||
BarChartGroupData(x: 3, barRods: [BarChartRodData(toY: 980, color: AppTheme.infoColor, width: 16)]),
|
||||
BarChartGroupData(x: 4, barRods: [BarChartRodData(toY: 1120, color: AppTheme.infoColor, width: 16)]),
|
||||
BarChartGroupData(x: 5, barRods: [BarChartRodData(toY: 1089, color: AppTheme.infoColor, width: 16)]),
|
||||
],
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: 200,
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: AppTheme.infoColor.withOpacity(0.1),
|
||||
strokeWidth: 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,828 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../core/models/membre_model.dart';
|
||||
import '../../../../../shared/theme/app_theme.dart';
|
||||
import 'members_interactive_card_widget.dart';
|
||||
import 'members_stats_widget.dart';
|
||||
|
||||
/// Widget de liste de membres améliorée avec animations
|
||||
class MembersEnhancedListWidget extends StatefulWidget {
|
||||
final List<MembreModel> members;
|
||||
final Function(MembreModel) onMemberTap;
|
||||
final Function(MembreModel)? onMemberCall;
|
||||
final Function(MembreModel)? onMemberMessage;
|
||||
final Function(MembreModel)? onMemberEdit;
|
||||
final bool isLoading;
|
||||
final String? searchQuery;
|
||||
final Map<String, dynamic> filters;
|
||||
|
||||
const MembersEnhancedListWidget({
|
||||
super.key,
|
||||
required this.members,
|
||||
required this.onMemberTap,
|
||||
this.onMemberCall,
|
||||
this.onMemberMessage,
|
||||
this.onMemberEdit,
|
||||
this.isLoading = false,
|
||||
this.searchQuery,
|
||||
this.filters = const {},
|
||||
});
|
||||
|
||||
@override
|
||||
State<MembersEnhancedListWidget> createState() => _MembersEnhancedListWidgetState();
|
||||
}
|
||||
|
||||
class _MembersEnhancedListWidgetState extends State<MembersEnhancedListWidget>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _listController;
|
||||
late Animation<double> _listAnimation;
|
||||
|
||||
List<String> _selectedMembers = [];
|
||||
String _sortBy = 'name';
|
||||
bool _sortAscending = true;
|
||||
String _viewMode = 'card'; // 'card', 'list', 'grid'
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listController = AnimationController(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
vsync: this,
|
||||
);
|
||||
_listAnimation = CurvedAnimation(
|
||||
parent: _listController,
|
||||
curve: Curves.easeOutQuart,
|
||||
);
|
||||
_listController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_listController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<MembreModel> get _filteredAndSortedMembers {
|
||||
List<MembreModel> filtered = List.from(widget.members);
|
||||
|
||||
// Appliquer les filtres
|
||||
if (widget.filters.isNotEmpty) {
|
||||
filtered = filtered.where((member) {
|
||||
bool matches = true;
|
||||
|
||||
if (widget.filters['status'] != null && widget.filters['status'] != 'Tous') {
|
||||
matches = matches && member.statut.toUpperCase() == widget.filters['status'].toUpperCase();
|
||||
}
|
||||
|
||||
if (widget.filters['ageRange'] != null && widget.filters['ageRange'] != 'Tous') {
|
||||
final ageRange = widget.filters['ageRange'] as String;
|
||||
final age = member.age;
|
||||
switch (ageRange) {
|
||||
case '18-30':
|
||||
matches = matches && age >= 18 && age <= 30;
|
||||
break;
|
||||
case '31-45':
|
||||
matches = matches && age >= 31 && age <= 45;
|
||||
break;
|
||||
case '46-60':
|
||||
matches = matches && age >= 46 && age <= 60;
|
||||
break;
|
||||
case '60+':
|
||||
matches = matches && age > 60;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Appliquer la recherche
|
||||
if (widget.searchQuery != null && widget.searchQuery!.isNotEmpty) {
|
||||
final query = widget.searchQuery!.toLowerCase();
|
||||
filtered = filtered.where((member) {
|
||||
return member.nomComplet.toLowerCase().contains(query) ||
|
||||
member.numeroMembre.toLowerCase().contains(query) ||
|
||||
member.email.toLowerCase().contains(query) ||
|
||||
member.telephone.contains(query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Trier
|
||||
filtered.sort((a, b) {
|
||||
int comparison = 0;
|
||||
switch (_sortBy) {
|
||||
case 'name':
|
||||
comparison = a.nomComplet.compareTo(b.nomComplet);
|
||||
break;
|
||||
case 'date':
|
||||
comparison = a.dateAdhesion.compareTo(b.dateAdhesion);
|
||||
break;
|
||||
case 'age':
|
||||
comparison = a.age.compareTo(b.age);
|
||||
break;
|
||||
case 'status':
|
||||
comparison = a.statut.compareTo(b.statut);
|
||||
break;
|
||||
}
|
||||
return _sortAscending ? comparison : -comparison;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
void _toggleMemberSelection(String memberId) {
|
||||
setState(() {
|
||||
if (_selectedMembers.contains(memberId)) {
|
||||
_selectedMembers.remove(memberId);
|
||||
} else {
|
||||
_selectedMembers.add(memberId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
setState(() {
|
||||
_selectedMembers.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void _changeSortBy(String sortBy) {
|
||||
setState(() {
|
||||
if (_sortBy == sortBy) {
|
||||
_sortAscending = !_sortAscending;
|
||||
} else {
|
||||
_sortBy = sortBy;
|
||||
_sortAscending = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _changeViewMode(String viewMode) {
|
||||
setState(() {
|
||||
_viewMode = viewMode;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filteredMembers = _filteredAndSortedMembers;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// En-tête avec contrôles
|
||||
_buildHeader(filteredMembers.length),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Statistiques des membres
|
||||
if (!widget.isLoading && filteredMembers.isNotEmpty)
|
||||
MembersStatsWidget(
|
||||
members: filteredMembers,
|
||||
searchQuery: widget.searchQuery ?? '',
|
||||
filters: widget.filters,
|
||||
),
|
||||
|
||||
// Barre de sélection (si des membres sont sélectionnés)
|
||||
if (_selectedMembers.isNotEmpty)
|
||||
_buildSelectionBar(),
|
||||
|
||||
// Liste des membres
|
||||
if (widget.isLoading)
|
||||
_buildLoadingState()
|
||||
else if (filteredMembers.isEmpty)
|
||||
_buildEmptyState()
|
||||
else
|
||||
_buildMembersList(filteredMembers),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(int memberCount) {
|
||||
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(
|
||||
children: [
|
||||
// Titre et compteur
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.people,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Membres ($memberCount)',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Modes d'affichage
|
||||
_buildViewModeToggle(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Contrôles de tri
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Trier par:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildSortChip('name', 'Nom'),
|
||||
const SizedBox(width: 4),
|
||||
_buildSortChip('date', 'Date'),
|
||||
const SizedBox(width: 4),
|
||||
_buildSortChip('age', 'Âge'),
|
||||
const SizedBox(width: 4),
|
||||
_buildSortChip('status', 'Statut'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildViewModeToggle() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildViewModeButton(Icons.view_agenda, 'card'),
|
||||
_buildViewModeButton(Icons.view_list, 'list'),
|
||||
_buildViewModeButton(Icons.grid_view, 'grid'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildViewModeButton(IconData icon, String mode) {
|
||||
final isSelected = _viewMode == mode;
|
||||
return InkWell(
|
||||
onTap: () => _changeViewMode(mode),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppTheme.primaryColor : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: isSelected ? Colors.white : AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSortChip(String sortKey, String label) {
|
||||
final isSelected = _sortBy == sortKey;
|
||||
return InkWell(
|
||||
onTap: () => _changeSortBy(sortKey),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppTheme.primaryColor.withOpacity(0.1) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppTheme.primaryColor : Colors.grey[300]!,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isSelected ? AppTheme.primaryColor : AppTheme.textSecondary,
|
||||
fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
if (isSelected) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
_sortAscending ? Icons.arrow_upward : Icons.arrow_downward,
|
||||
size: 12,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionBar() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.primaryColor.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${_selectedMembers.length} membre(s) sélectionné(s)',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _clearSelection,
|
||||
child: const Text('Désélectionner'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// TODO: Actions groupées
|
||||
},
|
||||
icon: const Icon(Icons.more_horiz, size: 16),
|
||||
label: const Text('Actions'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingState() {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
widget.searchQuery?.isNotEmpty == true ? Icons.search_off : Icons.people_outline,
|
||||
size: 64,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.searchQuery?.isNotEmpty == true
|
||||
? 'Aucun membre trouvé'
|
||||
: 'Aucun membre',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.searchQuery?.isNotEmpty == true
|
||||
? 'Essayez avec d\'autres termes de recherche'
|
||||
: 'Commencez par ajouter des membres',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMembersList(List<MembreModel> members) {
|
||||
if (_viewMode == 'grid') {
|
||||
return _buildGridView(members);
|
||||
} else if (_viewMode == 'list') {
|
||||
return _buildListView(members);
|
||||
} else {
|
||||
return _buildCardView(members);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCardView(List<MembreModel> members) {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: members.length,
|
||||
itemBuilder: (context, index) {
|
||||
final member = members[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: MembersInteractiveCardWidget(
|
||||
member: member,
|
||||
isSelected: _selectedMembers.contains(member.id),
|
||||
onTap: () {
|
||||
if (_selectedMembers.isNotEmpty) {
|
||||
_toggleMemberSelection(member.id!);
|
||||
} else {
|
||||
widget.onMemberTap(member);
|
||||
}
|
||||
},
|
||||
onCall: widget.onMemberCall != null
|
||||
? () => widget.onMemberCall!(member)
|
||||
: null,
|
||||
onMessage: widget.onMemberMessage != null
|
||||
? () => widget.onMemberMessage!(member)
|
||||
: null,
|
||||
onEdit: widget.onMemberEdit != null
|
||||
? () => widget.onMemberEdit!(member)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListView(List<MembreModel> members) {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: members.length,
|
||||
itemBuilder: (context, index) {
|
||||
final member = members[index];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: _buildCompactMemberTile(member),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridView(List<MembreModel> members) {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.85,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: members.length,
|
||||
itemBuilder: (context, index) {
|
||||
final member = members[index];
|
||||
return _buildGridMemberCard(member);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactMemberTile(MembreModel member) {
|
||||
final isSelected = _selectedMembers.contains(member.id);
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
if (_selectedMembers.isNotEmpty) {
|
||||
_toggleMemberSelection(member.id!);
|
||||
} else {
|
||||
widget.onMemberTap(member);
|
||||
}
|
||||
},
|
||||
onLongPress: () => _toggleMemberSelection(member.id!),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppTheme.primaryColor.withOpacity(0.1) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppTheme.primaryColor : Colors.grey[200]!,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppTheme.primaryColor,
|
||||
AppTheme.primaryColor.withOpacity(0.7),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
member.nomComplet.split(' ').map((e) => e[0]).take(2).join(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Informations
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
member.nomComplet,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
member.numeroMembre,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.phone,
|
||||
size: 14,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
member.telephone,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Badge de statut
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.successColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
member.statutLibelle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Actions rapides
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: AppTheme.textSecondary,
|
||||
size: 20,
|
||||
),
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'call':
|
||||
widget.onMemberCall?.call(member);
|
||||
break;
|
||||
case 'message':
|
||||
widget.onMemberMessage?.call(member);
|
||||
break;
|
||||
case 'edit':
|
||||
widget.onMemberEdit?.call(member);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'call',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.phone, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Appeler'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'message',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.message, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Message'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Modifier'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridMemberCard(MembreModel member) {
|
||||
final isSelected = _selectedMembers.contains(member.id);
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
if (_selectedMembers.isNotEmpty) {
|
||||
_toggleMemberSelection(member.id!);
|
||||
} else {
|
||||
widget.onMemberTap(member);
|
||||
}
|
||||
},
|
||||
onLongPress: () => _toggleMemberSelection(member.id!),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppTheme.primaryColor.withOpacity(0.1) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppTheme.primaryColor : Colors.grey[200]!,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Avatar
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppTheme.primaryColor,
|
||||
AppTheme.primaryColor.withOpacity(0.7),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
member.nomComplet.split(' ').map((e) => e[0]).take(2).join(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Nom
|
||||
Text(
|
||||
member.nomComplet,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Numéro membre
|
||||
Text(
|
||||
member.numeroMembre,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Badge de statut
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.successColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
member.statutLibelle,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
// Actions rapides
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => widget.onMemberCall?.call(member),
|
||||
icon: const Icon(Icons.phone, size: 18),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppTheme.successColor.withOpacity(0.1),
|
||||
foregroundColor: AppTheme.successColor,
|
||||
minimumSize: const Size(32, 32),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => widget.onMemberMessage?.call(member),
|
||||
icon: const Icon(Icons.message, size: 18),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppTheme.infoColor.withOpacity(0.1),
|
||||
foregroundColor: AppTheme.infoColor,
|
||||
minimumSize: const Size(32, 32),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => widget.onMemberEdit?.call(member),
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppTheme.warningColor.withOpacity(0.1),
|
||||
foregroundColor: AppTheme.warningColor,
|
||||
minimumSize: const Size(32, 32),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../../../core/models/membre_model.dart';
|
||||
import '../../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Carte membre interactive avec animations avancées
|
||||
class MembersInteractiveCardWidget extends StatefulWidget {
|
||||
final MembreModel member;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onCall;
|
||||
final VoidCallback? onMessage;
|
||||
final VoidCallback? onEdit;
|
||||
final bool isSelected;
|
||||
final bool showActions;
|
||||
|
||||
const MembersInteractiveCardWidget({
|
||||
super.key,
|
||||
required this.member,
|
||||
this.onTap,
|
||||
this.onCall,
|
||||
this.onMessage,
|
||||
this.onEdit,
|
||||
this.isSelected = false,
|
||||
this.showActions = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MembersInteractiveCardWidget> createState() => _MembersInteractiveCardWidgetState();
|
||||
}
|
||||
|
||||
class _MembersInteractiveCardWidgetState extends State<MembersInteractiveCardWidget>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _hoverController;
|
||||
late AnimationController _tapController;
|
||||
late AnimationController _actionsController;
|
||||
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _elevationAnimation;
|
||||
late Animation<double> _actionsAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
|
||||
bool _isHovered = false;
|
||||
bool _showActions = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_hoverController = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_tapController = AnimationController(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_actionsController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.02).animate(
|
||||
CurvedAnimation(parent: _hoverController, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
_elevationAnimation = Tween<double>(begin: 2.0, end: 8.0).animate(
|
||||
CurvedAnimation(parent: _hoverController, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
_actionsAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _actionsController, curve: Curves.elasticOut),
|
||||
);
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(1.0, 0.0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: _actionsController, curve: Curves.easeOut));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hoverController.dispose();
|
||||
_tapController.dispose();
|
||||
_actionsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onHover(bool isHovered) {
|
||||
setState(() {
|
||||
_isHovered = isHovered;
|
||||
});
|
||||
|
||||
if (isHovered) {
|
||||
_hoverController.forward();
|
||||
if (widget.showActions) {
|
||||
_showActions = true;
|
||||
_actionsController.forward();
|
||||
}
|
||||
} else {
|
||||
_hoverController.reverse();
|
||||
_showActions = false;
|
||||
_actionsController.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
void _onTapDown(TapDownDetails details) {
|
||||
_tapController.forward();
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
void _onTapUp(TapUpDetails details) {
|
||||
_tapController.reverse();
|
||||
}
|
||||
|
||||
void _onTapCancel() {
|
||||
_tapController.reverse();
|
||||
}
|
||||
|
||||
Color _getStatusColor() {
|
||||
switch (widget.member.statut.toUpperCase()) {
|
||||
case 'ACTIF':
|
||||
return AppTheme.successColor;
|
||||
case 'INACTIF':
|
||||
return AppTheme.warningColor;
|
||||
case 'SUSPENDU':
|
||||
return AppTheme.errorColor;
|
||||
default:
|
||||
return AppTheme.textSecondary;
|
||||
}
|
||||
}
|
||||
|
||||
String _getInitials() {
|
||||
final names = '${widget.member.prenom} ${widget.member.nom}'.split(' ');
|
||||
return names.take(2).map((name) => name.isNotEmpty ? name[0].toUpperCase() : '').join();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => _onHover(true),
|
||||
onExit: (_) => _onHover(false),
|
||||
child: GestureDetector(
|
||||
onTapDown: _onTapDown,
|
||||
onTapUp: _onTapUp,
|
||||
onTapCancel: _onTapCancel,
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedBuilder(
|
||||
animation: Listenable.merge([_hoverController, _tapController]),
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value * (1.0 - _tapController.value * 0.02),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: widget.isSelected
|
||||
? Border.all(color: AppTheme.primaryColor, width: 2)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: _elevationAnimation.value,
|
||||
offset: Offset(0, _elevationAnimation.value / 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Contenu principal
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// En-tête avec avatar et statut
|
||||
Row(
|
||||
children: [
|
||||
_buildAnimatedAvatar(),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.member.nomComplet,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.member.numeroMembre,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildStatusBadge(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Informations de contact
|
||||
_buildContactInfo(),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Informations supplémentaires
|
||||
_buildAdditionalInfo(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Actions flottantes
|
||||
if (_showActions && widget.showActions)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: ScaleTransition(
|
||||
scale: _actionsAnimation,
|
||||
child: _buildFloatingActions(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Indicateur de sélection
|
||||
if (widget.isSelected)
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.primaryColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check,
|
||||
color: Colors.white,
|
||||
size: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnimatedAvatar() {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: _isHovered ? 52 : 48,
|
||||
height: _isHovered ? 52 : 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppTheme.primaryColor,
|
||||
AppTheme.primaryColor.withOpacity(0.7),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(_isHovered ? 16 : 14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.primaryColor.withOpacity(0.3),
|
||||
blurRadius: _isHovered ? 8 : 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_getInitials(),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: _isHovered ? 18 : 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBadge() {
|
||||
final statusColor = _getStatusColor();
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: statusColor.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.member.statutLibelle,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactInfo() {
|
||||
return Column(
|
||||
children: [
|
||||
_buildInfoRow(Icons.email_outlined, widget.member.email),
|
||||
const SizedBox(height: 4),
|
||||
_buildInfoRow(Icons.phone_outlined, widget.member.telephone),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(IconData icon, String text) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAdditionalInfo() {
|
||||
return Row(
|
||||
children: [
|
||||
_buildInfoChip(
|
||||
Icons.cake_outlined,
|
||||
'${widget.member.age} ans',
|
||||
AppTheme.infoColor,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildInfoChip(
|
||||
Icons.calendar_today_outlined,
|
||||
'Depuis ${widget.member.dateAdhesion?.year ?? 'N/A'}',
|
||||
AppTheme.successColor,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoChip(IconData icon, String text, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 12,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFloatingActions() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
Icons.phone,
|
||||
AppTheme.successColor,
|
||||
widget.onCall,
|
||||
),
|
||||
_buildActionButton(
|
||||
Icons.message,
|
||||
AppTheme.infoColor,
|
||||
widget.onMessage,
|
||||
),
|
||||
_buildActionButton(
|
||||
Icons.edit,
|
||||
AppTheme.warningColor,
|
||||
widget.onEdit,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton(IconData icon, Color color, VoidCallback? onTap) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget de carte KPI réutilisable pour les membres
|
||||
class MembersKPICardWidget extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String? trend;
|
||||
final bool? isPositiveTrend;
|
||||
final List<String>? details;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const MembersKPICardWidget({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
this.trend,
|
||||
this.isPositiveTrend,
|
||||
this.details,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: 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 titre
|
||||
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 SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (trend != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (isPositiveTrend ?? true)
|
||||
? AppTheme.successColor.withOpacity(0.1)
|
||||
: AppTheme.errorColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
(isPositiveTrend ?? true)
|
||||
? Icons.trending_up
|
||||
: Icons.trending_down,
|
||||
size: 12,
|
||||
color: (isPositiveTrend ?? true)
|
||||
? AppTheme.successColor
|
||||
: AppTheme.errorColor,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
trend!,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: (isPositiveTrend ?? true)
|
||||
? AppTheme.successColor
|
||||
: AppTheme.errorColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Valeur principale
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Sous-titre
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
|
||||
// Détails optionnels
|
||||
if (details != null && details!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
...details!.take(2).map((detail) => Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
detail,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../shared/theme/app_theme.dart';
|
||||
import 'members_kpi_card_widget.dart';
|
||||
|
||||
/// Widget de section KPI pour le dashboard des membres
|
||||
class MembersKPISectionWidget extends StatelessWidget {
|
||||
const MembersKPISectionWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre de section
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.analytics,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Indicateurs Clés',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Grille de KPI
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 1.1,
|
||||
children: [
|
||||
// Total des membres
|
||||
MembersKPICardWidget(
|
||||
title: 'Total Membres',
|
||||
value: '1,247',
|
||||
subtitle: 'Membres enregistrés',
|
||||
icon: Icons.people,
|
||||
color: AppTheme.primaryColor,
|
||||
trend: '+24.7%',
|
||||
isPositiveTrend: true,
|
||||
details: const [
|
||||
'1,089 Actifs (87.3%)',
|
||||
'158 Inactifs (12.7%)',
|
||||
],
|
||||
onTap: () => _showMemberDetails(context, 'total'),
|
||||
),
|
||||
|
||||
// Nouveaux membres
|
||||
MembersKPICardWidget(
|
||||
title: 'Nouveaux Membres',
|
||||
value: '47',
|
||||
subtitle: 'Ce mois-ci',
|
||||
icon: Icons.person_add,
|
||||
color: AppTheme.successColor,
|
||||
trend: '+15.2%',
|
||||
isPositiveTrend: true,
|
||||
details: const [
|
||||
'28 Particuliers',
|
||||
'19 Professionnels',
|
||||
],
|
||||
onTap: () => _showMemberDetails(context, 'nouveaux'),
|
||||
),
|
||||
|
||||
// Membres actifs
|
||||
MembersKPICardWidget(
|
||||
title: 'Membres Actifs',
|
||||
value: '1,089',
|
||||
subtitle: 'Derniers 30 jours',
|
||||
icon: Icons.trending_up,
|
||||
color: AppTheme.infoColor,
|
||||
trend: '+8.3%',
|
||||
isPositiveTrend: true,
|
||||
details: const [
|
||||
'892 Très actifs',
|
||||
'197 Modérément actifs',
|
||||
],
|
||||
onTap: () => _showMemberDetails(context, 'actifs'),
|
||||
),
|
||||
|
||||
// Taux de rétention
|
||||
MembersKPICardWidget(
|
||||
title: 'Taux de Rétention',
|
||||
value: '94.2%',
|
||||
subtitle: 'Sur 12 mois',
|
||||
icon: Icons.favorite,
|
||||
color: AppTheme.warningColor,
|
||||
trend: '+2.1%',
|
||||
isPositiveTrend: true,
|
||||
details: const [
|
||||
'1,175 Fidèles',
|
||||
'72 Nouveaux',
|
||||
],
|
||||
onTap: () => _showMemberDetails(context, 'retention'),
|
||||
),
|
||||
|
||||
// Âge moyen
|
||||
MembersKPICardWidget(
|
||||
title: 'Âge Moyen',
|
||||
value: '34.5',
|
||||
subtitle: 'Années',
|
||||
icon: Icons.cake,
|
||||
color: AppTheme.errorColor,
|
||||
trend: '+0.8',
|
||||
isPositiveTrend: true,
|
||||
details: const [
|
||||
'18-30 ans: 42%',
|
||||
'31-50 ans: 38%',
|
||||
],
|
||||
onTap: () => _showMemberDetails(context, 'age'),
|
||||
),
|
||||
|
||||
// Répartition genre
|
||||
MembersKPICardWidget(
|
||||
title: 'Répartition Genre',
|
||||
value: '52/48',
|
||||
subtitle: 'Femmes/Hommes (%)',
|
||||
icon: Icons.people_outline,
|
||||
color: const Color(0xFF9C27B0),
|
||||
details: const [
|
||||
'649 Femmes (52%)',
|
||||
'598 Hommes (48%)',
|
||||
],
|
||||
onTap: () => _showMemberDetails(context, 'genre'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche les détails d'un KPI spécifique
|
||||
static void _showMemberDetails(BuildContext context, String type) {
|
||||
String title = '';
|
||||
String content = '';
|
||||
|
||||
switch (type) {
|
||||
case 'total':
|
||||
title = 'Total des Membres';
|
||||
content = 'Détails de tous les membres enregistrés dans le système.';
|
||||
break;
|
||||
case 'nouveaux':
|
||||
title = 'Nouveaux Membres';
|
||||
content = 'Liste des membres qui ont rejoint ce mois-ci.';
|
||||
break;
|
||||
case 'actifs':
|
||||
title = 'Membres Actifs';
|
||||
content = 'Membres ayant une activité récente sur la plateforme.';
|
||||
break;
|
||||
case 'retention':
|
||||
title = 'Taux de Rétention';
|
||||
content = 'Pourcentage de membres restés actifs sur 12 mois.';
|
||||
break;
|
||||
case 'age':
|
||||
title = 'Répartition par Âge';
|
||||
content = 'Distribution des membres par tranches d\'âge.';
|
||||
break;
|
||||
case 'genre':
|
||||
title = 'Répartition par Genre';
|
||||
content = 'Distribution des membres par genre.';
|
||||
break;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(content),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// TODO: Naviguer vers la vue détaillée
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Voir détails'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
import '../../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget de notifications en temps réel pour les membres
|
||||
class MembersNotificationsWidget extends StatefulWidget {
|
||||
const MembersNotificationsWidget({super.key});
|
||||
|
||||
@override
|
||||
State<MembersNotificationsWidget> createState() => _MembersNotificationsWidgetState();
|
||||
}
|
||||
|
||||
class _MembersNotificationsWidgetState extends State<MembersNotificationsWidget>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _pulseController;
|
||||
late AnimationController _slideController;
|
||||
late Animation<double> _pulseAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
|
||||
Timer? _notificationTimer;
|
||||
List<Map<String, dynamic>> _notifications = [];
|
||||
bool _hasUnreadNotifications = false;
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_pulseController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_slideController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.2).animate(
|
||||
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
||||
);
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, -1),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: _slideController, curve: Curves.easeOut));
|
||||
|
||||
_startNotificationSimulation();
|
||||
_loadInitialNotifications();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_notificationTimer?.cancel();
|
||||
_pulseController.dispose();
|
||||
_slideController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadInitialNotifications() {
|
||||
_notifications = [
|
||||
{
|
||||
'id': '1',
|
||||
'type': 'new_member',
|
||||
'title': 'Nouveau membre inscrit',
|
||||
'message': 'Marie Kouassi a rejoint la communauté',
|
||||
'timestamp': DateTime.now().subtract(const Duration(minutes: 5)),
|
||||
'isRead': false,
|
||||
'icon': Icons.person_add,
|
||||
'color': AppTheme.successColor,
|
||||
'priority': 'high',
|
||||
},
|
||||
{
|
||||
'id': '2',
|
||||
'type': 'payment',
|
||||
'title': 'Cotisation reçue',
|
||||
'message': 'Jean Baptiste a payé sa cotisation mensuelle',
|
||||
'timestamp': DateTime.now().subtract(const Duration(minutes: 15)),
|
||||
'isRead': false,
|
||||
'icon': Icons.payment,
|
||||
'color': AppTheme.primaryColor,
|
||||
'priority': 'medium',
|
||||
},
|
||||
{
|
||||
'id': '3',
|
||||
'type': 'reminder',
|
||||
'title': 'Rappel automatique',
|
||||
'message': '12 membres ont des cotisations en retard',
|
||||
'timestamp': DateTime.now().subtract(const Duration(hours: 1)),
|
||||
'isRead': true,
|
||||
'icon': Icons.notification_important,
|
||||
'color': AppTheme.warningColor,
|
||||
'priority': 'medium',
|
||||
},
|
||||
];
|
||||
|
||||
_updateNotificationState();
|
||||
}
|
||||
|
||||
void _startNotificationSimulation() {
|
||||
_notificationTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
||||
_addRandomNotification();
|
||||
});
|
||||
}
|
||||
|
||||
void _addRandomNotification() {
|
||||
final notifications = [
|
||||
{
|
||||
'type': 'new_member',
|
||||
'title': 'Nouveau membre inscrit',
|
||||
'message': 'Un nouveau membre a rejoint la communauté',
|
||||
'icon': Icons.person_add,
|
||||
'color': AppTheme.successColor,
|
||||
'priority': 'high',
|
||||
},
|
||||
{
|
||||
'type': 'update',
|
||||
'title': 'Profil mis à jour',
|
||||
'message': 'Un membre a modifié ses informations',
|
||||
'icon': Icons.edit,
|
||||
'color': AppTheme.infoColor,
|
||||
'priority': 'low',
|
||||
},
|
||||
{
|
||||
'type': 'activity',
|
||||
'title': 'Activité détectée',
|
||||
'message': 'Connexion d\'un membre inactif',
|
||||
'icon': Icons.trending_up,
|
||||
'color': AppTheme.successColor,
|
||||
'priority': 'medium',
|
||||
},
|
||||
];
|
||||
|
||||
final randomNotification = notifications[DateTime.now().millisecond % notifications.length];
|
||||
final newNotification = {
|
||||
'id': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
'timestamp': DateTime.now(),
|
||||
'isRead': false,
|
||||
...randomNotification,
|
||||
};
|
||||
|
||||
setState(() {
|
||||
_notifications.insert(0, newNotification);
|
||||
if (_notifications.length > 20) {
|
||||
_notifications = _notifications.take(20).toList();
|
||||
}
|
||||
});
|
||||
|
||||
_updateNotificationState();
|
||||
_showNotificationAnimation();
|
||||
}
|
||||
|
||||
void _updateNotificationState() {
|
||||
final hasUnread = _notifications.any((notification) => !notification['isRead']);
|
||||
if (hasUnread != _hasUnreadNotifications) {
|
||||
setState(() {
|
||||
_hasUnreadNotifications = hasUnread;
|
||||
});
|
||||
|
||||
if (hasUnread) {
|
||||
_pulseController.repeat(reverse: true);
|
||||
} else {
|
||||
_pulseController.stop();
|
||||
_pulseController.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showNotificationAnimation() {
|
||||
_slideController.forward().then((_) {
|
||||
Timer(const Duration(seconds: 3), () {
|
||||
if (mounted) {
|
||||
_slideController.reverse();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleExpanded() {
|
||||
setState(() {
|
||||
_isExpanded = !_isExpanded;
|
||||
});
|
||||
}
|
||||
|
||||
void _markAsRead(String notificationId) {
|
||||
setState(() {
|
||||
final index = _notifications.indexWhere((n) => n['id'] == notificationId);
|
||||
if (index != -1) {
|
||||
_notifications[index]['isRead'] = true;
|
||||
}
|
||||
});
|
||||
_updateNotificationState();
|
||||
}
|
||||
|
||||
void _markAllAsRead() {
|
||||
setState(() {
|
||||
for (var notification in _notifications) {
|
||||
notification['isRead'] = true;
|
||||
}
|
||||
});
|
||||
_updateNotificationState();
|
||||
}
|
||||
|
||||
void _clearNotifications() {
|
||||
setState(() {
|
||||
_notifications.clear();
|
||||
});
|
||||
_updateNotificationState();
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime timestamp) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(timestamp);
|
||||
|
||||
if (difference.inMinutes < 1) {
|
||||
return 'À l\'instant';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return 'Il y a ${difference.inMinutes}min';
|
||||
} else if (difference.inHours < 24) {
|
||||
return 'Il y a ${difference.inHours}h';
|
||||
} else {
|
||||
return 'Il y a ${difference.inDays}j';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// Notification flottante
|
||||
if (_slideController.isAnimating || _slideController.isCompleted)
|
||||
SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: _buildFloatingNotification(),
|
||||
),
|
||||
|
||||
// Widget principal des notifications
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 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(
|
||||
children: [
|
||||
// En-tête
|
||||
InkWell(
|
||||
onTap: _toggleExpanded,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _hasUnreadNotifications ? _pulseAnimation.value : 1.0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: _hasUnreadNotifications
|
||||
? AppTheme.errorColor.withOpacity(0.1)
|
||||
: AppTheme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.notifications,
|
||||
color: _hasUnreadNotifications
|
||||
? AppTheme.errorColor
|
||||
: AppTheme.primaryColor,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Notifications',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_notifications.where((n) => !n['isRead']).isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.errorColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'${_notifications.where((n) => !n['isRead']).length}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
AnimatedRotation(
|
||||
turns: _isExpanded ? 0.5 : 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des notifications
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
height: _isExpanded ? null : 0,
|
||||
child: _isExpanded ? _buildNotificationsList() : const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFloatingNotification() {
|
||||
if (_notifications.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final notification = _notifications.first;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: notification['color'].withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: notification['color'].withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
notification['icon'],
|
||||
color: notification['color'],
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
notification['title'],
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
notification['message'],
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationsList() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Actions
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _markAllAsRead,
|
||||
icon: const Icon(Icons.done_all, size: 16),
|
||||
label: const Text('Tout marquer lu'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.textSecondary,
|
||||
side: BorderSide(color: AppTheme.textSecondary.withOpacity(0.3)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _clearNotifications,
|
||||
icon: const Icon(Icons.clear_all, size: 16),
|
||||
label: const Text('Effacer tout'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.errorColor,
|
||||
side: BorderSide(color: AppTheme.errorColor.withOpacity(0.3)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Liste des notifications
|
||||
...(_notifications.take(5).map((notification) => _buildNotificationItem(notification))),
|
||||
|
||||
if (_notifications.length > 5)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// TODO: Naviguer vers la page complète des notifications
|
||||
},
|
||||
child: Text(
|
||||
'Voir toutes les notifications (${_notifications.length})',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationItem(Map<String, dynamic> notification) {
|
||||
return InkWell(
|
||||
onTap: () => _markAsRead(notification['id']),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: notification['color'].withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
notification['icon'],
|
||||
color: notification['color'],
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
notification['title'],
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: notification['isRead'] ? FontWeight.w500 : FontWeight.w600,
|
||||
color: notification['isRead'] ? AppTheme.textSecondary : AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!notification['isRead'])
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.errorColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
notification['message'],
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatTimestamp(notification['timestamp']),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../../shared/widgets/coming_soon_page.dart';
|
||||
import '../../pages/membre_create_page.dart';
|
||||
import 'members_action_card_widget.dart';
|
||||
|
||||
/// Widget de section d'actions rapides pour les membres
|
||||
class MembersQuickActionsWidget extends StatelessWidget {
|
||||
const MembersQuickActionsWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre de section
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.flash_on,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Actions Rapides',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Grille d'actions
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
childAspectRatio: 1.0,
|
||||
children: [
|
||||
// Ajouter membre
|
||||
MembersActionCardWidget(
|
||||
title: 'Nouveau Membre',
|
||||
subtitle: 'Inscription',
|
||||
icon: Icons.person_add,
|
||||
color: AppTheme.successColor,
|
||||
onTap: () => _handleAction(context, 'add_member'),
|
||||
),
|
||||
|
||||
// Rechercher membre
|
||||
MembersActionCardWidget(
|
||||
title: 'Rechercher',
|
||||
subtitle: 'Trouver membre',
|
||||
icon: Icons.search,
|
||||
color: AppTheme.infoColor,
|
||||
onTap: () => _handleAction(context, 'search_member'),
|
||||
),
|
||||
|
||||
// Import/Export
|
||||
MembersActionCardWidget(
|
||||
title: 'Import/Export',
|
||||
subtitle: 'Données',
|
||||
icon: Icons.import_export,
|
||||
color: AppTheme.warningColor,
|
||||
onTap: () => _handleAction(context, 'import_export'),
|
||||
),
|
||||
|
||||
// Envoyer message
|
||||
MembersActionCardWidget(
|
||||
title: 'Message Groupe',
|
||||
subtitle: 'Communication',
|
||||
icon: Icons.message,
|
||||
color: AppTheme.primaryColor,
|
||||
onTap: () => _handleAction(context, 'group_message'),
|
||||
badge: '12',
|
||||
),
|
||||
|
||||
// Statistiques
|
||||
MembersActionCardWidget(
|
||||
title: 'Statistiques',
|
||||
subtitle: 'Analyses',
|
||||
icon: Icons.bar_chart,
|
||||
color: const Color(0xFF9C27B0),
|
||||
onTap: () => _handleAction(context, 'statistics'),
|
||||
),
|
||||
|
||||
// Rapports
|
||||
MembersActionCardWidget(
|
||||
title: 'Rapports',
|
||||
subtitle: 'Documents',
|
||||
icon: Icons.description,
|
||||
color: AppTheme.errorColor,
|
||||
onTap: () => _handleAction(context, 'reports'),
|
||||
),
|
||||
|
||||
// Paramètres
|
||||
MembersActionCardWidget(
|
||||
title: 'Paramètres',
|
||||
subtitle: 'Configuration',
|
||||
icon: Icons.settings,
|
||||
color: const Color(0xFF607D8B),
|
||||
onTap: () => _handleAction(context, 'settings'),
|
||||
),
|
||||
|
||||
// Sauvegarde
|
||||
MembersActionCardWidget(
|
||||
title: 'Sauvegarde',
|
||||
subtitle: 'Backup',
|
||||
icon: Icons.backup,
|
||||
color: const Color(0xFF795548),
|
||||
onTap: () => _handleAction(context, 'backup'),
|
||||
),
|
||||
|
||||
// Support
|
||||
MembersActionCardWidget(
|
||||
title: 'Support',
|
||||
subtitle: 'Aide',
|
||||
icon: Icons.help_outline,
|
||||
color: const Color(0xFF009688),
|
||||
onTap: () => _handleAction(context, 'support'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Gère les actions des cartes
|
||||
static void _handleAction(BuildContext context, String action) {
|
||||
switch (action) {
|
||||
case 'add_member':
|
||||
// Navigation vers la page de création de membre
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const MembreCreatePage(),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case 'search_member':
|
||||
_showComingSoon(context, 'Rechercher Membre', 'Recherche avancée dans la base de membres.', Icons.search, AppTheme.infoColor);
|
||||
break;
|
||||
case 'import_export':
|
||||
_showComingSoon(context, 'Import/Export', 'Importer ou exporter les données des membres.', Icons.import_export, AppTheme.warningColor);
|
||||
break;
|
||||
case 'group_message':
|
||||
_showComingSoon(context, 'Message Groupe', 'Envoyer un message à tous les membres ou à un groupe.', Icons.message, AppTheme.primaryColor);
|
||||
break;
|
||||
case 'statistics':
|
||||
_showComingSoon(context, 'Statistiques', 'Analyses détaillées des données membres.', Icons.bar_chart, const Color(0xFF9C27B0));
|
||||
break;
|
||||
case 'reports':
|
||||
_showComingSoon(context, 'Rapports', 'Génération de rapports personnalisés.', Icons.description, AppTheme.errorColor);
|
||||
break;
|
||||
case 'settings':
|
||||
_showComingSoon(context, 'Paramètres', 'Configuration du module membres.', Icons.settings, const Color(0xFF607D8B));
|
||||
break;
|
||||
case 'backup':
|
||||
_showComingSoon(context, 'Sauvegarde', 'Sauvegarde automatique des données.', Icons.backup, const Color(0xFF795548));
|
||||
break;
|
||||
case 'support':
|
||||
_showComingSoon(context, 'Support', 'Aide et documentation du module.', Icons.help_outline, const Color(0xFF009688));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void _showComingSoon(BuildContext context, String title, String description, IconData icon, Color color) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ComingSoonPage(
|
||||
title: title,
|
||||
description: description,
|
||||
icon: icon,
|
||||
color: color,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../shared/theme/app_theme.dart';
|
||||
import 'members_activity_item_widget.dart';
|
||||
|
||||
/// Widget de section d'activités récentes pour les membres
|
||||
class MembersRecentActivitiesWidget extends StatelessWidget {
|
||||
const MembersRecentActivitiesWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre de section avec bouton "Voir tout"
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.history,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Activités Récentes',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _showAllActivities(context),
|
||||
child: const Text(
|
||||
'Voir tout',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Container des activités
|
||||
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(
|
||||
children: [
|
||||
// Nouvelle inscription
|
||||
MembersActivityItemWidget(
|
||||
title: 'Nouvelle inscription',
|
||||
description: 'Un nouveau membre a rejoint la communauté',
|
||||
time: 'Il y a 2h',
|
||||
icon: Icons.person_add,
|
||||
color: AppTheme.successColor,
|
||||
memberName: 'Marie Kouassi',
|
||||
onTap: () => _showActivityDetails(context, 'inscription'),
|
||||
),
|
||||
|
||||
// Mise à jour profil
|
||||
MembersActivityItemWidget(
|
||||
title: 'Profil mis à jour',
|
||||
description: 'Informations personnelles modifiées',
|
||||
time: 'Il y a 4h',
|
||||
icon: Icons.edit,
|
||||
color: AppTheme.infoColor,
|
||||
memberName: 'Jean Baptiste',
|
||||
onTap: () => _showActivityDetails(context, 'profil'),
|
||||
),
|
||||
|
||||
// Cotisation payée
|
||||
MembersActivityItemWidget(
|
||||
title: 'Cotisation payée',
|
||||
description: 'Paiement de cotisation mensuelle reçu',
|
||||
time: 'Il y a 6h',
|
||||
icon: Icons.payment,
|
||||
color: AppTheme.primaryColor,
|
||||
memberName: 'Fatou Traoré',
|
||||
onTap: () => _showActivityDetails(context, 'cotisation'),
|
||||
),
|
||||
|
||||
// Message envoyé
|
||||
MembersActivityItemWidget(
|
||||
title: 'Message de groupe',
|
||||
description: 'Notification envoyée à tous les membres',
|
||||
time: 'Il y a 8h',
|
||||
icon: Icons.message,
|
||||
color: AppTheme.warningColor,
|
||||
onTap: () => _showActivityDetails(context, 'message'),
|
||||
),
|
||||
|
||||
// Export de données
|
||||
MembersActivityItemWidget(
|
||||
title: 'Export de données',
|
||||
description: 'Liste des membres exportée en Excel',
|
||||
time: 'Il y a 1j',
|
||||
icon: Icons.file_download,
|
||||
color: const Color(0xFF9C27B0),
|
||||
onTap: () => _showActivityDetails(context, 'export'),
|
||||
),
|
||||
|
||||
// Sauvegarde automatique
|
||||
MembersActivityItemWidget(
|
||||
title: 'Sauvegarde automatique',
|
||||
description: 'Données sauvegardées avec succès',
|
||||
time: 'Il y a 1j',
|
||||
icon: Icons.backup,
|
||||
color: const Color(0xFF607D8B),
|
||||
onTap: () => _showActivityDetails(context, 'sauvegarde'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche toutes les activités
|
||||
static void _showAllActivities(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.9,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
builder: (context, scrollController) => Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Handle
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 8),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
|
||||
// En-tête
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Text(
|
||||
'Toutes les Activités',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Liste complète
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: 20, // Exemple avec plus d'activités
|
||||
itemBuilder: (context, index) {
|
||||
return MembersActivityItemWidget(
|
||||
title: 'Activité ${index + 1}',
|
||||
description: 'Description de l\'activité numéro ${index + 1}',
|
||||
time: 'Il y a ${index + 1}h',
|
||||
icon: _getActivityIcon(index),
|
||||
color: _getActivityColor(index),
|
||||
memberName: 'Membre ${index + 1}',
|
||||
onTap: () => _showActivityDetails(context, 'activite_$index'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche les détails d'une activité
|
||||
static void _showActivityDetails(BuildContext context, String activityType) {
|
||||
String title = '';
|
||||
String description = '';
|
||||
IconData icon = Icons.info;
|
||||
Color color = AppTheme.primaryColor;
|
||||
|
||||
switch (activityType) {
|
||||
case 'inscription':
|
||||
title = 'Nouvelle Inscription';
|
||||
description = 'Marie Kouassi a rejoint la communauté avec le numéro UF-2024-00001247.';
|
||||
icon = Icons.person_add;
|
||||
color = AppTheme.successColor;
|
||||
break;
|
||||
case 'profil':
|
||||
title = 'Mise à Jour Profil';
|
||||
description = 'Jean Baptiste a modifié ses informations de contact et son adresse.';
|
||||
icon = Icons.edit;
|
||||
color = AppTheme.infoColor;
|
||||
break;
|
||||
case 'cotisation':
|
||||
title = 'Cotisation Payée';
|
||||
description = 'Fatou Traoré a payé sa cotisation mensuelle de 25,000 FCFA.';
|
||||
icon = Icons.payment;
|
||||
color = AppTheme.primaryColor;
|
||||
break;
|
||||
case 'message':
|
||||
title = 'Message de Groupe';
|
||||
description = 'Notification envoyée à 1,247 membres concernant la prochaine assemblée générale.';
|
||||
icon = Icons.message;
|
||||
color = AppTheme.warningColor;
|
||||
break;
|
||||
case 'export':
|
||||
title = 'Export de Données';
|
||||
description = 'Liste complète des membres exportée au format Excel (1,247 entrées).';
|
||||
icon = Icons.file_download;
|
||||
color = const Color(0xFF9C27B0);
|
||||
break;
|
||||
case 'sauvegarde':
|
||||
title = 'Sauvegarde Automatique';
|
||||
description = 'Sauvegarde quotidienne effectuée avec succès. Toutes les données sont sécurisées.';
|
||||
icon = Icons.backup;
|
||||
color = const Color(0xFF607D8B);
|
||||
break;
|
||||
default:
|
||||
title = 'Activité';
|
||||
description = 'Détails de l\'activité sélectionnée.';
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: 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 SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Text(description),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// TODO: Action spécifique selon le type
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Voir plus'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Retourne une icône selon l'index
|
||||
static IconData _getActivityIcon(int index) {
|
||||
final icons = [
|
||||
Icons.person_add,
|
||||
Icons.edit,
|
||||
Icons.payment,
|
||||
Icons.message,
|
||||
Icons.file_download,
|
||||
Icons.backup,
|
||||
Icons.notifications,
|
||||
Icons.security,
|
||||
Icons.update,
|
||||
Icons.sync,
|
||||
];
|
||||
return icons[index % icons.length];
|
||||
}
|
||||
|
||||
/// Retourne une couleur selon l'index
|
||||
static Color _getActivityColor(int index) {
|
||||
final colors = [
|
||||
AppTheme.successColor,
|
||||
AppTheme.infoColor,
|
||||
AppTheme.primaryColor,
|
||||
AppTheme.warningColor,
|
||||
const Color(0xFF9C27B0),
|
||||
const Color(0xFF607D8B),
|
||||
AppTheme.errorColor,
|
||||
const Color(0xFF009688),
|
||||
const Color(0xFF795548),
|
||||
const Color(0xFFFF5722),
|
||||
];
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
import '../../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget de recherche intelligente pour les membres
|
||||
class MembersSmartSearchWidget extends StatefulWidget {
|
||||
final Function(String) onSearch;
|
||||
final Function(Map<String, dynamic>) onSuggestionSelected;
|
||||
final List<Map<String, dynamic>> recentSearches;
|
||||
|
||||
const MembersSmartSearchWidget({
|
||||
super.key,
|
||||
required this.onSearch,
|
||||
required this.onSuggestionSelected,
|
||||
this.recentSearches = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
State<MembersSmartSearchWidget> createState() => _MembersSmartSearchWidgetState();
|
||||
}
|
||||
|
||||
class _MembersSmartSearchWidgetState extends State<MembersSmartSearchWidget>
|
||||
with TickerProviderStateMixin {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
Timer? _debounceTimer;
|
||||
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
bool _isSearching = false;
|
||||
bool _showSuggestions = false;
|
||||
List<Map<String, dynamic>> _suggestions = [];
|
||||
List<Map<String, dynamic>> _searchHistory = [];
|
||||
|
||||
// Suggestions prédéfinies
|
||||
final List<Map<String, dynamic>> _predefinedSuggestions = [
|
||||
{
|
||||
'type': 'quick_filter',
|
||||
'title': 'Nouveaux membres',
|
||||
'subtitle': 'Inscrits ce mois',
|
||||
'icon': Icons.person_add,
|
||||
'color': AppTheme.successColor,
|
||||
'filter': {'timeRange': '30 jours', 'status': 'Actif'},
|
||||
},
|
||||
{
|
||||
'type': 'quick_filter',
|
||||
'title': 'Membres inactifs',
|
||||
'subtitle': 'Sans activité récente',
|
||||
'icon': Icons.person_off,
|
||||
'color': AppTheme.warningColor,
|
||||
'filter': {'status': 'Inactif'},
|
||||
},
|
||||
{
|
||||
'type': 'quick_filter',
|
||||
'title': 'Bureau exécutif',
|
||||
'subtitle': 'Responsables',
|
||||
'icon': Icons.admin_panel_settings,
|
||||
'color': AppTheme.primaryColor,
|
||||
'filter': {'role': 'Bureau'},
|
||||
},
|
||||
{
|
||||
'type': 'quick_filter',
|
||||
'title': 'Jeunes membres',
|
||||
'subtitle': '18-30 ans',
|
||||
'icon': Icons.people,
|
||||
'color': AppTheme.infoColor,
|
||||
'filter': {'ageRange': '18-30'},
|
||||
},
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchHistory = List.from(widget.recentSearches);
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
_scaleAnimation = Tween<double>(begin: 0.95, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
_focusNode.addListener(_onFocusChanged);
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
_animationController.dispose();
|
||||
_focusNode.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChanged() {
|
||||
setState(() {
|
||||
_showSuggestions = _focusNode.hasFocus;
|
||||
if (_showSuggestions) {
|
||||
_animationController.forward();
|
||||
_updateSuggestions();
|
||||
} else {
|
||||
_animationController.reverse();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
final query = _searchController.text;
|
||||
|
||||
if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel();
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
|
||||
if (query.isNotEmpty) {
|
||||
widget.onSearch(query);
|
||||
_addToSearchHistory(query);
|
||||
}
|
||||
_updateSuggestions();
|
||||
});
|
||||
}
|
||||
|
||||
void _updateSuggestions() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
List<Map<String, dynamic>> suggestions = [];
|
||||
|
||||
if (query.isEmpty) {
|
||||
// Afficher les suggestions rapides et l'historique
|
||||
suggestions.addAll(_predefinedSuggestions);
|
||||
if (_searchHistory.isNotEmpty) {
|
||||
suggestions.add({
|
||||
'type': 'divider',
|
||||
'title': 'Recherches récentes',
|
||||
});
|
||||
suggestions.addAll(_searchHistory.take(3));
|
||||
}
|
||||
} else {
|
||||
// Filtrer les suggestions basées sur la requête
|
||||
suggestions.addAll(_predefinedSuggestions.where((suggestion) =>
|
||||
suggestion['title'].toString().toLowerCase().contains(query) ||
|
||||
suggestion['subtitle'].toString().toLowerCase().contains(query)));
|
||||
|
||||
// Ajouter des suggestions de membres simulées
|
||||
suggestions.addAll(_generateMemberSuggestions(query));
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_suggestions = suggestions;
|
||||
});
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _generateMemberSuggestions(String query) {
|
||||
// Simulation de suggestions de membres basées sur la requête
|
||||
final memberSuggestions = <Map<String, dynamic>>[];
|
||||
|
||||
if (query.length >= 2) {
|
||||
memberSuggestions.addAll([
|
||||
{
|
||||
'type': 'member',
|
||||
'title': 'Jean-Baptiste Kouassi',
|
||||
'subtitle': 'MBR001 • Actif',
|
||||
'icon': Icons.person,
|
||||
'color': AppTheme.primaryColor,
|
||||
'memberId': 'c6ccf741-c55f-390e-96a7-531819fed1dd',
|
||||
},
|
||||
{
|
||||
'type': 'member',
|
||||
'title': 'Aminata Traoré',
|
||||
'subtitle': 'MBR002 • Actif',
|
||||
'icon': Icons.person,
|
||||
'color': AppTheme.successColor,
|
||||
'memberId': '9f4ea9cb-798b-3b1c-8444-4b313af999bd',
|
||||
},
|
||||
].where((member) =>
|
||||
member['title'].toString().toLowerCase().contains(query)).toList());
|
||||
}
|
||||
|
||||
return memberSuggestions;
|
||||
}
|
||||
|
||||
void _addToSearchHistory(String query) {
|
||||
final historyItem = {
|
||||
'type': 'history',
|
||||
'title': query,
|
||||
'subtitle': 'Recherche récente',
|
||||
'icon': Icons.history,
|
||||
'color': AppTheme.textSecondary,
|
||||
'timestamp': DateTime.now(),
|
||||
};
|
||||
|
||||
setState(() {
|
||||
_searchHistory.removeWhere((item) => item['title'] == query);
|
||||
_searchHistory.insert(0, historyItem);
|
||||
if (_searchHistory.length > 10) {
|
||||
_searchHistory = _searchHistory.take(10).toList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onSuggestionTap(Map<String, dynamic> suggestion) {
|
||||
switch (suggestion['type']) {
|
||||
case 'quick_filter':
|
||||
widget.onSuggestionSelected(suggestion);
|
||||
_searchController.text = suggestion['title'];
|
||||
break;
|
||||
case 'member':
|
||||
widget.onSuggestionSelected(suggestion);
|
||||
_searchController.text = suggestion['title'];
|
||||
break;
|
||||
case 'history':
|
||||
_searchController.text = suggestion['title'];
|
||||
widget.onSearch(suggestion['title']);
|
||||
break;
|
||||
}
|
||||
_focusNode.unfocus();
|
||||
}
|
||||
|
||||
void _clearSearch() {
|
||||
_searchController.clear();
|
||||
widget.onSearch('');
|
||||
_focusNode.unfocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// Barre de recherche
|
||||
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: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _focusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher un membre, rôle, statut...',
|
||||
hintStyle: const TextStyle(
|
||||
color: AppTheme.textHint,
|
||||
fontSize: 14,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(
|
||||
Icons.clear,
|
||||
color: AppTheme.textSecondary,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: _clearSearch,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.mic,
|
||||
color: AppTheme.textHint,
|
||||
size: 20,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.grey[50],
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Suggestions
|
||||
if (_showSuggestions && _suggestions.isNotEmpty)
|
||||
ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 15,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: _suggestions.map((suggestion) {
|
||||
if (suggestion['type'] == 'divider') {
|
||||
return _buildDivider(suggestion['title']);
|
||||
}
|
||||
return _buildSuggestionItem(suggestion);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider(String title) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuggestionItem(Map<String, dynamic> suggestion) {
|
||||
return InkWell(
|
||||
onTap: () => _onSuggestionTap(suggestion),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: suggestion['color'].withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
suggestion['icon'],
|
||||
color: suggestion['color'],
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
suggestion['title'],
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
if (suggestion['subtitle'] != null)
|
||||
Text(
|
||||
suggestion['subtitle'],
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.north_west,
|
||||
color: AppTheme.textHint,
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../core/models/membre_model.dart';
|
||||
import '../../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget de statistiques avancées pour les membres
|
||||
class MembersStatsWidget extends StatelessWidget {
|
||||
final List<MembreModel> members;
|
||||
final String searchQuery;
|
||||
final Map<String, dynamic> filters;
|
||||
|
||||
const MembersStatsWidget({
|
||||
super.key,
|
||||
required this.members,
|
||||
this.searchQuery = '',
|
||||
this.filters = const {},
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stats = _calculateStats();
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
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
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.analytics,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Statistiques des membres',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (searchQuery.isNotEmpty || filters.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.infoColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Filtré',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.infoColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Statistiques principales
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Total',
|
||||
stats['total'].toString(),
|
||||
Icons.people,
|
||||
AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Actifs',
|
||||
stats['actifs'].toString(),
|
||||
Icons.check_circle,
|
||||
AppTheme.successColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Âge moyen',
|
||||
'${stats['ageMoyen']} ans',
|
||||
Icons.cake,
|
||||
AppTheme.warningColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Statistiques détaillées
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildDetailedStat(
|
||||
'Nouveaux (30j)',
|
||||
stats['nouveaux'].toString(),
|
||||
stats['nouveauxPourcentage'],
|
||||
AppTheme.infoColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildDetailedStat(
|
||||
'Anciens (>1an)',
|
||||
stats['anciens'].toString(),
|
||||
stats['anciensPourcentage'],
|
||||
AppTheme.secondaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (stats['repartitionAge'].isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Répartition par âge
|
||||
const Text(
|
||||
'Répartition par âge',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildAgeDistribution(stats['repartitionAge']),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _calculateStats() {
|
||||
if (members.isEmpty) {
|
||||
return {
|
||||
'total': 0,
|
||||
'actifs': 0,
|
||||
'ageMoyen': 0,
|
||||
'nouveaux': 0,
|
||||
'nouveauxPourcentage': 0.0,
|
||||
'anciens': 0,
|
||||
'anciensPourcentage': 0.0,
|
||||
'repartitionAge': <String, int>{},
|
||||
};
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final total = members.length;
|
||||
final actifs = members.where((m) => m.statut.toUpperCase() == 'ACTIF').length;
|
||||
|
||||
// Calcul de l'âge moyen
|
||||
final ages = members.map((m) => m.age).where((age) => age > 0).toList();
|
||||
final ageMoyen = ages.isNotEmpty ? (ages.reduce((a, b) => a + b) / ages.length).round() : 0;
|
||||
|
||||
// Nouveaux membres (moins de 30 jours)
|
||||
final nouveaux = members.where((m) {
|
||||
final daysDiff = now.difference(m.dateAdhesion).inDays;
|
||||
return daysDiff <= 30;
|
||||
}).length;
|
||||
final nouveauxPourcentage = total > 0 ? (nouveaux / total * 100) : 0.0;
|
||||
|
||||
// Anciens membres (plus d'un an)
|
||||
final anciens = members.where((m) {
|
||||
final daysDiff = now.difference(m.dateAdhesion).inDays;
|
||||
return daysDiff > 365;
|
||||
}).length;
|
||||
final anciensPourcentage = total > 0 ? (anciens / total * 100) : 0.0;
|
||||
|
||||
// Répartition par tranche d'âge
|
||||
final repartitionAge = <String, int>{};
|
||||
for (final member in members) {
|
||||
final age = member.age;
|
||||
String tranche;
|
||||
if (age < 25) {
|
||||
tranche = '18-24';
|
||||
} else if (age < 35) {
|
||||
tranche = '25-34';
|
||||
} else if (age < 45) {
|
||||
tranche = '35-44';
|
||||
} else if (age < 55) {
|
||||
tranche = '45-54';
|
||||
} else {
|
||||
tranche = '55+';
|
||||
}
|
||||
repartitionAge[tranche] = (repartitionAge[tranche] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
'total': total,
|
||||
'actifs': actifs,
|
||||
'ageMoyen': ageMoyen,
|
||||
'nouveaux': nouveaux,
|
||||
'nouveauxPourcentage': nouveauxPourcentage,
|
||||
'anciens': anciens,
|
||||
'anciensPourcentage': anciensPourcentage,
|
||||
'repartitionAge': repartitionAge,
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
|
||||
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.2)),
|
||||
),
|
||||
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: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailedStat(String label, String value, double percentage, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey[200]!),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'(${percentage.toStringAsFixed(1)}%)',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAgeDistribution(Map<String, int> repartition) {
|
||||
final total = repartition.values.fold(0, (sum, count) => sum + count);
|
||||
if (total == 0) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
children: repartition.entries.map((entry) {
|
||||
final percentage = (entry.value / total * 100);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 50,
|
||||
child: Text(
|
||||
entry.key,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: percentage / 100,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: Text(
|
||||
'${entry.value}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget de section d'accueil pour le dashboard des membres
|
||||
class MembersWelcomeSectionWidget extends StatelessWidget {
|
||||
const MembersWelcomeSectionWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppTheme.primaryColor,
|
||||
AppTheme.primaryColor.withOpacity(0.8),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.primaryColor.withOpacity(0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.people,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Gestion des Membres',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'Tableau de bord complet',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Colors.white70,
|
||||
size: 16,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Suivez l\'évolution de votre communauté en temps réel',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
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: const 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),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignSystem.spacingMd),
|
||||
_buildShimmer(80, 32),
|
||||
const SizedBox(height: DesignSystem.spacingSm),
|
||||
_buildShimmer(120, 16),
|
||||
if (widget.subtitle != null) ...[
|
||||
const 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(),
|
||||
const SizedBox(height: DesignSystem.spacingSm),
|
||||
_buildTitle(),
|
||||
if (widget.subtitle != null) ...[
|
||||
const 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: const 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,
|
||||
),
|
||||
const 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/error/error_handler.dart';
|
||||
import '../../../../core/validation/form_validator.dart';
|
||||
import '../../../../core/feedback/user_feedback.dart';
|
||||
import '../../../../core/animations/loading_animations.dart';
|
||||
import '../../../../core/animations/page_transitions.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget de démonstration des nouvelles fonctionnalités d'erreur et validation
|
||||
class ErrorDemoWidget extends StatefulWidget {
|
||||
const ErrorDemoWidget({super.key});
|
||||
|
||||
@override
|
||||
State<ErrorDemoWidget> createState() => _ErrorDemoWidgetState();
|
||||
}
|
||||
|
||||
class _ErrorDemoWidgetState extends State<ErrorDemoWidget> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_emailController.dispose();
|
||||
_phoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Démonstration Gestion d\'Erreurs'),
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Test des nouvelles fonctionnalités',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Champ nom avec validation
|
||||
ValidatedTextField(
|
||||
controller: _nameController,
|
||||
label: 'Nom complet *',
|
||||
hintText: 'Entrez votre nom',
|
||||
prefixIcon: Icons.person,
|
||||
validators: [
|
||||
(value) => FormValidator.name(value, fieldName: 'Le nom'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Champ email avec validation
|
||||
ValidatedTextField(
|
||||
controller: _emailController,
|
||||
label: 'Email *',
|
||||
hintText: 'exemple@email.com',
|
||||
prefixIcon: Icons.email,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validators: [
|
||||
(value) => FormValidator.email(value),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Champ téléphone avec validation
|
||||
ValidatedTextField(
|
||||
controller: _phoneController,
|
||||
label: 'Téléphone *',
|
||||
hintText: '+225XXXXXXXX',
|
||||
prefixIcon: Icons.phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
validators: [
|
||||
(value) => FormValidator.phone(value),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Boutons de test
|
||||
const Text(
|
||||
'Tests de feedback utilisateur :',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Boutons de test des messages
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => UserFeedback.showSuccess(
|
||||
context,
|
||||
'Opération réussie !',
|
||||
),
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: const Text('Succès'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.successColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => UserFeedback.showWarning(
|
||||
context,
|
||||
'Attention : vérifiez vos données',
|
||||
),
|
||||
icon: const Icon(Icons.warning),
|
||||
label: const Text('Avertissement'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.warningColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => UserFeedback.showInfo(
|
||||
context,
|
||||
'Information importante',
|
||||
),
|
||||
icon: const Icon(Icons.info),
|
||||
label: const Text('Info'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.infoColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Boutons de test des dialogues
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _testConfirmationDialog(),
|
||||
icon: const Icon(Icons.help_outline),
|
||||
label: const Text('Confirmation'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _testInputDialog(),
|
||||
icon: const Icon(Icons.edit),
|
||||
label: const Text('Saisie'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.secondaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _testErrorDialog(),
|
||||
icon: const Icon(Icons.error),
|
||||
label: const Text('Erreur'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Bouton de test du chargement
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _testLoadingDialog(),
|
||||
icon: const Icon(Icons.hourglass_empty),
|
||||
label: const Text('Test Chargement'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Section animations de chargement
|
||||
const Text(
|
||||
'Animations de chargement :',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Démonstration des animations de chargement
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
LoadingAnimations.dots(),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Points', style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
LoadingAnimations.waves(),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Vagues', style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
LoadingAnimations.spinner(),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Spinner', style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
LoadingAnimations.pulse(),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Pulse', style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
LoadingAnimations.skeleton(height: 60),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Skeleton Loader', style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Bouton de validation du formulaire
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _validateForm(),
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Valider le formulaire'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _testConfirmationDialog() async {
|
||||
final result = await UserFeedback.showConfirmation(
|
||||
context,
|
||||
title: 'Confirmer l\'action',
|
||||
message: 'Êtes-vous sûr de vouloir continuer cette opération ?',
|
||||
icon: Icons.help_outline,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
UserFeedback.showSuccess(context, 'Action confirmée !');
|
||||
} else {
|
||||
UserFeedback.showInfo(context, 'Action annulée');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _testInputDialog() async {
|
||||
final result = await UserFeedback.showInputDialog(
|
||||
context,
|
||||
title: 'Saisir une valeur',
|
||||
label: 'Votre commentaire',
|
||||
hintText: 'Tapez votre commentaire ici...',
|
||||
validator: (value) => FormValidator.required(value, fieldName: 'Le commentaire'),
|
||||
);
|
||||
|
||||
if (result != null && result.isNotEmpty) {
|
||||
UserFeedback.showSuccess(context, 'Commentaire saisi : "$result"');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _testErrorDialog() async {
|
||||
await ErrorHandler.showErrorDialog(
|
||||
context,
|
||||
Exception('Erreur de démonstration'),
|
||||
title: 'Erreur de test',
|
||||
customMessage: 'Ceci est une erreur de démonstration pour tester le système de gestion d\'erreurs.',
|
||||
onRetry: () => UserFeedback.showInfo(context, 'Tentative de nouvelle opération...'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _testLoadingDialog() async {
|
||||
UserFeedback.showLoading(context, message: 'Traitement en cours...');
|
||||
|
||||
// Simuler une opération longue
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
|
||||
UserFeedback.hideLoading(context);
|
||||
UserFeedback.showSuccess(context, 'Opération terminée !');
|
||||
}
|
||||
|
||||
void _validateForm() {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
UserFeedback.showSuccess(
|
||||
context,
|
||||
'Formulaire valide ! Toutes les données sont correctes.',
|
||||
);
|
||||
} else {
|
||||
UserFeedback.showWarning(
|
||||
context,
|
||||
'Veuillez corriger les erreurs dans le formulaire',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
class MemberCard extends StatefulWidget {
|
||||
final Map<String, dynamic> member;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
const MemberCard({
|
||||
super.key,
|
||||
required this.member,
|
||||
this.onTap,
|
||||
this.onEdit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MemberCard> createState() => _MemberCardState();
|
||||
}
|
||||
|
||||
class _MemberCardState extends State<MemberCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.98,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _scaleAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap != null ? _handleTap : null,
|
||||
onTapDown: widget.onTap != null ? (_) => _animationController.forward() : null,
|
||||
onTapUp: widget.onTap != null ? (_) => _animationController.reverse() : null,
|
||||
onTapCancel: widget.onTap != null ? () => _animationController.reverse() : null,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 15,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
border: Border.all(
|
||||
color: _getStatusColor().withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_buildAvatar(),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildMemberInfo(),
|
||||
),
|
||||
_buildStatusBadge(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildMemberDetails(),
|
||||
const SizedBox(height: 12),
|
||||
_buildActionButtons(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar() {
|
||||
return Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
_getStatusColor(),
|
||||
_getStatusColor().withOpacity(0.7),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _getStatusColor().withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: widget.member['avatar'] != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
child: Image.network(
|
||||
widget.member['avatar'],
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Text(
|
||||
_getInitials(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemberInfo() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${widget.member['firstName']} ${widget.member['lastName']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.member['role'],
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: _getStatusColor(),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.member['email'],
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBadge() {
|
||||
final isActive = widget.member['status'] == 'Actif';
|
||||
final color = isActive ? AppTheme.successColor : AppTheme.textHint;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
widget.member['status'],
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemberDetails() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.backgroundLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildDetailRow(
|
||||
icon: Icons.phone,
|
||||
label: 'Téléphone',
|
||||
value: widget.member['phone'],
|
||||
color: AppTheme.infoColor,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildDetailRow(
|
||||
icon: Icons.calendar_today,
|
||||
label: 'Adhésion',
|
||||
value: _formatDate(widget.member['joinDate']),
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildDetailRow(
|
||||
icon: Icons.payment,
|
||||
label: 'Cotisation',
|
||||
value: widget.member['cotisationStatus'],
|
||||
color: _getCotisationColor(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required String value,
|
||||
required Color color,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: label == 'Cotisation' ? color : AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _callMember,
|
||||
icon: const Icon(Icons.phone, size: 16),
|
||||
label: const Text('Appeler'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.infoColor,
|
||||
side: BorderSide(color: AppTheme.infoColor.withOpacity(0.5)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _emailMember,
|
||||
icon: const Icon(Icons.email, size: 16),
|
||||
label: const Text('Email'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.primaryColor,
|
||||
side: BorderSide(color: AppTheme.primaryColor.withOpacity(0.5)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.secondaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: widget.onEdit,
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
color: AppTheme.secondaryColor,
|
||||
padding: const EdgeInsets.all(8),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 40,
|
||||
minHeight: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getInitials() {
|
||||
final firstName = widget.member['firstName'] as String;
|
||||
final lastName = widget.member['lastName'] as String;
|
||||
return '${firstName.isNotEmpty ? firstName[0] : ''}${lastName.isNotEmpty ? lastName[0] : ''}'.toUpperCase();
|
||||
}
|
||||
|
||||
Color _getStatusColor() {
|
||||
switch (widget.member['role']) {
|
||||
case 'Président':
|
||||
return AppTheme.primaryColor;
|
||||
case 'Secrétaire':
|
||||
return AppTheme.secondaryColor;
|
||||
case 'Trésorier':
|
||||
return AppTheme.accentColor;
|
||||
case 'Responsable événements':
|
||||
return AppTheme.warningColor;
|
||||
default:
|
||||
return AppTheme.infoColor;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getCotisationColor() {
|
||||
switch (widget.member['cotisationStatus']) {
|
||||
case 'À jour':
|
||||
return AppTheme.successColor;
|
||||
case 'En retard':
|
||||
return AppTheme.errorColor;
|
||||
case 'Exempt':
|
||||
return AppTheme.infoColor;
|
||||
default:
|
||||
return AppTheme.textSecondary;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(String dateString) {
|
||||
try {
|
||||
final date = DateTime.parse(dateString);
|
||||
final months = [
|
||||
'Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun',
|
||||
'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc'
|
||||
];
|
||||
return '${date.day} ${months[date.month - 1]} ${date.year}';
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTap() {
|
||||
HapticFeedback.selectionClick();
|
||||
widget.onTap?.call();
|
||||
}
|
||||
|
||||
void _callMember() {
|
||||
HapticFeedback.lightImpact();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Appel vers ${widget.member['phone']} - En développement'),
|
||||
backgroundColor: AppTheme.infoColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _emailMember() {
|
||||
HapticFeedback.lightImpact();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Email vers ${widget.member['email']} - En développement'),
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
class MembersFilterSheet extends StatefulWidget {
|
||||
final String selectedFilter;
|
||||
final Function(String) onFilterChanged;
|
||||
|
||||
const MembersFilterSheet({
|
||||
super.key,
|
||||
required this.selectedFilter,
|
||||
required this.onFilterChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MembersFilterSheet> createState() => _MembersFilterSheetState();
|
||||
}
|
||||
|
||||
class _MembersFilterSheetState extends State<MembersFilterSheet>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _slideAnimation;
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
String _tempSelectedFilter = '';
|
||||
|
||||
final List<Map<String, dynamic>> _filterOptions = [
|
||||
{
|
||||
'value': 'Tous',
|
||||
'label': 'Tous les membres',
|
||||
'icon': Icons.people,
|
||||
'color': AppTheme.primaryColor,
|
||||
'description': 'Afficher tous les membres',
|
||||
},
|
||||
{
|
||||
'value': 'Actifs',
|
||||
'label': 'Membres actifs',
|
||||
'icon': Icons.check_circle,
|
||||
'color': AppTheme.successColor,
|
||||
'description': 'Membres avec un statut actif',
|
||||
},
|
||||
{
|
||||
'value': 'Inactifs',
|
||||
'label': 'Membres inactifs',
|
||||
'icon': Icons.pause_circle,
|
||||
'color': AppTheme.textHint,
|
||||
'description': 'Membres avec un statut inactif',
|
||||
},
|
||||
{
|
||||
'value': 'Bureau',
|
||||
'label': 'Membres du bureau',
|
||||
'icon': Icons.star,
|
||||
'color': AppTheme.warningColor,
|
||||
'description': 'Président, secrétaire, trésorier',
|
||||
},
|
||||
{
|
||||
'value': 'En retard',
|
||||
'label': 'Cotisations en retard',
|
||||
'icon': Icons.warning,
|
||||
'color': AppTheme.errorColor,
|
||||
'description': 'Membres avec cotisations impayées',
|
||||
},
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempSelectedFilter = widget.selectedFilter;
|
||||
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_slideAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
));
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.2, 1.0, curve: Curves.easeOut),
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5 * _fadeAnimation.value),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, MediaQuery.of(context).size.height * _slideAnimation.value),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.7,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(24),
|
||||
topRight: Radius.circular(24),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHandle(),
|
||||
_buildHeader(),
|
||||
Flexible(child: _buildFilterOptions()),
|
||||
_buildActionButtons(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHandle() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.textHint.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.secondaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.filter_list,
|
||||
color: AppTheme.secondaryColor,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Filtrer les membres',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Sélectionnez un critère de filtrage',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _closeSheet,
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterOptions() {
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
|
||||
itemCount: _filterOptions.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final option = _filterOptions[index];
|
||||
final isSelected = _tempSelectedFilter == option['value'];
|
||||
|
||||
return _buildFilterOption(
|
||||
option: option,
|
||||
isSelected: isSelected,
|
||||
onTap: () => _selectFilter(option['value']),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterOption({
|
||||
required Map<String, dynamic> option,
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onTap();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? option['color'].withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? option['color']
|
||||
: AppTheme.textHint.withOpacity(0.2),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: option['color'].withOpacity(isSelected ? 0.2 : 0.1),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Icon(
|
||||
option['icon'],
|
||||
color: option['color'],
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
option['label'],
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected ? option['color'] : AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
option['description'],
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
opacity: isSelected ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Icon(
|
||||
Icons.check_circle,
|
||||
color: option['color'],
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.backgroundLight,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _resetFilter,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.textSecondary,
|
||||
side: BorderSide(color: AppTheme.textHint.withOpacity(0.5)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: const Text('Réinitialiser'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: ElevatedButton(
|
||||
onPressed: _applyFilter,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.secondaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
'Appliquer',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _selectFilter(String filter) {
|
||||
setState(() {
|
||||
_tempSelectedFilter = filter;
|
||||
});
|
||||
}
|
||||
|
||||
void _resetFilter() {
|
||||
setState(() {
|
||||
_tempSelectedFilter = 'Tous';
|
||||
});
|
||||
}
|
||||
|
||||
void _applyFilter() {
|
||||
widget.onFilterChanged(_tempSelectedFilter);
|
||||
_closeSheet();
|
||||
}
|
||||
|
||||
void _closeSheet() {
|
||||
_animationController.reverse().then((_) {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
class MembersSearchBar extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final Function(String) onChanged;
|
||||
final VoidCallback onClear;
|
||||
|
||||
const MembersSearchBar({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onChanged,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MembersSearchBar> createState() => _MembersSearchBarState();
|
||||
}
|
||||
|
||||
class _MembersSearchBarState extends State<MembersSearchBar>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
bool _hasText = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
widget.controller.addListener(_onTextChanged);
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onTextChanged);
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
final hasText = widget.controller.text.isNotEmpty;
|
||||
if (hasText != _hasText) {
|
||||
setState(() {
|
||||
_hasText = hasText;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _fadeAnimation,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: _fadeAnimation.value,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, 20 * (1 - _fadeAnimation.value)),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
onChanged: widget.onChanged,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher un membre...',
|
||||
hintStyle: TextStyle(
|
||||
color: AppTheme.textHint,
|
||||
fontSize: 16,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
color: AppTheme.secondaryColor,
|
||||
size: 24,
|
||||
),
|
||||
suffixIcon: _hasText
|
||||
? AnimatedOpacity(
|
||||
opacity: _hasText ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.clear,
|
||||
color: AppTheme.textHint,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: widget.onClear,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.transparent,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,456 +0,0 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/membre_model.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Card pour afficher un membre dans la liste
|
||||
class MembreCard extends StatelessWidget {
|
||||
const MembreCard({
|
||||
super.key,
|
||||
required this.membre,
|
||||
this.onTap,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
final MembreModel membre;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header avec avatar et actions
|
||||
Row(
|
||||
children: [
|
||||
// Avatar
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
child: Text(
|
||||
membre.initiales,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Informations principales
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
membre.nomComplet,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
membre.numeroMembre,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Badge de statut
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusColor(membre.statut).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _getStatusColor(membre.statut),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_getStatusLabel(membre.statut),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _getStatusColor(membre.statut),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Menu d'actions
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(
|
||||
Icons.more_vert,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'edit':
|
||||
onEdit?.call();
|
||||
break;
|
||||
case 'delete':
|
||||
onDelete?.call();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Modifier'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, size: 16, color: AppTheme.errorColor),
|
||||
SizedBox(width: 8),
|
||||
Text('Supprimer', style: TextStyle(color: AppTheme.errorColor)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Informations de contact
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildInfoItem(
|
||||
icon: Icons.email_outlined,
|
||||
text: membre.email,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildInfoItem(
|
||||
icon: Icons.phone_outlined,
|
||||
text: membre.telephone,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Adresse si disponible
|
||||
if (membre.adresseComplete.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildInfoItem(
|
||||
icon: Icons.location_on_outlined,
|
||||
text: membre.adresseComplete,
|
||||
),
|
||||
],
|
||||
|
||||
// Profession si disponible
|
||||
if (membre.profession?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildInfoItem(
|
||||
icon: Icons.work_outline,
|
||||
text: membre.profession!,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Footer avec date d'adhésion
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.calendar_today_outlined,
|
||||
size: 14,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Membre depuis ${_formatDate(membre.dateAdhesion)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Widget pour afficher une information avec icône
|
||||
Widget _buildInfoItem({
|
||||
required IconData icon,
|
||||
required String text,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Retourne la couleur associée au statut
|
||||
Color _getStatusColor(String statut) {
|
||||
switch (statut.toUpperCase()) {
|
||||
case 'ACTIF':
|
||||
return AppTheme.successColor;
|
||||
case 'INACTIF':
|
||||
return AppTheme.warningColor;
|
||||
case 'SUSPENDU':
|
||||
return AppTheme.errorColor;
|
||||
default:
|
||||
return AppTheme.textSecondary;
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne le label du statut
|
||||
String _getStatusLabel(String statut) {
|
||||
switch (statut.toUpperCase()) {
|
||||
case 'ACTIF':
|
||||
return 'ACTIF';
|
||||
case 'INACTIF':
|
||||
return 'INACTIF';
|
||||
case 'SUSPENDU':
|
||||
return 'SUSPENDU';
|
||||
default:
|
||||
return statut.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
/// Formate une date pour l'affichage
|
||||
String _formatDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inDays < 30) {
|
||||
return '${difference.inDays} jours';
|
||||
} else if (difference.inDays < 365) {
|
||||
final months = (difference.inDays / 30).floor();
|
||||
return '$months mois';
|
||||
} else {
|
||||
final years = (difference.inDays / 365).floor();
|
||||
return '$years an${years > 1 ? 's' : ''}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
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: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.account_balance_wallet,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
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: const Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
size: 48,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucune cotisation',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
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: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.list_alt,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../../core/models/membre_model.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Carte membre améliorée avec différents modes d'affichage
|
||||
class MembreEnhancedCard extends StatelessWidget {
|
||||
final MembreModel membre;
|
||||
final String viewMode;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onDelete;
|
||||
final VoidCallback? onCall;
|
||||
final VoidCallback? onMessage;
|
||||
|
||||
const MembreEnhancedCard({
|
||||
super.key,
|
||||
required this.membre,
|
||||
this.viewMode = 'card',
|
||||
this.onTap,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.onCall,
|
||||
this.onMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (viewMode) {
|
||||
case 'list':
|
||||
return _buildListView();
|
||||
case 'grid':
|
||||
return _buildGridView();
|
||||
case 'card':
|
||||
default:
|
||||
return _buildCardView();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCardView() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// En-tête avec avatar et actions
|
||||
Row(
|
||||
children: [
|
||||
_buildAvatar(size: 50),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
membre.nomComplet,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
membre.numeroMembre,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildStatusBadge(),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Informations de contact
|
||||
_buildContactInfo(),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Actions
|
||||
_buildActionButtons(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListView() {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: onTap,
|
||||
leading: _buildAvatar(size: 40),
|
||||
title: Text(
|
||||
membre.nomComplet,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
membre.numeroMembre,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
membre.telephone,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildStatusBadge(),
|
||||
const SizedBox(width: 8),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: _handleMenuAction,
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'call',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.phone, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Appeler'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'message',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.message, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Message'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Modifier'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridView() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildAvatar(size: 60),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
membre.prenom,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
membre.nom,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildStatusBadge(),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildGridAction(Icons.phone, onCall),
|
||||
_buildGridAction(Icons.message, onMessage),
|
||||
_buildGridAction(Icons.edit, onEdit),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar({required double size}) {
|
||||
return CircleAvatar(
|
||||
radius: size / 2,
|
||||
backgroundColor: AppTheme.primaryColor.withOpacity(0.1),
|
||||
child: Text(
|
||||
membre.initiales,
|
||||
style: TextStyle(
|
||||
fontSize: size * 0.4,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBadge() {
|
||||
Color color;
|
||||
switch (membre.statut.toUpperCase()) {
|
||||
case 'ACTIF':
|
||||
color = AppTheme.successColor;
|
||||
break;
|
||||
case 'INACTIF':
|
||||
color = AppTheme.warningColor;
|
||||
break;
|
||||
case 'SUSPENDU':
|
||||
color = AppTheme.errorColor;
|
||||
break;
|
||||
default:
|
||||
color = AppTheme.textSecondary;
|
||||
}
|
||||
|
||||
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(
|
||||
membre.statutLibelle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactInfo() {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.phone, size: 16, color: AppTheme.textHint),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
membre.telephone,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.email, size: 16, color: AppTheme.textHint),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
membre.email,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onCall,
|
||||
icon: const Icon(Icons.phone, size: 16),
|
||||
label: const Text('Appeler'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.primaryColor,
|
||||
side: BorderSide(color: AppTheme.primaryColor.withOpacity(0.3)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onMessage,
|
||||
icon: const Icon(Icons.message, size: 16),
|
||||
label: const Text('Message'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.secondaryColor,
|
||||
side: BorderSide(color: AppTheme.secondaryColor.withOpacity(0.3)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridAction(IconData icon, VoidCallback? onPressed) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onPressed?.call();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleMenuAction(String action) {
|
||||
HapticFeedback.lightImpact();
|
||||
switch (action) {
|
||||
case 'call':
|
||||
onCall?.call();
|
||||
break;
|
||||
case 'message':
|
||||
onMessage?.call();
|
||||
break;
|
||||
case 'edit':
|
||||
onEdit?.call();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
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' : ''}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
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: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.analytics,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
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: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.pie_chart,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
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: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bar_chart,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
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: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.timeline,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
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: [
|
||||
const 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);
|
||||
}
|
||||
}
|
||||
@@ -1,626 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../core/models/membre_model.dart';
|
||||
import '../../../../core/services/export_import_service.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: const 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: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performExport(List<MembreModel> membersToExport) async {
|
||||
// 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 = ExportOptions(
|
||||
format: _selectedFormat,
|
||||
includePersonalInfo: _includePersonalInfo,
|
||||
includeContactInfo: _includeContactInfo,
|
||||
includeAdhesionInfo: _includeAdhesionInfo,
|
||||
includeStatistics: _includeStatistics,
|
||||
includeInactiveMembers: _includeInactiveMembers,
|
||||
);
|
||||
|
||||
// Fermer le dialog avant l'export
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// Effectuer l'export réel
|
||||
final exportService = ExportImportService();
|
||||
await exportService.exportMembers(context, filteredMembers, exportOptions);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Barre de recherche pour les membres
|
||||
class MembresSearchBar extends StatefulWidget {
|
||||
const MembresSearchBar({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.onSearch,
|
||||
required this.onClear,
|
||||
this.hintText = 'Rechercher un membre...',
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final ValueChanged<String> onSearch;
|
||||
final VoidCallback onClear;
|
||||
final String hintText;
|
||||
|
||||
@override
|
||||
State<MembresSearchBar> createState() => _MembresSearchBarState();
|
||||
}
|
||||
|
||||
class _MembresSearchBarState extends State<MembresSearchBar> {
|
||||
bool _isSearching = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onTextChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
final hasText = widget.controller.text.isNotEmpty;
|
||||
if (_isSearching != hasText) {
|
||||
setState(() {
|
||||
_isSearching = hasText;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onSubmitted(String value) {
|
||||
if (value.trim().isNotEmpty) {
|
||||
widget.onSearch(value.trim());
|
||||
} else {
|
||||
widget.onClear();
|
||||
}
|
||||
}
|
||||
|
||||
void _onClearPressed() {
|
||||
widget.controller.clear();
|
||||
widget.onClear();
|
||||
FocusScope.of(context).unfocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
onSubmitted: _onSubmitted,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
hintStyle: const TextStyle(
|
||||
color: AppTheme.textHint,
|
||||
fontSize: 16,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
suffixIcon: _isSearching
|
||||
? IconButton(
|
||||
icon: const Icon(
|
||||
Icons.clear,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
onPressed: _onClearPressed,
|
||||
tooltip: 'Effacer la recherche',
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(
|
||||
color: AppTheme.primaryColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Card pour afficher les statistiques des membres
|
||||
class MembresStatsCard extends StatelessWidget {
|
||||
const MembresStatsCard({
|
||||
super.key,
|
||||
required this.stats,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> stats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final nombreMembresActifs = stats['nombreMembresActifs'] as int? ?? 0;
|
||||
final nombreMembresInactifs = stats['nombreMembresInactifs'] as int? ?? 0;
|
||||
final nombreMembresSuspendus = stats['nombreMembresSuspendus'] as int? ?? 0;
|
||||
final total = nombreMembresActifs + nombreMembresInactifs + nombreMembresSuspendus;
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.analytics,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Statistiques des membres',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Statistiques principales
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
title: 'Total',
|
||||
value: total.toString(),
|
||||
color: AppTheme.primaryColor,
|
||||
icon: Icons.people,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
title: 'Actifs',
|
||||
value: nombreMembresActifs.toString(),
|
||||
color: AppTheme.successColor,
|
||||
icon: Icons.check_circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
title: 'Inactifs',
|
||||
value: nombreMembresInactifs.toString(),
|
||||
color: AppTheme.warningColor,
|
||||
icon: Icons.pause_circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
title: 'Suspendus',
|
||||
value: nombreMembresSuspendus.toString(),
|
||||
color: AppTheme.errorColor,
|
||||
icon: Icons.block,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (total > 0) ...[
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Graphique en secteurs
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
sections: [
|
||||
if (nombreMembresActifs > 0)
|
||||
PieChartSectionData(
|
||||
value: nombreMembresActifs.toDouble(),
|
||||
title: '${(nombreMembresActifs / total * 100).round()}%',
|
||||
color: AppTheme.successColor,
|
||||
radius: 60,
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
if (nombreMembresInactifs > 0)
|
||||
PieChartSectionData(
|
||||
value: nombreMembresInactifs.toDouble(),
|
||||
title: '${(nombreMembresInactifs / total * 100).round()}%',
|
||||
color: AppTheme.warningColor,
|
||||
radius: 60,
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
if (nombreMembresSuspendus > 0)
|
||||
PieChartSectionData(
|
||||
value: nombreMembresSuspendus.toDouble(),
|
||||
title: '${(nombreMembresSuspendus / total * 100).round()}%',
|
||||
color: AppTheme.errorColor,
|
||||
radius: 60,
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
centerSpaceRadius: 40,
|
||||
sectionsSpace: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Légende
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (nombreMembresActifs > 0)
|
||||
_buildLegendItem('Actifs', AppTheme.successColor),
|
||||
if (nombreMembresInactifs > 0)
|
||||
_buildLegendItem('Inactifs', AppTheme.warningColor),
|
||||
if (nombreMembresSuspendus > 0)
|
||||
_buildLegendItem('Suspendus', AppTheme.errorColor),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Widget pour une statistique individuelle
|
||||
Widget _buildStatItem({
|
||||
required String title,
|
||||
required String value,
|
||||
required Color color,
|
||||
required IconData icon,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Widget pour un élément de légende
|
||||
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: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/models/membre_model.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget de statistiques pour la liste des membres
|
||||
class MembresStatsOverview extends StatelessWidget {
|
||||
final List<MembreModel> membres;
|
||||
final String searchQuery;
|
||||
|
||||
const MembresStatsOverview({
|
||||
super.key,
|
||||
required this.membres,
|
||||
this.searchQuery = '',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stats = _calculateStats();
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
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
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.analytics,
|
||||
color: AppTheme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Vue d\'ensemble',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (searchQuery.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.infoColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Filtré',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.infoColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Statistiques principales
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Total',
|
||||
stats['total'].toString(),
|
||||
Icons.people,
|
||||
AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Actifs',
|
||||
stats['actifs'].toString(),
|
||||
Icons.check_circle,
|
||||
AppTheme.successColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Âge moyen',
|
||||
'${stats['ageMoyen']} ans',
|
||||
Icons.cake,
|
||||
AppTheme.warningColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (stats['total'] > 0) ...[
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Statistiques détaillées
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildDetailedStat(
|
||||
'Nouveaux (30j)',
|
||||
stats['nouveaux'].toString(),
|
||||
stats['nouveauxPourcentage'],
|
||||
AppTheme.infoColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildDetailedStat(
|
||||
'Anciens (>1an)',
|
||||
stats['anciens'].toString(),
|
||||
stats['anciensPourcentage'],
|
||||
AppTheme.secondaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _calculateStats() {
|
||||
if (membres.isEmpty) {
|
||||
return {
|
||||
'total': 0,
|
||||
'actifs': 0,
|
||||
'ageMoyen': 0,
|
||||
'nouveaux': 0,
|
||||
'nouveauxPourcentage': 0.0,
|
||||
'anciens': 0,
|
||||
'anciensPourcentage': 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final total = membres.length;
|
||||
final actifs = membres.where((m) => m.statut.toUpperCase() == 'ACTIF').length;
|
||||
|
||||
// Calcul de l'âge moyen
|
||||
final ages = membres.map((m) => m.age).where((age) => age > 0).toList();
|
||||
final ageMoyen = ages.isNotEmpty ? (ages.reduce((a, b) => a + b) / ages.length).round() : 0;
|
||||
|
||||
// Nouveaux membres (moins de 30 jours)
|
||||
final nouveaux = membres.where((m) {
|
||||
final daysDiff = now.difference(m.dateAdhesion).inDays;
|
||||
return daysDiff <= 30;
|
||||
}).length;
|
||||
final nouveauxPourcentage = total > 0 ? (nouveaux / total * 100) : 0.0;
|
||||
|
||||
// Anciens membres (plus d'un an)
|
||||
final anciens = membres.where((m) {
|
||||
final daysDiff = now.difference(m.dateAdhesion).inDays;
|
||||
return daysDiff > 365;
|
||||
}).length;
|
||||
final anciensPourcentage = total > 0 ? (anciens / total * 100) : 0.0;
|
||||
|
||||
return {
|
||||
'total': total,
|
||||
'actifs': actifs,
|
||||
'ageMoyen': ageMoyen,
|
||||
'nouveaux': nouveaux,
|
||||
'nouveauxPourcentage': nouveauxPourcentage,
|
||||
'anciens': anciens,
|
||||
'anciensPourcentage': anciensPourcentage,
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
|
||||
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.2)),
|
||||
),
|
||||
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 _buildDetailedStat(String label, String value, double percentage, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey[200]!),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'(${percentage.toStringAsFixed(1)}%)',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Widget de contrôles pour les modes d'affichage et le tri
|
||||
class MembresViewControls extends StatelessWidget {
|
||||
final String viewMode;
|
||||
final String sortBy;
|
||||
final bool sortAscending;
|
||||
final int totalCount;
|
||||
final Function(String) onViewModeChanged;
|
||||
final Function(String) onSortChanged;
|
||||
final VoidCallback onSortDirectionChanged;
|
||||
|
||||
const MembresViewControls({
|
||||
super.key,
|
||||
required this.viewMode,
|
||||
required this.sortBy,
|
||||
required this.sortAscending,
|
||||
required this.totalCount,
|
||||
required this.onViewModeChanged,
|
||||
required this.onSortChanged,
|
||||
required this.onSortDirectionChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey[200]!),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Compteur
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'$totalCount membre${totalCount > 1 ? 's' : ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Contrôles de tri
|
||||
_buildSortControls(),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Modes d'affichage
|
||||
_buildViewModeControls(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSortControls() {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PopupMenuButton<String>(
|
||||
initialValue: sortBy,
|
||||
onSelected: onSortChanged,
|
||||
icon: const Icon(
|
||||
Icons.sort,
|
||||
size: 20,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'name',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.sort_by_alpha, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Nom'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'date',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.date_range, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Date d\'adhésion'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'age',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.cake, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Âge'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'status',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Statut'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Direction du tri
|
||||
GestureDetector(
|
||||
onTap: onSortDirectionChanged,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Icon(
|
||||
sortAscending ? Icons.arrow_upward : Icons.arrow_downward,
|
||||
size: 16,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildViewModeControls() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildViewModeButton('list', Icons.view_list, 'Liste'),
|
||||
_buildViewModeButton('card', Icons.view_module, 'Cartes'),
|
||||
_buildViewModeButton('grid', Icons.grid_view, 'Grille'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildViewModeButton(String mode, IconData icon, String tooltip) {
|
||||
final isSelected = viewMode == mode;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => onViewModeChanged(mode),
|
||||
child: Tooltip(
|
||||
message: tooltip,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppTheme.primaryColor : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: isSelected ? Colors.white : AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
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(),
|
||||
const 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) ...[
|
||||
const 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: const 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: const 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;
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
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(),
|
||||
const SizedBox(height: DesignSystem.spacingLg),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _buildChart(),
|
||||
),
|
||||
if (widget.showLegend) ...[
|
||||
const 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) ...[
|
||||
const 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: const 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(),
|
||||
const 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: const EdgeInsets.only(bottom: DesignSystem.spacingSm),
|
||||
padding: const 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),
|
||||
),
|
||||
),
|
||||
const 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: const 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,
|
||||
),
|
||||
),
|
||||
const 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;
|
||||
}
|
||||
@@ -1,544 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
import '../../../../shared/widgets/cards/sophisticated_card.dart';
|
||||
import '../../../../shared/widgets/avatars/sophisticated_avatar.dart';
|
||||
import '../../../../shared/widgets/badges/status_badge.dart';
|
||||
import '../../../../shared/widgets/badges/count_badge.dart';
|
||||
import '../../../../shared/widgets/buttons/buttons.dart';
|
||||
|
||||
class SophisticatedMemberCard extends StatefulWidget {
|
||||
final Map<String, dynamic> member;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onMessage;
|
||||
final VoidCallback? onCall;
|
||||
final bool showActions;
|
||||
final bool compact;
|
||||
|
||||
const SophisticatedMemberCard({
|
||||
super.key,
|
||||
required this.member,
|
||||
this.onTap,
|
||||
this.onEdit,
|
||||
this.onMessage,
|
||||
this.onCall,
|
||||
this.showActions = true,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SophisticatedMemberCard> createState() => _SophisticatedMemberCardState();
|
||||
}
|
||||
|
||||
class _SophisticatedMemberCardState extends State<SophisticatedMemberCard>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _expandController;
|
||||
late AnimationController _actionController;
|
||||
late Animation<double> _expandAnimation;
|
||||
late Animation<double> _actionAnimation;
|
||||
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_expandController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_actionController = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_expandAnimation = CurvedAnimation(
|
||||
parent: _expandController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
_actionAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _actionController,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
|
||||
_actionController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_expandController.dispose();
|
||||
_actionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SophisticatedCard(
|
||||
variant: CardVariant.elevated,
|
||||
size: widget.compact ? CardSize.compact : CardSize.standard,
|
||||
onTap: widget.onTap,
|
||||
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildMainContent(),
|
||||
AnimatedBuilder(
|
||||
animation: _expandAnimation,
|
||||
builder: (context, child) {
|
||||
return ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
heightFactor: _expandAnimation.value,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _buildExpandedContent(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMainContent() {
|
||||
return Row(
|
||||
children: [
|
||||
_buildAvatar(),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: _buildMemberInfo()),
|
||||
_buildTrailingActions(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar() {
|
||||
final roleColor = _getRoleColor();
|
||||
final isOnline = widget.member['status'] == 'Actif';
|
||||
|
||||
return SophisticatedAvatar(
|
||||
initials: _getInitials(),
|
||||
size: widget.compact ? AvatarSize.medium : AvatarSize.large,
|
||||
variant: AvatarVariant.gradient,
|
||||
backgroundColor: roleColor,
|
||||
showOnlineStatus: true,
|
||||
isOnline: isOnline,
|
||||
badge: _buildRoleBadge(),
|
||||
onTap: () => _toggleExpanded(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRoleBadge() {
|
||||
final role = widget.member['role'] as String;
|
||||
|
||||
if (role == 'Président' || role == 'Secrétaire' || role == 'Trésorier') {
|
||||
return CountBadge(
|
||||
count: 1,
|
||||
backgroundColor: AppTheme.warningColor,
|
||||
size: 16,
|
||||
suffix: '★',
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildMemberInfo() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${widget.member['firstName']} ${widget.member['lastName']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildStatusBadge(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildRoleChip(),
|
||||
if (!widget.compact) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildQuickInfo(),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBadge() {
|
||||
final status = widget.member['status'] as String;
|
||||
final cotisationStatus = widget.member['cotisationStatus'] as String;
|
||||
|
||||
if (cotisationStatus == 'En retard') {
|
||||
return StatusBadge(
|
||||
text: 'Retard',
|
||||
type: BadgeType.error,
|
||||
size: BadgeSize.small,
|
||||
variant: BadgeVariant.ghost,
|
||||
icon: Icons.warning,
|
||||
);
|
||||
}
|
||||
|
||||
return StatusBadge(
|
||||
text: status,
|
||||
type: status == 'Actif' ? BadgeType.success : BadgeType.neutral,
|
||||
size: BadgeSize.small,
|
||||
variant: BadgeVariant.ghost,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRoleChip() {
|
||||
final role = widget.member['role'] as String;
|
||||
final roleColor = _getRoleColor();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: roleColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: roleColor.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
role,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: roleColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickInfo() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildInfoItem(
|
||||
Icons.email_outlined,
|
||||
widget.member['email'],
|
||||
AppTheme.infoColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildInfoItem(
|
||||
Icons.phone_outlined,
|
||||
_formatPhone(widget.member['phone']),
|
||||
AppTheme.successColor,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoItem(IconData icon, String text, Color color) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrailingActions() {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _actionAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _actionAnimation.value,
|
||||
child: IconButton(
|
||||
onPressed: _toggleExpanded,
|
||||
icon: AnimatedRotation(
|
||||
turns: _isExpanded ? 0.5 : 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: const Icon(Icons.expand_more),
|
||||
),
|
||||
iconSize: 20,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppTheme.backgroundLight,
|
||||
foregroundColor: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (widget.compact) ...[
|
||||
const SizedBox(height: 4),
|
||||
_buildQuickActionButton(),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickActionButton() {
|
||||
return QuickButtons.iconGhost(
|
||||
icon: Icons.edit,
|
||||
onPressed: widget.onEdit ?? _editMember,
|
||||
size: 32,
|
||||
color: _getRoleColor(),
|
||||
tooltip: 'Modifier',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExpandedContent() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.backgroundLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildDetailedInfo(),
|
||||
if (widget.showActions) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildActionButtons(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailedInfo() {
|
||||
return Column(
|
||||
children: [
|
||||
_buildDetailRow(
|
||||
'Adhésion',
|
||||
_formatDate(widget.member['joinDate']),
|
||||
Icons.calendar_today,
|
||||
AppTheme.primaryColor,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildDetailRow(
|
||||
'Dernière activité',
|
||||
_formatDate(widget.member['lastActivity']),
|
||||
Icons.access_time,
|
||||
AppTheme.infoColor,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildDetailRow(
|
||||
'Cotisation',
|
||||
widget.member['cotisationStatus'],
|
||||
Icons.payment,
|
||||
_getCotisationColor(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value, IconData icon, Color color) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(icon, size: 16, color: color),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: label == 'Cotisation' ? color : AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: QuickButtons.outline(
|
||||
text: 'Appeler',
|
||||
icon: Icons.phone,
|
||||
onPressed: widget.onCall ?? _callMember,
|
||||
size: ButtonSize.small,
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: QuickButtons.outline(
|
||||
text: 'Message',
|
||||
icon: Icons.message,
|
||||
onPressed: widget.onMessage ?? _messageMember,
|
||||
size: ButtonSize.small,
|
||||
color: AppTheme.infoColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: QuickButtons.outline(
|
||||
text: 'Modifier',
|
||||
icon: Icons.edit,
|
||||
onPressed: widget.onEdit ?? _editMember,
|
||||
size: ButtonSize.small,
|
||||
color: AppTheme.warningColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
void _toggleExpanded() {
|
||||
setState(() {
|
||||
_isExpanded = !_isExpanded;
|
||||
if (_isExpanded) {
|
||||
_expandController.forward();
|
||||
} else {
|
||||
_expandController.reverse();
|
||||
}
|
||||
});
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
|
||||
String _getInitials() {
|
||||
final firstName = widget.member['firstName'] as String;
|
||||
final lastName = widget.member['lastName'] as String;
|
||||
return '${firstName.isNotEmpty ? firstName[0] : ''}${lastName.isNotEmpty ? lastName[0] : ''}'.toUpperCase();
|
||||
}
|
||||
|
||||
Color _getRoleColor() {
|
||||
switch (widget.member['role']) {
|
||||
case 'Président':
|
||||
return AppTheme.primaryColor;
|
||||
case 'Secrétaire':
|
||||
return AppTheme.secondaryColor;
|
||||
case 'Trésorier':
|
||||
return AppTheme.accentColor;
|
||||
case 'Responsable événements':
|
||||
return AppTheme.warningColor;
|
||||
default:
|
||||
return AppTheme.infoColor;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getCotisationColor() {
|
||||
switch (widget.member['cotisationStatus']) {
|
||||
case 'À jour':
|
||||
return AppTheme.successColor;
|
||||
case 'En retard':
|
||||
return AppTheme.errorColor;
|
||||
case 'Exempt':
|
||||
return AppTheme.infoColor;
|
||||
default:
|
||||
return AppTheme.textSecondary;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(String dateString) {
|
||||
try {
|
||||
final date = DateTime.parse(dateString);
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inDays < 1) {
|
||||
return 'Aujourd\'hui';
|
||||
} else if (difference.inDays < 7) {
|
||||
return 'Il y a ${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
|
||||
} else if (difference.inDays < 30) {
|
||||
final weeks = (difference.inDays / 7).floor();
|
||||
return 'Il y a $weeks semaine${weeks > 1 ? 's' : ''}';
|
||||
} else {
|
||||
final months = [
|
||||
'Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun',
|
||||
'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc'
|
||||
];
|
||||
return '${date.day} ${months[date.month - 1]} ${date.year}';
|
||||
}
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatPhone(String phone) {
|
||||
if (phone.length >= 10) {
|
||||
return '${phone.substring(0, 3)} ${phone.substring(3, 5)} ${phone.substring(5, 7)} ${phone.substring(7, 9)} ${phone.substring(9)}';
|
||||
}
|
||||
return phone;
|
||||
}
|
||||
|
||||
void _callMember() {
|
||||
HapticFeedback.lightImpact();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Appel vers ${widget.member['firstName']} ${widget.member['lastName']}'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _messageMember() {
|
||||
HapticFeedback.lightImpact();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Message vers ${widget.member['firstName']} ${widget.member['lastName']}'),
|
||||
backgroundColor: AppTheme.infoColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _editMember() {
|
||||
HapticFeedback.lightImpact();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Modification de ${widget.member['firstName']} ${widget.member['lastName']}'),
|
||||
backgroundColor: AppTheme.warningColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
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: const 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: const 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: const 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignSystem.spacingSm),
|
||||
Text(
|
||||
item.value,
|
||||
style: DesignSystem.headlineMedium.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const 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;
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
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: const EdgeInsets.all(DesignSystem.spacingLg),
|
||||
decoration: BoxDecoration(
|
||||
gradient: DesignSystem.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
|
||||
boxShadow: DesignSystem.shadowCard,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const SizedBox(height: DesignSystem.spacingLg),
|
||||
_buildMainStats(),
|
||||
const SizedBox(height: DesignSystem.spacingLg),
|
||||
_buildSecondaryStats(),
|
||||
const 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,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignSystem.spacingXs),
|
||||
Text(
|
||||
'Statistiques générales',
|
||||
style: DesignSystem.bodyMedium.copyWith(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
padding: const 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,
|
||||
),
|
||||
),
|
||||
const 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,
|
||||
),
|
||||
),
|
||||
const 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,
|
||||
),
|
||||
const SizedBox(width: DesignSystem.spacingXs),
|
||||
Text(
|
||||
label,
|
||||
style: (isSecondary ? DesignSystem.labelMedium : DesignSystem.labelLarge).copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user