feat(mobile): Implement Keycloak WebView authentication with HTTP callback

- Replace flutter_appauth with custom WebView implementation to resolve deep link issues
- Add KeycloakWebViewAuthService with integrated WebView for seamless authentication
- Configure Android manifest for HTTP cleartext traffic support
- Add network security config for development environment (192.168.1.11)
- Update Keycloak client to use HTTP callback endpoint (http://192.168.1.11:8080/auth/callback)
- Remove obsolete keycloak_auth_service.dart and temporary scripts
- Clean up dependencies and regenerate injection configuration
- Tested successfully on multiple Android devices (Xiaomi 2201116TG, SM A725F)

BREAKING CHANGE: Authentication flow now uses WebView instead of external browser
- Users will see Keycloak login page within the app instead of browser redirect
- Resolves ERR_CLEARTEXT_NOT_PERMITTED and deep link state management issues
- Maintains full OIDC compliance with PKCE flow and secure token storage

Technical improvements:
- WebView with custom navigation delegate for callback handling
- Automatic token extraction and user info parsing from JWT
- Proper error handling and user feedback
- Consistent authentication state management across app lifecycle
This commit is contained in:
DahoudG
2025-09-15 01:44:16 +00:00
parent 73459b3092
commit f89f6167cc
290 changed files with 34563 additions and 3528 deletions

View File

@@ -1,69 +0,0 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import 'welcome_screen.dart';
class AuthWrapper extends StatefulWidget {
const AuthWrapper({super.key});
@override
State<AuthWrapper> createState() => _AuthWrapperState();
}
class _AuthWrapperState extends State<AuthWrapper> {
bool _isLoading = true;
bool _isAuthenticated = false;
@override
void initState() {
super.initState();
_checkAuthenticationStatus();
}
Future<void> _checkAuthenticationStatus() async {
// Simulation de vérification d'authentification
// En production : vérifier le token JWT, SharedPreferences, etc.
await Future.delayed(const Duration(milliseconds: 500));
setState(() {
_isLoading = false;
// Pour le moment, toujours false (pas d'utilisateur connecté)
_isAuthenticated = false;
});
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return _buildLoadingScreen();
}
if (_isAuthenticated) {
// TODO: Retourner vers la navigation principale
return _buildLoadingScreen(); // Temporaire
} else {
return const WelcomeScreen();
}
}
Widget _buildLoadingScreen() {
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppTheme.primaryColor,
AppTheme.primaryDark,
],
),
),
child: const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
),
);
}
}

View File

