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:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user