@@ -0,0 +1,296 @@
import 'package:flutter/material.dart';
import '../../../../core/auth/services/keycloak_webview_auth_service.dart';
import '../../../../core/auth/models/auth_state.dart';
import '../../../../core/di/injection.dart';
import '../../../../shared/theme/app_theme.dart';
/// Page de connexion utilisant Keycloak OIDC
class KeycloakLoginPage extends StatefulWidget {
const KeycloakLoginPage({super.key});
@override
State<KeycloakLoginPage> createState() => _KeycloakLoginPageState();
}
class _KeycloakLoginPageState extends State<KeycloakLoginPage> {
late KeycloakWebViewAuthService _authService;
bool _isLoading = false;
@override
void initState() {
super.initState();
_authService = getIt<KeycloakWebViewAuthService>();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
body: StreamBuilder<AuthState>(
stream: _authService.authStateStream,
builder: (context, snapshot) {
final authState = snapshot.data ?? const AuthState.unknown();
if (authState.isAuthenticated) {
// Rediriger vers la page principale si déjà connecté
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushReplacementNamed('/main');
});
}
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: MediaQuery.of(context).size.height -
MediaQuery.of(context).padding.top -
MediaQuery.of(context).padding.bottom - 48,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo et titre
_buildHeader(),
const SizedBox(height: 48),
// Message d'accueil
_buildWelcomeMessage(),
const SizedBox(height: 32),
// Bouton de connexion
_buildLoginButton(authState),
const SizedBox(height: 16),
// Message d'erreur si présent
if (authState.errorMessage != null)
_buildErrorMessage(authState.errorMessage!),
const SizedBox(height: 32),
// Informations sur la sécurité
_buildSecurityInfo(),
],
),
),
),
);
},
),
);
}
Widget _buildHeader() {
return Column(
children: [
// Logo UnionFlow
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: AppTheme.primaryColor,
borderRadius: BorderRadius.circular(60),
boxShadow: [
BoxShadow(
color: AppTheme.primaryColor.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: const Icon(
Icons.groups,
size: 60,
color: Colors.white,
),
),
const SizedBox(height: 24),
// Titre
Text(
'UnionFlow',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
const SizedBox(height: 8),
// Sous-titre
Text(
'Gestion d\'organisations',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.grey[600],
),
),
],
);
}
Widget _buildWelcomeMessage() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
const Icon(
Icons.security,
size: 48,
color: AppTheme.primaryColor,
),
const SizedBox(height: 16),
Text(
'Connexion sécurisée',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Connectez-vous avec votre compte UnionFlow pour accéder à toutes les fonctionnalités de l\'application.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
],
),
),
);
}
Widget _buildLoginButton(AuthState authState) {
return ElevatedButton(
onPressed: authState.isLoading || _isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 3,
),
child: authState.isLoading || _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.login, size: 24),
const SizedBox(width: 12),
Text(
'Se connecter avec Keycloak',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
Widget _buildErrorMessage(String errorMessage) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.errorColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.errorColor.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(
Icons.error_outline,
color: AppTheme.errorColor,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
errorMessage,
style: const TextStyle(
color: AppTheme.errorColor,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
Widget _buildSecurityInfo() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withOpacity(0.3)),
),
child: Column(
children: [
Row(
children: [
const Icon(
Icons.info_outline,
color: Colors.blue,
size: 20,
),
const SizedBox(width: 8),
Text(
'Authentification sécurisée',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.blue,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Text(
'Vos données sont protégées par Keycloak, une solution d\'authentification enterprise. '
'Votre mot de passe n\'est jamais stocké sur cet appareil.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.blue[700],
),
),
],
),
);
}
Future<void> _handleLogin() async {
setState(() {
_isLoading = true;
});
try {
await _authService.loginWithWebView(context);
} catch (e) {
// L'erreur sera gérée par le stream AuthState
print('Erreur de connexion: $e');
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
}

View File

@@ -47,51 +47,56 @@ class ActionCardWidget extends StatelessWidget {
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withOpacity(0.2)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
color: Colors.black.withOpacity(0.04),
blurRadius: 8,
offset: const Offset(0, 1),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 24,
size: 18,
),
),
const SizedBox(height: 12),
const SizedBox(height: 8),
Text(
title,
style: const TextStyle(
fontSize: 14,
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
fontSize: 10,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),

View File

@@ -25,124 +25,138 @@ class QuickActionsWidget extends StatelessWidget {
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
const SizedBox(height: 12),
// Première ligne - Actions principales
// Grille compacte 3x4 - Actions organisées par priorité
// Première ligne - Actions principales (3 colonnes)
Row(
children: [
Expanded(
child: ActionCardWidget(
title: 'Nouveau membre',
subtitle: 'Inscription rapide',
subtitle: 'Inscription',
icon: Icons.person_add,
color: AppTheme.primaryColor,
),
),
const SizedBox(width: 12),
const SizedBox(width: 8),
Expanded(
child: ActionCardWidget(
title: 'Créer événement',
subtitle: 'Organiser activité',
subtitle: 'Organiser',
icon: Icons.event_available,
color: AppTheme.secondaryColor,
),
),
],
),
const SizedBox(height: 12),
// Deuxième ligne - Gestion financière
Row(
children: [
const SizedBox(width: 8),
Expanded(
child: ActionCardWidget(
title: 'Encaisser cotisation',
subtitle: 'Paiement immédiat',
title: 'Encaisser',
subtitle: 'Cotisation',
icon: Icons.payment,
color: AppTheme.successColor,
),
),
const SizedBox(width: 12),
Expanded(
child: ActionCardWidget(
title: 'Relances impayés',
subtitle: 'Notifications SMS',
icon: Icons.notifications_active,
color: AppTheme.warningColor,
),
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 8),
// Troisième ligne - Communication
// Deuxième ligne - Gestion et communication
Row(
children: [
Expanded(
child: ActionCardWidget(
title: 'Relances',
subtitle: 'SMS/Email',
icon: Icons.notifications_active,
color: AppTheme.warningColor,
),
),
const SizedBox(width: 8),
Expanded(
child: ActionCardWidget(
title: 'Message groupe',
subtitle: 'Diffusion WhatsApp',
subtitle: 'WhatsApp',
icon: Icons.message,
color: const Color(0xFF25D366),
),
),
const SizedBox(width: 12),
const SizedBox(width: 8),
Expanded(
child: ActionCardWidget(
title: 'Convoquer AG',
subtitle: 'Assemblée générale',
subtitle: 'Assemblée',
icon: Icons.groups,
color: const Color(0xFF9C27B0),
),
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 8),
// Quatrième ligne - Rapports et conformité
// Troisième ligne - Rapports et conformité
Row(
children: [
Expanded(
child: ActionCardWidget(
title: 'Rapport OHADA',
subtitle: 'Conformité légale',
subtitle: 'Conformité',
icon: Icons.gavel,
color: const Color(0xFF795548),
),
),
const SizedBox(width: 12),
const SizedBox(width: 8),
Expanded(
child: ActionCardWidget(
title: 'Export données',
subtitle: 'Sauvegarde Excel',
subtitle: 'Excel/PDF',
icon: Icons.file_download,
color: AppTheme.infoColor,
),
),
const SizedBox(width: 8),
Expanded(
child: ActionCardWidget(
title: 'Statistiques',
subtitle: 'Analyses',
icon: Icons.analytics,
color: const Color(0xFF00BCD4),
),
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 8),
// Cinquième ligne - Urgences et support
// Quatrième ligne - Support et urgences
Row(
children: [
Expanded(
child: ActionCardWidget(
title: 'Alerte urgente',
subtitle: 'Notification critique',
subtitle: 'Critique',
icon: Icons.emergency,
color: AppTheme.errorColor,
),
),
const SizedBox(width: 12),
const SizedBox(width: 8),
Expanded(
child: ActionCardWidget(
title: 'Support technique',
subtitle: 'Assistance UnionFlow',
title: 'Support tech',
subtitle: 'Assistance',
icon: Icons.support_agent,
color: const Color(0xFF607D8B),
),
),
const SizedBox(width: 8),
Expanded(
child: ActionCardWidget(
title: 'Paramètres',
subtitle: 'Configuration',
icon: Icons.settings,
color: const Color(0xFF9E9E9E),
),
),
],
),
],

View File

@@ -0,0 +1,103 @@
import 'package:injectable/injectable.dart';
import '../../../../core/models/evenement_model.dart';
import '../../../../core/services/api_service.dart';
import '../../domain/repositories/evenement_repository.dart';
/// Implémentation du repository pour les événements
/// Utilise l'ApiService pour communiquer avec le backend
@LazySingleton(as: EvenementRepository)
class EvenementRepositoryImpl implements EvenementRepository {
final ApiService _apiService;
EvenementRepositoryImpl(this._apiService);
@override
Future<List<EvenementModel>> getEvenementsAVenir({
int page = 0,
int size = 10,
}) async {
return await _apiService.getEvenementsAVenir(page: page, size: size);
}
@override
Future<List<EvenementModel>> getEvenementsPublics({
int page = 0,
int size = 20,
}) async {
return await _apiService.getEvenementsPublics(page: page, size: size);
}
@override
Future<List<EvenementModel>> getEvenements({
int page = 0,
int size = 20,
String sortField = 'dateDebut',
String sortDirection = 'asc',
}) async {
return await _apiService.getEvenements(
page: page,
size: size,
sortField: sortField,
sortDirection: sortDirection,
);
}
@override
Future<EvenementModel> getEvenementById(String id) async {
return await _apiService.getEvenementById(id);
}
@override
Future<List<EvenementModel>> rechercherEvenements(
String terme, {
int page = 0,
int size = 20,
}) async {
return await _apiService.rechercherEvenements(
terme,
page: page,
size: size,
);
}
@override
Future<List<EvenementModel>> getEvenementsByType(
TypeEvenement type, {
int page = 0,
int size = 20,
}) async {
return await _apiService.getEvenementsByType(
type,
page: page,
size: size,
);
}
@override
Future<EvenementModel> createEvenement(EvenementModel evenement) async {
return await _apiService.createEvenement(evenement);
}
@override
Future<EvenementModel> updateEvenement(String id, EvenementModel evenement) async {
return await _apiService.updateEvenement(id, evenement);
}
@override
Future<void> deleteEvenement(String id) async {
return await _apiService.deleteEvenement(id);
}
@override
Future<EvenementModel> changerStatutEvenement(
String id,
StatutEvenement nouveauStatut,
) async {
return await _apiService.changerStatutEvenement(id, nouveauStatut);
}
@override
Future<Map<String, dynamic>> getStatistiquesEvenements() async {
return await _apiService.getStatistiquesEvenements();
}
}

View File

@@ -0,0 +1,60 @@
import '../../../../core/models/evenement_model.dart';
/// Interface du repository pour les événements
/// Définit les contrats pour l'accès aux données des événements
abstract class EvenementRepository {
/// Récupère la liste des événements à venir
Future<List<EvenementModel>> getEvenementsAVenir({
int page = 0,
int size = 10,
});
/// Récupère la liste des événements publics
Future<List<EvenementModel>> getEvenementsPublics({
int page = 0,
int size = 20,
});
/// Récupère tous les événements avec pagination
Future<List<EvenementModel>> getEvenements({
int page = 0,
int size = 20,
String sortField = 'dateDebut',
String sortDirection = 'asc',
});
/// Récupère un événement par son ID
Future<EvenementModel> getEvenementById(String id);
/// Recherche d'événements par terme
Future<List<EvenementModel>> rechercherEvenements(
String terme, {
int page = 0,
int size = 20,
});
/// Récupère les événements par type
Future<List<EvenementModel>> getEvenementsByType(
TypeEvenement type, {
int page = 0,
int size = 20,
});
/// Crée un nouvel événement
Future<EvenementModel> createEvenement(EvenementModel evenement);
/// Met à jour un événement existant
Future<EvenementModel> updateEvenement(String id, EvenementModel evenement);
/// Supprime un événement
Future<void> deleteEvenement(String id);
/// Change le statut d'un événement
Future<EvenementModel> changerStatutEvenement(
String id,
StatutEvenement nouveauStatut,
);
/// Récupère les statistiques des événements
Future<Map<String, dynamic>> getStatistiquesEvenements();
}

View File

@@ -0,0 +1,388 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/models/evenement_model.dart';
import '../../domain/repositories/evenement_repository.dart';
import 'evenement_event.dart';
import 'evenement_state.dart';
/// BLoC pour la gestion des événements
@injectable
class EvenementBloc extends Bloc<EvenementEvent, EvenementState> {
final EvenementRepository _repository;
EvenementBloc(this._repository) : super(const EvenementInitial()) {
on<LoadEvenementsAVenir>(_onLoadEvenementsAVenir);
on<LoadEvenementsPublics>(_onLoadEvenementsPublics);
on<LoadEvenements>(_onLoadEvenements);
on<LoadEvenementById>(_onLoadEvenementById);
on<SearchEvenements>(_onSearchEvenements);
on<FilterEvenementsByType>(_onFilterEvenementsByType);
on<CreateEvenement>(_onCreateEvenement);
on<UpdateEvenement>(_onUpdateEvenement);
on<DeleteEvenement>(_onDeleteEvenement);
on<ChangeStatutEvenement>(_onChangeStatutEvenement);
on<LoadStatistiquesEvenements>(_onLoadStatistiquesEvenements);
on<ResetEvenementState>(_onResetEvenementState);
}
/// Charge les événements à venir
Future<void> _onLoadEvenementsAVenir(
LoadEvenementsAVenir event,
Emitter<EvenementState> emit,
) async {
try {
if (event.refresh || state is EvenementInitial) {
emit(const EvenementLoading());
} else if (state is EvenementLoaded) {
emit(EvenementLoadingMore((state as EvenementLoaded).evenements));
}
final evenements = await _repository.getEvenementsAVenir(
page: event.page,
size: event.size,
);
if (event.refresh || event.page == 0) {
emit(EvenementLoaded(
evenements: evenements,
hasReachedMax: evenements.length < event.size,
currentPage: event.page,
));
} else {
final currentState = state as EvenementLoaded;
final allEvenements = List<EvenementModel>.from(currentState.evenements)
..addAll(evenements);
emit(currentState.copyWith(
evenements: allEvenements,
hasReachedMax: evenements.length < event.size,
currentPage: event.page,
));
}
} catch (e) {
final currentEvenements = state is EvenementLoaded
? (state as EvenementLoaded).evenements
: null;
emit(EvenementError(
message: e.toString(),
evenements: currentEvenements,
));
}
}
/// Charge les événements publics
Future<void> _onLoadEvenementsPublics(
LoadEvenementsPublics event,
Emitter<EvenementState> emit,
) async {
try {
if (event.refresh || state is EvenementInitial) {
emit(const EvenementLoading());
} else if (state is EvenementLoaded) {
emit(EvenementLoadingMore((state as EvenementLoaded).evenements));
}
final evenements = await _repository.getEvenementsPublics(
page: event.page,
size: event.size,
);
if (event.refresh || event.page == 0) {
emit(EvenementLoaded(
evenements: evenements,
hasReachedMax: evenements.length < event.size,
currentPage: event.page,
));
} else {
final currentState = state as EvenementLoaded;
final allEvenements = List<EvenementModel>.from(currentState.evenements)
..addAll(evenements);
emit(currentState.copyWith(
evenements: allEvenements,
hasReachedMax: evenements.length < event.size,
currentPage: event.page,
));
}
} catch (e) {
final currentEvenements = state is EvenementLoaded
? (state as EvenementLoaded).evenements
: null;
emit(EvenementError(
message: e.toString(),
evenements: currentEvenements,
));
}
}
/// Charge tous les événements
Future<void> _onLoadEvenements(
LoadEvenements event,
Emitter<EvenementState> emit,
) async {
try {
if (event.refresh || state is EvenementInitial) {
emit(const EvenementLoading());
} else if (state is EvenementLoaded) {
emit(EvenementLoadingMore((state as EvenementLoaded).evenements));
}
final evenements = await _repository.getEvenements(
page: event.page,
size: event.size,
sortField: event.sortField,
sortDirection: event.sortDirection,
);
if (event.refresh || event.page == 0) {
emit(EvenementLoaded(
evenements: evenements,
hasReachedMax: evenements.length < event.size,
currentPage: event.page,
));
} else {
final currentState = state as EvenementLoaded;
final allEvenements = List<EvenementModel>.from(currentState.evenements)
..addAll(evenements);
emit(currentState.copyWith(
evenements: allEvenements,
hasReachedMax: evenements.length < event.size,
currentPage: event.page,
));
}
} catch (e) {
final currentEvenements = state is EvenementLoaded
? (state as EvenementLoaded).evenements
: null;
emit(EvenementError(
message: e.toString(),
evenements: currentEvenements,
));
}
}
/// Charge un événement par ID
Future<void> _onLoadEvenementById(
LoadEvenementById event,
Emitter<EvenementState> emit,
) async {
try {
emit(const EvenementLoading());
final evenement = await _repository.getEvenementById(event.id);
emit(EvenementDetailLoaded(evenement));
} catch (e) {
emit(EvenementError(message: e.toString()));
}
}
/// Recherche d'événements
Future<void> _onSearchEvenements(
SearchEvenements event,
Emitter<EvenementState> emit,
) async {
try {
if (event.refresh || event.page == 0) {
emit(const EvenementLoading());
} else if (state is EvenementLoaded) {
emit(EvenementLoadingMore((state as EvenementLoaded).evenements));
}
final evenements = await _repository.rechercherEvenements(
event.terme,
page: event.page,
size: event.size,
);
if (evenements.isEmpty && event.page == 0) {
emit(EvenementSearchEmpty(event.terme));
return;
}
if (event.refresh || event.page == 0) {
emit(EvenementLoaded(
evenements: evenements,
hasReachedMax: evenements.length < event.size,
currentPage: event.page,
searchTerm: event.terme,
));
} else {
final currentState = state as EvenementLoaded;
final allEvenements = List<EvenementModel>.from(currentState.evenements)
..addAll(evenements);
emit(currentState.copyWith(
evenements: allEvenements,
hasReachedMax: evenements.length < event.size,
currentPage: event.page,
searchTerm: event.terme,
));
}
} catch (e) {
final currentEvenements = state is EvenementLoaded
? (state as EvenementLoaded).evenements
: null;
emit(EvenementError(
message: e.toString(),
evenements: currentEvenements,
));
}
}
/// Filtre par type d'événement
Future<void> _onFilterEvenementsByType(
FilterEvenementsByType event,
Emitter<EvenementState> emit,
) async {
try {
if (event.refresh || event.page == 0) {
emit(const EvenementLoading());
} else if (state is EvenementLoaded) {
emit(EvenementLoadingMore((state as EvenementLoaded).evenements));
}
final evenements = await _repository.getEvenementsByType(
event.type,
page: event.page,
size: event.size,
);
if (evenements.isEmpty && event.page == 0) {
emit(const EvenementEmpty(message: 'Aucun événement de ce type trouvé'));
return;
}
if (event.refresh || event.page == 0) {
emit(EvenementLoaded(
evenements: evenements,
hasReachedMax: evenements.length < event.size,
currentPage: event.page,
filterType: event.type,
));
} else {
final currentState = state as EvenementLoaded;
final allEvenements = List<EvenementModel>.from(currentState.evenements)
..addAll(evenements);
emit(currentState.copyWith(
evenements: allEvenements,
hasReachedMax: evenements.length < event.size,
currentPage: event.page,
filterType: event.type,
));
}
} catch (e) {
final currentEvenements = state is EvenementLoaded
? (state as EvenementLoaded).evenements
: null;
emit(EvenementError(
message: e.toString(),
evenements: currentEvenements,
));
}
}
/// Crée un nouvel événement
Future<void> _onCreateEvenement(
CreateEvenement event,
Emitter<EvenementState> emit,
) async {
try {
emit(const EvenementLoading());
final evenement = await _repository.createEvenement(event.evenement);
emit(EvenementOperationSuccess(
message: 'Événement créé avec succès',
evenement: evenement,
));
} catch (e) {
emit(EvenementError(message: e.toString()));
}
}
/// Met à jour un événement
Future<void> _onUpdateEvenement(
UpdateEvenement event,
Emitter<EvenementState> emit,
) async {
try {
emit(const EvenementLoading());
final evenement = await _repository.updateEvenement(event.id, event.evenement);
emit(EvenementOperationSuccess(
message: 'Événement mis à jour avec succès',
evenement: evenement,
));
} catch (e) {
emit(EvenementError(message: e.toString()));
}
}
/// Supprime un événement
Future<void> _onDeleteEvenement(
DeleteEvenement event,
Emitter<EvenementState> emit,
) async {
try {
emit(const EvenementLoading());
await _repository.deleteEvenement(event.id);
emit(const EvenementOperationSuccess(
message: 'Événement supprimé avec succès',
));
} catch (e) {
emit(EvenementError(message: e.toString()));
}
}
/// Change le statut d'un événement
Future<void> _onChangeStatutEvenement(
ChangeStatutEvenement event,
Emitter<EvenementState> emit,
) async {
try {
emit(const EvenementLoading());
final evenement = await _repository.changerStatutEvenement(
event.id,
event.nouveauStatut,
);
emit(EvenementOperationSuccess(
message: 'Statut de l\'événement modifié avec succès',
evenement: evenement,
));
} catch (e) {
emit(EvenementError(message: e.toString()));
}
}
/// Charge les statistiques
Future<void> _onLoadStatistiquesEvenements(
LoadStatistiquesEvenements event,
Emitter<EvenementState> emit,
) async {
try {
emit(const EvenementLoading());
final statistiques = await _repository.getStatistiquesEvenements();
emit(EvenementStatistiquesLoaded(statistiques));
} catch (e) {
emit(EvenementError(message: e.toString()));
}
}
/// Réinitialise l'état
void _onResetEvenementState(
ResetEvenementState event,
Emitter<EvenementState> emit,
) {
emit(const EvenementInitial());
}
}

View File

@@ -0,0 +1,160 @@
import 'package:equatable/equatable.dart';
import '../../../../core/models/evenement_model.dart';
/// Événements du BLoC Evenement
abstract class EvenementEvent extends Equatable {
const EvenementEvent();
@override
List<Object?> get props => [];
}
/// Charge les événements à venir
class LoadEvenementsAVenir extends EvenementEvent {
final int page;
final int size;
final bool refresh;
const LoadEvenementsAVenir({
this.page = 0,
this.size = 10,
this.refresh = false,
});
@override
List<Object?> get props => [page, size, refresh];
}
/// Charge les événements publics
class LoadEvenementsPublics extends EvenementEvent {
final int page;
final int size;
final bool refresh;
const LoadEvenementsPublics({
this.page = 0,
this.size = 20,
this.refresh = false,
});
@override
List<Object?> get props => [page, size, refresh];
}
/// Charge tous les événements
class LoadEvenements extends EvenementEvent {
final int page;
final int size;
final String sortField;
final String sortDirection;
final bool refresh;
const LoadEvenements({
this.page = 0,
this.size = 20,
this.sortField = 'dateDebut',
this.sortDirection = 'asc',
this.refresh = false,
});
@override
List<Object?> get props => [page, size, sortField, sortDirection, refresh];
}
/// Charge un événement par ID
class LoadEvenementById extends EvenementEvent {
final String id;
const LoadEvenementById(this.id);
@override
List<Object?> get props => [id];
}
/// Recherche d'événements
class SearchEvenements extends EvenementEvent {
final String terme;
final int page;
final int size;
final bool refresh;
const SearchEvenements({
required this.terme,
this.page = 0,
this.size = 20,
this.refresh = false,
});
@override
List<Object?> get props => [terme, page, size, refresh];
}
/// Filtre par type d'événement
class FilterEvenementsByType extends EvenementEvent {
final TypeEvenement type;
final int page;
final int size;
final bool refresh;
const FilterEvenementsByType({
required this.type,
this.page = 0,
this.size = 20,
this.refresh = false,
});
@override
List<Object?> get props => [type, page, size, refresh];
}
/// Crée un nouvel événement
class CreateEvenement extends EvenementEvent {
final EvenementModel evenement;
const CreateEvenement(this.evenement);
@override
List<Object?> get props => [evenement];
}
/// Met à jour un événement
class UpdateEvenement extends EvenementEvent {
final String id;
final EvenementModel evenement;
const UpdateEvenement(this.id, this.evenement);
@override
List<Object?> get props => [id, evenement];
}
/// Supprime un événement
class DeleteEvenement extends EvenementEvent {
final String id;
const DeleteEvenement(this.id);
@override
List<Object?> get props => [id];
}
/// Change le statut d'un événement
class ChangeStatutEvenement extends EvenementEvent {
final String id;
final StatutEvenement nouveauStatut;
const ChangeStatutEvenement(this.id, this.nouveauStatut);
@override
List<Object?> get props => [id, nouveauStatut];
}
/// Charge les statistiques
class LoadStatistiquesEvenements extends EvenementEvent {
const LoadStatistiquesEvenements();
}
/// Réinitialise l'état
class ResetEvenementState extends EvenementEvent {
const ResetEvenementState();
}

View File

@@ -0,0 +1,142 @@
import 'package:equatable/equatable.dart';
import '../../../../core/models/evenement_model.dart';
/// États du BLoC Evenement
abstract class EvenementState extends Equatable {
const EvenementState();
@override
List<Object?> get props => [];
}
/// État initial
class EvenementInitial extends EvenementState {
const EvenementInitial();
}
/// État de chargement
class EvenementLoading extends EvenementState {
const EvenementLoading();
}
/// État de chargement avec données existantes (pour pagination)
class EvenementLoadingMore extends EvenementState {
final List<EvenementModel> evenements;
const EvenementLoadingMore(this.evenements);
@override
List<Object?> get props => [evenements];
}
/// État de succès avec liste d'événements
class EvenementLoaded extends EvenementState {
final List<EvenementModel> evenements;
final bool hasReachedMax;
final int currentPage;
final String? searchTerm;
final TypeEvenement? filterType;
const EvenementLoaded({
required this.evenements,
this.hasReachedMax = false,
this.currentPage = 0,
this.searchTerm,
this.filterType,
});
EvenementLoaded copyWith({
List<EvenementModel>? evenements,
bool? hasReachedMax,
int? currentPage,
String? searchTerm,
TypeEvenement? filterType,
}) {
return EvenementLoaded(
evenements: evenements ?? this.evenements,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
currentPage: currentPage ?? this.currentPage,
searchTerm: searchTerm ?? this.searchTerm,
filterType: filterType ?? this.filterType,
);
}
@override
List<Object?> get props => [
evenements,
hasReachedMax,
currentPage,
searchTerm,
filterType,
];
}
/// État de succès avec un événement spécifique
class EvenementDetailLoaded extends EvenementState {
final EvenementModel evenement;
const EvenementDetailLoaded(this.evenement);
@override
List<Object?> get props => [evenement];
}
/// État de succès avec statistiques
class EvenementStatistiquesLoaded extends EvenementState {
final Map<String, dynamic> statistiques;
const EvenementStatistiquesLoaded(this.statistiques);
@override
List<Object?> get props => [statistiques];
}
/// État de succès après création/modification
class EvenementOperationSuccess extends EvenementState {
final String message;
final EvenementModel? evenement;
const EvenementOperationSuccess({
required this.message,
this.evenement,
});
@override
List<Object?> get props => [message, evenement];
}
/// État d'erreur
class EvenementError extends EvenementState {
final String message;
final List<EvenementModel>? evenements; // Pour conserver les données en cas d'erreur de pagination
const EvenementError({
required this.message,
this.evenements,
});
@override
List<Object?> get props => [message, evenements];
}
/// État de recherche vide
class EvenementSearchEmpty extends EvenementState {
final String searchTerm;
const EvenementSearchEmpty(this.searchTerm);
@override
List<Object?> get props => [searchTerm];
}
/// État de liste vide
class EvenementEmpty extends EvenementState {
final String message;
const EvenementEmpty({
this.message = 'Aucun événement trouvé',
});
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,682 @@
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/evenement_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/buttons/buttons.dart';
import '../bloc/evenement_bloc.dart';
import '../bloc/evenement_event.dart';
import '../bloc/evenement_state.dart';
/// Page de création d'un nouvel événement
class EvenementCreatePage extends StatefulWidget {
const EvenementCreatePage({super.key});
@override
State<EvenementCreatePage> createState() => _EvenementCreatePageState();
}
class _EvenementCreatePageState extends State<EvenementCreatePage> {
final _formKey = GlobalKey<FormState>();
final _scrollController = ScrollController();
// Controllers pour les champs de texte
final _titreController = TextEditingController();
final _descriptionController = TextEditingController();
final _lieuController = TextEditingController();
final _adresseController = TextEditingController();
final _capaciteMaxController = TextEditingController();
final _prixController = TextEditingController();
final _notesController = TextEditingController();
// Variables pour les sélections
DateTime? _dateDebut;
DateTime? _dateFin;
TimeOfDay? _heureDebut;
TimeOfDay? _heureFin;
TypeEvenement _typeSelectionne = TypeEvenement.reunion;
bool _visiblePublic = true;
bool _inscriptionRequise = true;
bool _inscriptionPayante = false;
late EvenementBloc _evenementBloc;
@override
void initState() {
super.initState();
_evenementBloc = getIt<EvenementBloc>();
}
@override
void dispose() {
_titreController.dispose();
_descriptionController.dispose();
_lieuController.dispose();
_adresseController.dispose();
_capaciteMaxController.dispose();
_prixController.dispose();
_notesController.dispose();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _evenementBloc,
child: Scaffold(
backgroundColor: AppTheme.backgroundLight,
appBar: AppBar(
title: const Text('Nouvel Événement'),
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
elevation: 0,
actions: [
BlocBuilder<EvenementBloc, EvenementState>(
builder: (context, state) {
return TextButton(
onPressed: state is EvenementLoading ? null : _sauvegarder,
child: state is EvenementLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text(
'Créer',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
);
},
),
],
),
body: BlocListener<EvenementBloc, EvenementState>(
listener: (context, state) {
if (state is EvenementOperationSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Événement créé avec succès !'),
backgroundColor: AppTheme.successColor,
),
);
Navigator.of(context).pop(true); // Retourner true pour indiquer la création
} else if (state is EvenementError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur : ${state.message}'),
backgroundColor: AppTheme.errorColor,
),
);
}
},
child: Form(
key: _formKey,
child: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInformationsGenerales(),
const SizedBox(height: 24),
_buildDateEtHeure(),
const SizedBox(height: 24),
_buildLieuEtAdresse(),
const SizedBox(height: 24),
_buildParametres(),
const SizedBox(height: 24),
_buildInformationsComplementaires(),
const SizedBox(height: 32),
],
),
),
),
),
),
);
}
Widget _buildInformationsGenerales() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Informations générales',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
const SizedBox(height: 16),
TextFormField(
controller: _titreController,
decoration: const InputDecoration(
labelText: 'Titre de l\'événement *',
hintText: 'Ex: Assemblée générale 2025',
prefixIcon: Icon(Icons.title),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Le titre est obligatoire';
}
if (value.trim().length < 3) {
return 'Le titre doit contenir au moins 3 caractères';
}
return null;
},
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 16),
DropdownButtonFormField<TypeEvenement>(
value: _typeSelectionne,
decoration: const InputDecoration(
labelText: 'Type d\'événement *',
prefixIcon: Icon(Icons.category),
),
items: TypeEvenement.values.map((type) {
return DropdownMenuItem(
value: type,
child: Row(
children: [
Text(type.icone, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Text(type.libelle),
],
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_typeSelectionne = value;
});
}
},
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
hintText: 'Décrivez votre événement...',
prefixIcon: Icon(Icons.description),
),
maxLines: 4,
textCapitalization: TextCapitalization.sentences,
),
],
),
),
);
}
Widget _buildDateEtHeure() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Date et heure',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: InkWell(
onTap: _selectionnerDateDebut,
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Date de début *',
prefixIcon: Icon(Icons.calendar_today),
),
child: Text(
_dateDebut != null
? DateFormat('dd/MM/yyyy').format(_dateDebut!)
: 'Sélectionner',
style: TextStyle(
color: _dateDebut != null ? null : Colors.grey[600],
),
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: InkWell(
onTap: _selectionnerHeureDebut,
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Heure de début *',
prefixIcon: Icon(Icons.access_time),
),
child: Text(
_heureDebut != null
? _heureDebut!.format(context)
: 'Sélectionner',
style: TextStyle(
color: _heureDebut != null ? null : Colors.grey[600],
),
),
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: InkWell(
onTap: _selectionnerDateFin,
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Date de fin',
prefixIcon: Icon(Icons.calendar_today),
),
child: Text(
_dateFin != null
? DateFormat('dd/MM/yyyy').format(_dateFin!)
: 'Optionnel',
style: TextStyle(
color: _dateFin != null ? null : Colors.grey[600],
),
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: InkWell(
onTap: _selectionnerHeureFin,
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Heure de fin',
prefixIcon: Icon(Icons.access_time),
),
child: Text(
_heureFin != null
? _heureFin!.format(context)
: 'Optionnel',
style: TextStyle(
color: _heureFin != null ? null : Colors.grey[600],
),
),
),
),
),
],
),
],
),
),
);
}
Widget _buildLieuEtAdresse() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Lieu et adresse',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
const SizedBox(height: 16),
TextFormField(
controller: _lieuController,
decoration: const InputDecoration(
labelText: 'Lieu *',
hintText: 'Ex: Salle des fêtes',
prefixIcon: Icon(Icons.place),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Le lieu est obligatoire';
}
return null;
},
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 16),
TextFormField(
controller: _adresseController,
decoration: const InputDecoration(
labelText: 'Adresse complète',
hintText: 'Ex: 123 Rue de la République, 75001 Paris',
prefixIcon: Icon(Icons.location_on),
),
maxLines: 2,
textCapitalization: TextCapitalization.words,
),
],
),
),
);
}
Widget _buildParametres() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Paramètres',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Visible au public'),
subtitle: const Text('L\'événement sera visible par tous'),
value: _visiblePublic,
onChanged: (value) {
setState(() {
_visiblePublic = value;
});
},
activeColor: AppTheme.primaryColor,
),
SwitchListTile(
title: const Text('Inscription requise'),
subtitle: const Text('Les participants doivent s\'inscrire'),
value: _inscriptionRequise,
onChanged: (value) {
setState(() {
_inscriptionRequise = value;
if (!value) {
_inscriptionPayante = false;
}
});
},
activeColor: AppTheme.primaryColor,
),
if (_inscriptionRequise)
SwitchListTile(
title: const Text('Inscription payante'),
subtitle: const Text('L\'inscription nécessite un paiement'),
value: _inscriptionPayante,
onChanged: (value) {
setState(() {
_inscriptionPayante = value;
});
},
activeColor: AppTheme.primaryColor,
),
const SizedBox(height: 16),
TextFormField(
controller: _capaciteMaxController,
decoration: const InputDecoration(
labelText: 'Capacité maximale',
hintText: 'Nombre maximum de participants',
prefixIcon: Icon(Icons.people),
suffixText: 'personnes',
),
keyboardType: TextInputType.number,
validator: (value) {
if (value != null && value.isNotEmpty) {
final capacite = int.tryParse(value);
if (capacite == null || capacite <= 0) {
return 'La capacité doit être un nombre positif';
}
}
return null;
},
),
if (_inscriptionPayante) ...[
const SizedBox(height: 16),
TextFormField(
controller: _prixController,
decoration: const InputDecoration(
labelText: 'Prix de l\'inscription *',
hintText: '0.00',
prefixIcon: Icon(Icons.euro),
suffixText: '',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (_inscriptionPayante) {
if (value == null || value.trim().isEmpty) {
return 'Le prix est obligatoire pour une inscription payante';
}
final prix = double.tryParse(value.replaceAll(',', '.'));
if (prix == null || prix < 0) {
return 'Le prix doit être un nombre positif';
}
}
return null;
},
),
],
],
),
),
);
}
Widget _buildInformationsComplementaires() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Informations complémentaires',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
const SizedBox(height: 16),
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Notes internes',
hintText: 'Notes visibles uniquement par les organisateurs...',
prefixIcon: Icon(Icons.note),
),
maxLines: 3,
textCapitalization: TextCapitalization.sentences,
),
],
),
),
);
}
// Méthodes de sélection de date et heure
Future<void> _selectionnerDateDebut() async {
final date = await showDatePicker(
context: context,
initialDate: _dateDebut ?? DateTime.now(),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
);
if (date != null) {
setState(() {
_dateDebut = date;
// Si la date de fin est antérieure, la réinitialiser
if (_dateFin != null && _dateFin!.isBefore(date)) {
_dateFin = null;
}
});
}
}
Future<void> _selectionnerDateFin() async {
final date = await showDatePicker(
context: context,
initialDate: _dateFin ?? _dateDebut ?? DateTime.now(),
firstDate: _dateDebut ?? DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
);
if (date != null) {
setState(() {
_dateFin = date;
});
}
}
Future<void> _selectionnerHeureDebut() async {
final heure = await showTimePicker(
context: context,
initialTime: _heureDebut ?? TimeOfDay.now(),
);
if (heure != null) {
setState(() {
_heureDebut = heure;
});
}
}
Future<void> _selectionnerHeureFin() async {
final heure = await showTimePicker(
context: context,
initialTime: _heureFin ?? _heureDebut ?? TimeOfDay.now(),
);
if (heure != null) {
setState(() {
_heureFin = heure;
});
}
}
// Méthode de sauvegarde
void _sauvegarder() {
if (!_formKey.currentState!.validate()) {
// Faire défiler vers le premier champ en erreur
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
return;
}
// Validation des dates
if (_dateDebut == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('La date de début est obligatoire'),
backgroundColor: AppTheme.errorColor,
),
);
return;
}
if (_heureDebut == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('L\'heure de début est obligatoire'),
backgroundColor: AppTheme.errorColor,
),
);
return;
}
// Construire les DateTime complets
final dateTimeDebut = DateTime(
_dateDebut!.year,
_dateDebut!.month,
_dateDebut!.day,
_heureDebut!.hour,
_heureDebut!.minute,
);
DateTime? dateTimeFin;
if (_dateFin != null && _heureFin != null) {
dateTimeFin = DateTime(
_dateFin!.year,
_dateFin!.month,
_dateFin!.day,
_heureFin!.hour,
_heureFin!.minute,
);
// Vérifier que la date de fin est après le début
if (dateTimeFin.isBefore(dateTimeDebut)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('La date de fin doit être après la date de début'),
backgroundColor: AppTheme.errorColor,
),
);
return;
}
}
// Créer l'objet événement
final evenement = EvenementModel(
id: null,
titre: _titreController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
typeEvenement: _typeSelectionne,
dateDebut: dateTimeDebut,
dateFin: dateTimeFin,
lieu: _lieuController.text.trim(),
adresse: _adresseController.text.trim().isEmpty
? null
: _adresseController.text.trim(),
capaciteMax: _capaciteMaxController.text.isEmpty
? null
: int.tryParse(_capaciteMaxController.text),
prix: _inscriptionPayante && _prixController.text.isNotEmpty
? double.tryParse(_prixController.text.replaceAll(',', '.'))
: null,
visiblePublic: _visiblePublic,
inscriptionRequise: _inscriptionRequise,
instructionsParticulieres: _notesController.text.trim().isEmpty
? null
: _notesController.text.trim(),
statut: StatutEvenement.planifie,
actif: true,
creePar: null, // Sera défini par le backend
dateCreation: null, // Sera défini par le backend
modifiePar: null,
dateModification: null,
organisationId: null, // Sera défini par le backend selon l'utilisateur connecté
organisateurId: null, // Sera défini par le backend selon l'utilisateur connecté
);
// Envoyer l'événement au BLoC
_evenementBloc.add(CreateEvenement(evenement));
}
}

View File

@@ -0,0 +1,426 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/models/evenement_model.dart';
/// Page de détail d'un événement
class EvenementDetailPage extends StatelessWidget {
final EvenementModel evenement;
const EvenementDetailPage({
super.key,
required this.evenement,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dateFormat = DateFormat('EEEE dd MMMM yyyy', 'fr_FR');
final timeFormat = DateFormat('HH:mm');
return Scaffold(
body: CustomScrollView(
slivers: [
// App Bar avec image de fond
SliverAppBar(
expandedHeight: 200,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(
evenement.titre,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
shadows: [
Shadow(
offset: Offset(0, 1),
blurRadius: 3,
color: Colors.black54,
),
],
),
),
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
theme.primaryColor,
theme.primaryColor.withOpacity(0.8),
],
),
),
child: Center(
child: Text(
evenement.typeEvenement.icone,
style: const TextStyle(fontSize: 80),
),
),
),
),
actions: [
IconButton(
onPressed: () => _shareEvenement(context),
icon: const Icon(Icons.share),
),
PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'calendar':
_addToCalendar(context);
break;
case 'favorite':
_toggleFavorite(context);
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'calendar',
child: Row(
children: [
Icon(Icons.calendar_today),
SizedBox(width: 8),
Text('Ajouter au calendrier'),
],
),
),
const PopupMenuItem(
value: 'favorite',
child: Row(
children: [
Icon(Icons.favorite_border),
SizedBox(width: 8),
Text('Ajouter aux favoris'),
],
),
),
],
),
],
),
// Contenu principal
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Statut et type
Row(
children: [
_buildStatutChip(context),
const SizedBox(width: 8),
Chip(
label: Text(evenement.typeEvenement.libelle),
backgroundColor: theme.primaryColor.withOpacity(0.1),
),
],
),
const SizedBox(height: 16),
// Description
if (evenement.description != null) ...[
Text(
'Description',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
evenement.description!,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
],
// Informations pratiques
_buildSectionTitle(context, 'Informations pratiques'),
const SizedBox(height: 12),
_buildInfoRow(
context,
Icons.schedule,
'Date et heure',
'${dateFormat.format(evenement.dateDebut)}\n'
'${timeFormat.format(evenement.dateDebut)}'
'${evenement.dateFin != null ? ' - ${timeFormat.format(evenement.dateFin!)}' : ''}',
),
if (evenement.lieu != null)
_buildInfoRow(
context,
Icons.location_on,
'Lieu',
evenement.lieu!,
),
if (evenement.adresse != null)
_buildInfoRow(
context,
Icons.map,
'Adresse',
evenement.adresse!,
),
if (evenement.duree != null)
_buildInfoRow(
context,
Icons.timer,
'Durée',
evenement.dureeFormatee,
),
if (evenement.prix != null)
_buildInfoRow(
context,
Icons.euro,
'Prix',
evenement.prix! > 0
? '${evenement.prix!.toStringAsFixed(0)}'
: 'Gratuit',
),
if (evenement.capaciteMax != null)
_buildInfoRow(
context,
Icons.people,
'Capacité',
'${evenement.capaciteMax} personnes',
),
const SizedBox(height: 24),
// Inscription
if (evenement.inscriptionRequise) ...[
_buildSectionTitle(context, 'Inscription'),
const SizedBox(height: 12),
if (evenement.inscriptionsOuvertes) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.green.withOpacity(0.3)),
),
child: Column(
children: [
const Icon(
Icons.check_circle,
color: Colors.green,
size: 32,
),
const SizedBox(height: 8),
const Text(
'Inscriptions ouvertes',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
if (evenement.dateLimiteInscription != null) ...[
const SizedBox(height: 4),
Text(
'Jusqu\'au ${dateFormat.format(evenement.dateLimiteInscription!)}',
style: TextStyle(
color: Colors.green[700],
fontSize: 12,
),
),
],
],
),
),
] else ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.withOpacity(0.3)),
),
child: const Column(
children: [
Icon(
Icons.cancel,
color: Colors.red,
size: 32,
),
SizedBox(height: 8),
Text(
'Inscriptions fermées',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
],
),
),
],
const SizedBox(height: 24),
],
// Instructions particulières
if (evenement.instructionsParticulieres != null) ...[
_buildSectionTitle(context, 'Instructions particulières'),
const SizedBox(height: 8),
Text(
evenement.instructionsParticulieres!,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
],
// Matériel requis
if (evenement.materielRequis != null) ...[
_buildSectionTitle(context, 'Matériel requis'),
const SizedBox(height: 8),
Text(
evenement.materielRequis!,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
],
// Contact organisateur
if (evenement.contactOrganisateur != null) ...[
_buildSectionTitle(context, 'Contact organisateur'),
const SizedBox(height: 8),
Text(
evenement.contactOrganisateur!,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
],
// Espace pour le bouton flottant
const SizedBox(height: 80),
],
),
),
),
],
),
// Bouton d'action flottant
floatingActionButton: evenement.inscriptionRequise &&
evenement.inscriptionsOuvertes
? FloatingActionButton.extended(
onPressed: () => _inscrireAEvenement(context),
icon: const Icon(Icons.how_to_reg),
label: const Text('S\'inscrire'),
)
: null,
);
}
Widget _buildStatutChip(BuildContext context) {
final color = Color(int.parse(
evenement.statut.couleur.substring(1),
radix: 16,
) + 0xFF000000);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Text(
evenement.statut.libelle,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: color,
),
),
);
}
Widget _buildSectionTitle(BuildContext context, String title) {
return Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
);
}
Widget _buildInfoRow(
BuildContext context,
IconData icon,
String label,
String value,
) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 20,
color: Colors.grey[600],
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
value,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
);
}
void _shareEvenement(BuildContext context) {
// TODO: Implémenter le partage
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Partage - À implémenter')),
);
}
void _addToCalendar(BuildContext context) {
// TODO: Implémenter l'ajout au calendrier
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ajout au calendrier - À implémenter')),
);
}
void _toggleFavorite(BuildContext context) {
// TODO: Implémenter les favoris
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Favoris - À implémenter')),
);
}
void _inscrireAEvenement(BuildContext context) {
// TODO: Implémenter l'inscription
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Inscription - À implémenter')),
);
}
}

View File

@@ -0,0 +1,382 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/models/evenement_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../bloc/evenement_bloc.dart';
import '../bloc/evenement_event.dart';
import '../bloc/evenement_state.dart';
import '../widgets/evenement_card.dart';
import '../widgets/evenement_search_bar.dart';
import '../widgets/evenement_filter_chips.dart';
import 'evenement_detail_page.dart';
import 'evenement_create_page.dart';
/// Page principale des événements
class EvenementsPage extends StatelessWidget {
const EvenementsPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<EvenementBloc>()
..add(const LoadEvenementsAVenir()),
child: const _EvenementsPageContent(),
);
}
}
class _EvenementsPageContent extends StatefulWidget {
const _EvenementsPageContent();
@override
State<_EvenementsPageContent> createState() => _EvenementsPageContentState();
}
class _EvenementsPageContentState extends State<_EvenementsPageContent>
with TickerProviderStateMixin {
late TabController _tabController;
final ScrollController _scrollController = ScrollController();
String _searchTerm = '';
TypeEvenement? _selectedType;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_scrollController.addListener(_onScroll);
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
_onTabChanged(_tabController.index);
}
});
}
@override
void dispose() {
_tabController.dispose();
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_isBottom) {
final bloc = context.read<EvenementBloc>();
final state = bloc.state;
if (state is EvenementLoaded && !state.hasReachedMax) {
_loadMoreEvents(state);
}
}
}
bool get _isBottom {
if (!_scrollController.hasClients) return false;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.9);
}
void _loadMoreEvents(EvenementLoaded state) {
final nextPage = state.currentPage + 1;
switch (_tabController.index) {
case 0:
context.read<EvenementBloc>().add(
LoadEvenementsAVenir(page: nextPage),
);
break;
case 1:
context.read<EvenementBloc>().add(
LoadEvenementsPublics(page: nextPage),
);
break;
case 2:
if (_searchTerm.isNotEmpty) {
context.read<EvenementBloc>().add(
SearchEvenements(terme: _searchTerm, page: nextPage),
);
} else if (_selectedType != null) {
context.read<EvenementBloc>().add(
FilterEvenementsByType(type: _selectedType!, page: nextPage),
);
} else {
context.read<EvenementBloc>().add(
LoadEvenements(page: nextPage),
);
}
break;
}
}
void _onTabChanged(int index) {
context.read<EvenementBloc>().add(const ResetEvenementState());
switch (index) {
case 0:
context.read<EvenementBloc>().add(const LoadEvenementsAVenir());
break;
case 1:
context.read<EvenementBloc>().add(const LoadEvenementsPublics());
break;
case 2:
context.read<EvenementBloc>().add(const LoadEvenements());
break;
}
}
void _onSearch(String terme) {
setState(() {
_searchTerm = terme;
_selectedType = null;
});
if (terme.isNotEmpty) {
context.read<EvenementBloc>().add(
SearchEvenements(terme: terme, refresh: true),
);
} else {
context.read<EvenementBloc>().add(
const LoadEvenements(refresh: true),
);
}
}
void _onFilterByType(TypeEvenement? type) {
setState(() {
_selectedType = type;
_searchTerm = '';
});
if (type != null) {
context.read<EvenementBloc>().add(
FilterEvenementsByType(type: type, refresh: true),
);
} else {
context.read<EvenementBloc>().add(
const LoadEvenements(refresh: true),
);
}
}
void _onRefresh() {
switch (_tabController.index) {
case 0:
context.read<EvenementBloc>().add(
const LoadEvenementsAVenir(refresh: true),
);
break;
case 1:
context.read<EvenementBloc>().add(
const LoadEvenementsPublics(refresh: true),
);
break;
case 2:
if (_searchTerm.isNotEmpty) {
context.read<EvenementBloc>().add(
SearchEvenements(terme: _searchTerm, refresh: true),
);
} else if (_selectedType != null) {
context.read<EvenementBloc>().add(
FilterEvenementsByType(type: _selectedType!, refresh: true),
);
} else {
context.read<EvenementBloc>().add(
const LoadEvenements(refresh: true),
);
}
break;
}
}
void _navigateToDetail(EvenementModel evenement) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => EvenementDetailPage(evenement: evenement),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Événements'),
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'À venir', icon: Icon(Icons.upcoming)),
Tab(text: 'Publics', icon: Icon(Icons.public)),
Tab(text: 'Tous', icon: Icon(Icons.list)),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildEvenementsList(showSearch: false),
_buildEvenementsList(showSearch: false),
_buildEvenementsList(showSearch: true),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
final result = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (context) => const EvenementCreatePage(),
),
);
// Si un événement a été créé, recharger la liste
if (result == true && context.mounted) {
context.read<EvenementBloc>().add(const LoadEvenementsAVenir());
}
},
child: const Icon(Icons.add),
),
);
}
Widget _buildEvenementsList({required bool showSearch}) {
return Column(
children: [
if (showSearch) ...[
Padding(
padding: const EdgeInsets.all(16.0),
child: EvenementSearchBar(
onSearch: _onSearch,
initialValue: _searchTerm,
),
),
EvenementFilterChips(
selectedType: _selectedType,
onTypeSelected: _onFilterByType,
),
],
Expanded(
child: BlocConsumer<EvenementBloc, EvenementState>(
listener: (context, state) {
if (state is EvenementError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
),
);
}
},
builder: (context, state) {
if (state is EvenementLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is EvenementError && state.evenements == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(state.message, textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _onRefresh,
child: const Text('Réessayer'),
),
],
),
);
}
if (state is EvenementSearchEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.search_off,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(
'Aucun résultat pour "${state.searchTerm}"',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
const Text('Essayez avec d\'autres mots-clés'),
],
),
);
}
if (state is EvenementEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.event_busy,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(
state.message,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
);
}
final evenements = state is EvenementLoaded
? state.evenements
: state is EvenementLoadingMore
? state.evenements
: state is EvenementError
? state.evenements ?? <EvenementModel>[]
: <EvenementModel>[];
if (evenements.isEmpty) {
return const Center(
child: Text('Aucun événement disponible'),
);
}
return RefreshIndicator(
onRefresh: () async => _onRefresh(),
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: evenements.length +
(state is EvenementLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= evenements.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final evenement = evenements[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: EvenementCard(
evenement: evenement,
onTap: () => _navigateToDetail(evenement),
),
);
},
),
);
},
),
),
],
);
}
}

View File

@@ -0,0 +1,349 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../core/models/evenement_model.dart';
/// Widget carte pour afficher un événement
class EvenementCard extends StatelessWidget {
final EvenementModel evenement;
final VoidCallback? onTap;
final VoidCallback? onFavorite;
final bool showActions;
const EvenementCard({
super.key,
required this.evenement,
this.onTap,
this.onFavorite,
this.showActions = true,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dateFormat = DateFormat('dd/MM/yyyy');
final timeFormat = DateFormat('HH:mm');
return Card(
elevation: 2,
margin: EdgeInsets.zero,
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 type et statut
Row(
children: [
// Icône du type
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
evenement.typeEvenement.icone,
style: const TextStyle(fontSize: 20),
),
),
const SizedBox(width: 12),
// Type et statut
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
evenement.typeEvenement.libelle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.primaryColor,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
_buildStatutChip(context),
],
),
),
// Actions
if (showActions) ...[
if (onFavorite != null)
IconButton(
onPressed: onFavorite,
icon: const Icon(Icons.favorite_border),
iconSize: 20,
),
PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'share':
_shareEvenement(context);
break;
case 'calendar':
_addToCalendar(context);
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'share',
child: Row(
children: [
Icon(Icons.share, size: 20),
SizedBox(width: 8),
Text('Partager'),
],
),
),
const PopupMenuItem(
value: 'calendar',
child: Row(
children: [
Icon(Icons.calendar_today, size: 20),
SizedBox(width: 8),
Text('Ajouter au calendrier'),
],
),
),
],
child: const Icon(Icons.more_vert, size: 20),
),
],
],
),
const SizedBox(height: 12),
// Titre
Text(
evenement.titre,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (evenement.description != null) ...[
const SizedBox(height: 8),
Text(
evenement.description!,
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 12),
// Informations date et lieu
Row(
children: [
// Date
Expanded(
child: Row(
children: [
Icon(
Icons.schedule,
size: 16,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dateFormat.format(evenement.dateDebut),
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
timeFormat.format(evenement.dateDebut),
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
),
),
],
),
),
// Lieu
if (evenement.lieu != null)
Expanded(
child: Row(
children: [
Icon(
Icons.location_on,
size: 16,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Expanded(
child: Text(
evenement.lieu!,
style: theme.textTheme.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
// Informations supplémentaires
if (evenement.prix != null ||
evenement.capaciteMax != null ||
evenement.inscriptionRequise) ...[
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
// Prix
if (evenement.prix != null)
_buildInfoChip(
context,
evenement.prix! > 0
? '${evenement.prix!.toStringAsFixed(0)}'
: 'Gratuit',
Icons.euro,
evenement.prix! > 0 ? Colors.orange : Colors.green,
),
// Capacité
if (evenement.capaciteMax != null)
_buildInfoChip(
context,
'${evenement.capaciteMax} places',
Icons.people,
Colors.blue,
),
// Inscription requise
if (evenement.inscriptionRequise)
_buildInfoChip(
context,
evenement.inscriptionsOuvertes
? 'Inscriptions ouvertes'
: 'Inscriptions fermées',
Icons.how_to_reg,
evenement.inscriptionsOuvertes
? Colors.green
: Colors.red,
),
],
),
],
// Durée si disponible
if (evenement.duree != null) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.timer,
size: 16,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Text(
'Durée: ${evenement.dureeFormatee}',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
),
],
],
),
),
),
);
}
Widget _buildStatutChip(BuildContext context) {
final color = Color(int.parse(
evenement.statut.couleur.substring(1),
radix: 16,
) + 0xFF000000);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Text(
evenement.statut.libelle,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: color,
),
),
);
}
Widget _buildInfoChip(
BuildContext context,
String label,
IconData icon,
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: 14,
color: color,
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: color,
),
),
],
),
);
}
void _shareEvenement(BuildContext context) {
// TODO: Implémenter le partage
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Partage - À implémenter')),
);
}
void _addToCalendar(BuildContext context) {
// TODO: Implémenter l'ajout au calendrier
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ajout au calendrier - À implémenter')),
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import '../../../../core/models/evenement_model.dart';
/// Widget pour les filtres par type d'événement
class EvenementFilterChips extends StatelessWidget {
final TypeEvenement? selectedType;
final Function(TypeEvenement?) onTypeSelected;
const EvenementFilterChips({
super.key,
this.selectedType,
required this.onTypeSelected,
});
@override
Widget build(BuildContext context) {
return Container(
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ListView(
scrollDirection: Axis.horizontal,
children: [
// Chip "Tous"
Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: const Text('Tous'),
selected: selectedType == null,
onSelected: (selected) {
onTypeSelected(selected ? null : selectedType);
},
backgroundColor: Colors.grey[200],
selectedColor: Theme.of(context).primaryColor.withOpacity(0.2),
checkmarkColor: Theme.of(context).primaryColor,
),
),
// Chips pour chaque type
...TypeEvenement.values.map((type) => Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(type.icone),
const SizedBox(width: 4),
Text(type.libelle),
],
),
selected: selectedType == type,
onSelected: (selected) {
onTypeSelected(selected ? type : null);
},
backgroundColor: Colors.grey[200],
selectedColor: Theme.of(context).primaryColor.withOpacity(0.2),
checkmarkColor: Theme.of(context).primaryColor,
),
)),
],
),
);
}
}

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'dart:async';
/// Barre de recherche pour les événements
class EvenementSearchBar extends StatefulWidget {
final Function(String) onSearch;
final String? initialValue;
final String hintText;
final Duration debounceTime;
const EvenementSearchBar({
super.key,
required this.onSearch,
this.initialValue,
this.hintText = 'Rechercher un événement...',
this.debounceTime = const Duration(milliseconds: 500),
});
@override
State<EvenementSearchBar> createState() => _EvenementSearchBarState();
}
class _EvenementSearchBarState extends State<EvenementSearchBar> {
late TextEditingController _controller;
Timer? _debounceTimer;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialValue);
}
@override
void dispose() {
_controller.dispose();
_debounceTimer?.cancel();
super.dispose();
}
void _onSearchChanged(String value) {
_debounceTimer?.cancel();
_debounceTimer = Timer(widget.debounceTime, () {
widget.onSearch(value.trim());
});
}
void _clearSearch() {
_controller.clear();
widget.onSearch('');
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[300]!),
),
child: TextField(
controller: _controller,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: widget.hintText,
prefixIcon: const Icon(Icons.search, color: Colors.grey),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
onPressed: _clearSearch,
icon: const Icon(Icons.clear, color: Colors.grey),
)
: null,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
);
}
}

View File

@@ -65,6 +65,15 @@ class MembreRepositoryImpl implements MembreRepository {
}
}
@override
Future<List<MembreModel>> advancedSearchMembres(Map<String, dynamic> filters) async {
try {
return await _apiService.advancedSearchMembres(filters);
} catch (e) {
throw ServerFailure(message: e.toString());
}
}
@override
Future<Map<String, dynamic>> getMembresStats() async {
try {

View File

@@ -21,6 +21,9 @@ abstract class MembreRepository {
/// Recherche des membres par nom ou prénom
Future<List<MembreModel>> searchMembres(String query);
/// Recherche avancée des membres avec filtres multiples
Future<List<MembreModel>> advancedSearchMembres(Map<String, dynamic> filters);
/// Récupère les statistiques des membres
Future<Map<String, dynamic>> getMembresStats();
}

View File

@@ -16,6 +16,7 @@ class MembresBloc extends Bloc<MembresEvent, MembresState> {
on<LoadMembres>(_onLoadMembres);
on<RefreshMembres>(_onRefreshMembres);
on<SearchMembres>(_onSearchMembres);
on<AdvancedSearchMembres>(_onAdvancedSearchMembres);
on<LoadMembreById>(_onLoadMembreById);
on<CreateMembre>(_onCreateMembre);
on<UpdateMembre>(_onUpdateMembre);
@@ -101,6 +102,83 @@ class MembresBloc extends Bloc<MembresEvent, MembresState> {
}
}
/// 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,

View File

@@ -29,6 +29,16 @@ class SearchMembres extends MembresEvent {
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);

View File

@@ -4,6 +4,11 @@ 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';
@@ -92,28 +97,41 @@ class _MembreCreatePageState extends State<MembreCreatePage>
body: BlocConsumer<MembresBloc, MembresState>(
listener: (context, state) {
if (state is MembreCreated) {
// Fermer l'indicateur de chargement
UserFeedback.hideLoading(context);
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Membre créé avec succès !'),
backgroundColor: AppTheme.successColor,
),
// 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',
);
Navigator.of(context).pop(true); // Retourner true pour indiquer le succès
// 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;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppTheme.errorColor,
),
// Gérer l'erreur avec le nouveau système
ErrorHandler.handleError(
context,
state.failure,
onRetry: () => _submitForm(),
);
}
},
@@ -780,82 +798,122 @@ class _MembreCreatePageState extends State<MembreCreatePage>
}
bool _validatePersonalInfo() {
bool isValid = true;
final errors = <String>[];
if (_prenomController.text.trim().isEmpty) {
_showFieldError('Le prénom est requis');
isValid = false;
// 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 (_nomController.text.trim().isEmpty) {
_showFieldError('Le nom est requis');
isValid = false;
if (errors.isNotEmpty) {
UserFeedback.showWarning(context, errors.first);
return false;
}
return isValid;
return true;
}
bool _validateContactInfo() {
bool isValid = true;
final errors = <String>[];
if (_emailController.text.trim().isEmpty) {
_showFieldError('L\'email est requis');
isValid = false;
} else if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(_emailController.text)) {
_showFieldError('Format d\'email invalide');
isValid = false;
// 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;
}
if (_telephoneController.text.trim().isEmpty) {
_showFieldError('Le téléphone est requis');
isValid = false;
}
return isValid;
return true;
}
void _showFieldError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 2),
),
);
}
void _submitForm() {
if (!_formKey.currentState!.validate()) {
// 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;
});
// Créer le modèle membre
final membre = MembreModel(
id: '', // Sera généré par le backend
numeroMembre: _numeroMembreController.text.trim(),
nom: _nomController.text.trim(),
prenom: _prenomController.text.trim(),
email: _emailController.text.trim(),
telephone: _telephoneController.text.trim(),
dateNaissance: _dateNaissance,
adresse: _adresseController.text.trim().isNotEmpty ? _adresseController.text.trim() : null,
ville: _villeController.text.trim().isNotEmpty ? _villeController.text.trim() : null,
codePostal: _codePostalController.text.trim().isNotEmpty ? _codePostalController.text.trim() : null,
pays: _paysController.text.trim().isNotEmpty ? _paysController.text.trim() : null,
profession: _professionController.text.trim().isNotEmpty ? _professionController.text.trim() : null,
dateAdhesion: _dateAdhesion,
actif: _actif,
statut: 'ACTIF',
version: 1,
dateCreation: DateTime.now(),
);
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));
// 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 {

View File

@@ -7,6 +7,8 @@ import '../../../../core/models/membre_model.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/custom_text_field.dart';
import '../../../../shared/widgets/buttons/buttons.dart';
import '../../../../core/auth/services/permission_service.dart';
import '../../../../shared/widgets/permission_widget.dart';
import '../bloc/membres_bloc.dart';
import '../bloc/membres_event.dart';
import '../bloc/membres_state.dart';
@@ -25,7 +27,7 @@ class MembreEditPage extends StatefulWidget {
}
class _MembreEditPageState extends State<MembreEditPage>
with SingleTickerProviderStateMixin {
with SingleTickerProviderStateMixin, PermissionMixin {
late MembresBloc _membresBloc;
late TabController _tabController;
final _formKey = GlobalKey<FormState>();
@@ -53,12 +55,22 @@ class _MembreEditPageState extends State<MembreEditPage>
@override
void initState() {
super.initState();
// Vérification des permissions d'accès
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!permissionService.canEditMembers) {
showPermissionError(context, 'Vous n\'avez pas les permissions pour modifier les membres');
Navigator.of(context).pop();
return;
}
});
_membresBloc = getIt<MembresBloc>();
_tabController = TabController(length: 3, vsync: this);
// Pré-remplir les champs avec les données existantes
_populateFields();
// Écouter les changements pour détecter les modifications
_setupChangeListeners();
}
@@ -184,10 +196,12 @@ class _MembreEditPageState extends State<MembreEditPage>
),
actions: [
if (_hasChanges)
IconButton(
PermissionIconButton(
permission: () => permissionService.canEditMembers,
icon: const Icon(Icons.save),
onPressed: _submitForm,
tooltip: 'Sauvegarder',
disabledMessage: 'Vous n\'avez pas les permissions pour modifier ce membre',
),
IconButton(
icon: const Icon(Icons.help_outline),
@@ -939,6 +953,12 @@ class _MembreEditPageState extends State<MembreEditPage>
}
void _submitForm() {
// Vérification des permissions
if (!permissionService.canEditMembers) {
showPermissionError(context, 'Vous n\'avez pas les permissions pour modifier ce membre');
return;
}
if (!_formKey.currentState!.validate()) {
return;
}
@@ -948,6 +968,12 @@ class _MembreEditPageState extends State<MembreEditPage>
return;
}
// Log de l'action pour audit
permissionService.logAction('Modification membre', details: {
'membreId': widget.membre.id,
'nom': '${widget.membre.prenom} ${widget.membre.nom}',
});
setState(() {
_isLoading = true;
});

View File

@@ -5,6 +5,15 @@ 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';
class MembresDashboardPage extends StatefulWidget {
const MembresDashboardPage({super.key});
@@ -15,6 +24,8 @@ class MembresDashboardPage extends StatefulWidget {
class _MembresDashboardPageState extends State<MembresDashboardPage> {
late MembresBloc _membresBloc;
Map<String, dynamic> _currentFilters = {};
String _currentSearchQuery = '';
@override
void initState() {
@@ -27,6 +38,37 @@ class _MembresDashboardPageState extends State<MembresDashboardPage> {
_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(
@@ -117,37 +159,109 @@ class _MembresDashboardPageState extends State<MembresDashboardPage> {
}
Widget _buildDashboard() {
return Container(
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.dashboard,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'Dashboard Vide',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: Colors.grey,
),
),
SizedBox(height: 8),
Text(
'Prêt à être reconstruit pièce par pièce',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
textAlign: TextAlign.center,
),
],
),
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) {
// TODO: Modifier le membre
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Modification de ${member.nomComplet}'),
backgroundColor: AppTheme.warningColor,
),
);
},
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'),
);
}
},
),
],
),
);
}

View File

@@ -3,8 +3,12 @@ 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/coming_soon_page.dart';
import '../../../../shared/widgets/permission_widget.dart';
import '../bloc/membres_bloc.dart';
import '../bloc/membres_event.dart';
import '../bloc/membres_state.dart';
@@ -13,9 +17,12 @@ 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 'membres_dashboard_page.dart';
import '../widgets/error_demo_widget.dart';
/// Page de liste des membres avec fonctionnalités avancées
@@ -26,12 +33,17 @@ class MembresListPage extends StatefulWidget {
State<MembresListPage> createState() => _MembresListPageState();
}
class _MembresListPageState extends State<MembresListPage> {
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();
@@ -64,25 +76,46 @@ class _MembresListPageState extends State<MembresListPage> {
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
// Recherche avancée - Accessible à tous les utilisateurs connectés
PermissionIconButton(
permission: () => permissionService.isAuthenticated,
icon: const Icon(Icons.search),
onPressed: () => _showAdvancedSearch(),
tooltip: 'Recherche avancée',
),
IconButton(
// 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',
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () => _showAddMemberDialog(),
tooltip: 'Ajouter un membre',
// 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',
),
IconButton(
// 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',
),
],
),
@@ -158,21 +191,7 @@ class _MembresListPageState extends State<MembresListPage> {
),
child: membres.isEmpty
? _buildEmptyWidget(isSearchResult)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: membres.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MembreCard(
membre: membres[index],
onTap: () => _showMemberDetails(membres[index]),
onEdit: () => _showEditMemberDialog(membres[index]),
onDelete: () => _showDeleteConfirmation(membres[index]),
),
);
},
),
: _buildScrollableContent(membres),
);
}
@@ -190,6 +209,12 @@ class _MembresListPageState extends State<MembresListPage> {
),
],
),
floatingActionButton: PermissionFAB(
permission: () => permissionService.canCreateMembers,
onPressed: () => _showAddMemberDialog(),
tooltip: 'Ajouter un membre',
child: const Icon(Icons.add),
),
),
);
}
@@ -271,25 +296,13 @@ class _MembresListPageState extends State<MembresListPage> {
Text(
isSearchResult
? 'Essayez avec d\'autres termes de recherche'
: 'Commencez par ajouter votre premier membre',
: 'Utilisez le bouton + en bas pour ajouter votre premier membre',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
if (!isSearchResult) ...[
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _showAddMemberDialog,
icon: const Icon(Icons.add),
label: const Text('Ajouter un membre'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
),
],
],
),
),
@@ -323,8 +336,197 @@ class _MembresListPageState extends State<MembresListPage> {
);
}
/// 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(),
@@ -339,6 +541,14 @@ class _MembresListPageState extends State<MembresListPage> {
/// Affiche le dialog d'édition de membre
void _showEditMemberDialog(membre) {
// 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});
// TODO: Implémenter le formulaire d'édition
showDialog(
context: context,
@@ -353,6 +563,14 @@ class _MembresListPageState extends State<MembresListPage> {
/// 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,
@@ -367,9 +585,19 @@ class _MembresListPageState extends State<MembresListPage> {
/// Affiche les statistiques
void _showStatsDialog() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const MembresDashboardPage(),
// 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,
),
);
}
@@ -386,11 +614,24 @@ class _MembresListPageState extends State<MembresListPage> {
maxChildSize: 0.95,
builder: (context, scrollController) => MembresAdvancedSearch(
onSearch: (filters) {
// TODO: Implémenter la recherche avec filtres
// 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 avec ${filters.length} filtres - À implémenter'),
backgroundColor: AppTheme.infoColor,
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),
),
);
},
@@ -401,6 +642,14 @@ class _MembresListPageState extends State<MembresListPage> {
/// 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(
@@ -408,4 +657,136 @@ class _MembresListPageState extends State<MembresListPage> {
),
);
}
/// 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(),
),
);
}
}

View File

@@ -0,0 +1,117 @@
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,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,163 @@
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,
),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,311 @@
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(),
);
}
}

View File

@@ -0,0 +1,564 @@
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,
);
},
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,828 @@
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),
),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,471 @@
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,
),
),
);
}
}

View File

@@ -0,0 +1,169 @@
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,
),
),
),
],
),
)),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,200 @@
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'),
),
],
),
);
}
}

View File

@@ -0,0 +1,519 @@
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,
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,182 @@
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,
),
);
}
}

View File

@@ -0,0 +1,339 @@
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];
}
}

View File

@@ -0,0 +1,396 @@
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,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,380 @@
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(),
);
}
}

View File

@@ -0,0 +1,109 @@
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,
),
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,341 @@
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',
);
}
}
}

View File

@@ -0,0 +1,390 @@
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;
}
}
}

View File

@@ -1,6 +1,7 @@
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 {
@@ -390,7 +391,7 @@ class _MembresExportDialogState extends State<MembresExportDialog> {
}
}
void _performExport(List<MembreModel> membersToExport) {
Future<void> _performExport(List<MembreModel> membersToExport) async {
// Filtrer les membres selon les options
List<MembreModel> filteredMembers = membersToExport;
@@ -399,35 +400,22 @@ class _MembresExportDialogState extends State<MembresExportDialog> {
}
// Créer les options d'export
final exportOptions = {
'format': _selectedFormat,
'includePersonalInfo': _includePersonalInfo,
'includeContactInfo': _includeContactInfo,
'includeAdhesionInfo': _includeAdhesionInfo,
'includeStatistics': _includeStatistics,
'includeInactiveMembers': _includeInactiveMembers,
};
final exportOptions = ExportOptions(
format: _selectedFormat,
includePersonalInfo: _includePersonalInfo,
includeContactInfo: _includeContactInfo,
includeAdhesionInfo: _includeAdhesionInfo,
includeStatistics: _includeStatistics,
includeInactiveMembers: _includeInactiveMembers,
);
// TODO: Implémenter l'export réel selon le format
_showExportResult(filteredMembers.length, _selectedFormat);
}
void _showExportResult(int count, String format) {
// Fermer le dialog avant l'export
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Export $format de $count membres - À implémenter',
),
backgroundColor: AppTheme.infoColor,
action: SnackBarAction(
label: 'Voir',
onPressed: () {
// TODO: Ouvrir le fichier exporté
},
),
),
);
// Effectuer l'export réel
final exportService = ExportImportService();
await exportService.exportMembers(context, filteredMembers, exportOptions);
}
}

View File

@@ -0,0 +1,281 @@
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,
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,179 @@
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: 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: 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,
),
),
),
);
}
}

View File

@@ -70,7 +70,7 @@ class _ProfessionalLineChartState extends State<ProfessionalLineChart>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedBox(height: DesignSystem.spacingLg),
const SizedBox(height: DesignSystem.spacingLg),
Expanded(
child: _buildChart(),
),
@@ -89,7 +89,7 @@ class _ProfessionalLineChartState extends State<ProfessionalLineChart>
),
),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
const SizedBox(height: DesignSystem.spacingXs),
Text(
widget.subtitle!,
style: DesignSystem.bodyMedium.copyWith(
@@ -111,7 +111,7 @@ class _ProfessionalLineChartState extends State<ProfessionalLineChart>
touchTooltipData: LineTouchTooltipData(
tooltipBgColor: AppTheme.textPrimary.withOpacity(0.9),
tooltipRoundedRadius: DesignSystem.radiusSm,
tooltipPadding: EdgeInsets.all(DesignSystem.spacingSm),
tooltipPadding: const EdgeInsets.all(DesignSystem.spacingSm),
getTooltipItems: (List<LineBarSpot> touchedBarSpots) {
return touchedBarSpots.map((barSpot) {
final data = widget.data[barSpot.x.toInt()];
@@ -239,7 +239,7 @@ class _ProfessionalLineChartState extends State<ProfessionalLineChart>
return SideTitleWidget(
axisSide: meta.axisSide,
child: Padding(
padding: EdgeInsets.only(top: DesignSystem.spacingXs),
padding: const EdgeInsets.only(top: DesignSystem.spacingXs),
child: Text(
data.label,
style: DesignSystem.labelSmall.copyWith(

View File

@@ -65,7 +65,7 @@ class _ProfessionalPieChartState extends State<ProfessionalPieChart>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedBox(height: DesignSystem.spacingLg),
const SizedBox(height: DesignSystem.spacingLg),
Expanded(
child: Row(
children: [
@@ -74,7 +74,7 @@ class _ProfessionalPieChartState extends State<ProfessionalPieChart>
child: _buildChart(),
),
if (widget.showLegend) ...[
SizedBox(width: DesignSystem.spacingLg),
const SizedBox(width: DesignSystem.spacingLg),
Expanded(
flex: 2,
child: _buildLegend(),
@@ -98,7 +98,7 @@ class _ProfessionalPieChartState extends State<ProfessionalPieChart>
),
),
if (widget.subtitle != null) ...[
SizedBox(height: DesignSystem.spacingXs),
const SizedBox(height: DesignSystem.spacingXs),
Text(
widget.subtitle!,
style: DesignSystem.bodyMedium.copyWith(
@@ -177,7 +177,7 @@ class _ProfessionalPieChartState extends State<ProfessionalPieChart>
Widget _buildBadge(ChartDataPoint data) {
return Container(
padding: EdgeInsets.symmetric(
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingSm,
vertical: DesignSystem.spacingXs,
),
@@ -203,7 +203,7 @@ class _ProfessionalPieChartState extends State<ProfessionalPieChart>
children: [
if (widget.centerText != null) ...[
_buildCenterInfo(),
SizedBox(height: DesignSystem.spacingLg),
const SizedBox(height: DesignSystem.spacingLg),
],
...widget.data.asMap().entries.map((entry) {
final index = entry.key;
@@ -212,8 +212,8 @@ class _ProfessionalPieChartState extends State<ProfessionalPieChart>
return AnimatedContainer(
duration: DesignSystem.animationFast,
margin: EdgeInsets.only(bottom: DesignSystem.spacingSm),
padding: EdgeInsets.all(DesignSystem.spacingSm),
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),
@@ -232,7 +232,7 @@ class _ProfessionalPieChartState extends State<ProfessionalPieChart>
borderRadius: BorderRadius.circular(DesignSystem.radiusXs),
),
),
SizedBox(width: DesignSystem.spacingSm),
const SizedBox(width: DesignSystem.spacingSm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -262,7 +262,7 @@ class _ProfessionalPieChartState extends State<ProfessionalPieChart>
Widget _buildCenterInfo() {
return Container(
padding: EdgeInsets.all(DesignSystem.spacingMd),
padding: const EdgeInsets.all(DesignSystem.spacingMd),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
@@ -279,7 +279,7 @@ class _ProfessionalPieChartState extends State<ProfessionalPieChart>
color: AppTheme.textSecondary,
),
),
SizedBox(height: DesignSystem.spacingXs),
const SizedBox(height: DesignSystem.spacingXs),
Text(
widget.centerText!,
style: DesignSystem.headlineMedium.copyWith(

View File

@@ -147,7 +147,7 @@ class _StatsGridCardState extends State<StatsGridCard>
Widget _buildStatCard(_StatItem item) {
return Container(
padding: EdgeInsets.all(DesignSystem.spacingMd),
padding: const EdgeInsets.all(DesignSystem.spacingMd),
decoration: BoxDecoration(
color: AppTheme.surfaceLight,
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
@@ -164,7 +164,7 @@ class _StatsGridCardState extends State<StatsGridCard>
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: EdgeInsets.all(DesignSystem.spacingSm),
padding: const EdgeInsets.all(DesignSystem.spacingSm),
decoration: BoxDecoration(
color: item.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
@@ -176,7 +176,7 @@ class _StatsGridCardState extends State<StatsGridCard>
),
),
Container(
padding: EdgeInsets.symmetric(
padding: const EdgeInsets.symmetric(
horizontal: DesignSystem.spacingXs,
vertical: 2,
),
@@ -199,7 +199,7 @@ class _StatsGridCardState extends State<StatsGridCard>
),
],
),
SizedBox(height: DesignSystem.spacingSm),
const SizedBox(height: DesignSystem.spacingSm),
Text(
item.value,
style: DesignSystem.headlineMedium.copyWith(
@@ -207,7 +207,7 @@ class _StatsGridCardState extends State<StatsGridCard>
color: AppTheme.textPrimary,
),
),
SizedBox(height: DesignSystem.spacingXs),
const SizedBox(height: DesignSystem.spacingXs),
Text(
item.title,
style: DesignSystem.labelMedium.copyWith(

View File

@@ -74,7 +74,7 @@ class _StatsOverviewCardState extends State<StatsOverviewCard>
Widget _buildCard() {
return Container(
padding: EdgeInsets.all(DesignSystem.spacingLg),
padding: const EdgeInsets.all(DesignSystem.spacingLg),
decoration: BoxDecoration(
gradient: DesignSystem.primaryGradient,
borderRadius: BorderRadius.circular(DesignSystem.radiusLg),
@@ -84,11 +84,11 @@ class _StatsOverviewCardState extends State<StatsOverviewCard>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedBox(height: DesignSystem.spacingLg),
const SizedBox(height: DesignSystem.spacingLg),
_buildMainStats(),
SizedBox(height: DesignSystem.spacingLg),
const SizedBox(height: DesignSystem.spacingLg),
_buildSecondaryStats(),
SizedBox(height: DesignSystem.spacingMd),
const SizedBox(height: DesignSystem.spacingMd),
_buildProgressIndicator(),
],
),
@@ -109,7 +109,7 @@ class _StatsOverviewCardState extends State<StatsOverviewCard>
fontWeight: FontWeight.w700,
),
),
SizedBox(height: DesignSystem.spacingXs),
const SizedBox(height: DesignSystem.spacingXs),
Text(
'Statistiques générales',
style: DesignSystem.bodyMedium.copyWith(
@@ -119,7 +119,7 @@ class _StatsOverviewCardState extends State<StatsOverviewCard>
],
),
Container(
padding: EdgeInsets.all(DesignSystem.spacingSm),
padding: const EdgeInsets.all(DesignSystem.spacingSm),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
@@ -145,7 +145,7 @@ class _StatsOverviewCardState extends State<StatsOverviewCard>
Colors.white,
),
),
SizedBox(width: DesignSystem.spacingLg),
const SizedBox(width: DesignSystem.spacingLg),
Expanded(
child: _buildStatItem(
'Membres Actifs',
@@ -170,7 +170,7 @@ class _StatsOverviewCardState extends State<StatsOverviewCard>
isSecondary: true,
),
),
SizedBox(width: DesignSystem.spacingLg),
const SizedBox(width: DesignSystem.spacingLg),
Expanded(
child: _buildStatItem(
'Taux d\'activité',
@@ -201,7 +201,7 @@ class _StatsOverviewCardState extends State<StatsOverviewCard>
color: color,
size: isSecondary ? 16 : 20,
),
SizedBox(width: DesignSystem.spacingXs),
const SizedBox(width: DesignSystem.spacingXs),
Text(
label,
style: (isSecondary ? DesignSystem.labelMedium : DesignSystem.labelLarge).copyWith(
@@ -211,7 +211,7 @@ class _StatsOverviewCardState extends State<StatsOverviewCard>
),
],
),
SizedBox(height: DesignSystem.spacingXs),
const SizedBox(height: DesignSystem.spacingXs),
Text(
value,
style: (isSecondary ? DesignSystem.headlineMedium : DesignSystem.displayMedium).copyWith(
@@ -250,7 +250,7 @@ class _StatsOverviewCardState extends State<StatsOverviewCard>
),
],
),
SizedBox(height: DesignSystem.spacingXs),
const SizedBox(height: DesignSystem.spacingXs),
Container(
height: 6,
decoration: BoxDecoration(

View File

@@ -6,6 +6,7 @@ import '../../../../shared/widgets/buttons/buttons.dart';
import '../../../dashboard/presentation/pages/dashboard_page.dart';
import '../../../members/presentation/pages/membres_list_page.dart';
import '../../../cotisations/presentation/pages/cotisations_list_page.dart';
import '../../../evenements/presentation/pages/evenements_page.dart';
import '../widgets/custom_bottom_nav_bar.dart';
class MainNavigation extends StatefulWidget {
@@ -105,7 +106,8 @@ class _MainNavigationState extends State<MainNavigation>
Widget _buildFloatingActionButton() {
// Afficher le FAB seulement sur certains onglets
if (_currentIndex == 1 || _currentIndex == 2 || _currentIndex == 3) {
// IMPORTANT: L'onglet Membres (index 1) a son propre FAB, donc on ne l'affiche pas ici
if (_currentIndex == 2 || _currentIndex == 3) {
return ScaleTransition(
scale: _fabAnimation,
child: QuickButtons.fab(
@@ -212,20 +214,7 @@ class _MainNavigationState extends State<MainNavigation>
}
Widget _buildEventsPage() {
return const ComingSoonPage(
title: 'Module Événements',
description: 'Organisation et gestion d\'événements avec calendrier intégré',
icon: Icons.event_rounded,
color: AppTheme.warningColor,
features: [
'Calendrier interactif des événements',
'Gestion des inscriptions en ligne',
'Envoi d\'invitations automatiques',
'Suivi de la participation',
'Gestion des lieux et ressources',
'Sondages et feedback post-événement',
],
);
return const EvenementsPage();
}
Widget _buildMorePage() {