Refactoring - Version OK
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../../../shared/design_system/tokens/color_tokens.dart';
|
||||
import '../../../../shared/design_system/tokens/spacing_tokens.dart';
|
||||
|
||||
|
||||
/// Page À propos - UnionFlow Mobile
|
||||
///
|
||||
@@ -70,17 +73,17 @@ class _AboutPageState extends State<AboutPage> {
|
||||
/// Header harmonisé avec le design system
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(SpacingTokens.xl),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF6C5CE7), Color(0xFF5A4FCF)],
|
||||
colors: ColorTokens.primaryGradient,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.xl),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6C5CE7).withOpacity(0.3),
|
||||
color: ColorTokens.primary.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
@@ -175,11 +178,11 @@ class _AboutPageState extends State<AboutPage> {
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF6C5CE7), Color(0xFF5A4FCF)],
|
||||
colors: ColorTokens.primaryGradient,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.xxl),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.account_balance,
|
||||
@@ -294,19 +297,19 @@ class _AboutPageState extends State<AboutPage> {
|
||||
'UnionFlow Team',
|
||||
'Développement & Architecture',
|
||||
Icons.code,
|
||||
const Color(0xFF6C5CE7),
|
||||
ColorTokens.primary,
|
||||
),
|
||||
_buildTeamMember(
|
||||
'Design System',
|
||||
'Interface utilisateur & UX',
|
||||
Icons.design_services,
|
||||
const Color(0xFF0984E3),
|
||||
ColorTokens.info,
|
||||
),
|
||||
_buildTeamMember(
|
||||
'Support Technique',
|
||||
'Maintenance & Support',
|
||||
Icons.support_agent,
|
||||
const Color(0xFF00B894),
|
||||
ColorTokens.success,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -401,31 +404,31 @@ class _AboutPageState extends State<AboutPage> {
|
||||
'Gestion des membres',
|
||||
'Administration complète des adhérents',
|
||||
Icons.people,
|
||||
const Color(0xFF6C5CE7),
|
||||
ColorTokens.primary,
|
||||
),
|
||||
_buildFeatureItem(
|
||||
'Organisations',
|
||||
'Gestion des syndicats et fédérations',
|
||||
Icons.business,
|
||||
const Color(0xFF0984E3),
|
||||
ColorTokens.info,
|
||||
),
|
||||
_buildFeatureItem(
|
||||
'Événements',
|
||||
'Planification et suivi des événements',
|
||||
Icons.event,
|
||||
const Color(0xFF00B894),
|
||||
ColorTokens.success,
|
||||
),
|
||||
_buildFeatureItem(
|
||||
'Tableau de bord',
|
||||
'Statistiques et métriques en temps réel',
|
||||
Icons.dashboard,
|
||||
const Color(0xFFE17055),
|
||||
ColorTokens.warning,
|
||||
),
|
||||
_buildFeatureItem(
|
||||
'Authentification sécurisée',
|
||||
'Connexion via Keycloak OIDC',
|
||||
Icons.security,
|
||||
const Color(0xFF00CEC9),
|
||||
ColorTokens.tertiary,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -555,18 +558,18 @@ class _AboutPageState extends State<AboutPage> {
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
padding: const EdgeInsets.all(SpacingTokens.md),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF6C5CE7).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: ColorTokens.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.md),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: const Color(0xFF6C5CE7),
|
||||
color: ColorTokens.primary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: SpacingTokens.lg),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -784,8 +787,8 @@ class _AboutPageState extends State<AboutPage> {
|
||||
_launchUrl('mailto:support@unionflow.com?subject=Rapport de bug - UnionFlow Mobile');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF6C5CE7),
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: ColorTokens.primary,
|
||||
foregroundColor: ColorTokens.onPrimary,
|
||||
),
|
||||
child: const Text('Envoyer un email'),
|
||||
),
|
||||
@@ -815,8 +818,8 @@ class _AboutPageState extends State<AboutPage> {
|
||||
_launchUrl('mailto:support@unionflow.com?subject=Suggestion d\'amélioration - UnionFlow Mobile');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF6C5CE7),
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: ColorTokens.primary,
|
||||
foregroundColor: ColorTokens.onPrimary,
|
||||
),
|
||||
child: const Text('Envoyer une suggestion'),
|
||||
),
|
||||
@@ -847,8 +850,8 @@ class _AboutPageState extends State<AboutPage> {
|
||||
_showErrorSnackBar('Fonctionnalité bientôt disponible');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF6C5CE7),
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: ColorTokens.primary,
|
||||
foregroundColor: ColorTokens.onPrimary,
|
||||
),
|
||||
child: const Text('Évaluer maintenant'),
|
||||
),
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
/// Page de Connexion Adaptative - Démonstration des Rôles
|
||||
/// Interface de connexion avec sélection de rôles pour démonstration
|
||||
library login_page;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/auth/bloc/auth_bloc.dart';
|
||||
import '../../../../core/design_system/tokens/typography_tokens.dart';
|
||||
import 'keycloak_webview_auth_page.dart';
|
||||
|
||||
/// Page de connexion avec démonstration des rôles
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage>
|
||||
with TickerProviderStateMixin {
|
||||
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAnimations();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializeAnimations() {
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.0, 0.6, curve: Curves.easeOut),
|
||||
));
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0.0, 0.3),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic),
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
/// Ouvre la page WebView d'authentification
|
||||
void _openWebViewAuth(BuildContext context, AuthWebViewRequired state) {
|
||||
debugPrint('🚀 Ouverture WebView avec URL: ${state.authUrl}');
|
||||
debugPrint('🔑 State: ${state.state}');
|
||||
debugPrint('🔐 Code verifier: ${state.codeVerifier.substring(0, 10)}...');
|
||||
|
||||
debugPrint('📱 Tentative de navigation vers KeycloakWebViewAuthPage...');
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => KeycloakWebViewAuthPage(
|
||||
onAuthSuccess: (user) {
|
||||
debugPrint('✅ Authentification réussie pour: ${user.fullName}');
|
||||
debugPrint('🔄 Notification du BLoC avec les données utilisateur...');
|
||||
|
||||
// Notifier le BLoC du succès avec les données utilisateur
|
||||
context.read<AuthBloc>().add(AuthWebViewCallback(
|
||||
'success',
|
||||
user: user,
|
||||
));
|
||||
|
||||
// Fermer la WebView - la navigation sera gérée par le BlocListener
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
onAuthError: (error) {
|
||||
debugPrint('❌ Erreur d\'authentification: $error');
|
||||
// Fermer la WebView et afficher l'erreur
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur d\'authentification: $error'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
},
|
||||
onAuthCancel: () {
|
||||
debugPrint('❌ Authentification annulée par l\'utilisateur');
|
||||
// Fermer la WebView
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Authentification annulée'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
debugPrint('✅ Navigation vers KeycloakWebViewAuthPage lancée');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: BlocConsumer<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
debugPrint('🔄 État BLoC reçu: ${state.runtimeType}');
|
||||
|
||||
if (state is AuthAuthenticated) {
|
||||
debugPrint('✅ Utilisateur authentifié, navigation vers dashboard');
|
||||
Navigator.of(context).pushReplacementNamed('/dashboard');
|
||||
} else if (state is AuthError) {
|
||||
debugPrint('❌ Erreur d\'authentification: ${state.message}');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
} else if (state is AuthWebViewRequired) {
|
||||
debugPrint('🚀 État AuthWebViewRequired reçu, ouverture WebView...');
|
||||
// Ouvrir la page WebView d'authentification immédiatement
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_openWebViewAuth(context, state);
|
||||
});
|
||||
} else if (state is AuthLoading) {
|
||||
debugPrint('⏳ État de chargement...');
|
||||
} else {
|
||||
debugPrint('ℹ️ État non géré: ${state.runtimeType}');
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
// Vérification supplémentaire dans le builder
|
||||
if (state is AuthWebViewRequired) {
|
||||
debugPrint('🔄 Builder détecte AuthWebViewRequired, ouverture WebView...');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_openWebViewAuth(context, state);
|
||||
});
|
||||
}
|
||||
|
||||
return _buildLoginContent(context, state);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginContent(BuildContext context, AuthState state) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFF6C5CE7),
|
||||
Color(0xFF5A4FCF),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: _buildLoginUI(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginUI() {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Logo et titre
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Information Keycloak
|
||||
_buildKeycloakInfo(),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Bouton de connexion
|
||||
_buildLoginButton(),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Informations de démonstration
|
||||
_buildDemoInfo(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.account_circle,
|
||||
size: 60,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'UnionFlow',
|
||||
style: TypographyTokens.headlineLarge.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Dashboard Adaptatif Révolutionnaire',
|
||||
style: TypographyTokens.bodyLarge.copyWith(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKeycloakInfo() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.security,
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Authentification Keycloak',
|
||||
style: TypographyTokens.bodyLarge.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Connectez-vous avec vos identifiants UnionFlow',
|
||||
style: TypographyTokens.bodyMedium.copyWith(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'localhost:8180/realms/unionflow',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Widget _buildLoginButton() {
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final isLoading = state is AuthLoading;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton(
|
||||
onPressed: isLoading ? null : _handleLogin,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF6C5CE7),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.login, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Se Connecter avec Keycloak',
|
||||
style: TypographyTokens.bodyLarge.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDemoInfo() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Mode Démonstration',
|
||||
style: TypographyTokens.bodyMedium.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Sélectionnez un rôle ci-dessus pour voir le dashboard adaptatif correspondant. Chaque rôle affiche une interface unique !',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
void _handleLogin() {
|
||||
// Démarrer l'authentification Keycloak
|
||||
context.read<AuthBloc>().add(const AuthLoginRequested());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/// Gestionnaire de cache pour le dashboard
|
||||
/// Cache intelligent basé sur les rôles utilisateurs
|
||||
library dashboard_cache_manager;
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/user_role.dart';
|
||||
|
||||
/// Gestionnaire de cache pour optimiser les performances du dashboard
|
||||
class DashboardCacheManager {
|
||||
static final Map<String, dynamic> _cache = {};
|
||||
static final Map<String, DateTime> _cacheTimestamps = {};
|
||||
static const Duration _cacheExpiry = Duration(minutes: 15);
|
||||
|
||||
/// Invalide le cache pour un rôle spécifique
|
||||
static Future<void> invalidateForRole(UserRole role) async {
|
||||
final keysToRemove = _cache.keys
|
||||
.where((key) => key.startsWith('dashboard_${role.name}'))
|
||||
.toList();
|
||||
|
||||
for (final key in keysToRemove) {
|
||||
_cache.remove(key);
|
||||
_cacheTimestamps.remove(key);
|
||||
}
|
||||
|
||||
debugPrint('🗑️ Cache invalidé pour le rôle: ${role.displayName}');
|
||||
}
|
||||
|
||||
/// Vide complètement le cache
|
||||
static Future<void> clear() async {
|
||||
_cache.clear();
|
||||
_cacheTimestamps.clear();
|
||||
debugPrint('🧹 Cache dashboard complètement vidé');
|
||||
}
|
||||
|
||||
/// Obtient une valeur du cache
|
||||
static T? get<T>(String key) {
|
||||
final timestamp = _cacheTimestamps[key];
|
||||
if (timestamp == null) return null;
|
||||
|
||||
// Vérifier l'expiration
|
||||
if (DateTime.now().difference(timestamp) > _cacheExpiry) {
|
||||
_cache.remove(key);
|
||||
_cacheTimestamps.remove(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return _cache[key] as T?;
|
||||
}
|
||||
|
||||
/// Met une valeur en cache
|
||||
static void set<T>(String key, T value) {
|
||||
_cache[key] = value;
|
||||
_cacheTimestamps[key] = DateTime.now();
|
||||
}
|
||||
|
||||
/// Obtient les statistiques du cache
|
||||
static Map<String, dynamic> getStats() {
|
||||
final now = DateTime.now();
|
||||
final validEntries = _cacheTimestamps.entries
|
||||
.where((entry) => now.difference(entry.value) <= _cacheExpiry)
|
||||
.length;
|
||||
|
||||
return {
|
||||
'totalEntries': _cache.length,
|
||||
'validEntries': validEntries,
|
||||
'expiredEntries': _cache.length - validEntries,
|
||||
'cacheHitRate': '${(validEntries / _cache.length * 100).toStringAsFixed(1)}%',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
/// Service d'Authentification Keycloak
|
||||
/// Gère l'authentification avec votre instance Keycloak sur port 8180
|
||||
library keycloak_auth_service;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_appauth/flutter_appauth.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:jwt_decoder/jwt_decoder.dart';
|
||||
import '../models/user.dart';
|
||||
import '../models/user_role.dart';
|
||||
import 'keycloak_role_mapper.dart';
|
||||
import 'keycloak_webview_auth_service.dart';
|
||||
|
||||
/// Configuration Keycloak pour votre instance
|
||||
class KeycloakConfig {
|
||||
/// URL de base de votre Keycloak
|
||||
static const String baseUrl = 'http://192.168.1.11:8180';
|
||||
|
||||
/// Realm UnionFlow
|
||||
static const String realm = 'unionflow';
|
||||
|
||||
/// Client ID pour l'application mobile
|
||||
static const String clientId = 'unionflow-mobile';
|
||||
|
||||
/// URL de redirection après authentification
|
||||
static const String redirectUrl = 'dev.lions.unionflow-mobile://auth/callback';
|
||||
|
||||
/// Scopes demandés
|
||||
static const List<String> scopes = ['openid', 'profile', 'email', 'roles'];
|
||||
|
||||
/// Endpoints calculés
|
||||
static String get authorizationEndpoint =>
|
||||
'$baseUrl/realms/$realm/protocol/openid-connect/auth';
|
||||
|
||||
static String get tokenEndpoint =>
|
||||
'$baseUrl/realms/$realm/protocol/openid-connect/token';
|
||||
|
||||
static String get userInfoEndpoint =>
|
||||
'$baseUrl/realms/$realm/protocol/openid-connect/userinfo';
|
||||
|
||||
static String get logoutEndpoint =>
|
||||
'$baseUrl/realms/$realm/protocol/openid-connect/logout';
|
||||
}
|
||||
|
||||
/// Service d'authentification Keycloak ultra-sophistiqué
|
||||
class KeycloakAuthService {
|
||||
static const FlutterAppAuth _appAuth = FlutterAppAuth();
|
||||
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
),
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device,
|
||||
),
|
||||
);
|
||||
|
||||
// Clés de stockage sécurisé
|
||||
static const String _accessTokenKey = 'keycloak_access_token';
|
||||
static const String _refreshTokenKey = 'keycloak_refresh_token';
|
||||
static const String _idTokenKey = 'keycloak_id_token';
|
||||
static const String _userInfoKey = 'keycloak_user_info';
|
||||
|
||||
/// Authentification avec Keycloak via WebView (solution HTTP compatible)
|
||||
///
|
||||
/// Cette méthode utilise maintenant KeycloakWebViewAuthService pour contourner
|
||||
/// les limitations HTTPS de flutter_appauth
|
||||
static Future<AuthorizationTokenResponse?> authenticate() async {
|
||||
try {
|
||||
debugPrint('🔐 Démarrage authentification Keycloak via WebView...');
|
||||
|
||||
// Utiliser le service WebView pour l'authentification
|
||||
// Cette méthode retourne null car l'authentification WebView
|
||||
// est gérée différemment (via callback)
|
||||
debugPrint('💡 Authentification WebView - utilisez authenticateWithWebView() à la place');
|
||||
|
||||
return null;
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur authentification Keycloak: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rafraîchit le token d'accès
|
||||
static Future<TokenResponse?> refreshToken() async {
|
||||
try {
|
||||
final String? refreshToken = await _secureStorage.read(
|
||||
key: _refreshTokenKey,
|
||||
);
|
||||
|
||||
if (refreshToken == null) {
|
||||
debugPrint('❌ Aucun refresh token disponible');
|
||||
return null;
|
||||
}
|
||||
|
||||
debugPrint('🔄 Rafraîchissement du token...');
|
||||
|
||||
final TokenRequest request = TokenRequest(
|
||||
KeycloakConfig.clientId,
|
||||
KeycloakConfig.redirectUrl,
|
||||
refreshToken: refreshToken,
|
||||
serviceConfiguration: AuthorizationServiceConfiguration(
|
||||
authorizationEndpoint: KeycloakConfig.authorizationEndpoint,
|
||||
tokenEndpoint: KeycloakConfig.tokenEndpoint,
|
||||
),
|
||||
);
|
||||
|
||||
final TokenResponse? result = await _appAuth.token(request);
|
||||
|
||||
if (result != null) {
|
||||
await _storeTokens(result);
|
||||
debugPrint('✅ Token rafraîchi avec succès');
|
||||
return result;
|
||||
}
|
||||
|
||||
debugPrint('❌ Échec du rafraîchissement du token');
|
||||
return null;
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur rafraîchissement token: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère l'utilisateur authentifié depuis les tokens
|
||||
static Future<User?> getCurrentUser() async {
|
||||
try {
|
||||
final String? accessToken = await _secureStorage.read(
|
||||
key: _accessTokenKey,
|
||||
);
|
||||
|
||||
final String? idToken = await _secureStorage.read(
|
||||
key: _idTokenKey,
|
||||
);
|
||||
|
||||
if (accessToken == null || idToken == null) {
|
||||
debugPrint('❌ Tokens manquants');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Vérifier si les tokens sont expirés
|
||||
if (JwtDecoder.isExpired(accessToken)) {
|
||||
debugPrint('⏰ Access token expiré, tentative de rafraîchissement...');
|
||||
final TokenResponse? refreshResult = await refreshToken();
|
||||
if (refreshResult == null) {
|
||||
debugPrint('❌ Impossible de rafraîchir le token');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Décoder les tokens JWT
|
||||
final Map<String, dynamic> accessTokenPayload =
|
||||
JwtDecoder.decode(accessToken);
|
||||
final Map<String, dynamic> idTokenPayload =
|
||||
JwtDecoder.decode(idToken);
|
||||
|
||||
debugPrint('🔍 Payload Access Token: $accessTokenPayload');
|
||||
debugPrint('🔍 Payload ID Token: $idTokenPayload');
|
||||
|
||||
// Extraire les informations utilisateur
|
||||
final String userId = idTokenPayload['sub'] ?? '';
|
||||
final String email = idTokenPayload['email'] ?? '';
|
||||
final String firstName = idTokenPayload['given_name'] ?? '';
|
||||
final String lastName = idTokenPayload['family_name'] ?? '';
|
||||
|
||||
|
||||
// Extraire les rôles Keycloak
|
||||
final List<String> keycloakRoles = _extractKeycloakRoles(accessTokenPayload);
|
||||
debugPrint('🎭 Rôles Keycloak extraits: $keycloakRoles');
|
||||
|
||||
// Si aucun rôle, assigner un rôle par défaut
|
||||
if (keycloakRoles.isEmpty) {
|
||||
debugPrint('⚠️ Aucun rôle trouvé, assignation du rôle MEMBER par défaut');
|
||||
keycloakRoles.add('member');
|
||||
}
|
||||
|
||||
// Mapper vers notre système de rôles
|
||||
final UserRole primaryRole = KeycloakRoleMapper.mapToUserRole(keycloakRoles);
|
||||
final List<String> permissions = KeycloakRoleMapper.mapToPermissions(keycloakRoles);
|
||||
|
||||
debugPrint('🎯 Rôle principal mappé: ${primaryRole.displayName}');
|
||||
debugPrint('🔐 Permissions mappées: ${permissions.length} permissions');
|
||||
debugPrint('📋 Permissions détaillées: $permissions');
|
||||
|
||||
// Créer l'utilisateur
|
||||
final User user = User(
|
||||
id: userId,
|
||||
email: email,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
|
||||
primaryRole: primaryRole,
|
||||
organizationContexts: const [], // À implémenter selon vos besoins
|
||||
additionalPermissions: permissions,
|
||||
revokedPermissions: const [],
|
||||
preferences: const UserPreferences(),
|
||||
lastLoginAt: DateTime.now(),
|
||||
createdAt: DateTime.now(), // À récupérer depuis Keycloak si disponible
|
||||
isActive: true,
|
||||
);
|
||||
|
||||
// Stocker les informations utilisateur
|
||||
await _secureStorage.write(
|
||||
key: _userInfoKey,
|
||||
value: jsonEncode(user.toJson()),
|
||||
);
|
||||
|
||||
debugPrint('✅ Utilisateur récupéré: ${user.fullName} (${user.primaryRole.displayName})');
|
||||
return user;
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur récupération utilisateur: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Déconnexion complète
|
||||
static Future<bool> logout() async {
|
||||
try {
|
||||
debugPrint('🚪 Déconnexion Keycloak...');
|
||||
|
||||
final String? idToken = await _secureStorage.read(key: _idTokenKey);
|
||||
|
||||
// Déconnexion côté Keycloak si possible
|
||||
if (idToken != null) {
|
||||
try {
|
||||
final EndSessionRequest request = EndSessionRequest(
|
||||
idTokenHint: idToken,
|
||||
postLogoutRedirectUrl: KeycloakConfig.redirectUrl,
|
||||
serviceConfiguration: AuthorizationServiceConfiguration(
|
||||
authorizationEndpoint: KeycloakConfig.authorizationEndpoint,
|
||||
tokenEndpoint: KeycloakConfig.tokenEndpoint,
|
||||
endSessionEndpoint: KeycloakConfig.logoutEndpoint,
|
||||
),
|
||||
);
|
||||
|
||||
await _appAuth.endSession(request);
|
||||
debugPrint('✅ Déconnexion Keycloak côté serveur réussie');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Déconnexion côté serveur échouée: $e');
|
||||
// Continue quand même avec la déconnexion locale
|
||||
}
|
||||
}
|
||||
|
||||
// Nettoyage local des tokens
|
||||
await _clearTokens();
|
||||
|
||||
debugPrint('✅ Déconnexion locale terminée');
|
||||
return true;
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur déconnexion: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur est authentifié
|
||||
static Future<bool> isAuthenticated() async {
|
||||
try {
|
||||
final String? accessToken = await _secureStorage.read(
|
||||
key: _accessTokenKey,
|
||||
);
|
||||
|
||||
if (accessToken == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier si le token est expiré
|
||||
if (JwtDecoder.isExpired(accessToken)) {
|
||||
// Tenter de rafraîchir
|
||||
final TokenResponse? refreshResult = await refreshToken();
|
||||
return refreshResult != null;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('💥 Erreur vérification authentification: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stocke les tokens de manière sécurisée
|
||||
static Future<void> _storeTokens(TokenResponse tokenResponse) async {
|
||||
if (tokenResponse.accessToken != null) {
|
||||
await _secureStorage.write(
|
||||
key: _accessTokenKey,
|
||||
value: tokenResponse.accessToken!,
|
||||
);
|
||||
}
|
||||
|
||||
if (tokenResponse.refreshToken != null) {
|
||||
await _secureStorage.write(
|
||||
key: _refreshTokenKey,
|
||||
value: tokenResponse.refreshToken!,
|
||||
);
|
||||
}
|
||||
|
||||
if (tokenResponse.idToken != null) {
|
||||
await _secureStorage.write(
|
||||
key: _idTokenKey,
|
||||
value: tokenResponse.idToken!,
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint('🔒 Tokens stockés de manière sécurisée');
|
||||
}
|
||||
|
||||
/// Nettoie tous les tokens stockés
|
||||
static Future<void> _clearTokens() async {
|
||||
await _secureStorage.delete(key: _accessTokenKey);
|
||||
await _secureStorage.delete(key: _refreshTokenKey);
|
||||
await _secureStorage.delete(key: _idTokenKey);
|
||||
await _secureStorage.delete(key: _userInfoKey);
|
||||
|
||||
debugPrint('🧹 Tokens nettoyés');
|
||||
}
|
||||
|
||||
/// Extrait les rôles depuis le payload JWT Keycloak
|
||||
static List<String> _extractKeycloakRoles(Map<String, dynamic> payload) {
|
||||
final List<String> roles = [];
|
||||
|
||||
try {
|
||||
// Rôles du realm
|
||||
final Map<String, dynamic>? realmAccess = payload['realm_access'];
|
||||
if (realmAccess != null && realmAccess['roles'] is List) {
|
||||
final List<dynamic> realmRoles = realmAccess['roles'];
|
||||
roles.addAll(realmRoles.cast<String>());
|
||||
}
|
||||
|
||||
// Rôles des clients
|
||||
final Map<String, dynamic>? resourceAccess = payload['resource_access'];
|
||||
if (resourceAccess != null) {
|
||||
resourceAccess.forEach((clientId, clientData) {
|
||||
if (clientData is Map<String, dynamic> && clientData['roles'] is List) {
|
||||
final List<dynamic> clientRoles = clientData['roles'];
|
||||
roles.addAll(clientRoles.cast<String>());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Filtrer les rôles système Keycloak
|
||||
return roles.where((role) =>
|
||||
!role.startsWith('default-roles-') &&
|
||||
role != 'offline_access' &&
|
||||
role != 'uma_authorization'
|
||||
).toList();
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('💥 Erreur extraction rôles: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère le token d'accès actuel
|
||||
static Future<String?> getAccessToken() async {
|
||||
try {
|
||||
final String? accessToken = await _secureStorage.read(
|
||||
key: _accessTokenKey,
|
||||
);
|
||||
|
||||
if (accessToken != null && !JwtDecoder.isExpired(accessToken)) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
// Token expiré, tenter de rafraîchir
|
||||
final TokenResponse? refreshResult = await refreshToken();
|
||||
return refreshResult?.accessToken;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('💥 Erreur récupération access token: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MÉTHODES WEBVIEW - Délégation vers KeycloakWebViewAuthService
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Prépare l'authentification WebView
|
||||
///
|
||||
/// Retourne les paramètres nécessaires pour lancer la WebView d'authentification
|
||||
static Future<Map<String, String>> prepareWebViewAuthentication() async {
|
||||
return KeycloakWebViewAuthService.prepareAuthentication();
|
||||
}
|
||||
|
||||
/// Traite le callback WebView et finalise l'authentification
|
||||
///
|
||||
/// Cette méthode doit être appelée quand l'URL de callback est interceptée
|
||||
static Future<User> handleWebViewCallback(String callbackUrl) async {
|
||||
return KeycloakWebViewAuthService.handleAuthCallback(callbackUrl);
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur est authentifié (compatible WebView)
|
||||
static Future<bool> isWebViewAuthenticated() async {
|
||||
return KeycloakWebViewAuthService.isAuthenticated();
|
||||
}
|
||||
|
||||
/// Récupère l'utilisateur authentifié (compatible WebView)
|
||||
static Future<User?> getCurrentWebViewUser() async {
|
||||
return KeycloakWebViewAuthService.getCurrentUser();
|
||||
}
|
||||
|
||||
/// Déconnecte l'utilisateur (compatible WebView)
|
||||
static Future<bool> logoutWebView() async {
|
||||
return KeycloakWebViewAuthService.logout();
|
||||
}
|
||||
|
||||
/// Nettoie les données d'authentification WebView
|
||||
static Future<void> clearWebViewAuthData() async {
|
||||
return KeycloakWebViewAuthService.clearAuthData();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
/// Mapper de Rôles Keycloak vers UserRole
|
||||
/// Convertit les rôles Keycloak existants vers notre système de rôles sophistiqué
|
||||
library keycloak_role_mapper;
|
||||
|
||||
import '../models/user_role.dart';
|
||||
import '../models/permission_matrix.dart';
|
||||
|
||||
/// Service de mapping des rôles Keycloak
|
||||
class KeycloakRoleMapper {
|
||||
|
||||
/// Mapping des rôles Keycloak vers UserRole
|
||||
static const Map<String, UserRole> _keycloakToUserRole = {
|
||||
// Rôles administratifs
|
||||
'SUPER_ADMINISTRATEUR': UserRole.superAdmin,
|
||||
'ADMIN': UserRole.superAdmin,
|
||||
'ADMINISTRATEUR_ORGANISATION': UserRole.orgAdmin,
|
||||
'PRESIDENT': UserRole.orgAdmin,
|
||||
|
||||
// Rôles de gestion
|
||||
'RESPONSABLE_TECHNIQUE': UserRole.moderator,
|
||||
'RESPONSABLE_MEMBRES': UserRole.moderator,
|
||||
'TRESORIER': UserRole.moderator,
|
||||
'SECRETAIRE': UserRole.moderator,
|
||||
'GESTIONNAIRE_MEMBRE': UserRole.moderator,
|
||||
'ORGANISATEUR_EVENEMENT': UserRole.moderator,
|
||||
|
||||
// Rôles membres
|
||||
'MEMBRE_ACTIF': UserRole.activeMember,
|
||||
'MEMBRE_SIMPLE': UserRole.simpleMember,
|
||||
'MEMBRE': UserRole.activeMember,
|
||||
};
|
||||
|
||||
/// Mapping des rôles Keycloak vers permissions spécifiques
|
||||
static const Map<String, List<String>> _keycloakToPermissions = {
|
||||
'SUPER_ADMINISTRATEUR': [
|
||||
// Permissions Super Admin - Accès total
|
||||
PermissionMatrix.SYSTEM_ADMIN,
|
||||
PermissionMatrix.SYSTEM_CONFIG,
|
||||
PermissionMatrix.SYSTEM_SECURITY,
|
||||
PermissionMatrix.ORG_CREATE,
|
||||
PermissionMatrix.ORG_DELETE,
|
||||
PermissionMatrix.ORG_CONFIG,
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_EDIT_ALL,
|
||||
PermissionMatrix.MEMBERS_DELETE_ALL,
|
||||
PermissionMatrix.FINANCES_VIEW_ALL,
|
||||
PermissionMatrix.FINANCES_EDIT_ALL,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_EDIT_ALL,
|
||||
PermissionMatrix.SOLIDARITY_VIEW_ALL,
|
||||
PermissionMatrix.SOLIDARITY_EDIT_ALL,
|
||||
PermissionMatrix.REPORTS_GENERATE,
|
||||
PermissionMatrix.DASHBOARD_ANALYTICS,
|
||||
],
|
||||
'ADMIN': [
|
||||
// Permissions Super Admin - Accès total (compatibilité)
|
||||
PermissionMatrix.SYSTEM_ADMIN,
|
||||
PermissionMatrix.SYSTEM_CONFIG,
|
||||
PermissionMatrix.SYSTEM_SECURITY,
|
||||
PermissionMatrix.ORG_CREATE,
|
||||
PermissionMatrix.ORG_DELETE,
|
||||
PermissionMatrix.ORG_CONFIG,
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_EDIT_ALL,
|
||||
PermissionMatrix.MEMBERS_DELETE_ALL,
|
||||
PermissionMatrix.FINANCES_VIEW_ALL,
|
||||
PermissionMatrix.FINANCES_EDIT_ALL,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_EDIT_ALL,
|
||||
PermissionMatrix.SOLIDARITY_VIEW_ALL,
|
||||
PermissionMatrix.SOLIDARITY_EDIT_ALL,
|
||||
PermissionMatrix.REPORTS_GENERATE,
|
||||
PermissionMatrix.DASHBOARD_ANALYTICS,
|
||||
],
|
||||
'ADMINISTRATEUR_ORGANISATION': [
|
||||
// Permissions Admin Organisation
|
||||
PermissionMatrix.ORG_CONFIG,
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_EDIT_ALL,
|
||||
PermissionMatrix.FINANCES_VIEW_ALL,
|
||||
PermissionMatrix.FINANCES_EDIT_ALL,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_EDIT_ALL,
|
||||
PermissionMatrix.SOLIDARITY_VIEW_ALL,
|
||||
PermissionMatrix.SOLIDARITY_EDIT_ALL,
|
||||
PermissionMatrix.REPORTS_GENERATE,
|
||||
PermissionMatrix.DASHBOARD_ANALYTICS,
|
||||
],
|
||||
|
||||
'PRESIDENT': [
|
||||
// Permissions Président - Gestion organisation
|
||||
PermissionMatrix.ORG_CONFIG,
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_EDIT_ALL,
|
||||
PermissionMatrix.FINANCES_VIEW_ALL,
|
||||
PermissionMatrix.FINANCES_EDIT_ALL,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_EDIT_ALL,
|
||||
PermissionMatrix.SOLIDARITY_VIEW_ALL,
|
||||
PermissionMatrix.SOLIDARITY_EDIT_ALL,
|
||||
PermissionMatrix.REPORTS_GENERATE,
|
||||
PermissionMatrix.DASHBOARD_ANALYTICS,
|
||||
PermissionMatrix.COMM_SEND_ALL,
|
||||
],
|
||||
|
||||
'RESPONSABLE_TECHNIQUE': [
|
||||
// Permissions Responsable Technique
|
||||
PermissionMatrix.SYSTEM_MONITORING,
|
||||
PermissionMatrix.SYSTEM_MAINTENANCE,
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_EDIT_BASIC,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_EDIT_ALL,
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
PermissionMatrix.REPORTS_GENERATE,
|
||||
],
|
||||
|
||||
'RESPONSABLE_MEMBRES': [
|
||||
// Permissions Responsable Membres
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_EDIT_ALL,
|
||||
PermissionMatrix.MEMBERS_DELETE_ALL,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_EDIT_ALL,
|
||||
PermissionMatrix.SOLIDARITY_VIEW_ALL,
|
||||
PermissionMatrix.SOLIDARITY_EDIT_ALL,
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
PermissionMatrix.REPORTS_GENERATE,
|
||||
],
|
||||
|
||||
'TRESORIER': [
|
||||
// Permissions Trésorier - Focus finances
|
||||
PermissionMatrix.FINANCES_VIEW_ALL,
|
||||
PermissionMatrix.FINANCES_EDIT_ALL,
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_EDIT_BASIC,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.REPORTS_GENERATE,
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
],
|
||||
|
||||
'SECRETAIRE': [
|
||||
// Permissions Secrétaire - Communication et membres
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_EDIT_BASIC,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_EDIT_ALL,
|
||||
PermissionMatrix.COMM_SEND_ALL,
|
||||
PermissionMatrix.COMM_MODERATE,
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
],
|
||||
|
||||
'GESTIONNAIRE_MEMBRE': [
|
||||
// Permissions Gestionnaire de Membres
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_EDIT_ALL,
|
||||
PermissionMatrix.MEMBERS_CREATE,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.SOLIDARITY_VIEW_ALL,
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
PermissionMatrix.COMM_SEND_MEMBERS,
|
||||
],
|
||||
|
||||
'ORGANISATEUR_EVENEMENT': [
|
||||
// Permissions Organisateur d'Événements
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_EDIT_ALL,
|
||||
PermissionMatrix.EVENTS_CREATE,
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.SOLIDARITY_VIEW_ALL,
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
PermissionMatrix.COMM_SEND_MEMBERS,
|
||||
],
|
||||
|
||||
'MEMBRE_ACTIF': [
|
||||
// Permissions Membre Actif
|
||||
PermissionMatrix.MEMBERS_VIEW_OWN,
|
||||
PermissionMatrix.MEMBERS_EDIT_OWN,
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_PARTICIPATE,
|
||||
PermissionMatrix.EVENTS_CREATE,
|
||||
PermissionMatrix.SOLIDARITY_VIEW_ALL,
|
||||
PermissionMatrix.SOLIDARITY_PARTICIPATE,
|
||||
PermissionMatrix.SOLIDARITY_CREATE,
|
||||
PermissionMatrix.FINANCES_VIEW_OWN,
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
PermissionMatrix.COMM_SEND_MEMBERS,
|
||||
],
|
||||
|
||||
'MEMBRE_SIMPLE': [
|
||||
// Permissions Membre Simple
|
||||
PermissionMatrix.MEMBERS_VIEW_OWN,
|
||||
PermissionMatrix.MEMBERS_EDIT_OWN,
|
||||
PermissionMatrix.EVENTS_VIEW_PUBLIC,
|
||||
PermissionMatrix.EVENTS_PARTICIPATE,
|
||||
PermissionMatrix.SOLIDARITY_VIEW_PUBLIC,
|
||||
PermissionMatrix.SOLIDARITY_PARTICIPATE,
|
||||
PermissionMatrix.FINANCES_VIEW_OWN,
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
],
|
||||
|
||||
'MEMBRE': [
|
||||
// Permissions Membre Standard (compatibilité)
|
||||
PermissionMatrix.MEMBERS_VIEW_OWN,
|
||||
PermissionMatrix.MEMBERS_EDIT_OWN,
|
||||
PermissionMatrix.EVENTS_VIEW_PUBLIC,
|
||||
PermissionMatrix.EVENTS_PARTICIPATE,
|
||||
PermissionMatrix.SOLIDARITY_VIEW_PUBLIC,
|
||||
PermissionMatrix.SOLIDARITY_PARTICIPATE,
|
||||
PermissionMatrix.FINANCES_VIEW_OWN,
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
],
|
||||
};
|
||||
|
||||
/// Mappe une liste de rôles Keycloak vers le UserRole principal
|
||||
static UserRole mapToUserRole(List<String> keycloakRoles) {
|
||||
// Priorité des rôles (du plus élevé au plus bas)
|
||||
const List<String> rolePriority = [
|
||||
'SUPER_ADMINISTRATEUR',
|
||||
'ADMIN',
|
||||
'ADMINISTRATEUR_ORGANISATION',
|
||||
'PRESIDENT',
|
||||
'RESPONSABLE_TECHNIQUE',
|
||||
'RESPONSABLE_MEMBRES',
|
||||
'TRESORIER',
|
||||
'SECRETAIRE',
|
||||
'GESTIONNAIRE_MEMBRE',
|
||||
'ORGANISATEUR_EVENEMENT',
|
||||
'MEMBRE_ACTIF',
|
||||
'MEMBRE_SIMPLE',
|
||||
'MEMBRE',
|
||||
];
|
||||
|
||||
// Trouver le rôle avec la priorité la plus élevée
|
||||
for (final String priorityRole in rolePriority) {
|
||||
if (keycloakRoles.contains(priorityRole)) {
|
||||
return _keycloakToUserRole[priorityRole] ?? UserRole.simpleMember;
|
||||
}
|
||||
}
|
||||
|
||||
// Par défaut, visiteur si aucun rôle reconnu
|
||||
return UserRole.visitor;
|
||||
}
|
||||
|
||||
/// Mappe une liste de rôles Keycloak vers les permissions
|
||||
static List<String> mapToPermissions(List<String> keycloakRoles) {
|
||||
final Set<String> permissions = <String>{};
|
||||
|
||||
// Ajouter les permissions pour chaque rôle
|
||||
for (final String role in keycloakRoles) {
|
||||
final List<String>? rolePermissions = _keycloakToPermissions[role];
|
||||
if (rolePermissions != null) {
|
||||
permissions.addAll(rolePermissions);
|
||||
}
|
||||
}
|
||||
|
||||
// Ajouter les permissions de base pour tous les utilisateurs authentifiés
|
||||
permissions.add(PermissionMatrix.DASHBOARD_VIEW);
|
||||
permissions.add(PermissionMatrix.MEMBERS_VIEW_OWN);
|
||||
|
||||
return permissions.toList();
|
||||
}
|
||||
|
||||
/// Vérifie si un rôle Keycloak est reconnu
|
||||
static bool isValidKeycloakRole(String role) {
|
||||
return _keycloakToUserRole.containsKey(role);
|
||||
}
|
||||
|
||||
/// Récupère tous les rôles Keycloak supportés
|
||||
static List<String> getSupportedKeycloakRoles() {
|
||||
return _keycloakToUserRole.keys.toList();
|
||||
}
|
||||
|
||||
/// Récupère le UserRole correspondant à un rôle Keycloak spécifique
|
||||
static UserRole? getUserRoleForKeycloakRole(String keycloakRole) {
|
||||
return _keycloakToUserRole[keycloakRole];
|
||||
}
|
||||
|
||||
/// Récupère les permissions pour un rôle Keycloak spécifique
|
||||
static List<String> getPermissionsForKeycloakRole(String keycloakRole) {
|
||||
return _keycloakToPermissions[keycloakRole] ?? [];
|
||||
}
|
||||
|
||||
/// Analyse détaillée du mapping des rôles
|
||||
static Map<String, dynamic> analyzeRoleMapping(List<String> keycloakRoles) {
|
||||
final UserRole primaryRole = mapToUserRole(keycloakRoles);
|
||||
final List<String> permissions = mapToPermissions(keycloakRoles);
|
||||
|
||||
final Map<String, List<String>> roleBreakdown = {};
|
||||
for (final String role in keycloakRoles) {
|
||||
if (isValidKeycloakRole(role)) {
|
||||
roleBreakdown[role] = getPermissionsForKeycloakRole(role);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'keycloakRoles': keycloakRoles,
|
||||
'primaryRole': primaryRole.name,
|
||||
'primaryRoleDisplayName': primaryRole.displayName,
|
||||
'totalPermissions': permissions.length,
|
||||
'permissions': permissions,
|
||||
'roleBreakdown': roleBreakdown,
|
||||
'unrecognizedRoles': keycloakRoles
|
||||
.where((role) => !isValidKeycloakRole(role))
|
||||
.toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Suggestions d'amélioration du mapping
|
||||
static Map<String, dynamic> getMappingSuggestions(List<String> keycloakRoles) {
|
||||
final List<String> unrecognized = keycloakRoles
|
||||
.where((role) => !isValidKeycloakRole(role))
|
||||
.toList();
|
||||
|
||||
final List<String> suggestions = [];
|
||||
|
||||
if (unrecognized.isNotEmpty) {
|
||||
suggestions.add(
|
||||
'Rôles non reconnus détectés: ${unrecognized.join(", ")}. '
|
||||
'Considérez ajouter ces rôles au mapping ou les ignorer.',
|
||||
);
|
||||
}
|
||||
|
||||
if (keycloakRoles.isEmpty) {
|
||||
suggestions.add(
|
||||
'Aucun rôle Keycloak détecté. L\'utilisateur sera traité comme visiteur.',
|
||||
);
|
||||
}
|
||||
|
||||
final UserRole primaryRole = mapToUserRole(keycloakRoles);
|
||||
if (primaryRole == UserRole.visitor && keycloakRoles.isNotEmpty) {
|
||||
suggestions.add(
|
||||
'L\'utilisateur a des rôles Keycloak mais est mappé comme visiteur. '
|
||||
'Vérifiez la configuration du mapping.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
'unrecognizedRoles': unrecognized,
|
||||
'suggestions': suggestions,
|
||||
'mappingHealth': suggestions.isEmpty ? 'excellent' : 'needs_attention',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
/// Service d'Authentification Keycloak via WebView
|
||||
///
|
||||
/// Implémentation professionnelle et sécurisée de l'authentification OAuth2/OIDC
|
||||
/// avec Keycloak utilisant WebView pour contourner les limitations HTTPS de flutter_appauth.
|
||||
///
|
||||
/// Fonctionnalités :
|
||||
/// - Flow OAuth2 Authorization Code avec PKCE
|
||||
/// - Gestion sécurisée des tokens JWT
|
||||
/// - Support HTTP/HTTPS
|
||||
/// - Gestion complète des erreurs et timeouts
|
||||
/// - Validation rigoureuse des paramètres
|
||||
/// - Logging détaillé pour le debugging
|
||||
library keycloak_webview_auth_service;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:jwt_decoder/jwt_decoder.dart';
|
||||
import '../models/user.dart';
|
||||
import '../models/user_role.dart';
|
||||
import 'keycloak_role_mapper.dart';
|
||||
|
||||
/// Configuration Keycloak pour l'authentification WebView
|
||||
class KeycloakWebViewConfig {
|
||||
/// URL de base de l'instance Keycloak
|
||||
static const String baseUrl = 'http://192.168.1.11:8180';
|
||||
|
||||
/// Realm UnionFlow
|
||||
static const String realm = 'unionflow';
|
||||
|
||||
/// Client ID pour l'application mobile
|
||||
static const String clientId = 'unionflow-mobile';
|
||||
|
||||
/// URL de redirection après authentification
|
||||
static const String redirectUrl = 'dev.lions.unionflow-mobile://auth/callback';
|
||||
|
||||
/// Scopes OAuth2 demandés
|
||||
static const List<String> scopes = ['openid', 'profile', 'email', 'roles'];
|
||||
|
||||
/// Timeout pour les requêtes HTTP (en secondes)
|
||||
static const int httpTimeoutSeconds = 30;
|
||||
|
||||
/// Timeout pour l'authentification WebView (en secondes)
|
||||
static const int authTimeoutSeconds = 300; // 5 minutes
|
||||
|
||||
/// Endpoints calculés
|
||||
static String get authorizationEndpoint =>
|
||||
'$baseUrl/realms/$realm/protocol/openid-connect/auth';
|
||||
|
||||
static String get tokenEndpoint =>
|
||||
'$baseUrl/realms/$realm/protocol/openid-connect/token';
|
||||
|
||||
static String get userInfoEndpoint =>
|
||||
'$baseUrl/realms/$realm/protocol/openid-connect/userinfo';
|
||||
|
||||
static String get logoutEndpoint =>
|
||||
'$baseUrl/realms/$realm/protocol/openid-connect/logout';
|
||||
|
||||
static String get jwksEndpoint =>
|
||||
'$baseUrl/realms/$realm/protocol/openid-connect/certs';
|
||||
}
|
||||
|
||||
/// Résultat de l'authentification WebView
|
||||
class WebViewAuthResult {
|
||||
final String accessToken;
|
||||
final String idToken;
|
||||
final String? refreshToken;
|
||||
final int expiresIn;
|
||||
final String tokenType;
|
||||
final List<String> scopes;
|
||||
|
||||
const WebViewAuthResult({
|
||||
required this.accessToken,
|
||||
required this.idToken,
|
||||
this.refreshToken,
|
||||
required this.expiresIn,
|
||||
required this.tokenType,
|
||||
required this.scopes,
|
||||
});
|
||||
|
||||
/// Création depuis la réponse token de Keycloak
|
||||
factory WebViewAuthResult.fromTokenResponse(Map<String, dynamic> response) {
|
||||
return WebViewAuthResult(
|
||||
accessToken: response['access_token'] ?? '',
|
||||
idToken: response['id_token'] ?? '',
|
||||
refreshToken: response['refresh_token'],
|
||||
expiresIn: response['expires_in'] ?? 3600,
|
||||
tokenType: response['token_type'] ?? 'Bearer',
|
||||
scopes: (response['scope'] as String?)?.split(' ') ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exceptions spécifiques à l'authentification WebView
|
||||
class KeycloakWebViewAuthException implements Exception {
|
||||
final String message;
|
||||
final String? code;
|
||||
final dynamic originalError;
|
||||
|
||||
const KeycloakWebViewAuthException(
|
||||
this.message, {
|
||||
this.code,
|
||||
this.originalError,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'KeycloakWebViewAuthException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Service d'authentification Keycloak via WebView
|
||||
///
|
||||
/// Implémentation complète et sécurisée du flow OAuth2 Authorization Code avec PKCE
|
||||
class KeycloakWebViewAuthService {
|
||||
// Stockage sécurisé des tokens
|
||||
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
),
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device,
|
||||
),
|
||||
);
|
||||
|
||||
// Clés de stockage sécurisé
|
||||
static const String _accessTokenKey = 'keycloak_webview_access_token';
|
||||
static const String _idTokenKey = 'keycloak_webview_id_token';
|
||||
static const String _refreshTokenKey = 'keycloak_webview_refresh_token';
|
||||
static const String _userInfoKey = 'keycloak_webview_user_info';
|
||||
static const String _authStateKey = 'keycloak_webview_auth_state';
|
||||
|
||||
// Client HTTP avec timeout configuré
|
||||
static final http.Client _httpClient = http.Client();
|
||||
|
||||
/// Génère un code verifier PKCE sécurisé
|
||||
static String _generateCodeVerifier() {
|
||||
const String charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
final Random random = Random.secure();
|
||||
return List.generate(128, (i) => charset[random.nextInt(charset.length)]).join();
|
||||
}
|
||||
|
||||
/// Génère le code challenge PKCE à partir du verifier
|
||||
static String _generateCodeChallenge(String verifier) {
|
||||
final List<int> bytes = utf8.encode(verifier);
|
||||
final Digest digest = sha256.convert(bytes);
|
||||
return base64Url.encode(digest.bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
/// Génère un state sécurisé pour la protection CSRF
|
||||
static String _generateState() {
|
||||
final Random random = Random.secure();
|
||||
final List<int> bytes = List.generate(32, (i) => random.nextInt(256));
|
||||
return base64Url.encode(bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
/// Construit l'URL d'autorisation Keycloak avec tous les paramètres
|
||||
static Future<Map<String, String>> _buildAuthorizationUrl() async {
|
||||
final String codeVerifier = _generateCodeVerifier();
|
||||
final String codeChallenge = _generateCodeChallenge(codeVerifier);
|
||||
final String state = _generateState();
|
||||
|
||||
// Stocker les paramètres pour la validation ultérieure
|
||||
await _secureStorage.write(
|
||||
key: _authStateKey,
|
||||
value: jsonEncode({
|
||||
'code_verifier': codeVerifier,
|
||||
'state': state,
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
}),
|
||||
);
|
||||
|
||||
final Map<String, String> params = {
|
||||
'response_type': 'code',
|
||||
'client_id': KeycloakWebViewConfig.clientId,
|
||||
'redirect_uri': KeycloakWebViewConfig.redirectUrl,
|
||||
'scope': KeycloakWebViewConfig.scopes.join(' '),
|
||||
'state': state,
|
||||
'code_challenge': codeChallenge,
|
||||
'code_challenge_method': 'S256',
|
||||
'kc_locale': 'fr',
|
||||
'prompt': 'login',
|
||||
};
|
||||
|
||||
final String queryString = params.entries
|
||||
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
|
||||
.join('&');
|
||||
|
||||
final String authUrl = '${KeycloakWebViewConfig.authorizationEndpoint}?$queryString';
|
||||
|
||||
debugPrint('🔐 URL d\'autorisation générée: $authUrl');
|
||||
|
||||
return {
|
||||
'url': authUrl,
|
||||
'state': state,
|
||||
'code_verifier': codeVerifier,
|
||||
};
|
||||
}
|
||||
|
||||
/// Valide la réponse de redirection et extrait le code d'autorisation
|
||||
static Future<String> _validateCallbackAndExtractCode(
|
||||
String callbackUrl,
|
||||
String expectedState,
|
||||
) async {
|
||||
debugPrint('🔍 Validation du callback: $callbackUrl');
|
||||
|
||||
final Uri uri = Uri.parse(callbackUrl);
|
||||
|
||||
// Vérifier que c'est bien notre URL de redirection
|
||||
if (!callbackUrl.startsWith(KeycloakWebViewConfig.redirectUrl)) {
|
||||
throw const KeycloakWebViewAuthException(
|
||||
'URL de callback invalide',
|
||||
code: 'INVALID_CALLBACK_URL',
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier la présence d'erreurs
|
||||
final String? error = uri.queryParameters['error'];
|
||||
if (error != null) {
|
||||
final String? errorDescription = uri.queryParameters['error_description'];
|
||||
throw KeycloakWebViewAuthException(
|
||||
'Erreur d\'authentification: ${errorDescription ?? error}',
|
||||
code: error,
|
||||
);
|
||||
}
|
||||
|
||||
// Valider le state pour la protection CSRF
|
||||
final String? receivedState = uri.queryParameters['state'];
|
||||
if (receivedState != expectedState) {
|
||||
throw const KeycloakWebViewAuthException(
|
||||
'State invalide - possible attaque CSRF',
|
||||
code: 'INVALID_STATE',
|
||||
);
|
||||
}
|
||||
|
||||
// Extraire le code d'autorisation
|
||||
final String? code = uri.queryParameters['code'];
|
||||
if (code == null || code.isEmpty) {
|
||||
throw const KeycloakWebViewAuthException(
|
||||
'Code d\'autorisation manquant',
|
||||
code: 'MISSING_AUTH_CODE',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint('✅ Code d\'autorisation extrait avec succès');
|
||||
return code;
|
||||
}
|
||||
|
||||
/// Échange le code d'autorisation contre des tokens
|
||||
static Future<WebViewAuthResult> _exchangeCodeForTokens(
|
||||
String authCode,
|
||||
String codeVerifier,
|
||||
) async {
|
||||
debugPrint('🔄 Échange du code d\'autorisation contre les tokens...');
|
||||
|
||||
try {
|
||||
final Map<String, String> body = {
|
||||
'grant_type': 'authorization_code',
|
||||
'client_id': KeycloakWebViewConfig.clientId,
|
||||
'code': authCode,
|
||||
'redirect_uri': KeycloakWebViewConfig.redirectUrl,
|
||||
'code_verifier': codeVerifier,
|
||||
};
|
||||
|
||||
final http.Response response = await _httpClient
|
||||
.post(
|
||||
Uri.parse(KeycloakWebViewConfig.tokenEndpoint),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: body,
|
||||
)
|
||||
.timeout(const Duration(seconds: KeycloakWebViewConfig.httpTimeoutSeconds));
|
||||
|
||||
debugPrint('📡 Réponse token endpoint: ${response.statusCode}');
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final String errorBody = response.body;
|
||||
debugPrint('❌ Erreur échange tokens: $errorBody');
|
||||
|
||||
Map<String, dynamic>? errorJson;
|
||||
try {
|
||||
errorJson = jsonDecode(errorBody);
|
||||
} catch (e) {
|
||||
// Ignore JSON parsing errors
|
||||
}
|
||||
|
||||
final String errorMessage = errorJson?['error_description'] ??
|
||||
errorJson?['error'] ??
|
||||
'Erreur HTTP ${response.statusCode}';
|
||||
|
||||
throw KeycloakWebViewAuthException(
|
||||
'Échec de l\'échange de tokens: $errorMessage',
|
||||
code: errorJson?['error'],
|
||||
);
|
||||
}
|
||||
|
||||
final Map<String, dynamic> tokenResponse = jsonDecode(response.body);
|
||||
|
||||
// Valider la présence des tokens requis
|
||||
if (!tokenResponse.containsKey('access_token') ||
|
||||
!tokenResponse.containsKey('id_token')) {
|
||||
throw const KeycloakWebViewAuthException(
|
||||
'Tokens manquants dans la réponse',
|
||||
code: 'MISSING_TOKENS',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint('✅ Tokens reçus avec succès');
|
||||
return WebViewAuthResult.fromTokenResponse(tokenResponse);
|
||||
|
||||
} on TimeoutException {
|
||||
throw const KeycloakWebViewAuthException(
|
||||
'Timeout lors de l\'échange des tokens',
|
||||
code: 'TIMEOUT',
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is KeycloakWebViewAuthException) rethrow;
|
||||
|
||||
throw KeycloakWebViewAuthException(
|
||||
'Erreur lors de l\'échange des tokens: $e',
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stocke les tokens de manière sécurisée
|
||||
static Future<void> _storeTokens(WebViewAuthResult authResult) async {
|
||||
debugPrint('💾 Stockage sécurisé des tokens...');
|
||||
|
||||
try {
|
||||
await Future.wait([
|
||||
_secureStorage.write(key: _accessTokenKey, value: authResult.accessToken),
|
||||
_secureStorage.write(key: _idTokenKey, value: authResult.idToken),
|
||||
if (authResult.refreshToken != null)
|
||||
_secureStorage.write(key: _refreshTokenKey, value: authResult.refreshToken!),
|
||||
]);
|
||||
|
||||
debugPrint('✅ Tokens stockés avec succès');
|
||||
} catch (e) {
|
||||
throw KeycloakWebViewAuthException(
|
||||
'Erreur lors du stockage des tokens: $e',
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Valide et parse un token JWT
|
||||
static Map<String, dynamic> _parseAndValidateJWT(String token, String tokenType) {
|
||||
try {
|
||||
// Vérifier l'expiration
|
||||
if (JwtDecoder.isExpired(token)) {
|
||||
throw KeycloakWebViewAuthException(
|
||||
'$tokenType expiré',
|
||||
code: 'TOKEN_EXPIRED',
|
||||
);
|
||||
}
|
||||
|
||||
// Parser le payload
|
||||
final Map<String, dynamic> payload = JwtDecoder.decode(token);
|
||||
|
||||
// Validations de base
|
||||
if (payload['iss'] == null) {
|
||||
throw const KeycloakWebViewAuthException(
|
||||
'Token JWT invalide: issuer manquant',
|
||||
code: 'INVALID_JWT',
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier l'issuer
|
||||
const String expectedIssuer = '${KeycloakWebViewConfig.baseUrl}/realms/${KeycloakWebViewConfig.realm}';
|
||||
if (payload['iss'] != expectedIssuer) {
|
||||
throw KeycloakWebViewAuthException(
|
||||
'Token JWT invalide: issuer incorrect (attendu: $expectedIssuer, reçu: ${payload['iss']})',
|
||||
code: 'INVALID_ISSUER',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint('✅ $tokenType validé avec succès');
|
||||
return payload;
|
||||
|
||||
} catch (e) {
|
||||
if (e is KeycloakWebViewAuthException) rethrow;
|
||||
|
||||
throw KeycloakWebViewAuthException(
|
||||
'Erreur lors de la validation du $tokenType: $e',
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Méthode principale d'authentification
|
||||
///
|
||||
/// Retourne les paramètres nécessaires pour lancer la WebView d'authentification
|
||||
static Future<Map<String, String>> prepareAuthentication() async {
|
||||
debugPrint('🚀 Préparation de l\'authentification WebView...');
|
||||
|
||||
try {
|
||||
// Nettoyer les données d'authentification précédentes
|
||||
await clearAuthData();
|
||||
|
||||
// Générer l'URL d'autorisation avec PKCE
|
||||
final Map<String, String> authParams = await _buildAuthorizationUrl();
|
||||
|
||||
debugPrint('✅ Authentification préparée avec succès');
|
||||
return authParams;
|
||||
|
||||
} catch (e) {
|
||||
throw KeycloakWebViewAuthException(
|
||||
'Erreur lors de la préparation de l\'authentification: $e',
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Traite le callback de redirection et finalise l'authentification
|
||||
static Future<User> handleAuthCallback(String callbackUrl) async {
|
||||
debugPrint('🔄 Traitement du callback d\'authentification...');
|
||||
debugPrint('📋 URL de callback: $callbackUrl');
|
||||
|
||||
try {
|
||||
// Récupérer les paramètres d'authentification stockés
|
||||
debugPrint('🔍 Récupération de l\'état d\'authentification...');
|
||||
final String? authStateJson = await _secureStorage.read(key: _authStateKey);
|
||||
if (authStateJson == null) {
|
||||
debugPrint('❌ État d\'authentification manquant');
|
||||
throw const KeycloakWebViewAuthException(
|
||||
'État d\'authentification manquant',
|
||||
code: 'MISSING_AUTH_STATE',
|
||||
);
|
||||
}
|
||||
|
||||
final Map<String, dynamic> authState = jsonDecode(authStateJson);
|
||||
final String expectedState = authState['state'];
|
||||
final String codeVerifier = authState['code_verifier'];
|
||||
debugPrint('✅ État d\'authentification récupéré');
|
||||
|
||||
// Valider le callback et extraire le code
|
||||
debugPrint('🔍 Validation du callback...');
|
||||
final String authCode = await _validateCallbackAndExtractCode(
|
||||
callbackUrl,
|
||||
expectedState,
|
||||
);
|
||||
debugPrint('✅ Code d\'autorisation extrait: ${authCode.substring(0, 10)}...');
|
||||
|
||||
// Échanger le code contre des tokens
|
||||
debugPrint('🔄 Échange du code contre les tokens...');
|
||||
final WebViewAuthResult authResult = await _exchangeCodeForTokens(
|
||||
authCode,
|
||||
codeVerifier,
|
||||
);
|
||||
debugPrint('✅ Tokens reçus avec succès');
|
||||
|
||||
// Stocker les tokens
|
||||
debugPrint('💾 Stockage des tokens...');
|
||||
await _storeTokens(authResult);
|
||||
debugPrint('✅ Tokens stockés');
|
||||
|
||||
// Créer l'utilisateur depuis les tokens
|
||||
debugPrint('👤 Création de l\'utilisateur...');
|
||||
final User user = await _createUserFromTokens(authResult);
|
||||
debugPrint('✅ Utilisateur créé: ${user.fullName}');
|
||||
|
||||
// Nettoyer l'état d'authentification temporaire
|
||||
await _secureStorage.delete(key: _authStateKey);
|
||||
|
||||
debugPrint('🎉 Authentification WebView terminée avec succès');
|
||||
return user;
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur lors du traitement du callback: $e');
|
||||
debugPrint('📋 Stack trace: $stackTrace');
|
||||
|
||||
// Nettoyer en cas d'erreur
|
||||
await _secureStorage.delete(key: _authStateKey);
|
||||
|
||||
if (e is KeycloakWebViewAuthException) rethrow;
|
||||
|
||||
throw KeycloakWebViewAuthException(
|
||||
'Erreur lors du traitement du callback: $e',
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un utilisateur depuis les tokens JWT
|
||||
static Future<User> _createUserFromTokens(WebViewAuthResult authResult) async {
|
||||
debugPrint('👤 Création de l\'utilisateur depuis les tokens...');
|
||||
|
||||
try {
|
||||
// Parser et valider les tokens
|
||||
final Map<String, dynamic> accessTokenPayload = _parseAndValidateJWT(
|
||||
authResult.accessToken,
|
||||
'Access Token',
|
||||
);
|
||||
final Map<String, dynamic> idTokenPayload = _parseAndValidateJWT(
|
||||
authResult.idToken,
|
||||
'ID Token',
|
||||
);
|
||||
|
||||
// Extraire les informations utilisateur
|
||||
final String userId = idTokenPayload['sub'] ?? '';
|
||||
final String email = idTokenPayload['email'] ?? '';
|
||||
final String firstName = idTokenPayload['given_name'] ?? '';
|
||||
final String lastName = idTokenPayload['family_name'] ?? '';
|
||||
|
||||
if (userId.isEmpty || email.isEmpty) {
|
||||
throw const KeycloakWebViewAuthException(
|
||||
'Informations utilisateur manquantes dans les tokens',
|
||||
code: 'MISSING_USER_INFO',
|
||||
);
|
||||
}
|
||||
|
||||
// Extraire les rôles Keycloak
|
||||
final List<String> keycloakRoles = _extractKeycloakRoles(accessTokenPayload);
|
||||
|
||||
// Mapper vers notre système de rôles
|
||||
final UserRole primaryRole = KeycloakRoleMapper.mapToUserRole(keycloakRoles);
|
||||
final List<String> permissions = KeycloakRoleMapper.mapToPermissions(keycloakRoles);
|
||||
|
||||
// Créer l'utilisateur
|
||||
final User user = User(
|
||||
id: userId,
|
||||
email: email,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
primaryRole: primaryRole,
|
||||
organizationContexts: const [],
|
||||
additionalPermissions: permissions,
|
||||
revokedPermissions: const [],
|
||||
preferences: const UserPreferences(
|
||||
language: 'fr',
|
||||
theme: 'system',
|
||||
notificationsEnabled: true,
|
||||
emailNotifications: true,
|
||||
pushNotifications: true,
|
||||
dashboardLayout: 'adaptive',
|
||||
timezone: 'Europe/Paris',
|
||||
),
|
||||
lastLoginAt: DateTime.now(),
|
||||
createdAt: DateTime.now(),
|
||||
isActive: true,
|
||||
);
|
||||
|
||||
// Stocker les informations utilisateur
|
||||
await _secureStorage.write(
|
||||
key: _userInfoKey,
|
||||
value: jsonEncode(user.toJson()),
|
||||
);
|
||||
|
||||
debugPrint('✅ Utilisateur créé: ${user.fullName} (${user.primaryRole.displayName})');
|
||||
return user;
|
||||
|
||||
} catch (e) {
|
||||
if (e is KeycloakWebViewAuthException) rethrow;
|
||||
|
||||
throw KeycloakWebViewAuthException(
|
||||
'Erreur lors de la création de l\'utilisateur: $e',
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrait les rôles Keycloak depuis le payload du token
|
||||
static List<String> _extractKeycloakRoles(Map<String, dynamic> tokenPayload) {
|
||||
try {
|
||||
final List<String> roles = <String>[];
|
||||
|
||||
// Rôles realm
|
||||
final Map<String, dynamic>? realmAccess = tokenPayload['realm_access'];
|
||||
if (realmAccess != null && realmAccess['roles'] is List) {
|
||||
roles.addAll(List<String>.from(realmAccess['roles']));
|
||||
}
|
||||
|
||||
// Rôles client
|
||||
final Map<String, dynamic>? resourceAccess = tokenPayload['resource_access'];
|
||||
if (resourceAccess != null) {
|
||||
final Map<String, dynamic>? clientAccess = resourceAccess[KeycloakWebViewConfig.clientId];
|
||||
if (clientAccess != null && clientAccess['roles'] is List) {
|
||||
roles.addAll(List<String>.from(clientAccess['roles']));
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer les rôles système
|
||||
return roles.where((role) =>
|
||||
!role.startsWith('default-roles-') &&
|
||||
role != 'offline_access' &&
|
||||
role != 'uma_authorization'
|
||||
).toList();
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('💥 Erreur extraction rôles: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Nettoie toutes les données d'authentification
|
||||
static Future<void> clearAuthData() async {
|
||||
debugPrint('🧹 Nettoyage des données d\'authentification...');
|
||||
|
||||
try {
|
||||
await Future.wait([
|
||||
_secureStorage.delete(key: _accessTokenKey),
|
||||
_secureStorage.delete(key: _idTokenKey),
|
||||
_secureStorage.delete(key: _refreshTokenKey),
|
||||
_secureStorage.delete(key: _userInfoKey),
|
||||
_secureStorage.delete(key: _authStateKey),
|
||||
]);
|
||||
|
||||
debugPrint('✅ Données d\'authentification nettoyées');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur lors du nettoyage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur est authentifié
|
||||
static Future<bool> isAuthenticated() async {
|
||||
try {
|
||||
final String? accessToken = await _secureStorage.read(key: _accessTokenKey);
|
||||
|
||||
if (accessToken == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier si le token est expiré
|
||||
return !JwtDecoder.isExpired(accessToken);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('💥 Erreur vérification authentification: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère l'utilisateur authentifié
|
||||
static Future<User?> getCurrentUser() async {
|
||||
try {
|
||||
final String? userInfoJson = await _secureStorage.read(key: _userInfoKey);
|
||||
|
||||
if (userInfoJson == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Map<String, dynamic> userJson = jsonDecode(userInfoJson);
|
||||
return User.fromJson(userJson);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('💥 Erreur récupération utilisateur: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Déconnecte l'utilisateur
|
||||
static Future<bool> logout() async {
|
||||
debugPrint('🚪 Déconnexion de l\'utilisateur...');
|
||||
|
||||
try {
|
||||
// Nettoyer les données locales
|
||||
await clearAuthData();
|
||||
|
||||
debugPrint('✅ Déconnexion réussie');
|
||||
return true;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('💥 Erreur déconnexion: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
/// Moteur de permissions ultra-performant avec cache intelligent
|
||||
/// Vérifications contextuelles et audit trail intégré
|
||||
library permission_engine;
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/user.dart';
|
||||
import '../models/user_role.dart';
|
||||
import '../models/permission_matrix.dart';
|
||||
|
||||
/// Moteur de permissions haute performance avec cache multi-niveaux
|
||||
///
|
||||
/// Fonctionnalités :
|
||||
/// - Cache mémoire ultra-rapide avec TTL
|
||||
/// - Vérifications contextuelles avancées
|
||||
/// - Audit trail automatique
|
||||
/// - Support des permissions héritées
|
||||
/// - Invalidation intelligente du cache
|
||||
class PermissionEngine {
|
||||
static final PermissionEngine _instance = PermissionEngine._internal();
|
||||
factory PermissionEngine() => _instance;
|
||||
PermissionEngine._internal();
|
||||
|
||||
/// Cache mémoire des permissions avec TTL
|
||||
static final Map<String, _CachedPermission> _permissionCache = {};
|
||||
|
||||
/// Cache des permissions effectives par utilisateur
|
||||
static final Map<String, _CachedUserPermissions> _userPermissionsCache = {};
|
||||
|
||||
/// Durée de vie du cache (5 minutes par défaut)
|
||||
static const Duration _defaultCacheTTL = Duration(minutes: 5);
|
||||
|
||||
/// Durée de vie du cache pour les super admins (plus long)
|
||||
static const Duration _superAdminCacheTTL = Duration(minutes: 15);
|
||||
|
||||
/// Compteur de hits/miss du cache pour monitoring
|
||||
static int _cacheHits = 0;
|
||||
static int _cacheMisses = 0;
|
||||
|
||||
/// Stream pour les événements d'audit
|
||||
static final StreamController<PermissionAuditEvent> _auditController =
|
||||
StreamController<PermissionAuditEvent>.broadcast();
|
||||
|
||||
/// Stream des événements d'audit
|
||||
static Stream<PermissionAuditEvent> get auditStream => _auditController.stream;
|
||||
|
||||
/// Vérifie si un utilisateur a une permission spécifique
|
||||
///
|
||||
/// [user] - Utilisateur à vérifier
|
||||
/// [permission] - Permission à vérifier
|
||||
/// [organizationId] - Contexte organisationnel optionnel
|
||||
/// [auditLog] - Activer l'audit trail (défaut: true)
|
||||
static Future<bool> hasPermission(
|
||||
User user,
|
||||
String permission, {
|
||||
String? organizationId,
|
||||
bool auditLog = true,
|
||||
}) async {
|
||||
final cacheKey = _generateCacheKey(user.id, permission, organizationId);
|
||||
|
||||
// Vérification du cache
|
||||
final cachedResult = _getCachedPermission(cacheKey);
|
||||
if (cachedResult != null) {
|
||||
_cacheHits++;
|
||||
if (auditLog && !cachedResult.result) {
|
||||
_logAuditEvent(user, permission, false, 'CACHED_DENIED', organizationId);
|
||||
}
|
||||
return cachedResult.result;
|
||||
}
|
||||
|
||||
_cacheMisses++;
|
||||
|
||||
// Calcul de la permission
|
||||
final result = await _computePermission(user, permission, organizationId);
|
||||
|
||||
// Mise en cache
|
||||
_cachePermission(cacheKey, result, user.primaryRole);
|
||||
|
||||
// Audit trail
|
||||
if (auditLog) {
|
||||
_logAuditEvent(
|
||||
user,
|
||||
permission,
|
||||
result,
|
||||
result ? 'GRANTED' : 'DENIED',
|
||||
organizationId,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Vérifie plusieurs permissions en une seule fois
|
||||
static Future<Map<String, bool>> hasPermissions(
|
||||
User user,
|
||||
List<String> permissions, {
|
||||
String? organizationId,
|
||||
bool auditLog = true,
|
||||
}) async {
|
||||
final results = <String, bool>{};
|
||||
|
||||
// Traitement en parallèle pour les performances
|
||||
final futures = permissions.map((permission) =>
|
||||
hasPermission(user, permission, organizationId: organizationId, auditLog: auditLog)
|
||||
.then((result) => MapEntry(permission, result))
|
||||
);
|
||||
|
||||
final entries = await Future.wait(futures);
|
||||
for (final entry in entries) {
|
||||
results[entry.key] = entry.value;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// Obtient toutes les permissions effectives d'un utilisateur
|
||||
static Future<List<String>> getEffectivePermissions(
|
||||
User user, {
|
||||
String? organizationId,
|
||||
}) async {
|
||||
final cacheKey = '${user.id}_effective_${organizationId ?? 'global'}';
|
||||
|
||||
// Vérification du cache utilisateur
|
||||
final cachedUserPermissions = _getCachedUserPermissions(cacheKey);
|
||||
if (cachedUserPermissions != null) {
|
||||
_cacheHits++;
|
||||
return cachedUserPermissions.permissions;
|
||||
}
|
||||
|
||||
_cacheMisses++;
|
||||
|
||||
// Calcul des permissions effectives
|
||||
final permissions = user.getEffectivePermissions(organizationId: organizationId);
|
||||
|
||||
// Mise en cache
|
||||
_cacheUserPermissions(cacheKey, permissions, user.primaryRole);
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
/// Vérifie si un utilisateur peut effectuer une action sur un domaine
|
||||
static Future<bool> canPerformAction(
|
||||
User user,
|
||||
String domain,
|
||||
String action, {
|
||||
String scope = 'own',
|
||||
String? organizationId,
|
||||
}) async {
|
||||
final permission = '$domain.$action.$scope';
|
||||
return hasPermission(user, permission, organizationId: organizationId);
|
||||
}
|
||||
|
||||
/// Invalide le cache pour un utilisateur spécifique
|
||||
static void invalidateUserCache(String userId) {
|
||||
final keysToRemove = <String>[];
|
||||
|
||||
// Invalider le cache des permissions
|
||||
for (final key in _permissionCache.keys) {
|
||||
if (key.startsWith('${userId}_')) {
|
||||
keysToRemove.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (final key in keysToRemove) {
|
||||
_permissionCache.remove(key);
|
||||
}
|
||||
|
||||
// Invalider le cache des permissions utilisateur
|
||||
final userKeysToRemove = <String>[];
|
||||
for (final key in _userPermissionsCache.keys) {
|
||||
if (key.startsWith('${userId}_')) {
|
||||
userKeysToRemove.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (final key in userKeysToRemove) {
|
||||
_userPermissionsCache.remove(key);
|
||||
}
|
||||
|
||||
debugPrint('Cache invalidé pour l\'utilisateur: $userId');
|
||||
}
|
||||
|
||||
/// Invalide tout le cache
|
||||
static void invalidateAllCache() {
|
||||
_permissionCache.clear();
|
||||
_userPermissionsCache.clear();
|
||||
debugPrint('Cache complet invalidé');
|
||||
}
|
||||
|
||||
/// Obtient les statistiques du cache
|
||||
static Map<String, dynamic> getCacheStats() {
|
||||
final totalRequests = _cacheHits + _cacheMisses;
|
||||
final hitRate = totalRequests > 0 ? (_cacheHits / totalRequests * 100) : 0.0;
|
||||
|
||||
return {
|
||||
'cacheHits': _cacheHits,
|
||||
'cacheMisses': _cacheMisses,
|
||||
'hitRate': hitRate.toStringAsFixed(2),
|
||||
'permissionCacheSize': _permissionCache.length,
|
||||
'userPermissionsCacheSize': _userPermissionsCache.length,
|
||||
};
|
||||
}
|
||||
|
||||
/// Nettoie le cache expiré
|
||||
static void cleanExpiredCache() {
|
||||
final now = DateTime.now();
|
||||
|
||||
// Nettoyer le cache des permissions
|
||||
_permissionCache.removeWhere((key, cached) => cached.expiresAt.isBefore(now));
|
||||
|
||||
// Nettoyer le cache des permissions utilisateur
|
||||
_userPermissionsCache.removeWhere((key, cached) => cached.expiresAt.isBefore(now));
|
||||
|
||||
debugPrint('Cache expiré nettoyé');
|
||||
}
|
||||
|
||||
// === MÉTHODES PRIVÉES ===
|
||||
|
||||
/// Calcule une permission sans cache
|
||||
static Future<bool> _computePermission(
|
||||
User user,
|
||||
String permission,
|
||||
String? organizationId,
|
||||
) async {
|
||||
// Vérification des permissions publiques
|
||||
if (PermissionMatrix.isPublicPermission(permission)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Vérification utilisateur actif
|
||||
if (!user.isActive) return false;
|
||||
|
||||
// Vérification directe de l'utilisateur
|
||||
if (user.hasPermission(permission, organizationId: organizationId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Vérifications contextuelles avancées
|
||||
return _checkContextualPermissions(user, permission, organizationId);
|
||||
}
|
||||
|
||||
/// Vérifications contextuelles avancées
|
||||
static Future<bool> _checkContextualPermissions(
|
||||
User user,
|
||||
String permission,
|
||||
String? organizationId,
|
||||
) async {
|
||||
// Logique contextuelle future (intégration avec le serveur)
|
||||
// Pour l'instant, retourne false
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Génère une clé de cache unique
|
||||
static String _generateCacheKey(String userId, String permission, String? organizationId) {
|
||||
return '${userId}_${permission}_${organizationId ?? 'global'}';
|
||||
}
|
||||
|
||||
/// Obtient une permission depuis le cache
|
||||
static _CachedPermission? _getCachedPermission(String key) {
|
||||
final cached = _permissionCache[key];
|
||||
if (cached != null && cached.expiresAt.isAfter(DateTime.now())) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (cached != null) {
|
||||
_permissionCache.remove(key);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Met en cache une permission
|
||||
static void _cachePermission(String key, bool result, UserRole userRole) {
|
||||
final ttl = userRole == UserRole.superAdmin ? _superAdminCacheTTL : _defaultCacheTTL;
|
||||
|
||||
_permissionCache[key] = _CachedPermission(
|
||||
result: result,
|
||||
expiresAt: DateTime.now().add(ttl),
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtient les permissions utilisateur depuis le cache
|
||||
static _CachedUserPermissions? _getCachedUserPermissions(String key) {
|
||||
final cached = _userPermissionsCache[key];
|
||||
if (cached != null && cached.expiresAt.isAfter(DateTime.now())) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (cached != null) {
|
||||
_userPermissionsCache.remove(key);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Met en cache les permissions utilisateur
|
||||
static void _cacheUserPermissions(String key, List<String> permissions, UserRole userRole) {
|
||||
final ttl = userRole == UserRole.superAdmin ? _superAdminCacheTTL : _defaultCacheTTL;
|
||||
|
||||
_userPermissionsCache[key] = _CachedUserPermissions(
|
||||
permissions: permissions,
|
||||
expiresAt: DateTime.now().add(ttl),
|
||||
);
|
||||
}
|
||||
|
||||
/// Enregistre un événement d'audit
|
||||
static void _logAuditEvent(
|
||||
User user,
|
||||
String permission,
|
||||
bool granted,
|
||||
String reason,
|
||||
String? organizationId,
|
||||
) {
|
||||
final event = PermissionAuditEvent(
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
permission: permission,
|
||||
granted: granted,
|
||||
reason: reason,
|
||||
organizationId: organizationId,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
|
||||
_auditController.add(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Classe pour les permissions mises en cache
|
||||
class _CachedPermission {
|
||||
final bool result;
|
||||
final DateTime expiresAt;
|
||||
|
||||
_CachedPermission({required this.result, required this.expiresAt});
|
||||
}
|
||||
|
||||
/// Classe pour les permissions utilisateur mises en cache
|
||||
class _CachedUserPermissions {
|
||||
final List<String> permissions;
|
||||
final DateTime expiresAt;
|
||||
|
||||
_CachedUserPermissions({required this.permissions, required this.expiresAt});
|
||||
}
|
||||
|
||||
/// Événement d'audit des permissions
|
||||
class PermissionAuditEvent {
|
||||
final String userId;
|
||||
final String userEmail;
|
||||
final String permission;
|
||||
final bool granted;
|
||||
final String reason;
|
||||
final String? organizationId;
|
||||
final DateTime timestamp;
|
||||
|
||||
PermissionAuditEvent({
|
||||
required this.userId,
|
||||
required this.userEmail,
|
||||
required this.permission,
|
||||
required this.granted,
|
||||
required this.reason,
|
||||
this.organizationId,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'userId': userId,
|
||||
'userEmail': userEmail,
|
||||
'permission': permission,
|
||||
'granted': granted,
|
||||
'reason': reason,
|
||||
'organizationId': organizationId,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/// Système de permissions granulaires ultra-sophistiqué
|
||||
/// Plus de 50 permissions atomiques avec héritage intelligent
|
||||
library permission_matrix;
|
||||
|
||||
/// Matrice de permissions atomiques pour contrôle granulaire
|
||||
///
|
||||
/// Chaque permission suit la convention : `domain.action.scope`
|
||||
/// Exemples : `members.edit.own`, `finances.view.all`, `system.admin.global`
|
||||
class PermissionMatrix {
|
||||
// === PERMISSIONS SYSTÈME ===
|
||||
static const String SYSTEM_ADMIN = 'system.admin.global';
|
||||
static const String SYSTEM_CONFIG = 'system.config.global';
|
||||
static const String SYSTEM_MONITORING = 'system.monitoring.view';
|
||||
static const String SYSTEM_BACKUP = 'system.backup.manage';
|
||||
static const String SYSTEM_SECURITY = 'system.security.manage';
|
||||
static const String SYSTEM_AUDIT = 'system.audit.view';
|
||||
static const String SYSTEM_LOGS = 'system.logs.view';
|
||||
static const String SYSTEM_MAINTENANCE = 'system.maintenance.execute';
|
||||
|
||||
// === PERMISSIONS ORGANISATION ===
|
||||
static const String ORG_CREATE = 'organization.create.global';
|
||||
static const String ORG_DELETE = 'organization.delete.own';
|
||||
static const String ORG_CONFIG = 'organization.config.own';
|
||||
static const String ORG_BRANDING = 'organization.branding.manage';
|
||||
static const String ORG_SETTINGS = 'organization.settings.manage';
|
||||
static const String ORG_PERMISSIONS = 'organization.permissions.manage';
|
||||
static const String ORG_WORKFLOWS = 'organization.workflows.manage';
|
||||
static const String ORG_INTEGRATIONS = 'organization.integrations.manage';
|
||||
|
||||
// === PERMISSIONS DASHBOARD ===
|
||||
static const String DASHBOARD_VIEW = 'dashboard.view.own';
|
||||
static const String DASHBOARD_ADMIN = 'dashboard.admin.view';
|
||||
static const String DASHBOARD_ANALYTICS = 'dashboard.analytics.view';
|
||||
static const String DASHBOARD_REPORTS = 'dashboard.reports.generate';
|
||||
static const String DASHBOARD_EXPORT = 'dashboard.export.data';
|
||||
static const String DASHBOARD_CUSTOMIZE = 'dashboard.customize.layout';
|
||||
|
||||
// === PERMISSIONS MEMBRES ===
|
||||
static const String MEMBERS_VIEW_ALL = 'members.view.all';
|
||||
static const String MEMBERS_VIEW_OWN = 'members.view.own';
|
||||
static const String MEMBERS_CREATE = 'members.create.organization';
|
||||
static const String MEMBERS_EDIT_ALL = 'members.edit.all';
|
||||
static const String MEMBERS_EDIT_OWN = 'members.edit.own';
|
||||
static const String MEMBERS_EDIT_BASIC = 'members.edit.basic';
|
||||
static const String MEMBERS_DELETE = 'members.delete.organization';
|
||||
static const String MEMBERS_DELETE_ALL = 'members.delete.all';
|
||||
static const String MEMBERS_APPROVE = 'members.approve.requests';
|
||||
static const String MEMBERS_SUSPEND = 'members.suspend.organization';
|
||||
static const String MEMBERS_EXPORT = 'members.export.data';
|
||||
static const String MEMBERS_IMPORT = 'members.import.data';
|
||||
static const String MEMBERS_COMMUNICATE = 'members.communicate.all';
|
||||
|
||||
// === PERMISSIONS FINANCES ===
|
||||
static const String FINANCES_VIEW_ALL = 'finances.view.all';
|
||||
static const String FINANCES_VIEW_OWN = 'finances.view.own';
|
||||
static const String FINANCES_EDIT_ALL = 'finances.edit.all';
|
||||
static const String FINANCES_MANAGE = 'finances.manage.organization';
|
||||
static const String FINANCES_APPROVE = 'finances.approve.transactions';
|
||||
static const String FINANCES_REPORTS = 'finances.reports.generate';
|
||||
static const String FINANCES_BUDGET = 'finances.budget.manage';
|
||||
static const String FINANCES_AUDIT = 'finances.audit.access';
|
||||
|
||||
// === PERMISSIONS ÉVÉNEMENTS ===
|
||||
static const String EVENTS_VIEW_ALL = 'events.view.all';
|
||||
static const String EVENTS_VIEW_PUBLIC = 'events.view.public';
|
||||
static const String EVENTS_CREATE = 'events.create.organization';
|
||||
static const String EVENTS_EDIT_ALL = 'events.edit.all';
|
||||
static const String EVENTS_EDIT_OWN = 'events.edit.own';
|
||||
static const String EVENTS_DELETE = 'events.delete.organization';
|
||||
static const String EVENTS_PARTICIPATE = 'events.participate.public';
|
||||
static const String EVENTS_MODERATE = 'events.moderate.organization';
|
||||
static const String EVENTS_ANALYTICS = 'events.analytics.view';
|
||||
|
||||
// === PERMISSIONS SOLIDARITÉ ===
|
||||
static const String SOLIDARITY_VIEW_ALL = 'solidarity.view.all';
|
||||
static const String SOLIDARITY_VIEW_OWN = 'solidarity.view.own';
|
||||
static const String SOLIDARITY_VIEW_PUBLIC = 'solidarity.view.public';
|
||||
static const String SOLIDARITY_CREATE = 'solidarity.create.request';
|
||||
static const String SOLIDARITY_EDIT_ALL = 'solidarity.edit.all';
|
||||
static const String SOLIDARITY_APPROVE = 'solidarity.approve.requests';
|
||||
static const String SOLIDARITY_PARTICIPATE = 'solidarity.participate.actions';
|
||||
static const String SOLIDARITY_MANAGE = 'solidarity.manage.organization';
|
||||
static const String SOLIDARITY_FUND = 'solidarity.fund.manage';
|
||||
|
||||
// === PERMISSIONS COMMUNICATION ===
|
||||
static const String COMM_SEND_ALL = 'communication.send.all';
|
||||
static const String COMM_SEND_MEMBERS = 'communication.send.members';
|
||||
static const String COMM_MODERATE = 'communication.moderate.organization';
|
||||
static const String COMM_BROADCAST = 'communication.broadcast.organization';
|
||||
static const String COMM_TEMPLATES = 'communication.templates.manage';
|
||||
|
||||
// === PERMISSIONS RAPPORTS ===
|
||||
static const String REPORTS_VIEW_ALL = 'reports.view.all';
|
||||
static const String REPORTS_GENERATE = 'reports.generate.organization';
|
||||
static const String REPORTS_EXPORT = 'reports.export.data';
|
||||
static const String REPORTS_SCHEDULE = 'reports.schedule.automated';
|
||||
|
||||
// === PERMISSIONS MODÉRATION ===
|
||||
static const String MODERATION_CONTENT = 'moderation.content.manage';
|
||||
static const String MODERATION_USERS = 'moderation.users.manage';
|
||||
static const String MODERATION_REPORTS = 'moderation.reports.handle';
|
||||
|
||||
/// Toutes les permissions disponibles dans le système
|
||||
static const List<String> ALL_PERMISSIONS = [
|
||||
// Système
|
||||
SYSTEM_ADMIN, SYSTEM_CONFIG, SYSTEM_MONITORING, SYSTEM_BACKUP,
|
||||
SYSTEM_SECURITY, SYSTEM_AUDIT, SYSTEM_LOGS, SYSTEM_MAINTENANCE,
|
||||
|
||||
// Organisation
|
||||
ORG_CREATE, ORG_DELETE, ORG_CONFIG, ORG_BRANDING, ORG_SETTINGS,
|
||||
ORG_PERMISSIONS, ORG_WORKFLOWS, ORG_INTEGRATIONS,
|
||||
|
||||
// Dashboard
|
||||
DASHBOARD_VIEW, DASHBOARD_ADMIN, DASHBOARD_ANALYTICS, DASHBOARD_REPORTS,
|
||||
DASHBOARD_EXPORT, DASHBOARD_CUSTOMIZE,
|
||||
|
||||
// Membres
|
||||
MEMBERS_VIEW_ALL, MEMBERS_VIEW_OWN, MEMBERS_CREATE, MEMBERS_EDIT_ALL,
|
||||
MEMBERS_EDIT_OWN, MEMBERS_DELETE, MEMBERS_APPROVE, MEMBERS_SUSPEND,
|
||||
MEMBERS_EXPORT, MEMBERS_IMPORT, MEMBERS_COMMUNICATE,
|
||||
|
||||
// Finances
|
||||
FINANCES_VIEW_ALL, FINANCES_VIEW_OWN, FINANCES_MANAGE, FINANCES_APPROVE,
|
||||
FINANCES_REPORTS, FINANCES_BUDGET, FINANCES_AUDIT,
|
||||
|
||||
// Événements
|
||||
EVENTS_VIEW_ALL, EVENTS_VIEW_PUBLIC, EVENTS_CREATE, EVENTS_EDIT_ALL,
|
||||
EVENTS_EDIT_OWN, EVENTS_DELETE, EVENTS_MODERATE, EVENTS_ANALYTICS,
|
||||
|
||||
// Solidarité
|
||||
SOLIDARITY_VIEW_ALL, SOLIDARITY_VIEW_OWN, SOLIDARITY_CREATE,
|
||||
SOLIDARITY_APPROVE, SOLIDARITY_MANAGE, SOLIDARITY_FUND,
|
||||
|
||||
// Communication
|
||||
COMM_SEND_ALL, COMM_SEND_MEMBERS, COMM_MODERATE, COMM_BROADCAST,
|
||||
COMM_TEMPLATES,
|
||||
|
||||
// Rapports
|
||||
REPORTS_VIEW_ALL, REPORTS_GENERATE, REPORTS_EXPORT, REPORTS_SCHEDULE,
|
||||
|
||||
// Modération
|
||||
MODERATION_CONTENT, MODERATION_USERS, MODERATION_REPORTS,
|
||||
];
|
||||
|
||||
/// Permissions publiques (accessibles sans authentification)
|
||||
static const List<String> PUBLIC_PERMISSIONS = [
|
||||
EVENTS_VIEW_PUBLIC,
|
||||
];
|
||||
|
||||
/// Vérifie si une permission est publique
|
||||
static bool isPublicPermission(String permission) {
|
||||
return PUBLIC_PERMISSIONS.contains(permission);
|
||||
}
|
||||
|
||||
/// Obtient le domaine d'une permission (partie avant le premier point)
|
||||
static String getDomain(String permission) {
|
||||
return permission.split('.').first;
|
||||
}
|
||||
|
||||
/// Obtient l'action d'une permission (partie du milieu)
|
||||
static String getAction(String permission) {
|
||||
final parts = permission.split('.');
|
||||
return parts.length > 1 ? parts[1] : '';
|
||||
}
|
||||
|
||||
/// Obtient la portée d'une permission (partie après le dernier point)
|
||||
static String getScope(String permission) {
|
||||
return permission.split('.').last;
|
||||
}
|
||||
|
||||
/// Vérifie si une permission implique une autre (héritage)
|
||||
static bool implies(String higherPermission, String lowerPermission) {
|
||||
// Exemple : 'members.edit.all' implique 'members.view.all'
|
||||
final higherParts = higherPermission.split('.');
|
||||
final lowerParts = lowerPermission.split('.');
|
||||
|
||||
if (higherParts.length != 3 || lowerParts.length != 3) return false;
|
||||
|
||||
// Même domaine requis
|
||||
if (higherParts[0] != lowerParts[0]) return false;
|
||||
|
||||
// Vérification des implications d'actions
|
||||
return _actionImplies(higherParts[1], lowerParts[1]) &&
|
||||
_scopeImplies(higherParts[2], lowerParts[2]);
|
||||
}
|
||||
|
||||
/// Vérifie si une action implique une autre
|
||||
static bool _actionImplies(String higherAction, String lowerAction) {
|
||||
const actionHierarchy = {
|
||||
'admin': ['manage', 'edit', 'create', 'delete', 'view'],
|
||||
'manage': ['edit', 'create', 'delete', 'view'],
|
||||
'edit': ['view'],
|
||||
'create': ['view'],
|
||||
'delete': ['view'],
|
||||
};
|
||||
|
||||
return actionHierarchy[higherAction]?.contains(lowerAction) ??
|
||||
higherAction == lowerAction;
|
||||
}
|
||||
|
||||
/// Vérifie si une portée implique une autre
|
||||
static bool _scopeImplies(String higherScope, String lowerScope) {
|
||||
const scopeHierarchy = {
|
||||
'global': ['all', 'organization', 'own'],
|
||||
'all': ['organization', 'own'],
|
||||
'organization': ['own'],
|
||||
};
|
||||
|
||||
return scopeHierarchy[higherScope]?.contains(lowerScope) ??
|
||||
higherScope == lowerScope;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/// Modèles de données utilisateur avec contexte et permissions
|
||||
/// Support des relations multi-organisations et permissions contextuelles
|
||||
library user_models;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'user_role.dart';
|
||||
|
||||
/// Modèle utilisateur principal avec contexte multi-organisations
|
||||
///
|
||||
/// Supporte les utilisateurs ayant des rôles différents dans plusieurs organisations
|
||||
/// avec des permissions contextuelles et des préférences personnalisées
|
||||
class User extends Equatable {
|
||||
/// Identifiant unique de l'utilisateur
|
||||
final String id;
|
||||
|
||||
/// Informations personnelles
|
||||
final String email;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String? avatar;
|
||||
final String? phone;
|
||||
|
||||
/// Rôle principal de l'utilisateur (le plus élevé)
|
||||
final UserRole primaryRole;
|
||||
|
||||
/// Contextes organisationnels (rôles dans différentes organisations)
|
||||
final List<UserOrganizationContext> organizationContexts;
|
||||
|
||||
/// Permissions supplémentaires accordées spécifiquement
|
||||
final List<String> additionalPermissions;
|
||||
|
||||
/// Permissions révoquées spécifiquement
|
||||
final List<String> revokedPermissions;
|
||||
|
||||
/// Préférences utilisateur
|
||||
final UserPreferences preferences;
|
||||
|
||||
/// Métadonnées
|
||||
final DateTime createdAt;
|
||||
final DateTime lastLoginAt;
|
||||
final bool isActive;
|
||||
final bool isVerified;
|
||||
|
||||
/// Constructeur du modèle utilisateur
|
||||
const User({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.primaryRole,
|
||||
this.avatar,
|
||||
this.phone,
|
||||
this.organizationContexts = const [],
|
||||
this.additionalPermissions = const [],
|
||||
this.revokedPermissions = const [],
|
||||
this.preferences = const UserPreferences(),
|
||||
required this.createdAt,
|
||||
required this.lastLoginAt,
|
||||
this.isActive = true,
|
||||
this.isVerified = false,
|
||||
});
|
||||
|
||||
|
||||
|
||||
/// Nom complet de l'utilisateur
|
||||
String get fullName => '$firstName $lastName';
|
||||
|
||||
/// Initiales de l'utilisateur
|
||||
String get initials => '${firstName[0]}${lastName[0]}'.toUpperCase();
|
||||
|
||||
/// Vérifie si l'utilisateur a une permission dans le contexte actuel
|
||||
bool hasPermission(String permission, {String? organizationId}) {
|
||||
// Vérification des permissions révoquées
|
||||
if (revokedPermissions.contains(permission)) return false;
|
||||
|
||||
// Vérification des permissions additionnelles
|
||||
if (additionalPermissions.contains(permission)) return true;
|
||||
|
||||
// Vérification du rôle principal
|
||||
if (primaryRole.hasPermission(permission)) return true;
|
||||
|
||||
// Vérification dans le contexte organisationnel spécifique
|
||||
if (organizationId != null) {
|
||||
final context = getOrganizationContext(organizationId);
|
||||
if (context?.role.hasPermission(permission) == true) return true;
|
||||
}
|
||||
|
||||
// Vérification dans tous les contextes organisationnels
|
||||
return organizationContexts.any((context) =>
|
||||
context.role.hasPermission(permission));
|
||||
}
|
||||
|
||||
/// Obtient le contexte organisationnel pour une organisation
|
||||
UserOrganizationContext? getOrganizationContext(String organizationId) {
|
||||
try {
|
||||
return organizationContexts.firstWhere(
|
||||
(context) => context.organizationId == organizationId,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient le rôle dans une organisation spécifique
|
||||
UserRole getRoleInOrganization(String organizationId) {
|
||||
final context = getOrganizationContext(organizationId);
|
||||
return context?.role ?? primaryRole;
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur est membre d'une organisation
|
||||
bool isMemberOfOrganization(String organizationId) {
|
||||
return organizationContexts.any(
|
||||
(context) => context.organizationId == organizationId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtient toutes les permissions effectives de l'utilisateur
|
||||
List<String> getEffectivePermissions({String? organizationId}) {
|
||||
final permissions = <String>{};
|
||||
|
||||
// Permissions du rôle principal
|
||||
permissions.addAll(primaryRole.getEffectivePermissions());
|
||||
|
||||
// Permissions des contextes organisationnels
|
||||
if (organizationId != null) {
|
||||
final context = getOrganizationContext(organizationId);
|
||||
if (context != null) {
|
||||
permissions.addAll(context.role.getEffectivePermissions());
|
||||
}
|
||||
} else {
|
||||
for (final context in organizationContexts) {
|
||||
permissions.addAll(context.role.getEffectivePermissions());
|
||||
}
|
||||
}
|
||||
|
||||
// Permissions additionnelles
|
||||
permissions.addAll(additionalPermissions);
|
||||
|
||||
// Retirer les permissions révoquées
|
||||
permissions.removeAll(revokedPermissions);
|
||||
|
||||
return permissions.toList()..sort();
|
||||
}
|
||||
|
||||
/// Crée une copie de l'utilisateur avec des modifications
|
||||
User copyWith({
|
||||
String? email,
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? avatar,
|
||||
String? phone,
|
||||
UserRole? primaryRole,
|
||||
List<UserOrganizationContext>? organizationContexts,
|
||||
List<String>? additionalPermissions,
|
||||
List<String>? revokedPermissions,
|
||||
UserPreferences? preferences,
|
||||
DateTime? lastLoginAt,
|
||||
bool? isActive,
|
||||
bool? isVerified,
|
||||
}) {
|
||||
return User(
|
||||
id: id,
|
||||
email: email ?? this.email,
|
||||
firstName: firstName ?? this.firstName,
|
||||
lastName: lastName ?? this.lastName,
|
||||
avatar: avatar ?? this.avatar,
|
||||
phone: phone ?? this.phone,
|
||||
primaryRole: primaryRole ?? this.primaryRole,
|
||||
organizationContexts: organizationContexts ?? this.organizationContexts,
|
||||
additionalPermissions: additionalPermissions ?? this.additionalPermissions,
|
||||
revokedPermissions: revokedPermissions ?? this.revokedPermissions,
|
||||
preferences: preferences ?? this.preferences,
|
||||
createdAt: createdAt,
|
||||
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
|
||||
isActive: isActive ?? this.isActive,
|
||||
isVerified: isVerified ?? this.isVerified,
|
||||
);
|
||||
}
|
||||
|
||||
/// Conversion vers Map pour sérialisation
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'email': email,
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'avatar': avatar,
|
||||
'phone': phone,
|
||||
'primaryRole': primaryRole.name,
|
||||
'organizationContexts': organizationContexts.map((c) => c.toJson()).toList(),
|
||||
'additionalPermissions': additionalPermissions,
|
||||
'revokedPermissions': revokedPermissions,
|
||||
'preferences': preferences.toJson(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'lastLoginAt': lastLoginAt.toIso8601String(),
|
||||
'isActive': isActive,
|
||||
'isVerified': isVerified,
|
||||
};
|
||||
}
|
||||
|
||||
/// Création depuis Map pour désérialisation
|
||||
factory User.fromJson(Map<String, dynamic> json) {
|
||||
return User(
|
||||
id: json['id'],
|
||||
email: json['email'],
|
||||
firstName: json['firstName'],
|
||||
lastName: json['lastName'],
|
||||
avatar: json['avatar'],
|
||||
phone: json['phone'],
|
||||
primaryRole: UserRole.fromString(json['primaryRole']) ?? UserRole.visitor,
|
||||
organizationContexts: (json['organizationContexts'] as List?)
|
||||
?.map((c) => UserOrganizationContext.fromJson(c))
|
||||
.toList() ?? [],
|
||||
additionalPermissions: List<String>.from(json['additionalPermissions'] ?? []),
|
||||
revokedPermissions: List<String>.from(json['revokedPermissions'] ?? []),
|
||||
preferences: UserPreferences.fromJson(json['preferences'] ?? {}),
|
||||
createdAt: DateTime.parse(json['createdAt']),
|
||||
lastLoginAt: DateTime.parse(json['lastLoginAt']),
|
||||
isActive: json['isActive'] ?? true,
|
||||
isVerified: json['isVerified'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id, email, firstName, lastName, avatar, phone, primaryRole,
|
||||
organizationContexts, additionalPermissions, revokedPermissions,
|
||||
preferences, createdAt, lastLoginAt, isActive, isVerified,
|
||||
];
|
||||
}
|
||||
|
||||
/// Contexte organisationnel d'un utilisateur
|
||||
///
|
||||
/// Définit le rôle et les permissions spécifiques dans une organisation
|
||||
class UserOrganizationContext extends Equatable {
|
||||
/// Identifiant de l'organisation
|
||||
final String organizationId;
|
||||
|
||||
/// Nom de l'organisation
|
||||
final String organizationName;
|
||||
|
||||
/// Rôle de l'utilisateur dans cette organisation
|
||||
final UserRole role;
|
||||
|
||||
/// Permissions spécifiques dans cette organisation
|
||||
final List<String> specificPermissions;
|
||||
|
||||
/// Date d'adhésion à l'organisation
|
||||
final DateTime joinedAt;
|
||||
|
||||
/// Statut dans l'organisation
|
||||
final bool isActive;
|
||||
|
||||
/// Constructeur du contexte organisationnel
|
||||
const UserOrganizationContext({
|
||||
required this.organizationId,
|
||||
required this.organizationName,
|
||||
required this.role,
|
||||
this.specificPermissions = const [],
|
||||
required this.joinedAt,
|
||||
this.isActive = true,
|
||||
});
|
||||
|
||||
/// Conversion vers Map
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'organizationId': organizationId,
|
||||
'organizationName': organizationName,
|
||||
'role': role.name,
|
||||
'specificPermissions': specificPermissions,
|
||||
'joinedAt': joinedAt.toIso8601String(),
|
||||
'isActive': isActive,
|
||||
};
|
||||
}
|
||||
|
||||
/// Création depuis Map
|
||||
factory UserOrganizationContext.fromJson(Map<String, dynamic> json) {
|
||||
return UserOrganizationContext(
|
||||
organizationId: json['organizationId'],
|
||||
organizationName: json['organizationName'],
|
||||
role: UserRole.fromString(json['role']) ?? UserRole.visitor,
|
||||
specificPermissions: List<String>.from(json['specificPermissions'] ?? []),
|
||||
joinedAt: DateTime.parse(json['joinedAt']),
|
||||
isActive: json['isActive'] ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
organizationId, organizationName, role, specificPermissions, joinedAt, isActive,
|
||||
];
|
||||
}
|
||||
|
||||
/// Préférences utilisateur personnalisables
|
||||
class UserPreferences extends Equatable {
|
||||
/// Langue préférée
|
||||
final String language;
|
||||
|
||||
/// Thème préféré
|
||||
final String theme;
|
||||
|
||||
/// Notifications activées
|
||||
final bool notificationsEnabled;
|
||||
|
||||
/// Notifications par email
|
||||
final bool emailNotifications;
|
||||
|
||||
/// Notifications push
|
||||
final bool pushNotifications;
|
||||
|
||||
/// Layout du dashboard préféré
|
||||
final String dashboardLayout;
|
||||
|
||||
/// Timezone
|
||||
final String timezone;
|
||||
|
||||
/// Constructeur des préférences
|
||||
const UserPreferences({
|
||||
this.language = 'fr',
|
||||
this.theme = 'system',
|
||||
this.notificationsEnabled = true,
|
||||
this.emailNotifications = true,
|
||||
this.pushNotifications = true,
|
||||
this.dashboardLayout = 'default',
|
||||
this.timezone = 'Europe/Paris',
|
||||
});
|
||||
|
||||
/// Conversion vers Map
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'language': language,
|
||||
'theme': theme,
|
||||
'notificationsEnabled': notificationsEnabled,
|
||||
'emailNotifications': emailNotifications,
|
||||
'pushNotifications': pushNotifications,
|
||||
'dashboardLayout': dashboardLayout,
|
||||
'timezone': timezone,
|
||||
};
|
||||
}
|
||||
|
||||
/// Création depuis Map
|
||||
factory UserPreferences.fromJson(Map<String, dynamic> json) {
|
||||
return UserPreferences(
|
||||
language: json['language'] ?? 'fr',
|
||||
theme: json['theme'] ?? 'system',
|
||||
notificationsEnabled: json['notificationsEnabled'] ?? true,
|
||||
emailNotifications: json['emailNotifications'] ?? true,
|
||||
pushNotifications: json['pushNotifications'] ?? true,
|
||||
dashboardLayout: json['dashboardLayout'] ?? 'default',
|
||||
timezone: json['timezone'] ?? 'Europe/Paris',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
language, theme, notificationsEnabled, emailNotifications,
|
||||
pushNotifications, dashboardLayout, timezone,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/// Système de rôles utilisateurs avec hiérarchie intelligente
|
||||
/// 6 niveaux de rôles avec permissions héritées et contextuelles
|
||||
library user_role;
|
||||
|
||||
import 'permission_matrix.dart';
|
||||
|
||||
/// Énumération des rôles utilisateurs avec hiérarchie et permissions
|
||||
///
|
||||
/// Chaque rôle a un niveau numérique pour faciliter les comparaisons
|
||||
/// et une liste de permissions spécifiques avec héritage intelligent
|
||||
enum UserRole {
|
||||
/// Super Administrateur - Niveau système (100)
|
||||
/// Accès complet à toutes les fonctionnalités multi-organisations
|
||||
superAdmin(
|
||||
level: 100,
|
||||
displayName: 'Super Administrateur',
|
||||
description: 'Accès complet système et multi-organisations',
|
||||
color: 0xFF6C5CE7, // Violet sophistiqué
|
||||
permissions: _superAdminPermissions,
|
||||
),
|
||||
|
||||
/// Administrateur d'Organisation - Niveau organisation (80)
|
||||
/// Gestion complète de son organisation uniquement
|
||||
orgAdmin(
|
||||
level: 80,
|
||||
displayName: 'Administrateur',
|
||||
description: 'Gestion complète de l\'organisation',
|
||||
color: 0xFF0984E3, // Bleu corporate
|
||||
permissions: _orgAdminPermissions,
|
||||
),
|
||||
|
||||
/// Modérateur/Gestionnaire - Niveau intermédiaire (60)
|
||||
/// Gestion partielle selon permissions accordées
|
||||
moderator(
|
||||
level: 60,
|
||||
displayName: 'Modérateur',
|
||||
description: 'Gestion partielle et modération',
|
||||
color: 0xFFE17055, // Orange focus
|
||||
permissions: _moderatorPermissions,
|
||||
),
|
||||
|
||||
/// Membre Actif - Niveau utilisateur (40)
|
||||
/// Accès aux fonctionnalités membres avec participation active
|
||||
activeMember(
|
||||
level: 40,
|
||||
displayName: 'Membre Actif',
|
||||
description: 'Participation active aux activités',
|
||||
color: 0xFF00B894, // Vert communauté
|
||||
permissions: _activeMemberPermissions,
|
||||
),
|
||||
|
||||
/// Membre Simple - Niveau basique (20)
|
||||
/// Accès limité aux informations personnelles
|
||||
simpleMember(
|
||||
level: 20,
|
||||
displayName: 'Membre',
|
||||
description: 'Accès aux informations de base',
|
||||
color: 0xFF00CEC9, // Teal simple
|
||||
permissions: _simpleMemberPermissions,
|
||||
),
|
||||
|
||||
/// Visiteur/Invité - Niveau public (0)
|
||||
/// Accès aux informations publiques uniquement
|
||||
visitor(
|
||||
level: 0,
|
||||
displayName: 'Visiteur',
|
||||
description: 'Accès aux informations publiques',
|
||||
color: 0xFF6C5CE7, // Indigo accueillant
|
||||
permissions: _visitorPermissions,
|
||||
);
|
||||
|
||||
/// Constructeur du rôle avec toutes ses propriétés
|
||||
const UserRole({
|
||||
required this.level,
|
||||
required this.displayName,
|
||||
required this.description,
|
||||
required this.color,
|
||||
required this.permissions,
|
||||
});
|
||||
|
||||
/// Niveau numérique du rôle (0-100)
|
||||
final int level;
|
||||
|
||||
/// Nom d'affichage du rôle
|
||||
final String displayName;
|
||||
|
||||
/// Description détaillée du rôle
|
||||
final String description;
|
||||
|
||||
/// Couleur thématique du rôle (format 0xFFRRGGBB)
|
||||
final int color;
|
||||
|
||||
/// Liste des permissions spécifiques au rôle
|
||||
final List<String> permissions;
|
||||
|
||||
/// Vérifie si ce rôle a un niveau supérieur ou égal à un autre
|
||||
bool hasLevelOrAbove(UserRole other) => level >= other.level;
|
||||
|
||||
/// Vérifie si ce rôle a un niveau strictement supérieur à un autre
|
||||
bool hasLevelAbove(UserRole other) => level > other.level;
|
||||
|
||||
/// Vérifie si ce rôle possède une permission spécifique
|
||||
bool hasPermission(String permission) {
|
||||
// Vérification directe
|
||||
if (permissions.contains(permission)) return true;
|
||||
|
||||
// Vérification par héritage (permissions impliquées)
|
||||
return permissions.any((p) => PermissionMatrix.implies(p, permission));
|
||||
}
|
||||
|
||||
/// Obtient toutes les permissions effectives (directes + héritées)
|
||||
List<String> getEffectivePermissions() {
|
||||
final effective = <String>{};
|
||||
|
||||
// Ajouter les permissions directes
|
||||
effective.addAll(permissions);
|
||||
|
||||
// Ajouter les permissions impliquées
|
||||
for (final permission in permissions) {
|
||||
for (final allPermission in PermissionMatrix.ALL_PERMISSIONS) {
|
||||
if (PermissionMatrix.implies(permission, allPermission)) {
|
||||
effective.add(allPermission);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effective.toList()..sort();
|
||||
}
|
||||
|
||||
/// Vérifie si ce rôle peut effectuer une action sur un domaine
|
||||
bool canPerformAction(String domain, String action, {String scope = 'own'}) {
|
||||
final permission = '$domain.$action.$scope';
|
||||
return hasPermission(permission);
|
||||
}
|
||||
|
||||
/// Obtient le rôle à partir de son nom
|
||||
static UserRole? fromString(String roleName) {
|
||||
return UserRole.values.firstWhere(
|
||||
(role) => role.name == roleName,
|
||||
orElse: () => UserRole.visitor,
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtient tous les rôles avec un niveau inférieur ou égal
|
||||
List<UserRole> getSubordinateRoles() {
|
||||
return UserRole.values.where((role) => role.level < level).toList();
|
||||
}
|
||||
|
||||
/// Obtient tous les rôles avec un niveau supérieur ou égal
|
||||
List<UserRole> getSuperiorRoles() {
|
||||
return UserRole.values.where((role) => role.level >= level).toList();
|
||||
}
|
||||
}
|
||||
|
||||
// === DÉFINITIONS DES PERMISSIONS PAR RÔLE ===
|
||||
|
||||
/// Permissions du Super Administrateur (accès complet)
|
||||
const List<String> _superAdminPermissions = [
|
||||
// Toutes les permissions système
|
||||
PermissionMatrix.SYSTEM_ADMIN,
|
||||
PermissionMatrix.SYSTEM_CONFIG,
|
||||
PermissionMatrix.SYSTEM_MONITORING,
|
||||
PermissionMatrix.SYSTEM_BACKUP,
|
||||
PermissionMatrix.SYSTEM_SECURITY,
|
||||
PermissionMatrix.SYSTEM_AUDIT,
|
||||
PermissionMatrix.SYSTEM_LOGS,
|
||||
PermissionMatrix.SYSTEM_MAINTENANCE,
|
||||
|
||||
// Gestion globale des organisations
|
||||
PermissionMatrix.ORG_CREATE,
|
||||
PermissionMatrix.ORG_DELETE,
|
||||
PermissionMatrix.ORG_CONFIG,
|
||||
|
||||
// Accès complet aux dashboards
|
||||
PermissionMatrix.DASHBOARD_ADMIN,
|
||||
PermissionMatrix.DASHBOARD_ANALYTICS,
|
||||
PermissionMatrix.DASHBOARD_REPORTS,
|
||||
PermissionMatrix.DASHBOARD_EXPORT,
|
||||
|
||||
// Gestion complète des membres
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_EDIT_ALL,
|
||||
PermissionMatrix.MEMBERS_DELETE,
|
||||
PermissionMatrix.MEMBERS_EXPORT,
|
||||
PermissionMatrix.MEMBERS_IMPORT,
|
||||
|
||||
// Accès complet aux finances
|
||||
PermissionMatrix.FINANCES_VIEW_ALL,
|
||||
PermissionMatrix.FINANCES_MANAGE,
|
||||
PermissionMatrix.FINANCES_AUDIT,
|
||||
|
||||
// Tous les rapports
|
||||
PermissionMatrix.REPORTS_VIEW_ALL,
|
||||
PermissionMatrix.REPORTS_GENERATE,
|
||||
PermissionMatrix.REPORTS_EXPORT,
|
||||
PermissionMatrix.REPORTS_SCHEDULE,
|
||||
];
|
||||
|
||||
/// Permissions de l'Administrateur d'Organisation
|
||||
const List<String> _orgAdminPermissions = [
|
||||
// Configuration organisation
|
||||
PermissionMatrix.ORG_CONFIG,
|
||||
PermissionMatrix.ORG_BRANDING,
|
||||
PermissionMatrix.ORG_SETTINGS,
|
||||
PermissionMatrix.ORG_PERMISSIONS,
|
||||
PermissionMatrix.ORG_WORKFLOWS,
|
||||
|
||||
// Dashboard organisation
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
PermissionMatrix.DASHBOARD_ANALYTICS,
|
||||
PermissionMatrix.DASHBOARD_REPORTS,
|
||||
PermissionMatrix.DASHBOARD_CUSTOMIZE,
|
||||
|
||||
// Gestion des membres
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_CREATE,
|
||||
PermissionMatrix.MEMBERS_EDIT_ALL,
|
||||
PermissionMatrix.MEMBERS_APPROVE,
|
||||
PermissionMatrix.MEMBERS_SUSPEND,
|
||||
PermissionMatrix.MEMBERS_COMMUNICATE,
|
||||
|
||||
// Gestion financière
|
||||
PermissionMatrix.FINANCES_VIEW_ALL,
|
||||
PermissionMatrix.FINANCES_MANAGE,
|
||||
PermissionMatrix.FINANCES_REPORTS,
|
||||
PermissionMatrix.FINANCES_BUDGET,
|
||||
|
||||
// Gestion des événements
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_CREATE,
|
||||
PermissionMatrix.EVENTS_EDIT_ALL,
|
||||
PermissionMatrix.EVENTS_DELETE,
|
||||
PermissionMatrix.EVENTS_ANALYTICS,
|
||||
|
||||
// Gestion de la solidarité
|
||||
PermissionMatrix.SOLIDARITY_VIEW_ALL,
|
||||
PermissionMatrix.SOLIDARITY_APPROVE,
|
||||
PermissionMatrix.SOLIDARITY_MANAGE,
|
||||
PermissionMatrix.SOLIDARITY_FUND,
|
||||
|
||||
// Communication
|
||||
PermissionMatrix.COMM_SEND_ALL,
|
||||
PermissionMatrix.COMM_BROADCAST,
|
||||
PermissionMatrix.COMM_TEMPLATES,
|
||||
|
||||
// Rapports organisation
|
||||
PermissionMatrix.REPORTS_GENERATE,
|
||||
PermissionMatrix.REPORTS_EXPORT,
|
||||
];
|
||||
|
||||
/// Permissions du Modérateur
|
||||
const List<String> _moderatorPermissions = [
|
||||
// Dashboard limité
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
|
||||
// Modération des membres
|
||||
PermissionMatrix.MEMBERS_VIEW_ALL,
|
||||
PermissionMatrix.MEMBERS_APPROVE,
|
||||
PermissionMatrix.MODERATION_USERS,
|
||||
|
||||
// Modération du contenu
|
||||
PermissionMatrix.MODERATION_CONTENT,
|
||||
PermissionMatrix.MODERATION_REPORTS,
|
||||
|
||||
// Événements limités
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_MODERATE,
|
||||
|
||||
// Communication modérée
|
||||
PermissionMatrix.COMM_MODERATE,
|
||||
PermissionMatrix.COMM_SEND_MEMBERS,
|
||||
];
|
||||
|
||||
/// Permissions du Membre Actif
|
||||
const List<String> _activeMemberPermissions = [
|
||||
// Dashboard personnel
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
|
||||
// Profil personnel
|
||||
PermissionMatrix.MEMBERS_VIEW_OWN,
|
||||
PermissionMatrix.MEMBERS_EDIT_OWN,
|
||||
|
||||
// Finances personnelles
|
||||
PermissionMatrix.FINANCES_VIEW_OWN,
|
||||
|
||||
// Événements
|
||||
PermissionMatrix.EVENTS_VIEW_ALL,
|
||||
PermissionMatrix.EVENTS_CREATE,
|
||||
PermissionMatrix.EVENTS_EDIT_OWN,
|
||||
|
||||
// Solidarité
|
||||
PermissionMatrix.SOLIDARITY_VIEW_ALL,
|
||||
PermissionMatrix.SOLIDARITY_CREATE,
|
||||
];
|
||||
|
||||
/// Permissions du Membre Simple
|
||||
const List<String> _simpleMemberPermissions = [
|
||||
// Dashboard basique
|
||||
PermissionMatrix.DASHBOARD_VIEW,
|
||||
|
||||
// Profil personnel uniquement
|
||||
PermissionMatrix.MEMBERS_VIEW_OWN,
|
||||
PermissionMatrix.MEMBERS_EDIT_OWN,
|
||||
|
||||
// Finances personnelles
|
||||
PermissionMatrix.FINANCES_VIEW_OWN,
|
||||
|
||||
// Événements publics
|
||||
PermissionMatrix.EVENTS_VIEW_PUBLIC,
|
||||
|
||||
// Solidarité consultation
|
||||
PermissionMatrix.SOLIDARITY_VIEW_OWN,
|
||||
];
|
||||
|
||||
/// Permissions du Visiteur
|
||||
const List<String> _visitorPermissions = [
|
||||
// Événements publics uniquement
|
||||
PermissionMatrix.EVENTS_VIEW_PUBLIC,
|
||||
];
|
||||
@@ -0,0 +1,468 @@
|
||||
/// BLoC d'authentification Keycloak adaptatif avec gestion des rôles
|
||||
/// Support Keycloak avec contextes multi-organisations et états sophistiqués
|
||||
library auth_bloc;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../data/models/user.dart';
|
||||
import '../../data/models/user_role.dart';
|
||||
import '../../data/datasources/permission_engine.dart';
|
||||
import '../../data/datasources/keycloak_auth_service.dart';
|
||||
import '../../data/datasources/dashboard_cache_manager.dart';
|
||||
|
||||
// === ÉVÉNEMENTS ===
|
||||
|
||||
/// Événements d'authentification
|
||||
abstract class AuthEvent extends Equatable {
|
||||
const AuthEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Événement de connexion Keycloak
|
||||
class AuthLoginRequested extends AuthEvent {
|
||||
const AuthLoginRequested();
|
||||
}
|
||||
|
||||
/// Événement de déconnexion
|
||||
class AuthLogoutRequested extends AuthEvent {
|
||||
const AuthLogoutRequested();
|
||||
}
|
||||
|
||||
/// Événement de changement de contexte organisationnel
|
||||
class AuthOrganizationContextChanged extends AuthEvent {
|
||||
final String organizationId;
|
||||
|
||||
const AuthOrganizationContextChanged(this.organizationId);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [organizationId];
|
||||
}
|
||||
|
||||
/// Événement de rafraîchissement du token
|
||||
class AuthTokenRefreshRequested extends AuthEvent {
|
||||
const AuthTokenRefreshRequested();
|
||||
}
|
||||
|
||||
/// Événement de vérification de l'état d'authentification
|
||||
class AuthStatusChecked extends AuthEvent {
|
||||
const AuthStatusChecked();
|
||||
}
|
||||
|
||||
/// Événement de mise à jour du profil utilisateur
|
||||
class AuthUserProfileUpdated extends AuthEvent {
|
||||
final User updatedUser;
|
||||
|
||||
const AuthUserProfileUpdated(this.updatedUser);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [updatedUser];
|
||||
}
|
||||
|
||||
/// Événement de callback WebView
|
||||
class AuthWebViewCallback extends AuthEvent {
|
||||
final String callbackUrl;
|
||||
final User? user;
|
||||
|
||||
const AuthWebViewCallback(this.callbackUrl, {this.user});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [callbackUrl, user];
|
||||
}
|
||||
|
||||
// === ÉTATS ===
|
||||
|
||||
/// États d'authentification
|
||||
abstract class AuthState extends Equatable {
|
||||
const AuthState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// État initial
|
||||
class AuthInitial extends AuthState {
|
||||
const AuthInitial();
|
||||
}
|
||||
|
||||
/// État de chargement
|
||||
class AuthLoading extends AuthState {
|
||||
const AuthLoading();
|
||||
}
|
||||
|
||||
/// État authentifié avec contexte riche
|
||||
class AuthAuthenticated extends AuthState {
|
||||
final User user;
|
||||
final String? currentOrganizationId;
|
||||
final UserRole effectiveRole;
|
||||
final List<String> effectivePermissions;
|
||||
final DateTime authenticatedAt;
|
||||
final String? accessToken;
|
||||
|
||||
const AuthAuthenticated({
|
||||
required this.user,
|
||||
this.currentOrganizationId,
|
||||
required this.effectiveRole,
|
||||
required this.effectivePermissions,
|
||||
required this.authenticatedAt,
|
||||
this.accessToken,
|
||||
});
|
||||
|
||||
/// Vérifie si l'utilisateur a une permission
|
||||
bool hasPermission(String permission) {
|
||||
return effectivePermissions.contains(permission);
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur peut effectuer une action
|
||||
bool canPerformAction(String domain, String action, {String scope = 'own'}) {
|
||||
final permission = '$domain.$action.$scope';
|
||||
return hasPermission(permission);
|
||||
}
|
||||
|
||||
/// Obtient le contexte organisationnel actuel
|
||||
UserOrganizationContext? get currentOrganizationContext {
|
||||
if (currentOrganizationId == null) return null;
|
||||
return user.getOrganizationContext(currentOrganizationId!);
|
||||
}
|
||||
|
||||
/// Crée une copie avec des modifications
|
||||
AuthAuthenticated copyWith({
|
||||
User? user,
|
||||
String? currentOrganizationId,
|
||||
UserRole? effectiveRole,
|
||||
List<String>? effectivePermissions,
|
||||
DateTime? authenticatedAt,
|
||||
String? accessToken,
|
||||
}) {
|
||||
return AuthAuthenticated(
|
||||
user: user ?? this.user,
|
||||
currentOrganizationId: currentOrganizationId ?? this.currentOrganizationId,
|
||||
effectiveRole: effectiveRole ?? this.effectiveRole,
|
||||
effectivePermissions: effectivePermissions ?? this.effectivePermissions,
|
||||
authenticatedAt: authenticatedAt ?? this.authenticatedAt,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
user,
|
||||
currentOrganizationId,
|
||||
effectiveRole,
|
||||
effectivePermissions,
|
||||
authenticatedAt,
|
||||
accessToken,
|
||||
];
|
||||
}
|
||||
|
||||
/// État non authentifié
|
||||
class AuthUnauthenticated extends AuthState {
|
||||
final String? message;
|
||||
|
||||
const AuthUnauthenticated({this.message});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// État d'erreur
|
||||
class AuthError extends AuthState {
|
||||
final String message;
|
||||
final String? errorCode;
|
||||
|
||||
const AuthError({
|
||||
required this.message,
|
||||
this.errorCode,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, errorCode];
|
||||
}
|
||||
|
||||
/// État indiquant qu'une WebView d'authentification est requise
|
||||
class AuthWebViewRequired extends AuthState {
|
||||
final String authUrl;
|
||||
final String state;
|
||||
final String codeVerifier;
|
||||
|
||||
const AuthWebViewRequired({
|
||||
required this.authUrl,
|
||||
required this.state,
|
||||
required this.codeVerifier,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [authUrl, state, codeVerifier];
|
||||
}
|
||||
|
||||
// === BLOC ===
|
||||
|
||||
/// BLoC d'authentification adaptatif
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
AuthBloc() : super(const AuthInitial()) {
|
||||
on<AuthLoginRequested>(_onLoginRequested);
|
||||
on<AuthLogoutRequested>(_onLogoutRequested);
|
||||
on<AuthOrganizationContextChanged>(_onOrganizationContextChanged);
|
||||
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
|
||||
on<AuthStatusChecked>(_onStatusChecked);
|
||||
on<AuthUserProfileUpdated>(_onUserProfileUpdated);
|
||||
on<AuthWebViewCallback>(_onWebViewCallback);
|
||||
}
|
||||
|
||||
/// Gère la demande de connexion Keycloak via WebView
|
||||
///
|
||||
/// Cette méthode prépare l'authentification WebView et émet un état spécial
|
||||
/// pour indiquer qu'une WebView doit être ouverte
|
||||
Future<void> _onLoginRequested(
|
||||
AuthLoginRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
debugPrint('🔐 Préparation authentification Keycloak WebView...');
|
||||
|
||||
// Préparer l'authentification WebView
|
||||
final Map<String, String> authParams = await KeycloakAuthService.prepareWebViewAuthentication();
|
||||
|
||||
debugPrint('✅ Authentification WebView préparée');
|
||||
|
||||
// Émettre un état spécial pour indiquer qu'une WebView doit être ouverte
|
||||
debugPrint('🚀 Émission de l\'état AuthWebViewRequired...');
|
||||
emit(AuthWebViewRequired(
|
||||
authUrl: authParams['url']!,
|
||||
state: authParams['state']!,
|
||||
codeVerifier: authParams['code_verifier']!,
|
||||
));
|
||||
debugPrint('✅ État AuthWebViewRequired émis');
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur préparation authentification Keycloak: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de préparation: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Traite le callback WebView et finalise l'authentification
|
||||
Future<void> _onWebViewCallback(
|
||||
AuthWebViewCallback event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
debugPrint('🔄 Traitement callback WebView...');
|
||||
|
||||
// Utiliser l'utilisateur fourni ou traiter le callback
|
||||
final User user;
|
||||
if (event.user != null) {
|
||||
debugPrint('👤 Utilisation des données utilisateur fournies: ${event.user!.fullName}');
|
||||
user = event.user!;
|
||||
} else {
|
||||
debugPrint('🔄 Traitement du callback URL: ${event.callbackUrl}');
|
||||
user = await KeycloakAuthService.handleWebViewCallback(event.callbackUrl);
|
||||
}
|
||||
|
||||
debugPrint('👤 Utilisateur authentifié: ${user.fullName} (${user.primaryRole.displayName})');
|
||||
|
||||
// Calculer les permissions effectives
|
||||
debugPrint('🔐 Calcul des permissions effectives...');
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(user);
|
||||
debugPrint('✅ Permissions effectives calculées: ${effectivePermissions.length} permissions');
|
||||
|
||||
// Invalider le cache pour forcer le rechargement
|
||||
debugPrint('🧹 Invalidation du cache pour le rôle ${user.primaryRole.displayName}...');
|
||||
await DashboardCacheManager.invalidateForRole(user.primaryRole);
|
||||
debugPrint('✅ Cache invalidé');
|
||||
|
||||
emit(AuthAuthenticated(
|
||||
user: user,
|
||||
currentOrganizationId: null, // À implémenter selon vos besoins
|
||||
effectiveRole: user.primaryRole,
|
||||
effectivePermissions: effectivePermissions,
|
||||
authenticatedAt: DateTime.now(),
|
||||
accessToken: '', // Token géré par KeycloakWebViewAuthService
|
||||
));
|
||||
|
||||
debugPrint('🎉 Authentification complète réussie - navigation vers dashboard');
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur authentification: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de connexion: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère la demande de déconnexion Keycloak
|
||||
Future<void> _onLogoutRequested(
|
||||
AuthLogoutRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
debugPrint('🚪 Démarrage déconnexion Keycloak...');
|
||||
|
||||
// Déconnexion Keycloak
|
||||
final logoutSuccess = await KeycloakAuthService.logout();
|
||||
|
||||
if (!logoutSuccess) {
|
||||
debugPrint('⚠️ Déconnexion Keycloak partielle');
|
||||
}
|
||||
|
||||
// Nettoyer le cache local
|
||||
await DashboardCacheManager.clear();
|
||||
|
||||
// Invalider le cache des permissions
|
||||
if (state is AuthAuthenticated) {
|
||||
final authState = state as AuthAuthenticated;
|
||||
PermissionEngine.invalidateUserCache(authState.user.id);
|
||||
}
|
||||
|
||||
debugPrint('✅ Déconnexion complète réussie');
|
||||
emit(const AuthUnauthenticated(message: 'Déconnexion réussie'));
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur déconnexion: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de déconnexion: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère le changement de contexte organisationnel
|
||||
Future<void> _onOrganizationContextChanged(
|
||||
AuthOrganizationContextChanged event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
if (state is! AuthAuthenticated) return;
|
||||
|
||||
final currentState = state as AuthAuthenticated;
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
// Recalculer le rôle effectif et les permissions
|
||||
final effectiveRole = currentState.user.getRoleInOrganization(event.organizationId);
|
||||
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(
|
||||
currentState.user,
|
||||
organizationId: event.organizationId,
|
||||
);
|
||||
|
||||
// Invalider le cache pour le nouveau contexte
|
||||
PermissionEngine.invalidateUserCache(currentState.user.id);
|
||||
|
||||
emit(currentState.copyWith(
|
||||
currentOrganizationId: event.organizationId,
|
||||
effectiveRole: effectiveRole,
|
||||
effectivePermissions: effectivePermissions,
|
||||
));
|
||||
|
||||
} catch (e) {
|
||||
emit(AuthError(message: 'Erreur de changement de contexte: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère le rafraîchissement du token
|
||||
Future<void> _onTokenRefreshRequested(
|
||||
AuthTokenRefreshRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
if (state is! AuthAuthenticated) return;
|
||||
|
||||
final currentState = state as AuthAuthenticated;
|
||||
|
||||
try {
|
||||
// Simulation du rafraîchissement (à remplacer par l'API réelle)
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
final newToken = 'refreshed_token_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
emit(currentState.copyWith(accessToken: newToken));
|
||||
|
||||
} catch (e) {
|
||||
emit(AuthError(message: 'Erreur de rafraîchissement: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie l'état d'authentification Keycloak
|
||||
Future<void> _onStatusChecked(
|
||||
AuthStatusChecked event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthLoading());
|
||||
|
||||
try {
|
||||
debugPrint('🔍 Vérification état authentification Keycloak...');
|
||||
|
||||
// Vérifier si l'utilisateur est authentifié avec Keycloak
|
||||
final bool isAuthenticated = await KeycloakAuthService.isAuthenticated();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
debugPrint('❌ Utilisateur non authentifié');
|
||||
emit(const AuthUnauthenticated());
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer l'utilisateur actuel
|
||||
final User? user = await KeycloakAuthService.getCurrentUser();
|
||||
|
||||
if (user == null) {
|
||||
debugPrint('❌ Impossible de récupérer l\'utilisateur');
|
||||
emit(const AuthUnauthenticated());
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculer les permissions effectives
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(user);
|
||||
|
||||
// Récupérer le token d'accès
|
||||
final String? accessToken = await KeycloakAuthService.getAccessToken();
|
||||
|
||||
debugPrint('✅ Utilisateur authentifié: ${user.fullName}');
|
||||
|
||||
emit(AuthAuthenticated(
|
||||
user: user,
|
||||
currentOrganizationId: null, // À implémenter selon vos besoins
|
||||
effectiveRole: user.primaryRole,
|
||||
effectivePermissions: effectivePermissions,
|
||||
authenticatedAt: DateTime.now(),
|
||||
accessToken: accessToken ?? '',
|
||||
));
|
||||
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('💥 Erreur vérification authentification: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
emit(AuthError(message: 'Erreur de vérification: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour le profil utilisateur
|
||||
Future<void> _onUserProfileUpdated(
|
||||
AuthUserProfileUpdated event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
if (state is! AuthAuthenticated) return;
|
||||
|
||||
final currentState = state as AuthAuthenticated;
|
||||
|
||||
try {
|
||||
// Recalculer les permissions si nécessaire
|
||||
final effectivePermissions = await PermissionEngine.getEffectivePermissions(
|
||||
event.updatedUser,
|
||||
organizationId: currentState.currentOrganizationId,
|
||||
);
|
||||
|
||||
emit(currentState.copyWith(
|
||||
user: event.updatedUser,
|
||||
effectivePermissions: effectivePermissions,
|
||||
));
|
||||
|
||||
} catch (e) {
|
||||
emit(AuthError(message: 'Erreur de mise à jour: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,26 +1,18 @@
|
||||
/// Page d'Authentification Keycloak via WebView
|
||||
///
|
||||
/// Interface utilisateur professionnelle pour l'authentification Keycloak
|
||||
/// utilisant WebView avec gestion complète des états et des erreurs.
|
||||
///
|
||||
/// Fonctionnalités :
|
||||
/// - WebView sécurisée avec contrôles de navigation
|
||||
/// - Indicateurs de progression et de chargement
|
||||
/// - Gestion des erreurs réseau et timeouts
|
||||
/// - Interface utilisateur adaptative
|
||||
/// - Support des thèmes sombre/clair
|
||||
/// - Logging détaillé pour le debugging
|
||||
/// Page d'Authentification UnionFlow
|
||||
///
|
||||
/// Interface utilisateur pour la connexion sécurisée
|
||||
/// avec gestion complète des états et des erreurs.
|
||||
library keycloak_webview_auth_page;
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import '../../../../core/auth/services/keycloak_webview_auth_service.dart';
|
||||
import '../../../../core/auth/models/user.dart';
|
||||
import '../../../../core/design_system/tokens/color_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/spacing_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/typography_tokens.dart';
|
||||
import '../../data/datasources/keycloak_webview_auth_service.dart';
|
||||
import '../../data/models/user.dart';
|
||||
import '../../../../shared/design_system/tokens/color_tokens.dart';
|
||||
import '../../../../shared/design_system/tokens/spacing_tokens.dart';
|
||||
import '../../../../shared/design_system/tokens/typography_tokens.dart';
|
||||
|
||||
/// États de l'authentification WebView
|
||||
enum KeycloakWebViewAuthState {
|
||||
@@ -79,12 +71,11 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
|
||||
KeycloakWebViewAuthState _authState = KeycloakWebViewAuthState.initializing;
|
||||
String? _errorMessage;
|
||||
double _loadingProgress = 0.0;
|
||||
String _currentUrl = '';
|
||||
|
||||
|
||||
|
||||
|
||||
// Paramètres d'authentification
|
||||
String? _authUrl;
|
||||
String? _expectedState;
|
||||
String? _codeVerifier;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -130,8 +121,6 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
|
||||
await KeycloakWebViewAuthService.prepareAuthentication();
|
||||
|
||||
_authUrl = authParams['url'];
|
||||
_expectedState = authParams['state'];
|
||||
_codeVerifier = authParams['code_verifier'];
|
||||
|
||||
if (_authUrl == null) {
|
||||
throw Exception('URL d\'authentification manquante');
|
||||
@@ -202,7 +191,6 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
|
||||
debugPrint('📄 Chargement de la page: $url');
|
||||
|
||||
setState(() {
|
||||
_currentUrl = url;
|
||||
_loadingProgress = 0.0;
|
||||
});
|
||||
|
||||
@@ -214,7 +202,6 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
|
||||
debugPrint('✅ Page chargée: $url');
|
||||
|
||||
setState(() {
|
||||
_currentUrl = url;
|
||||
if (_authState == KeycloakWebViewAuthState.loading) {
|
||||
_authState = KeycloakWebViewAuthState.ready;
|
||||
}
|
||||
@@ -358,7 +345,7 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
|
||||
foregroundColor: ColorTokens.onPrimary,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
'Connexion Keycloak',
|
||||
'Connexion Sécurisée',
|
||||
style: TypographyTokens.headlineSmall.copyWith(
|
||||
color: ColorTokens.onPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -461,7 +448,7 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: SpacingTokens.xxxl),
|
||||
Text(
|
||||
'Authentification en cours...',
|
||||
'Connexion en cours...',
|
||||
style: TypographyTokens.headlineSmall.copyWith(
|
||||
color: ColorTokens.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -469,7 +456,7 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xl),
|
||||
Text(
|
||||
'Veuillez patienter pendant que nous\nfinalisons votre connexion.',
|
||||
'Veuillez patienter pendant que nous\nvérifions vos informations.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TypographyTokens.bodyMedium.copyWith(
|
||||
color: ColorTokens.onSurface.withOpacity(0.7),
|
||||
@@ -550,8 +537,8 @@ class _KeycloakWebViewAuthPageState extends State<KeycloakWebViewAuthPage>
|
||||
const SizedBox(height: SpacingTokens.xxxl),
|
||||
Text(
|
||||
_authState == KeycloakWebViewAuthState.timeout
|
||||
? 'Timeout d\'authentification'
|
||||
: 'Erreur d\'authentification',
|
||||
? 'Délai d\'attente dépassé'
|
||||
: 'Erreur de connexion',
|
||||
style: TypographyTokens.headlineSmall.copyWith(
|
||||
color: ColorTokens.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -0,0 +1,738 @@
|
||||
/// Page de Connexion UnionFlow - Design System Unifié (Version Premium)
|
||||
/// Interface de connexion moderne orientée métier avec animations avancées
|
||||
/// Utilise la palette Bleu Roi (#4169E1) + Bleu Pétrole (#2C5F6F)
|
||||
library login_page;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../bloc/auth_bloc.dart';
|
||||
import '../../../../shared/design_system/unionflow_design_system.dart';
|
||||
import 'keycloak_webview_auth_page.dart';
|
||||
|
||||
/// Page de connexion UnionFlow
|
||||
/// Présente l'application et permet l'authentification sécurisée
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage>
|
||||
with TickerProviderStateMixin {
|
||||
|
||||
late AnimationController _animationController;
|
||||
late AnimationController _backgroundController;
|
||||
late AnimationController _pulseController;
|
||||
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _backgroundAnimation;
|
||||
late Animation<double> _pulseAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAnimations();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
_backgroundController.dispose();
|
||||
_pulseController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializeAnimations() {
|
||||
// Animation principale d'entrée
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1400),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
|
||||
));
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0.0, 0.4),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
|
||||
));
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.8,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.0, 0.6, curve: Curves.easeOutBack),
|
||||
));
|
||||
|
||||
// Animation de fond subtile
|
||||
_backgroundController = AnimationController(
|
||||
duration: const Duration(seconds: 8),
|
||||
vsync: this,
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_backgroundAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _backgroundController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
// Animation de pulsation pour le logo
|
||||
_pulseController = AnimationController(
|
||||
duration: const Duration(seconds: 3),
|
||||
vsync: this,
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_pulseAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 1.08,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _pulseController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
/// Ouvre la page WebView d'authentification
|
||||
void _openWebViewAuth(BuildContext context, AuthWebViewRequired state) {
|
||||
debugPrint('🚀 Ouverture WebView avec URL: ${state.authUrl}');
|
||||
debugPrint('🔑 State: ${state.state}');
|
||||
debugPrint('🔐 Code verifier: ${state.codeVerifier.substring(0, 10)}...');
|
||||
|
||||
debugPrint('📱 Tentative de navigation vers KeycloakWebViewAuthPage...');
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => KeycloakWebViewAuthPage(
|
||||
onAuthSuccess: (user) {
|
||||
debugPrint('✅ Authentification réussie pour: ${user.fullName}');
|
||||
debugPrint('🔄 Notification du BLoC avec les données utilisateur...');
|
||||
|
||||
context.read<AuthBloc>().add(AuthWebViewCallback(
|
||||
'success',
|
||||
user: user,
|
||||
));
|
||||
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
onAuthError: (error) {
|
||||
debugPrint('❌ Erreur d\'authentification: $error');
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur d\'authentification: $error'),
|
||||
backgroundColor: ColorTokens.error,
|
||||
duration: const Duration(seconds: 5),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
onAuthCancel: () {
|
||||
debugPrint('❌ Authentification annulée par l\'utilisateur');
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Authentification annulée'),
|
||||
backgroundColor: ColorTokens.warning,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
debugPrint('✅ Navigation vers KeycloakWebViewAuthPage lancée');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: BlocConsumer<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
debugPrint('🔄 État BLoC reçu: ${state.runtimeType}');
|
||||
|
||||
if (state is AuthAuthenticated) {
|
||||
debugPrint('✅ Utilisateur authentifié, navigation vers dashboard');
|
||||
Navigator.of(context).pushReplacementNamed('/dashboard');
|
||||
} else if (state is AuthError) {
|
||||
debugPrint('❌ Erreur d\'authentification: ${state.message}');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
backgroundColor: ColorTokens.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
} else if (state is AuthWebViewRequired) {
|
||||
debugPrint('🚀 État AuthWebViewRequired reçu, ouverture WebView...');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_openWebViewAuth(context, state);
|
||||
});
|
||||
} else if (state is AuthLoading) {
|
||||
debugPrint('⏳ État de chargement...');
|
||||
} else {
|
||||
debugPrint('ℹ️ État non géré: ${state.runtimeType}');
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is AuthWebViewRequired) {
|
||||
debugPrint('🔄 Builder détecte AuthWebViewRequired, ouverture WebView...');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_openWebViewAuth(context, state);
|
||||
});
|
||||
}
|
||||
|
||||
return _buildLoginContent(context, state);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginContent(BuildContext context, AuthState state) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond animé avec dégradé dynamique
|
||||
AnimatedBuilder(
|
||||
animation: _backgroundAnimation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
ColorTokens.background,
|
||||
Color.lerp(
|
||||
ColorTokens.background,
|
||||
ColorTokens.surface,
|
||||
_backgroundAnimation.value * 0.3,
|
||||
)!,
|
||||
ColorTokens.surface,
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Éléments décoratifs de fond
|
||||
_buildBackgroundDecoration(),
|
||||
|
||||
// Contenu principal
|
||||
SafeArea(
|
||||
child: AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: _buildLoginUI(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackgroundDecoration() {
|
||||
return Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: _backgroundAnimation,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Cercle décoratif haut gauche
|
||||
Positioned(
|
||||
top: -100 + (_backgroundAnimation.value * 30),
|
||||
left: -100 + (_backgroundAnimation.value * 20),
|
||||
child: Container(
|
||||
width: 300,
|
||||
height: 300,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
ColorTokens.primary.withOpacity(0.15),
|
||||
ColorTokens.primary.withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Cercle décoratif bas droit
|
||||
Positioned(
|
||||
bottom: -150 - (_backgroundAnimation.value * 30),
|
||||
right: -120 - (_backgroundAnimation.value * 20),
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 400,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
ColorTokens.primary.withOpacity(0.12),
|
||||
ColorTokens.primary.withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Cercle décoratif centre
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).size.height * 0.3,
|
||||
right: -50,
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
ColorTokens.secondary.withOpacity(0.1),
|
||||
ColorTokens.secondary.withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginUI() {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.xxxl),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: SpacingTokens.giant),
|
||||
|
||||
// Logo et branding premium
|
||||
_buildBranding(),
|
||||
const SizedBox(height: SpacingTokens.giant),
|
||||
|
||||
// Features cards
|
||||
_buildFeatureCards(),
|
||||
const SizedBox(height: SpacingTokens.giant),
|
||||
|
||||
// Card de connexion principale
|
||||
_buildLoginCard(),
|
||||
const SizedBox(height: SpacingTokens.xxxl),
|
||||
|
||||
// Footer amélioré
|
||||
_buildFooter(),
|
||||
const SizedBox(height: SpacingTokens.giant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBranding() {
|
||||
return ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Logo animé avec effet de pulsation
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _pulseAnimation.value,
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: ColorTokens.primaryGradient,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusXl),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ColorTokens.primary.withOpacity(0.3),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 10),
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.account_balance_outlined,
|
||||
size: 32,
|
||||
color: ColorTokens.onPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xxxl),
|
||||
|
||||
// Titre avec gradient
|
||||
ShaderMask(
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
colors: ColorTokens.primaryGradient,
|
||||
).createShader(bounds),
|
||||
child: Text(
|
||||
'Bienvenue',
|
||||
style: TypographyTokens.displaySmall.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -1,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Sous-titre élégant
|
||||
Text(
|
||||
'Connectez-vous à votre espace UnionFlow',
|
||||
style: TypographyTokens.bodyLarge.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.5,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeatureCards() {
|
||||
final features = [
|
||||
{
|
||||
'icon': Icons.account_balance_wallet_rounded,
|
||||
'title': 'Cotisations',
|
||||
'color': ColorTokens.primary,
|
||||
},
|
||||
{
|
||||
'icon': Icons.event_rounded,
|
||||
'title': 'Événements',
|
||||
'color': ColorTokens.secondary,
|
||||
},
|
||||
{
|
||||
'icon': Icons.volunteer_activism_rounded,
|
||||
'title': 'Solidarité',
|
||||
'color': ColorTokens.primary,
|
||||
},
|
||||
];
|
||||
|
||||
return Row(
|
||||
children: features.map((feature) {
|
||||
final index = features.indexOf(feature);
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: index < features.length - 1 ? SpacingTokens.md : 0,
|
||||
),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: Duration(milliseconds: 600 + (index * 150)),
|
||||
curve: Curves.easeOutBack,
|
||||
builder: (context, value, child) {
|
||||
return Transform.scale(
|
||||
scale: value,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: SpacingTokens.lg,
|
||||
horizontal: SpacingTokens.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.surface,
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
|
||||
border: Border.all(
|
||||
color: (feature['color'] as Color).withOpacity(0.15),
|
||||
width: 1.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ColorTokens.shadow.withOpacity(0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: (feature['color'] as Color).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
|
||||
),
|
||||
child: Icon(
|
||||
feature['icon'] as IconData,
|
||||
size: 24,
|
||||
color: feature['color'] as Color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.sm),
|
||||
Text(
|
||||
feature['title'] as String,
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginCard() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.surface,
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusXxl),
|
||||
border: Border.all(
|
||||
color: ColorTokens.outline.withOpacity(0.08),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ColorTokens.shadow.withOpacity(0.1),
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 12),
|
||||
spreadRadius: -4,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(SpacingTokens.huge),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Titre de la card
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.xs),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusSm),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.fingerprint_rounded,
|
||||
size: 20,
|
||||
color: ColorTokens.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.md),
|
||||
Text(
|
||||
'Authentification',
|
||||
style: TypographyTokens.titleMedium.copyWith(
|
||||
color: ColorTokens.onSurface,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xxl),
|
||||
|
||||
// Bouton de connexion principal
|
||||
_buildLoginButton(),
|
||||
|
||||
const SizedBox(height: SpacingTokens.xxl),
|
||||
|
||||
// Divider avec texte
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: ColorTokens.outline.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.md),
|
||||
child: Text(
|
||||
'Sécurisé',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: ColorTokens.outline.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: SpacingTokens.xxl),
|
||||
|
||||
// Informations de sécurité améliorées
|
||||
Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.primary.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
|
||||
border: Border.all(
|
||||
color: ColorTokens.primary.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.verified_user_rounded,
|
||||
size: 20,
|
||||
color: ColorTokens.primary,
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Connexion sécurisée',
|
||||
style: TypographyTokens.labelMedium.copyWith(
|
||||
color: ColorTokens.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
Text(
|
||||
'Vos données sont protégées et chiffrées',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
return Column(
|
||||
children: [
|
||||
// Aide
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: SpacingTokens.lg,
|
||||
vertical: SpacingTokens.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.surface.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
|
||||
border: Border.all(
|
||||
color: ColorTokens.outline.withOpacity(0.08),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.help_outline_rounded,
|
||||
size: 18,
|
||||
color: ColorTokens.onSurfaceVariant.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.sm),
|
||||
Text(
|
||||
'Besoin d\'aide ?',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant.withOpacity(0.8),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xl),
|
||||
|
||||
// Copyright
|
||||
Text(
|
||||
'© 2025 UnionFlow. Tous droits réservés.',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant.withOpacity(0.5),
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
Text(
|
||||
'Version 1.0.0',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant.withOpacity(0.4),
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 11,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoginButton() {
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final isLoading = state is AuthLoading;
|
||||
|
||||
return UFPrimaryButton(
|
||||
label: 'Se connecter',
|
||||
icon: Icons.login_rounded,
|
||||
onPressed: isLoading ? null : _handleLogin,
|
||||
isLoading: isLoading,
|
||||
isFullWidth: true,
|
||||
height: 56,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleLogin() {
|
||||
// Démarrer l'authentification Keycloak
|
||||
context.read<AuthBloc>().add(const AuthLoginRequested());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../shared/design_system/tokens/color_tokens.dart';
|
||||
import '../../../../shared/design_system/tokens/spacing_tokens.dart';
|
||||
|
||||
/// Page Sauvegarde & Restauration - UnionFlow Mobile
|
||||
///
|
||||
///
|
||||
/// Page complète de gestion des sauvegardes avec création, restauration,
|
||||
/// planification et monitoring des sauvegardes système.
|
||||
class BackupPage extends StatefulWidget {
|
||||
@@ -37,7 +39,7 @@ class _BackupPageState extends State<BackupPage>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F9FA),
|
||||
backgroundColor: ColorTokens.background,
|
||||
body: Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
@@ -60,18 +62,18 @@ class _BackupPageState extends State<BackupPage>
|
||||
/// Header harmonisé
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(20),
|
||||
margin: const EdgeInsets.all(SpacingTokens.lg),
|
||||
padding: const EdgeInsets.all(SpacingTokens.xl),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF6C5CE7), Color(0xFF5A4FCF)],
|
||||
colors: ColorTokens.primaryGradient,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusXl),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6C5CE7).withOpacity(0.3),
|
||||
color: ColorTokens.primary.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
/// BLoC pour la gestion des contributions
|
||||
library contributions_bloc;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/utils/logger.dart';
|
||||
import '../data/models/contribution_model.dart';
|
||||
import 'contributions_event.dart';
|
||||
import 'contributions_state.dart';
|
||||
|
||||
/// BLoC pour gérer l'état des contributions
|
||||
class ContributionsBloc extends Bloc<ContributionsEvent, ContributionsState> {
|
||||
ContributionsBloc() : super(const ContributionsInitial()) {
|
||||
on<LoadContributions>(_onLoadContributions);
|
||||
on<LoadContributionById>(_onLoadContributionById);
|
||||
on<CreateContribution>(_onCreateContribution);
|
||||
on<UpdateContribution>(_onUpdateContribution);
|
||||
on<DeleteContribution>(_onDeleteContribution);
|
||||
on<SearchContributions>(_onSearchContributions);
|
||||
on<LoadContributionsByMembre>(_onLoadContributionsByMembre);
|
||||
on<LoadContributionsPayees>(_onLoadContributionsPayees);
|
||||
on<LoadContributionsNonPayees>(_onLoadContributionsNonPayees);
|
||||
on<LoadContributionsEnRetard>(_onLoadContributionsEnRetard);
|
||||
on<RecordPayment>(_onRecordPayment);
|
||||
on<LoadContributionsStats>(_onLoadContributionsStats);
|
||||
on<GenerateAnnualContributions>(_onGenerateAnnualContributions);
|
||||
on<SendPaymentReminder>(_onSendPaymentReminder);
|
||||
}
|
||||
|
||||
/// Charger la liste des contributions
|
||||
Future<void> _onLoadContributions(
|
||||
LoadContributions event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('ContributionsBloc', 'LoadContributions', data: {
|
||||
'page': event.page,
|
||||
'size': event.size,
|
||||
});
|
||||
|
||||
emit(const ContributionsLoading(message: 'Chargement des contributions...'));
|
||||
|
||||
// Simuler un délai réseau
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Données mock
|
||||
final contributions = _getMockContributions();
|
||||
final total = contributions.length;
|
||||
final totalPages = (total / event.size).ceil();
|
||||
|
||||
// Pagination
|
||||
final start = event.page * event.size;
|
||||
final end = (start + event.size).clamp(0, total);
|
||||
final paginatedContributions = contributions.sublist(
|
||||
start.clamp(0, total),
|
||||
end,
|
||||
);
|
||||
|
||||
emit(ContributionsLoaded(
|
||||
contributions: paginatedContributions,
|
||||
total: total,
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
totalPages: totalPages,
|
||||
));
|
||||
|
||||
AppLogger.blocState('ContributionsBloc', 'ContributionsLoaded', data: {
|
||||
'count': paginatedContributions.length,
|
||||
'total': total,
|
||||
});
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors du chargement des contributions',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
emit(ContributionsError(
|
||||
message: 'Erreur lors du chargement des contributions',
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Charger une contribution par ID
|
||||
Future<void> _onLoadContributionById(
|
||||
LoadContributionById event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('ContributionsBloc', 'LoadContributionById', data: {
|
||||
'id': event.id,
|
||||
});
|
||||
|
||||
emit(const ContributionsLoading(message: 'Chargement de la contribution...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
final contributions = _getMockContributions();
|
||||
final contribution = contributions.firstWhere(
|
||||
(c) => c.id == event.id,
|
||||
orElse: () => throw Exception('Contribution non trouvée'),
|
||||
);
|
||||
|
||||
emit(ContributionDetailLoaded(contribution: contribution));
|
||||
|
||||
AppLogger.blocState('ContributionsBloc', 'ContributionDetailLoaded');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors du chargement de la contribution',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
emit(ContributionsError(
|
||||
message: 'Contribution non trouvée',
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Créer une nouvelle contribution
|
||||
Future<void> _onCreateContribution(
|
||||
CreateContribution event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('ContributionsBloc', 'CreateContribution');
|
||||
|
||||
emit(const ContributionsLoading(message: 'Création de la contribution...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final newContribution = event.contribution.copyWith(
|
||||
id: 'cont_${DateTime.now().millisecondsSinceEpoch}',
|
||||
dateCreation: DateTime.now(),
|
||||
);
|
||||
|
||||
emit(ContributionCreated(contribution: newContribution));
|
||||
|
||||
AppLogger.blocState('ContributionsBloc', 'ContributionCreated');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors de la création de la contribution',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
emit(ContributionsError(
|
||||
message: 'Erreur lors de la création de la contribution',
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Mettre à jour une contribution
|
||||
Future<void> _onUpdateContribution(
|
||||
UpdateContribution event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('ContributionsBloc', 'UpdateContribution', data: {
|
||||
'id': event.id,
|
||||
});
|
||||
|
||||
emit(const ContributionsLoading(message: 'Mise à jour de la contribution...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final updatedContribution = event.contribution.copyWith(
|
||||
id: event.id,
|
||||
dateModification: DateTime.now(),
|
||||
);
|
||||
|
||||
emit(ContributionUpdated(contribution: updatedContribution));
|
||||
|
||||
AppLogger.blocState('ContributionsBloc', 'ContributionUpdated');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors de la mise à jour de la contribution',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
emit(ContributionsError(
|
||||
message: 'Erreur lors de la mise à jour de la contribution',
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprimer une contribution
|
||||
Future<void> _onDeleteContribution(
|
||||
DeleteContribution event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('ContributionsBloc', 'DeleteContribution', data: {
|
||||
'id': event.id,
|
||||
});
|
||||
|
||||
emit(const ContributionsLoading(message: 'Suppression de la contribution...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
emit(ContributionDeleted(id: event.id));
|
||||
|
||||
AppLogger.blocState('ContributionsBloc', 'ContributionDeleted');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors de la suppression de la contribution',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
emit(ContributionsError(
|
||||
message: 'Erreur lors de la suppression de la contribution',
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rechercher des contributions
|
||||
Future<void> _onSearchContributions(
|
||||
SearchContributions event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('ContributionsBloc', 'SearchContributions');
|
||||
|
||||
emit(const ContributionsLoading(message: 'Recherche en cours...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
var contributions = _getMockContributions();
|
||||
|
||||
// Filtrer par membre
|
||||
if (event.membreId != null) {
|
||||
contributions = contributions
|
||||
.where((c) => c.membreId == event.membreId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtrer par statut
|
||||
if (event.statut != null) {
|
||||
contributions = contributions
|
||||
.where((c) => c.statut == event.statut)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtrer par type
|
||||
if (event.type != null) {
|
||||
contributions = contributions
|
||||
.where((c) => c.type == event.type)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtrer par année
|
||||
if (event.annee != null) {
|
||||
contributions = contributions
|
||||
.where((c) => c.annee == event.annee)
|
||||
.toList();
|
||||
}
|
||||
|
||||
final total = contributions.length;
|
||||
final totalPages = (total / event.size).ceil();
|
||||
|
||||
// Pagination
|
||||
final start = event.page * event.size;
|
||||
final end = (start + event.size).clamp(0, total);
|
||||
final paginatedContributions = contributions.sublist(
|
||||
start.clamp(0, total),
|
||||
end,
|
||||
);
|
||||
|
||||
emit(ContributionsLoaded(
|
||||
contributions: paginatedContributions,
|
||||
total: total,
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
totalPages: totalPages,
|
||||
));
|
||||
|
||||
AppLogger.blocState('ContributionsBloc', 'ContributionsLoaded (search)');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors de la recherche de contributions',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
emit(ContributionsError(
|
||||
message: 'Erreur lors de la recherche',
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Charger les contributions d'un membre
|
||||
Future<void> _onLoadContributionsByMembre(
|
||||
LoadContributionsByMembre event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('ContributionsBloc', 'LoadContributionsByMembre', data: {
|
||||
'membreId': event.membreId,
|
||||
});
|
||||
|
||||
emit(const ContributionsLoading(message: 'Chargement des contributions du membre...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final contributions = _getMockContributions()
|
||||
.where((c) => c.membreId == event.membreId)
|
||||
.toList();
|
||||
|
||||
final total = contributions.length;
|
||||
final totalPages = (total / event.size).ceil();
|
||||
|
||||
emit(ContributionsLoaded(
|
||||
contributions: contributions,
|
||||
total: total,
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
totalPages: totalPages,
|
||||
));
|
||||
|
||||
AppLogger.blocState('ContributionsBloc', 'ContributionsLoaded (by membre)');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors du chargement des contributions du membre',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
emit(ContributionsError(
|
||||
message: 'Erreur lors du chargement',
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Charger les contributions payées
|
||||
Future<void> _onLoadContributionsPayees(
|
||||
LoadContributionsPayees event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Chargement des contributions payées...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final contributions = _getMockContributions()
|
||||
.where((c) => c.statut == ContributionStatus.payee)
|
||||
.toList();
|
||||
|
||||
final total = contributions.length;
|
||||
final totalPages = (total / event.size).ceil();
|
||||
|
||||
emit(ContributionsLoaded(
|
||||
contributions: contributions,
|
||||
total: total,
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
totalPages: totalPages,
|
||||
));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Charger les contributions non payées
|
||||
Future<void> _onLoadContributionsNonPayees(
|
||||
LoadContributionsNonPayees event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Chargement des contributions non payées...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final contributions = _getMockContributions()
|
||||
.where((c) => c.statut == ContributionStatus.nonPayee)
|
||||
.toList();
|
||||
|
||||
final total = contributions.length;
|
||||
final totalPages = (total / event.size).ceil();
|
||||
|
||||
emit(ContributionsLoaded(
|
||||
contributions: contributions,
|
||||
total: total,
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
totalPages: totalPages,
|
||||
));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Charger les contributions en retard
|
||||
Future<void> _onLoadContributionsEnRetard(
|
||||
LoadContributionsEnRetard event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Chargement des contributions en retard...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final contributions = _getMockContributions()
|
||||
.where((c) => c.statut == ContributionStatus.enRetard)
|
||||
.toList();
|
||||
|
||||
final total = contributions.length;
|
||||
final totalPages = (total / event.size).ceil();
|
||||
|
||||
emit(ContributionsLoaded(
|
||||
contributions: contributions,
|
||||
total: total,
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
totalPages: totalPages,
|
||||
));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Enregistrer un paiement
|
||||
Future<void> _onRecordPayment(
|
||||
RecordPayment event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('ContributionsBloc', 'RecordPayment');
|
||||
|
||||
emit(const ContributionsLoading(message: 'Enregistrement du paiement...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final contributions = _getMockContributions();
|
||||
final contribution = contributions.firstWhere((c) => c.id == event.contributionId);
|
||||
|
||||
final updatedContribution = contribution.copyWith(
|
||||
montantPaye: event.montant,
|
||||
datePaiement: event.datePaiement,
|
||||
methodePaiement: event.methodePaiement,
|
||||
numeroPaiement: event.numeroPaiement,
|
||||
referencePaiement: event.referencePaiement,
|
||||
statut: event.montant >= contribution.montant
|
||||
? ContributionStatus.payee
|
||||
: ContributionStatus.partielle,
|
||||
dateModification: DateTime.now(),
|
||||
);
|
||||
|
||||
emit(PaymentRecorded(contribution: updatedContribution));
|
||||
|
||||
AppLogger.blocState('ContributionsBloc', 'PaymentRecorded');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur lors de l\'enregistrement du paiement', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Charger les statistiques
|
||||
Future<void> _onLoadContributionsStats(
|
||||
LoadContributionsStats event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Chargement des statistiques...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final contributions = _getMockContributions();
|
||||
|
||||
final stats = {
|
||||
'total': contributions.length,
|
||||
'payees': contributions.where((c) => c.statut == ContributionStatus.payee).length,
|
||||
'nonPayees': contributions.where((c) => c.statut == ContributionStatus.nonPayee).length,
|
||||
'enRetard': contributions.where((c) => c.statut == ContributionStatus.enRetard).length,
|
||||
'partielles': contributions.where((c) => c.statut == ContributionStatus.partielle).length,
|
||||
'montantTotal': contributions.fold<double>(0, (sum, c) => sum + c.montant),
|
||||
'montantPaye': contributions.fold<double>(0, (sum, c) => sum + (c.montantPaye ?? 0)),
|
||||
'montantRestant': contributions.fold<double>(0, (sum, c) => sum + c.montantRestant),
|
||||
'tauxRecouvrement': 0.0,
|
||||
};
|
||||
|
||||
if (stats['montantTotal']! > 0) {
|
||||
stats['tauxRecouvrement'] = (stats['montantPaye']! / stats['montantTotal']!) * 100;
|
||||
}
|
||||
|
||||
emit(ContributionsStatsLoaded(stats: stats));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Générer les contributions annuelles
|
||||
Future<void> _onGenerateAnnualContributions(
|
||||
GenerateAnnualContributions event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Génération des contributions...'));
|
||||
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
// Simuler la génération de 50 contributions
|
||||
emit(const ContributionsGenerated(nombreGenere: 50));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoyer un rappel de paiement
|
||||
Future<void> _onSendPaymentReminder(
|
||||
SendPaymentReminder event,
|
||||
Emitter<ContributionsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const ContributionsLoading(message: 'Envoi du rappel...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
emit(ReminderSent(contributionId: event.contributionId));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(ContributionsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Données mock pour les tests
|
||||
List<ContributionModel> _getMockContributions() {
|
||||
final now = DateTime.now();
|
||||
return [
|
||||
ContributionModel(
|
||||
id: 'cont_001',
|
||||
membreId: 'mbr_001',
|
||||
membreNom: 'Dupont',
|
||||
membrePrenom: 'Jean',
|
||||
montant: 50000,
|
||||
dateEcheance: DateTime(now.year, 12, 31),
|
||||
annee: now.year,
|
||||
statut: ContributionStatus.payee,
|
||||
montantPaye: 50000,
|
||||
datePaiement: DateTime(now.year, 1, 15),
|
||||
methodePaiement: PaymentMethod.virement,
|
||||
),
|
||||
ContributionModel(
|
||||
id: 'cont_002',
|
||||
membreId: 'mbr_002',
|
||||
membreNom: 'Martin',
|
||||
membrePrenom: 'Marie',
|
||||
montant: 50000,
|
||||
dateEcheance: DateTime(now.year, 12, 31),
|
||||
annee: now.year,
|
||||
statut: ContributionStatus.nonPayee,
|
||||
),
|
||||
ContributionModel(
|
||||
id: 'cont_003',
|
||||
membreId: 'mbr_003',
|
||||
membreNom: 'Bernard',
|
||||
membrePrenom: 'Pierre',
|
||||
montant: 50000,
|
||||
dateEcheance: DateTime(now.year - 1, 12, 31),
|
||||
annee: now.year - 1,
|
||||
statut: ContributionStatus.enRetard,
|
||||
),
|
||||
ContributionModel(
|
||||
id: 'cont_004',
|
||||
membreId: 'mbr_004',
|
||||
membreNom: 'Dubois',
|
||||
membrePrenom: 'Sophie',
|
||||
montant: 50000,
|
||||
dateEcheance: DateTime(now.year, 12, 31),
|
||||
annee: now.year,
|
||||
statut: ContributionStatus.partielle,
|
||||
montantPaye: 25000,
|
||||
datePaiement: DateTime(now.year, 2, 10),
|
||||
methodePaiement: PaymentMethod.especes,
|
||||
),
|
||||
ContributionModel(
|
||||
id: 'cont_005',
|
||||
membreId: 'mbr_005',
|
||||
membreNom: 'Petit',
|
||||
membrePrenom: 'Luc',
|
||||
montant: 50000,
|
||||
dateEcheance: DateTime(now.year, 12, 31),
|
||||
annee: now.year,
|
||||
statut: ContributionStatus.payee,
|
||||
montantPaye: 50000,
|
||||
datePaiement: DateTime(now.year, 3, 5),
|
||||
methodePaiement: PaymentMethod.mobileMoney,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/// Événements pour le BLoC des contributions
|
||||
library contributions_event;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../data/models/contribution_model.dart';
|
||||
|
||||
/// Classe de base pour tous les événements de contributions
|
||||
abstract class ContributionsEvent extends Equatable {
|
||||
const ContributionsEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Charger la liste des contributions
|
||||
class LoadContributions extends ContributionsEvent {
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const LoadContributions({
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size];
|
||||
}
|
||||
|
||||
/// Charger une contribution par ID
|
||||
class LoadContributionById extends ContributionsEvent {
|
||||
final String id;
|
||||
|
||||
const LoadContributionById({required this.id});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Créer une nouvelle contribution
|
||||
class CreateContribution extends ContributionsEvent {
|
||||
final ContributionModel contribution;
|
||||
|
||||
const CreateContribution({required this.contribution});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contribution];
|
||||
}
|
||||
|
||||
/// Mettre à jour une contribution
|
||||
class UpdateContribution extends ContributionsEvent {
|
||||
final String id;
|
||||
final ContributionModel contribution;
|
||||
|
||||
const UpdateContribution({
|
||||
required this.id,
|
||||
required this.contribution,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, contribution];
|
||||
}
|
||||
|
||||
/// Supprimer une contribution
|
||||
class DeleteContribution extends ContributionsEvent {
|
||||
final String id;
|
||||
|
||||
const DeleteContribution({required this.id});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Rechercher des contributions
|
||||
class SearchContributions extends ContributionsEvent {
|
||||
final String? membreId;
|
||||
final ContributionStatus? statut;
|
||||
final ContributionType? type;
|
||||
final int? annee;
|
||||
final String? query;
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const SearchContributions({
|
||||
this.membreId,
|
||||
this.statut,
|
||||
this.type,
|
||||
this.annee,
|
||||
this.query,
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membreId, statut, type, annee, query, page, size];
|
||||
}
|
||||
|
||||
/// Charger les contributions d'un membre
|
||||
class LoadContributionsByMembre extends ContributionsEvent {
|
||||
final String membreId;
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const LoadContributionsByMembre({
|
||||
required this.membreId,
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membreId, page, size];
|
||||
}
|
||||
|
||||
/// Charger les contributions payées
|
||||
class LoadContributionsPayees extends ContributionsEvent {
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const LoadContributionsPayees({
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size];
|
||||
}
|
||||
|
||||
/// Charger les contributions non payées
|
||||
class LoadContributionsNonPayees extends ContributionsEvent {
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const LoadContributionsNonPayees({
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size];
|
||||
}
|
||||
|
||||
/// Charger les contributions en retard
|
||||
class LoadContributionsEnRetard extends ContributionsEvent {
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const LoadContributionsEnRetard({
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size];
|
||||
}
|
||||
|
||||
/// Enregistrer un paiement
|
||||
class RecordPayment extends ContributionsEvent {
|
||||
final String contributionId;
|
||||
final double montant;
|
||||
final PaymentMethod methodePaiement;
|
||||
final String? numeroPaiement;
|
||||
final String? referencePaiement;
|
||||
final DateTime datePaiement;
|
||||
final String? notes;
|
||||
final String? reference;
|
||||
|
||||
const RecordPayment({
|
||||
required this.contributionId,
|
||||
required this.montant,
|
||||
required this.methodePaiement,
|
||||
this.numeroPaiement,
|
||||
this.referencePaiement,
|
||||
required this.datePaiement,
|
||||
this.notes,
|
||||
this.reference,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
contributionId,
|
||||
montant,
|
||||
methodePaiement,
|
||||
numeroPaiement,
|
||||
referencePaiement,
|
||||
datePaiement,
|
||||
notes,
|
||||
reference,
|
||||
];
|
||||
}
|
||||
|
||||
/// Charger les statistiques des contributions
|
||||
class LoadContributionsStats extends ContributionsEvent {
|
||||
final int? annee;
|
||||
|
||||
const LoadContributionsStats({this.annee});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [annee];
|
||||
}
|
||||
|
||||
/// Générer les contributions annuelles
|
||||
class GenerateAnnualContributions extends ContributionsEvent {
|
||||
final int annee;
|
||||
final double montant;
|
||||
final DateTime dateEcheance;
|
||||
|
||||
const GenerateAnnualContributions({
|
||||
required this.annee,
|
||||
required this.montant,
|
||||
required this.dateEcheance,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [annee, montant, dateEcheance];
|
||||
}
|
||||
|
||||
/// Envoyer un rappel de paiement
|
||||
class SendPaymentReminder extends ContributionsEvent {
|
||||
final String contributionId;
|
||||
|
||||
const SendPaymentReminder({required this.contributionId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contributionId];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/// États pour le BLoC des contributions
|
||||
library contributions_state;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../data/models/contribution_model.dart';
|
||||
|
||||
/// Classe de base pour tous les états de contributions
|
||||
abstract class ContributionsState extends Equatable {
|
||||
const ContributionsState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// État initial
|
||||
class ContributionsInitial extends ContributionsState {
|
||||
const ContributionsInitial();
|
||||
}
|
||||
|
||||
/// État de chargement
|
||||
class ContributionsLoading extends ContributionsState {
|
||||
final String? message;
|
||||
|
||||
const ContributionsLoading({this.message});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// État de rafraîchissement
|
||||
class ContributionsRefreshing extends ContributionsState {
|
||||
const ContributionsRefreshing();
|
||||
}
|
||||
|
||||
/// État chargé avec succès
|
||||
class ContributionsLoaded extends ContributionsState {
|
||||
final List<ContributionModel> contributions;
|
||||
final int total;
|
||||
final int page;
|
||||
final int size;
|
||||
final int totalPages;
|
||||
|
||||
const ContributionsLoaded({
|
||||
required this.contributions,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.size,
|
||||
required this.totalPages,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contributions, total, page, size, totalPages];
|
||||
}
|
||||
|
||||
/// État détail d'une contribution chargé
|
||||
class ContributionDetailLoaded extends ContributionsState {
|
||||
final ContributionModel contribution;
|
||||
|
||||
const ContributionDetailLoaded({required this.contribution});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contribution];
|
||||
}
|
||||
|
||||
/// État contribution créée
|
||||
class ContributionCreated extends ContributionsState {
|
||||
final ContributionModel contribution;
|
||||
|
||||
const ContributionCreated({required this.contribution});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contribution];
|
||||
}
|
||||
|
||||
/// État contribution mise à jour
|
||||
class ContributionUpdated extends ContributionsState {
|
||||
final ContributionModel contribution;
|
||||
|
||||
const ContributionUpdated({required this.contribution});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contribution];
|
||||
}
|
||||
|
||||
/// État contribution supprimée
|
||||
class ContributionDeleted extends ContributionsState {
|
||||
final String id;
|
||||
|
||||
const ContributionDeleted({required this.id});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// État paiement enregistré
|
||||
class PaymentRecorded extends ContributionsState {
|
||||
final ContributionModel contribution;
|
||||
|
||||
const PaymentRecorded({required this.contribution});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contribution];
|
||||
}
|
||||
|
||||
/// État statistiques chargées
|
||||
class ContributionsStatsLoaded extends ContributionsState {
|
||||
final Map<String, dynamic> stats;
|
||||
|
||||
const ContributionsStatsLoaded({required this.stats});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [stats];
|
||||
}
|
||||
|
||||
/// État contributions générées
|
||||
class ContributionsGenerated extends ContributionsState {
|
||||
final int nombreGenere;
|
||||
|
||||
const ContributionsGenerated({required this.nombreGenere});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [nombreGenere];
|
||||
}
|
||||
|
||||
/// État rappel envoyé
|
||||
class ReminderSent extends ContributionsState {
|
||||
final String contributionId;
|
||||
|
||||
const ReminderSent({required this.contributionId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contributionId];
|
||||
}
|
||||
|
||||
/// État d'erreur générique
|
||||
class ContributionsError extends ContributionsState {
|
||||
final String message;
|
||||
final dynamic error;
|
||||
|
||||
const ContributionsError({
|
||||
required this.message,
|
||||
this.error,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, error];
|
||||
}
|
||||
|
||||
/// État d'erreur réseau
|
||||
class ContributionsNetworkError extends ContributionsState {
|
||||
final String message;
|
||||
|
||||
const ContributionsNetworkError({required this.message});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// État d'erreur de validation
|
||||
class ContributionsValidationError extends ContributionsState {
|
||||
final String message;
|
||||
final Map<String, String>? fieldErrors;
|
||||
|
||||
const ContributionsValidationError({
|
||||
required this.message,
|
||||
this.fieldErrors,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, fieldErrors];
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/// Modèle de données pour les cotisations
|
||||
library cotisation_model;
|
||||
/// Modèle de données pour les contributions
|
||||
library contribution_model;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'cotisation_model.g.dart';
|
||||
part 'contribution_model.g.dart';
|
||||
|
||||
/// Statut d'une cotisation
|
||||
enum StatutCotisation {
|
||||
/// Statut d'une contribution
|
||||
enum ContributionStatus {
|
||||
@JsonValue('PAYEE')
|
||||
payee,
|
||||
@JsonValue('NON_PAYEE')
|
||||
@@ -20,8 +20,8 @@ enum StatutCotisation {
|
||||
annulee,
|
||||
}
|
||||
|
||||
/// Type de cotisation
|
||||
enum TypeCotisation {
|
||||
/// Type de contribution
|
||||
enum ContributionType {
|
||||
@JsonValue('ANNUELLE')
|
||||
annuelle,
|
||||
@JsonValue('MENSUELLE')
|
||||
@@ -35,7 +35,7 @@ enum TypeCotisation {
|
||||
}
|
||||
|
||||
/// Méthode de paiement
|
||||
enum MethodePaiement {
|
||||
enum PaymentMethod {
|
||||
@JsonValue('ESPECES')
|
||||
especes,
|
||||
@JsonValue('CHEQUE')
|
||||
@@ -56,9 +56,9 @@ enum MethodePaiement {
|
||||
autre,
|
||||
}
|
||||
|
||||
/// Modèle complet d'une cotisation
|
||||
/// Modèle complet d'une contribution
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class CotisationModel extends Equatable {
|
||||
class ContributionModel extends Equatable {
|
||||
/// Identifiant unique
|
||||
final String? id;
|
||||
|
||||
@@ -71,9 +71,9 @@ class CotisationModel extends Equatable {
|
||||
final String? organisationId;
|
||||
final String? organisationNom;
|
||||
|
||||
/// Informations de la cotisation
|
||||
final TypeCotisation type;
|
||||
final StatutCotisation statut;
|
||||
/// Informations de la contribution
|
||||
final ContributionType type;
|
||||
final ContributionStatus statut;
|
||||
final double montant;
|
||||
final double? montantPaye;
|
||||
final String devise;
|
||||
@@ -84,7 +84,7 @@ class CotisationModel extends Equatable {
|
||||
final DateTime? dateRappel;
|
||||
|
||||
/// Paiement
|
||||
final MethodePaiement? methodePaiement;
|
||||
final PaymentMethod? methodePaiement;
|
||||
final String? numeroPaiement;
|
||||
final String? referencePaiement;
|
||||
|
||||
@@ -105,15 +105,15 @@ class CotisationModel extends Equatable {
|
||||
final String? creeParId;
|
||||
final String? modifieParId;
|
||||
|
||||
const CotisationModel({
|
||||
const ContributionModel({
|
||||
this.id,
|
||||
required this.membreId,
|
||||
this.membreNom,
|
||||
this.membrePrenom,
|
||||
this.organisationId,
|
||||
this.organisationNom,
|
||||
this.type = TypeCotisation.annuelle,
|
||||
this.statut = StatutCotisation.nonPayee,
|
||||
this.type = ContributionType.annuelle,
|
||||
this.statut = ContributionStatus.nonPayee,
|
||||
required this.montant,
|
||||
this.montantPaye,
|
||||
this.devise = 'XOF',
|
||||
@@ -137,29 +137,29 @@ class CotisationModel extends Equatable {
|
||||
});
|
||||
|
||||
/// Désérialisation depuis JSON
|
||||
factory CotisationModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$CotisationModelFromJson(json);
|
||||
factory ContributionModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$ContributionModelFromJson(json);
|
||||
|
||||
/// Sérialisation vers JSON
|
||||
Map<String, dynamic> toJson() => _$CotisationModelToJson(this);
|
||||
Map<String, dynamic> toJson() => _$ContributionModelToJson(this);
|
||||
|
||||
/// Copie avec modifications
|
||||
CotisationModel copyWith({
|
||||
ContributionModel copyWith({
|
||||
String? id,
|
||||
String? membreId,
|
||||
String? membreNom,
|
||||
String? membrePrenom,
|
||||
String? organisationId,
|
||||
String? organisationNom,
|
||||
TypeCotisation? type,
|
||||
StatutCotisation? statut,
|
||||
ContributionType? type,
|
||||
ContributionStatus? statut,
|
||||
double? montant,
|
||||
double? montantPaye,
|
||||
String? devise,
|
||||
DateTime? dateEcheance,
|
||||
DateTime? datePaiement,
|
||||
DateTime? dateRappel,
|
||||
MethodePaiement? methodePaiement,
|
||||
PaymentMethod? methodePaiement,
|
||||
String? numeroPaiement,
|
||||
String? referencePaiement,
|
||||
int? annee,
|
||||
@@ -174,7 +174,7 @@ class CotisationModel extends Equatable {
|
||||
String? creeParId,
|
||||
String? modifieParId,
|
||||
}) {
|
||||
return CotisationModel(
|
||||
return ContributionModel(
|
||||
id: id ?? this.id,
|
||||
membreId: membreId ?? this.membreId,
|
||||
membreNom: membreNom ?? this.membreNom,
|
||||
@@ -226,10 +226,10 @@ class CotisationModel extends Equatable {
|
||||
return (montantPaye! / montant) * 100;
|
||||
}
|
||||
|
||||
/// Vérifie si la cotisation est payée
|
||||
bool get estPayee => statut == StatutCotisation.payee;
|
||||
/// Vérifie si la contribution est payée
|
||||
bool get estPayee => statut == ContributionStatus.payee;
|
||||
|
||||
/// Vérifie si la cotisation est en retard
|
||||
/// Vérifie si la contribution est en retard
|
||||
bool get estEnRetard {
|
||||
if (estPayee) return false;
|
||||
return DateTime.now().isAfter(dateEcheance);
|
||||
@@ -243,36 +243,36 @@ class CotisationModel extends Equatable {
|
||||
/// Libellé de la période
|
||||
String get libellePeriode {
|
||||
switch (type) {
|
||||
case TypeCotisation.annuelle:
|
||||
case ContributionType.annuelle:
|
||||
return 'Année $annee';
|
||||
case TypeCotisation.mensuelle:
|
||||
case ContributionType.mensuelle:
|
||||
if (mois != null) {
|
||||
return '${_getNomMois(mois!)} $annee';
|
||||
}
|
||||
return 'Année $annee';
|
||||
case TypeCotisation.trimestrielle:
|
||||
case ContributionType.trimestrielle:
|
||||
if (trimestre != null) {
|
||||
return 'T$trimestre $annee';
|
||||
}
|
||||
return 'Année $annee';
|
||||
case TypeCotisation.semestrielle:
|
||||
case ContributionType.semestrielle:
|
||||
if (semestre != null) {
|
||||
return 'S$semestre $annee';
|
||||
}
|
||||
return 'Année $annee';
|
||||
case TypeCotisation.exceptionnelle:
|
||||
case ContributionType.exceptionnelle:
|
||||
return 'Exceptionnelle $annee';
|
||||
}
|
||||
}
|
||||
|
||||
/// Nom du mois
|
||||
String _getNomMois(int mois) {
|
||||
const mois_fr = [
|
||||
const moisFr = [
|
||||
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
|
||||
];
|
||||
if (mois >= 1 && mois <= 12) {
|
||||
return mois_fr[mois - 1];
|
||||
return moisFr[mois - 1];
|
||||
}
|
||||
return 'Mois $mois';
|
||||
}
|
||||
@@ -311,6 +311,6 @@ class CotisationModel extends Equatable {
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'CotisationModel(id: $id, membre: $membreNomComplet, montant: $montant $devise, statut: $statut)';
|
||||
'ContributionModel(id: $id, membre: $membreNomComplet, montant: $montant $devise, statut: $statut)';
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'cotisation_model.dart';
|
||||
part of 'contribution_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
CotisationModel _$CotisationModelFromJson(Map<String, dynamic> json) =>
|
||||
CotisationModel(
|
||||
ContributionModel _$ContributionModelFromJson(Map<String, dynamic> json) =>
|
||||
ContributionModel(
|
||||
id: json['id'] as String?,
|
||||
membreId: json['membreId'] as String,
|
||||
membreNom: json['membreNom'] as String?,
|
||||
membrePrenom: json['membrePrenom'] as String?,
|
||||
organisationId: json['organisationId'] as String?,
|
||||
organisationNom: json['organisationNom'] as String?,
|
||||
type: $enumDecodeNullable(_$TypeCotisationEnumMap, json['type']) ??
|
||||
TypeCotisation.annuelle,
|
||||
statut: $enumDecodeNullable(_$StatutCotisationEnumMap, json['statut']) ??
|
||||
StatutCotisation.nonPayee,
|
||||
type: $enumDecodeNullable(_$ContributionTypeEnumMap, json['type']) ??
|
||||
ContributionType.annuelle,
|
||||
statut:
|
||||
$enumDecodeNullable(_$ContributionStatusEnumMap, json['statut']) ??
|
||||
ContributionStatus.nonPayee,
|
||||
montant: (json['montant'] as num).toDouble(),
|
||||
montantPaye: (json['montantPaye'] as num?)?.toDouble(),
|
||||
devise: json['devise'] as String? ?? 'XOF',
|
||||
@@ -28,8 +29,8 @@ CotisationModel _$CotisationModelFromJson(Map<String, dynamic> json) =>
|
||||
dateRappel: json['dateRappel'] == null
|
||||
? null
|
||||
: DateTime.parse(json['dateRappel'] as String),
|
||||
methodePaiement: $enumDecodeNullable(
|
||||
_$MethodePaiementEnumMap, json['methodePaiement']),
|
||||
methodePaiement:
|
||||
$enumDecodeNullable(_$PaymentMethodEnumMap, json['methodePaiement']),
|
||||
numeroPaiement: json['numeroPaiement'] as String?,
|
||||
referencePaiement: json['referencePaiement'] as String?,
|
||||
annee: (json['annee'] as num).toInt(),
|
||||
@@ -49,7 +50,7 @@ CotisationModel _$CotisationModelFromJson(Map<String, dynamic> json) =>
|
||||
modifieParId: json['modifieParId'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CotisationModelToJson(CotisationModel instance) =>
|
||||
Map<String, dynamic> _$ContributionModelToJson(ContributionModel instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'membreId': instance.membreId,
|
||||
@@ -57,15 +58,15 @@ Map<String, dynamic> _$CotisationModelToJson(CotisationModel instance) =>
|
||||
'membrePrenom': instance.membrePrenom,
|
||||
'organisationId': instance.organisationId,
|
||||
'organisationNom': instance.organisationNom,
|
||||
'type': _$TypeCotisationEnumMap[instance.type]!,
|
||||
'statut': _$StatutCotisationEnumMap[instance.statut]!,
|
||||
'type': _$ContributionTypeEnumMap[instance.type]!,
|
||||
'statut': _$ContributionStatusEnumMap[instance.statut]!,
|
||||
'montant': instance.montant,
|
||||
'montantPaye': instance.montantPaye,
|
||||
'devise': instance.devise,
|
||||
'dateEcheance': instance.dateEcheance.toIso8601String(),
|
||||
'datePaiement': instance.datePaiement?.toIso8601String(),
|
||||
'dateRappel': instance.dateRappel?.toIso8601String(),
|
||||
'methodePaiement': _$MethodePaiementEnumMap[instance.methodePaiement],
|
||||
'methodePaiement': _$PaymentMethodEnumMap[instance.methodePaiement],
|
||||
'numeroPaiement': instance.numeroPaiement,
|
||||
'referencePaiement': instance.referencePaiement,
|
||||
'annee': instance.annee,
|
||||
@@ -81,30 +82,30 @@ Map<String, dynamic> _$CotisationModelToJson(CotisationModel instance) =>
|
||||
'modifieParId': instance.modifieParId,
|
||||
};
|
||||
|
||||
const _$TypeCotisationEnumMap = {
|
||||
TypeCotisation.annuelle: 'ANNUELLE',
|
||||
TypeCotisation.mensuelle: 'MENSUELLE',
|
||||
TypeCotisation.trimestrielle: 'TRIMESTRIELLE',
|
||||
TypeCotisation.semestrielle: 'SEMESTRIELLE',
|
||||
TypeCotisation.exceptionnelle: 'EXCEPTIONNELLE',
|
||||
const _$ContributionTypeEnumMap = {
|
||||
ContributionType.annuelle: 'ANNUELLE',
|
||||
ContributionType.mensuelle: 'MENSUELLE',
|
||||
ContributionType.trimestrielle: 'TRIMESTRIELLE',
|
||||
ContributionType.semestrielle: 'SEMESTRIELLE',
|
||||
ContributionType.exceptionnelle: 'EXCEPTIONNELLE',
|
||||
};
|
||||
|
||||
const _$StatutCotisationEnumMap = {
|
||||
StatutCotisation.payee: 'PAYEE',
|
||||
StatutCotisation.nonPayee: 'NON_PAYEE',
|
||||
StatutCotisation.enRetard: 'EN_RETARD',
|
||||
StatutCotisation.partielle: 'PARTIELLE',
|
||||
StatutCotisation.annulee: 'ANNULEE',
|
||||
const _$ContributionStatusEnumMap = {
|
||||
ContributionStatus.payee: 'PAYEE',
|
||||
ContributionStatus.nonPayee: 'NON_PAYEE',
|
||||
ContributionStatus.enRetard: 'EN_RETARD',
|
||||
ContributionStatus.partielle: 'PARTIELLE',
|
||||
ContributionStatus.annulee: 'ANNULEE',
|
||||
};
|
||||
|
||||
const _$MethodePaiementEnumMap = {
|
||||
MethodePaiement.especes: 'ESPECES',
|
||||
MethodePaiement.cheque: 'CHEQUE',
|
||||
MethodePaiement.virement: 'VIREMENT',
|
||||
MethodePaiement.carteBancaire: 'CARTE_BANCAIRE',
|
||||
MethodePaiement.waveMoney: 'WAVE_MONEY',
|
||||
MethodePaiement.orangeMoney: 'ORANGE_MONEY',
|
||||
MethodePaiement.freeMoney: 'FREE_MONEY',
|
||||
MethodePaiement.mobileMoney: 'MOBILE_MONEY',
|
||||
MethodePaiement.autre: 'AUTRE',
|
||||
const _$PaymentMethodEnumMap = {
|
||||
PaymentMethod.especes: 'ESPECES',
|
||||
PaymentMethod.cheque: 'CHEQUE',
|
||||
PaymentMethod.virement: 'VIREMENT',
|
||||
PaymentMethod.carteBancaire: 'CARTE_BANCAIRE',
|
||||
PaymentMethod.waveMoney: 'WAVE_MONEY',
|
||||
PaymentMethod.orangeMoney: 'ORANGE_MONEY',
|
||||
PaymentMethod.freeMoney: 'FREE_MONEY',
|
||||
PaymentMethod.mobileMoney: 'MOBILE_MONEY',
|
||||
PaymentMethod.autre: 'AUTRE',
|
||||
};
|
||||
@@ -2,13 +2,13 @@
|
||||
library cotisations_di;
|
||||
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../bloc/cotisations_bloc.dart';
|
||||
import '../bloc/contributions_bloc.dart';
|
||||
|
||||
/// Enregistrer les dépendances du module Cotisations
|
||||
void registerCotisationsDependencies(GetIt getIt) {
|
||||
// BLoC
|
||||
getIt.registerFactory<CotisationsBloc>(
|
||||
() => CotisationsBloc(),
|
||||
getIt.registerFactory<ContributionsBloc>(
|
||||
() => ContributionsBloc(),
|
||||
);
|
||||
|
||||
// Repository sera ajouté ici quand l'API backend sera prête
|
||||
@@ -1,28 +1,28 @@
|
||||
/// Page de gestion des cotisations
|
||||
library cotisations_page;
|
||||
/// Page de gestion des contributions
|
||||
library contributions_page;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../../core/widgets/loading_widget.dart';
|
||||
import '../../../../core/widgets/error_widget.dart';
|
||||
import '../../bloc/cotisations_bloc.dart';
|
||||
import '../../bloc/cotisations_event.dart';
|
||||
import '../../bloc/cotisations_state.dart';
|
||||
import '../../data/models/cotisation_model.dart';
|
||||
import '../../../../shared/widgets/error_widget.dart';
|
||||
import '../../../../shared/widgets/loading_widget.dart';
|
||||
import '../../bloc/contributions_bloc.dart';
|
||||
import '../../bloc/contributions_event.dart';
|
||||
import '../../bloc/contributions_state.dart';
|
||||
import '../../data/models/contribution_model.dart';
|
||||
import 'package:unionflow_mobile_apps/features/contributions/presentation/widgets/create_contribution_dialog.dart';
|
||||
import '../widgets/payment_dialog.dart';
|
||||
import '../widgets/create_cotisation_dialog.dart';
|
||||
import '../../../members/bloc/membres_bloc.dart';
|
||||
|
||||
/// Page principale des cotisations
|
||||
class CotisationsPage extends StatefulWidget {
|
||||
const CotisationsPage({super.key});
|
||||
/// Page principale des contributions
|
||||
class ContributionsPage extends StatefulWidget {
|
||||
const ContributionsPage({super.key});
|
||||
|
||||
@override
|
||||
State<CotisationsPage> createState() => _CotisationsPageState();
|
||||
State<ContributionsPage> createState() => _ContributionsPageState();
|
||||
}
|
||||
|
||||
class _CotisationsPageState extends State<CotisationsPage>
|
||||
class _ContributionsPageState extends State<ContributionsPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA');
|
||||
@@ -31,7 +31,7 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
_loadCotisations();
|
||||
_loadContributions();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -40,30 +40,30 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadCotisations() {
|
||||
void _loadContributions() {
|
||||
final currentTab = _tabController.index;
|
||||
switch (currentTab) {
|
||||
case 0:
|
||||
context.read<CotisationsBloc>().add(const LoadCotisations());
|
||||
context.read<ContributionsBloc>().add(const LoadContributions());
|
||||
break;
|
||||
case 1:
|
||||
context.read<CotisationsBloc>().add(const LoadCotisationsPayees());
|
||||
context.read<ContributionsBloc>().add(const LoadContributionsPayees());
|
||||
break;
|
||||
case 2:
|
||||
context.read<CotisationsBloc>().add(const LoadCotisationsNonPayees());
|
||||
context.read<ContributionsBloc>().add(const LoadContributionsNonPayees());
|
||||
break;
|
||||
case 3:
|
||||
context.read<CotisationsBloc>().add(const LoadCotisationsEnRetard());
|
||||
context.read<ContributionsBloc>().add(const LoadContributionsEnRetard());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<CotisationsBloc, CotisationsState>(
|
||||
return BlocListener<ContributionsBloc, ContributionsState>(
|
||||
listener: (context, state) {
|
||||
// Gestion des erreurs avec SnackBar
|
||||
if (state is CotisationsError) {
|
||||
if (state is ContributionsError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.message),
|
||||
@@ -72,7 +72,7 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
action: SnackBarAction(
|
||||
label: 'Réessayer',
|
||||
textColor: Colors.white,
|
||||
onPressed: _loadCotisations,
|
||||
onPressed: _loadContributions,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -83,7 +83,7 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
title: const Text('Cotisations'),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
onTap: (_) => _loadCotisations(),
|
||||
onTap: (_) => _loadContributions(),
|
||||
tabs: const [
|
||||
Tab(text: 'Toutes', icon: Icon(Icons.list)),
|
||||
Tab(text: 'Payées', icon: Icon(Icons.check_circle)),
|
||||
@@ -100,57 +100,57 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => _showCreateDialog(),
|
||||
tooltip: 'Nouvelle cotisation',
|
||||
tooltip: 'Nouvelle contribution',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildCotisationsList(),
|
||||
_buildCotisationsList(),
|
||||
_buildCotisationsList(),
|
||||
_buildCotisationsList(),
|
||||
_buildContributionsList(),
|
||||
_buildContributionsList(),
|
||||
_buildContributionsList(),
|
||||
_buildContributionsList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCotisationsList() {
|
||||
return BlocBuilder<CotisationsBloc, CotisationsState>(
|
||||
Widget _buildContributionsList() {
|
||||
return BlocBuilder<ContributionsBloc, ContributionsState>(
|
||||
builder: (context, state) {
|
||||
if (state is CotisationsLoading) {
|
||||
if (state is ContributionsLoading) {
|
||||
return const Center(child: AppLoadingWidget());
|
||||
}
|
||||
|
||||
if (state is CotisationsError) {
|
||||
if (state is ContributionsError) {
|
||||
return Center(
|
||||
child: AppErrorWidget(
|
||||
message: state.message,
|
||||
onRetry: _loadCotisations,
|
||||
onRetry: _loadContributions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is CotisationsLoaded) {
|
||||
if (state.cotisations.isEmpty) {
|
||||
if (state is ContributionsLoaded) {
|
||||
if (state.contributions.isEmpty) {
|
||||
return const Center(
|
||||
child: EmptyDataWidget(
|
||||
message: 'Aucune cotisation trouvée',
|
||||
message: 'Aucune contribution trouvée',
|
||||
icon: Icons.payment,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => _loadCotisations(),
|
||||
onRefresh: () async => _loadContributions(),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: state.cotisations.length,
|
||||
itemCount: state.contributions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final cotisation = state.cotisations[index];
|
||||
return _buildCotisationCard(cotisation);
|
||||
final contribution = state.contributions[index];
|
||||
return _buildContributionCard(contribution);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -161,11 +161,11 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCotisationCard(CotisationModel cotisation) {
|
||||
Widget _buildContributionCard(ContributionModel contribution) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: InkWell(
|
||||
onTap: () => _showCotisationDetails(cotisation),
|
||||
onTap: () => _showContributionDetails(contribution),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -179,7 +179,7 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
cotisation.membreNomComplet,
|
||||
contribution.membreNomComplet,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -187,7 +187,7 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
cotisation.libellePeriode,
|
||||
contribution.libellePeriode,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
@@ -196,7 +196,7 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildStatutChip(cotisation.statut),
|
||||
_buildStatutChip(contribution.statut),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
@@ -215,7 +215,7 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_currencyFormat.format(cotisation.montant),
|
||||
_currencyFormat.format(contribution.montant),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -223,7 +223,7 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
),
|
||||
],
|
||||
),
|
||||
if (cotisation.montantPaye != null)
|
||||
if (contribution.montantPaye != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -236,7 +236,7 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_currencyFormat.format(cotisation.montantPaye),
|
||||
_currencyFormat.format(contribution.montantPaye),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -257,21 +257,21 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
DateFormat('dd/MM/yyyy').format(cotisation.dateEcheance),
|
||||
DateFormat('dd/MM/yyyy').format(contribution.dateEcheance),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: cotisation.estEnRetard ? Colors.red : null,
|
||||
color: contribution.estEnRetard ? Colors.red : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (cotisation.statut == StatutCotisation.partielle)
|
||||
if (contribution.statut == ContributionStatus.partielle)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: LinearProgressIndicator(
|
||||
value: cotisation.pourcentagePaye / 100,
|
||||
value: contribution.pourcentagePaye / 100,
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue),
|
||||
),
|
||||
@@ -283,33 +283,33 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatutChip(StatutCotisation statut) {
|
||||
Widget _buildStatutChip(ContributionStatus statut) {
|
||||
Color color;
|
||||
String label;
|
||||
IconData icon;
|
||||
|
||||
switch (statut) {
|
||||
case StatutCotisation.payee:
|
||||
case ContributionStatus.payee:
|
||||
color = Colors.green;
|
||||
label = 'Payée';
|
||||
icon = Icons.check_circle;
|
||||
break;
|
||||
case StatutCotisation.nonPayee:
|
||||
case ContributionStatus.nonPayee:
|
||||
color = Colors.orange;
|
||||
label = 'Non payée';
|
||||
icon = Icons.pending;
|
||||
break;
|
||||
case StatutCotisation.enRetard:
|
||||
case ContributionStatus.enRetard:
|
||||
color = Colors.red;
|
||||
label = 'En retard';
|
||||
icon = Icons.warning;
|
||||
break;
|
||||
case StatutCotisation.partielle:
|
||||
case ContributionStatus.partielle:
|
||||
color = Colors.blue;
|
||||
label = 'Partielle';
|
||||
icon = Icons.hourglass_bottom;
|
||||
break;
|
||||
case StatutCotisation.annulee:
|
||||
case ContributionStatus.annulee:
|
||||
color = Colors.grey;
|
||||
label = 'Annulée';
|
||||
icon = Icons.cancel;
|
||||
@@ -328,41 +328,41 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
);
|
||||
}
|
||||
|
||||
void _showCotisationDetails(CotisationModel cotisation) {
|
||||
void _showContributionDetails(ContributionModel contribution) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(cotisation.membreNomComplet),
|
||||
title: Text(contribution.membreNomComplet),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDetailRow('Période', cotisation.libellePeriode),
|
||||
_buildDetailRow('Montant', _currencyFormat.format(cotisation.montant)),
|
||||
if (cotisation.montantPaye != null)
|
||||
_buildDetailRow('Payé', _currencyFormat.format(cotisation.montantPaye)),
|
||||
_buildDetailRow('Restant', _currencyFormat.format(cotisation.montantRestant)),
|
||||
_buildDetailRow('Période', contribution.libellePeriode),
|
||||
_buildDetailRow('Montant', _currencyFormat.format(contribution.montant)),
|
||||
if (contribution.montantPaye != null)
|
||||
_buildDetailRow('Payé', _currencyFormat.format(contribution.montantPaye)),
|
||||
_buildDetailRow('Restant', _currencyFormat.format(contribution.montantRestant)),
|
||||
_buildDetailRow(
|
||||
'Échéance',
|
||||
DateFormat('dd/MM/yyyy').format(cotisation.dateEcheance),
|
||||
DateFormat('dd/MM/yyyy').format(contribution.dateEcheance),
|
||||
),
|
||||
if (cotisation.datePaiement != null)
|
||||
if (contribution.datePaiement != null)
|
||||
_buildDetailRow(
|
||||
'Date paiement',
|
||||
DateFormat('dd/MM/yyyy').format(cotisation.datePaiement!),
|
||||
DateFormat('dd/MM/yyyy').format(contribution.datePaiement!),
|
||||
),
|
||||
if (cotisation.methodePaiement != null)
|
||||
_buildDetailRow('Méthode', _getMethodePaiementLabel(cotisation.methodePaiement!)),
|
||||
if (contribution.methodePaiement != null)
|
||||
_buildDetailRow('Méthode', _getMethodePaiementLabel(contribution.methodePaiement!)),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (cotisation.statut != StatutCotisation.payee)
|
||||
if (contribution.statut != ContributionStatus.payee)
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_showPaymentDialog(cotisation);
|
||||
_showPaymentDialog(contribution);
|
||||
},
|
||||
icon: const Icon(Icons.payment),
|
||||
label: const Text('Enregistrer paiement'),
|
||||
@@ -401,35 +401,35 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
);
|
||||
}
|
||||
|
||||
String _getMethodePaiementLabel(MethodePaiement methode) {
|
||||
String _getMethodePaiementLabel(PaymentMethod methode) {
|
||||
switch (methode) {
|
||||
case MethodePaiement.especes:
|
||||
case PaymentMethod.especes:
|
||||
return 'Espèces';
|
||||
case MethodePaiement.cheque:
|
||||
case PaymentMethod.cheque:
|
||||
return 'Chèque';
|
||||
case MethodePaiement.virement:
|
||||
case PaymentMethod.virement:
|
||||
return 'Virement';
|
||||
case MethodePaiement.carteBancaire:
|
||||
case PaymentMethod.carteBancaire:
|
||||
return 'Carte bancaire';
|
||||
case MethodePaiement.waveMoney:
|
||||
case PaymentMethod.waveMoney:
|
||||
return 'Wave Money';
|
||||
case MethodePaiement.orangeMoney:
|
||||
case PaymentMethod.orangeMoney:
|
||||
return 'Orange Money';
|
||||
case MethodePaiement.freeMoney:
|
||||
case PaymentMethod.freeMoney:
|
||||
return 'Free Money';
|
||||
case MethodePaiement.mobileMoney:
|
||||
case PaymentMethod.mobileMoney:
|
||||
return 'Mobile Money';
|
||||
case MethodePaiement.autre:
|
||||
case PaymentMethod.autre:
|
||||
return 'Autre';
|
||||
}
|
||||
}
|
||||
|
||||
void _showPaymentDialog(CotisationModel cotisation) {
|
||||
void _showPaymentDialog(ContributionModel contribution) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => BlocProvider.value(
|
||||
value: context.read<CotisationsBloc>(),
|
||||
child: PaymentDialog(cotisation: cotisation),
|
||||
value: context.read<ContributionsBloc>(),
|
||||
child: PaymentDialog(cotisation: contribution),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -439,24 +439,24 @@ class _CotisationsPageState extends State<CotisationsPage>
|
||||
context: context,
|
||||
builder: (context) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: context.read<CotisationsBloc>()),
|
||||
BlocProvider.value(value: context.read<ContributionsBloc>()),
|
||||
BlocProvider.value(value: context.read<MembresBloc>()),
|
||||
],
|
||||
child: const CreateCotisationDialog(),
|
||||
child: const CreateContributionDialog(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showStats() {
|
||||
context.read<CotisationsBloc>().add(const LoadCotisationsStats());
|
||||
context.read<ContributionsBloc>().add(const LoadContributionsStats());
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Statistiques'),
|
||||
content: BlocBuilder<CotisationsBloc, CotisationsState>(
|
||||
content: BlocBuilder<ContributionsBloc, ContributionsState>(
|
||||
builder: (context, state) {
|
||||
if (state is CotisationsStatsLoaded) {
|
||||
if (state is ContributionsStatsLoaded) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -4,9 +4,9 @@ library cotisations_page_wrapper;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../../bloc/cotisations_bloc.dart';
|
||||
import '../../bloc/cotisations_event.dart';
|
||||
import 'cotisations_page.dart';
|
||||
import '../../bloc/contributions_bloc.dart';
|
||||
import '../../bloc/contributions_event.dart';
|
||||
import 'contributions_page.dart';
|
||||
|
||||
final _getIt = GetIt.instance;
|
||||
|
||||
@@ -16,14 +16,14 @@ class CotisationsPageWrapper extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<CotisationsBloc>(
|
||||
return BlocProvider<ContributionsBloc>(
|
||||
create: (context) {
|
||||
final bloc = _getIt<CotisationsBloc>();
|
||||
final bloc = _getIt<ContributionsBloc>();
|
||||
// Charger les cotisations au démarrage
|
||||
bloc.add(const LoadCotisations());
|
||||
bloc.add(const LoadContributions());
|
||||
return bloc;
|
||||
},
|
||||
child: const CotisationsPage(),
|
||||
child: const ContributionsPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/// Dialogue de création de contribution
|
||||
library create_contribution_dialog;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../bloc/contributions_bloc.dart';
|
||||
import '../../bloc/contributions_event.dart';
|
||||
import '../../data/models/contribution_model.dart';
|
||||
import '../../../members/bloc/membres_bloc.dart';
|
||||
import '../../../members/bloc/membres_event.dart';
|
||||
import '../../../members/bloc/membres_state.dart';
|
||||
|
||||
|
||||
class CreateContributionDialog extends StatefulWidget {
|
||||
const CreateContributionDialog({super.key});
|
||||
|
||||
@override
|
||||
State<CreateContributionDialog> createState() => _CreateContributionDialogState();
|
||||
}
|
||||
|
||||
class _CreateContributionDialogState extends State<CreateContributionDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _montantController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
|
||||
ContributionType _selectedType = ContributionType.mensuelle;
|
||||
dynamic _selectedMembre;
|
||||
DateTime _dateEcheance = DateTime.now().add(const Duration(days: 30));
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Charger la liste des membres
|
||||
context.read<MembresBloc>().add(const LoadMembres());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_montantController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Nouvelle contribution'),
|
||||
content: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.8,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Sélection du membre
|
||||
BlocBuilder<MembresBloc, MembresState>(
|
||||
builder: (context, state) {
|
||||
if (state is MembresLoaded) {
|
||||
return DropdownButtonFormField<dynamic>(
|
||||
value: _selectedMembre,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Membre',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: state.membres.map((membre) {
|
||||
return DropdownMenuItem(
|
||||
value: membre,
|
||||
child: Text('${membre.nom} ${membre.prenom}'),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedMembre = value;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null) {
|
||||
return 'Veuillez sélectionner un membre';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
return const CircularProgressIndicator();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Type de contribution
|
||||
DropdownButtonFormField<ContributionType>(
|
||||
value: _selectedType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Type de contribution',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: ContributionType.values.map((type) {
|
||||
return DropdownMenuItem(
|
||||
value: type,
|
||||
child: Text(_getTypeLabel(type)),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedType = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Montant
|
||||
TextFormField(
|
||||
controller: _montantController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Montant (FCFA)',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.attach_money),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez saisir un montant';
|
||||
}
|
||||
if (double.tryParse(value) == null) {
|
||||
return 'Montant invalide';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Date d'échéance
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _dateEcheance,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (date != null) {
|
||||
setState(() {
|
||||
_dateEcheance = date;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Date d\'échéance',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.calendar_today),
|
||||
),
|
||||
child: Text(
|
||||
DateFormat('dd/MM/yyyy').format(_dateEcheance),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Description
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (optionnel)',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.description),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _createContribution,
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Créer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getTypeLabel(ContributionType type) {
|
||||
switch (type) {
|
||||
case ContributionType.mensuelle:
|
||||
return 'Mensuelle';
|
||||
case ContributionType.trimestrielle:
|
||||
return 'Trimestrielle';
|
||||
case ContributionType.semestrielle:
|
||||
return 'Semestrielle';
|
||||
case ContributionType.annuelle:
|
||||
return 'Annuelle';
|
||||
case ContributionType.exceptionnelle:
|
||||
return 'Exceptionnelle';
|
||||
}
|
||||
}
|
||||
|
||||
void _createContribution() {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_selectedMembre == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Veuillez sélectionner un membre'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
final contribution = ContributionModel(
|
||||
membreId: _selectedMembre!.id!,
|
||||
membreNom: _selectedMembre!.nom,
|
||||
membrePrenom: _selectedMembre!.prenom,
|
||||
type: _selectedType,
|
||||
annee: DateTime.now().year,
|
||||
montant: double.parse(_montantController.text),
|
||||
dateEcheance: _dateEcheance,
|
||||
description: _descriptionController.text.isNotEmpty ? _descriptionController.text : null,
|
||||
statut: ContributionStatus.nonPayee,
|
||||
dateCreation: DateTime.now(),
|
||||
dateModification: DateTime.now(),
|
||||
);
|
||||
|
||||
context.read<ContributionsBloc>().add(CreateContribution(contribution: contribution));
|
||||
Navigator.pop(context);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Contribution créée avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
/// Dialogue de paiement de cotisation
|
||||
/// Formulaire pour enregistrer un paiement de cotisation
|
||||
/// Dialogue de paiement de contribution
|
||||
/// Formulaire pour enregistrer un paiement de contribution
|
||||
library payment_dialog;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../bloc/cotisations_bloc.dart';
|
||||
import '../../bloc/cotisations_event.dart';
|
||||
import '../../data/models/cotisation_model.dart';
|
||||
import '../../bloc/contributions_bloc.dart';
|
||||
import '../../bloc/contributions_event.dart';
|
||||
import '../../data/models/contribution_model.dart';
|
||||
|
||||
/// Dialogue de paiement de cotisation
|
||||
/// Dialogue de paiement de contribution
|
||||
class PaymentDialog extends StatefulWidget {
|
||||
final CotisationModel cotisation;
|
||||
final ContributionModel cotisation;
|
||||
|
||||
const PaymentDialog({
|
||||
super.key,
|
||||
@@ -28,7 +28,7 @@ class _PaymentDialogState extends State<PaymentDialog> {
|
||||
final _referenceController = TextEditingController();
|
||||
final _notesController = TextEditingController();
|
||||
|
||||
MethodePaiement _selectedMethode = MethodePaiement.waveMoney;
|
||||
PaymentMethod _selectedMethode = PaymentMethod.waveMoney;
|
||||
DateTime _datePaiement = DateTime.now();
|
||||
|
||||
@override
|
||||
@@ -191,14 +191,14 @@ class _PaymentDialogState extends State<PaymentDialog> {
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Méthode de paiement
|
||||
DropdownButtonFormField<MethodePaiement>(
|
||||
DropdownButtonFormField<PaymentMethod>(
|
||||
value: _selectedMethode,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Méthode de paiement *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.payment),
|
||||
),
|
||||
items: MethodePaiement.values.map((methode) {
|
||||
items: PaymentMethod.values.map((methode) {
|
||||
return DropdownMenuItem(
|
||||
value: methode,
|
||||
child: Row(
|
||||
@@ -294,48 +294,48 @@ class _PaymentDialogState extends State<PaymentDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getMethodeIcon(MethodePaiement methode) {
|
||||
IconData _getMethodeIcon(PaymentMethod methode) {
|
||||
switch (methode) {
|
||||
case MethodePaiement.waveMoney:
|
||||
case PaymentMethod.waveMoney:
|
||||
return Icons.phone_android;
|
||||
case MethodePaiement.orangeMoney:
|
||||
case PaymentMethod.orangeMoney:
|
||||
return Icons.phone_iphone;
|
||||
case MethodePaiement.freeMoney:
|
||||
case PaymentMethod.freeMoney:
|
||||
return Icons.smartphone;
|
||||
case MethodePaiement.mobileMoney:
|
||||
case PaymentMethod.mobileMoney:
|
||||
return Icons.mobile_friendly;
|
||||
case MethodePaiement.especes:
|
||||
case PaymentMethod.especes:
|
||||
return Icons.money;
|
||||
case MethodePaiement.cheque:
|
||||
case PaymentMethod.cheque:
|
||||
return Icons.receipt_long;
|
||||
case MethodePaiement.virement:
|
||||
case PaymentMethod.virement:
|
||||
return Icons.account_balance;
|
||||
case MethodePaiement.carteBancaire:
|
||||
case PaymentMethod.carteBancaire:
|
||||
return Icons.credit_card;
|
||||
case MethodePaiement.autre:
|
||||
case PaymentMethod.autre:
|
||||
return Icons.more_horiz;
|
||||
}
|
||||
}
|
||||
|
||||
String _getMethodeLabel(MethodePaiement methode) {
|
||||
String _getMethodeLabel(PaymentMethod methode) {
|
||||
switch (methode) {
|
||||
case MethodePaiement.waveMoney:
|
||||
case PaymentMethod.waveMoney:
|
||||
return 'Wave Money';
|
||||
case MethodePaiement.orangeMoney:
|
||||
case PaymentMethod.orangeMoney:
|
||||
return 'Orange Money';
|
||||
case MethodePaiement.freeMoney:
|
||||
case PaymentMethod.freeMoney:
|
||||
return 'Free Money';
|
||||
case MethodePaiement.especes:
|
||||
case PaymentMethod.especes:
|
||||
return 'Espèces';
|
||||
case MethodePaiement.cheque:
|
||||
case PaymentMethod.cheque:
|
||||
return 'Chèque';
|
||||
case MethodePaiement.virement:
|
||||
case PaymentMethod.virement:
|
||||
return 'Virement bancaire';
|
||||
case MethodePaiement.carteBancaire:
|
||||
case PaymentMethod.carteBancaire:
|
||||
return 'Carte bancaire';
|
||||
case MethodePaiement.mobileMoney:
|
||||
case PaymentMethod.mobileMoney:
|
||||
return 'Mobile Money (autre)';
|
||||
case MethodePaiement.autre:
|
||||
case PaymentMethod.autre:
|
||||
return 'Autre';
|
||||
}
|
||||
}
|
||||
@@ -359,20 +359,20 @@ class _PaymentDialogState extends State<PaymentDialog> {
|
||||
final montant = double.parse(_montantController.text);
|
||||
|
||||
// Créer la cotisation mise à jour
|
||||
final cotisationUpdated = widget.cotisation.copyWith(
|
||||
widget.cotisation.copyWith(
|
||||
montantPaye: (widget.cotisation.montantPaye ?? 0) + montant,
|
||||
datePaiement: _datePaiement,
|
||||
methodePaiement: _selectedMethode,
|
||||
referencePaiement: _referenceController.text.isNotEmpty ? _referenceController.text : null,
|
||||
notes: _notesController.text.isNotEmpty ? _notesController.text : null,
|
||||
statut: (widget.cotisation.montantPaye ?? 0) + montant >= widget.cotisation.montant
|
||||
? StatutCotisation.payee
|
||||
: StatutCotisation.partielle,
|
||||
? ContributionStatus.payee
|
||||
: ContributionStatus.partielle,
|
||||
);
|
||||
|
||||
// Envoyer l'événement au BLoC
|
||||
context.read<CotisationsBloc>().add(EnregistrerPaiement(
|
||||
cotisationId: widget.cotisation.id!,
|
||||
context.read<ContributionsBloc>().add(RecordPayment(
|
||||
contributionId: widget.cotisation.id!,
|
||||
montant: montant,
|
||||
methodePaiement: _selectedMethode,
|
||||
datePaiement: _datePaiement,
|
||||
@@ -1,597 +0,0 @@
|
||||
/// BLoC pour la gestion des cotisations
|
||||
library cotisations_bloc;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/utils/logger.dart';
|
||||
import '../data/models/cotisation_model.dart';
|
||||
import 'cotisations_event.dart';
|
||||
import 'cotisations_state.dart';
|
||||
|
||||
/// BLoC pour gérer l'état des cotisations
|
||||
class CotisationsBloc extends Bloc<CotisationsEvent, CotisationsState> {
|
||||
CotisationsBloc() : super(const CotisationsInitial()) {
|
||||
on<LoadCotisations>(_onLoadCotisations);
|
||||
on<LoadCotisationById>(_onLoadCotisationById);
|
||||
on<CreateCotisation>(_onCreateCotisation);
|
||||
on<UpdateCotisation>(_onUpdateCotisation);
|
||||
on<DeleteCotisation>(_onDeleteCotisation);
|
||||
on<SearchCotisations>(_onSearchCotisations);
|
||||
on<LoadCotisationsByMembre>(_onLoadCotisationsByMembre);
|
||||
on<LoadCotisationsPayees>(_onLoadCotisationsPayees);
|
||||
on<LoadCotisationsNonPayees>(_onLoadCotisationsNonPayees);
|
||||
on<LoadCotisationsEnRetard>(_onLoadCotisationsEnRetard);
|
||||
on<EnregistrerPaiement>(_onEnregistrerPaiement);
|
||||
on<LoadCotisationsStats>(_onLoadCotisationsStats);
|
||||
on<GenererCotisationsAnnuelles>(_onGenererCotisationsAnnuelles);
|
||||
on<EnvoyerRappelPaiement>(_onEnvoyerRappelPaiement);
|
||||
}
|
||||
|
||||
/// Charger la liste des cotisations
|
||||
Future<void> _onLoadCotisations(
|
||||
LoadCotisations event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('CotisationsBloc', 'LoadCotisations', data: {
|
||||
'page': event.page,
|
||||
'size': event.size,
|
||||
});
|
||||
|
||||
emit(const CotisationsLoading(message: 'Chargement des cotisations...'));
|
||||
|
||||
// Simuler un délai réseau
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Données mock
|
||||
final cotisations = _getMockCotisations();
|
||||
final total = cotisations.length;
|
||||
final totalPages = (total / event.size).ceil();
|
||||
|
||||
// Pagination
|
||||
final start = event.page * event.size;
|
||||
final end = (start + event.size).clamp(0, total);
|
||||
final paginatedCotisations = cotisations.sublist(
|
||||
start.clamp(0, total),
|
||||
end,
|
||||
);
|
||||
|
||||
emit(CotisationsLoaded(
|
||||
cotisations: paginatedCotisations,
|
||||
total: total,
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
totalPages: totalPages,
|
||||
));
|
||||
|
||||
AppLogger.blocState('CotisationsBloc', 'CotisationsLoaded', data: {
|
||||
'count': paginatedCotisations.length,
|
||||
'total': total,
|
||||
});
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors du chargement des cotisations',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
emit(CotisationsError(
|
||||
message: 'Erreur lors du chargement des cotisations',
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Charger une cotisation par ID
|
||||
Future<void> _onLoadCotisationById(
|
||||
LoadCotisationById event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('CotisationsBloc', 'LoadCotisationById', data: {
|
||||
'id': event.id,
|
||||
});
|
||||
|
||||
emit(const CotisationsLoading(message: 'Chargement de la cotisation...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
final cotisations = _getMockCotisations();
|
||||
final cotisation = cotisations.firstWhere(
|
||||
(c) => c.id == event.id,
|
||||
orElse: () => throw Exception('Cotisation non trouvée'),
|
||||
);
|
||||
|
||||
emit(CotisationDetailLoaded(cotisation: cotisation));
|
||||
|
||||
AppLogger.blocState('CotisationsBloc', 'CotisationDetailLoaded');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors du chargement de la cotisation',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
emit(CotisationsError(
|
||||
message: 'Cotisation non trouvée',
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Créer une nouvelle cotisation
|
||||
Future<void> _onCreateCotisation(
|
||||
CreateCotisation event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('CotisationsBloc', 'CreateCotisation');
|
||||
|
||||
emit(const CotisationsLoading(message: 'Création de la cotisation...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final newCotisation = event.cotisation.copyWith(
|
||||
id: 'cot_${DateTime.now().millisecondsSinceEpoch}',
|
||||
dateCreation: DateTime.now(),
|
||||
);
|
||||
|
||||
emit(CotisationCreated(cotisation: newCotisation));
|
||||
|
||||
AppLogger.blocState('CotisationsBloc', 'CotisationCreated');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors de la création de la cotisation',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
emit(CotisationsError(
|
||||
message: 'Erreur lors de la création de la cotisation',
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Mettre à jour une cotisation
|
||||
Future<void> _onUpdateCotisation(
|
||||
UpdateCotisation event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('CotisationsBloc', 'UpdateCotisation', data: {
|
||||
'id': event.id,
|
||||
});
|
||||
|
||||
emit(const CotisationsLoading(message: 'Mise à jour de la cotisation...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final updatedCotisation = event.cotisation.copyWith(
|
||||
id: event.id,
|
||||
dateModification: DateTime.now(),
|
||||
);
|
||||
|
||||
emit(CotisationUpdated(cotisation: updatedCotisation));
|
||||
|
||||
AppLogger.blocState('CotisationsBloc', 'CotisationUpdated');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors de la mise à jour de la cotisation',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
emit(CotisationsError(
|
||||
message: 'Erreur lors de la mise à jour de la cotisation',
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprimer une cotisation
|
||||
Future<void> _onDeleteCotisation(
|
||||
DeleteCotisation event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('CotisationsBloc', 'DeleteCotisation', data: {
|
||||
'id': event.id,
|
||||
});
|
||||
|
||||
emit(const CotisationsLoading(message: 'Suppression de la cotisation...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
emit(CotisationDeleted(id: event.id));
|
||||
|
||||
AppLogger.blocState('CotisationsBloc', 'CotisationDeleted');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors de la suppression de la cotisation',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
emit(CotisationsError(
|
||||
message: 'Erreur lors de la suppression de la cotisation',
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rechercher des cotisations
|
||||
Future<void> _onSearchCotisations(
|
||||
SearchCotisations event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('CotisationsBloc', 'SearchCotisations');
|
||||
|
||||
emit(const CotisationsLoading(message: 'Recherche en cours...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
var cotisations = _getMockCotisations();
|
||||
|
||||
// Filtrer par membre
|
||||
if (event.membreId != null) {
|
||||
cotisations = cotisations
|
||||
.where((c) => c.membreId == event.membreId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtrer par statut
|
||||
if (event.statut != null) {
|
||||
cotisations = cotisations
|
||||
.where((c) => c.statut == event.statut)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtrer par type
|
||||
if (event.type != null) {
|
||||
cotisations = cotisations
|
||||
.where((c) => c.type == event.type)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Filtrer par année
|
||||
if (event.annee != null) {
|
||||
cotisations = cotisations
|
||||
.where((c) => c.annee == event.annee)
|
||||
.toList();
|
||||
}
|
||||
|
||||
final total = cotisations.length;
|
||||
final totalPages = (total / event.size).ceil();
|
||||
|
||||
// Pagination
|
||||
final start = event.page * event.size;
|
||||
final end = (start + event.size).clamp(0, total);
|
||||
final paginatedCotisations = cotisations.sublist(
|
||||
start.clamp(0, total),
|
||||
end,
|
||||
);
|
||||
|
||||
emit(CotisationsLoaded(
|
||||
cotisations: paginatedCotisations,
|
||||
total: total,
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
totalPages: totalPages,
|
||||
));
|
||||
|
||||
AppLogger.blocState('CotisationsBloc', 'CotisationsLoaded (search)');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors de la recherche de cotisations',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
emit(CotisationsError(
|
||||
message: 'Erreur lors de la recherche',
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Charger les cotisations d'un membre
|
||||
Future<void> _onLoadCotisationsByMembre(
|
||||
LoadCotisationsByMembre event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('CotisationsBloc', 'LoadCotisationsByMembre', data: {
|
||||
'membreId': event.membreId,
|
||||
});
|
||||
|
||||
emit(const CotisationsLoading(message: 'Chargement des cotisations du membre...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final cotisations = _getMockCotisations()
|
||||
.where((c) => c.membreId == event.membreId)
|
||||
.toList();
|
||||
|
||||
final total = cotisations.length;
|
||||
final totalPages = (total / event.size).ceil();
|
||||
|
||||
emit(CotisationsLoaded(
|
||||
cotisations: cotisations,
|
||||
total: total,
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
totalPages: totalPages,
|
||||
));
|
||||
|
||||
AppLogger.blocState('CotisationsBloc', 'CotisationsLoaded (by membre)');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors du chargement des cotisations du membre',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
emit(CotisationsError(
|
||||
message: 'Erreur lors du chargement',
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Charger les cotisations payées
|
||||
Future<void> _onLoadCotisationsPayees(
|
||||
LoadCotisationsPayees event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const CotisationsLoading(message: 'Chargement des cotisations payées...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final cotisations = _getMockCotisations()
|
||||
.where((c) => c.statut == StatutCotisation.payee)
|
||||
.toList();
|
||||
|
||||
final total = cotisations.length;
|
||||
final totalPages = (total / event.size).ceil();
|
||||
|
||||
emit(CotisationsLoaded(
|
||||
cotisations: cotisations,
|
||||
total: total,
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
totalPages: totalPages,
|
||||
));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(CotisationsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Charger les cotisations non payées
|
||||
Future<void> _onLoadCotisationsNonPayees(
|
||||
LoadCotisationsNonPayees event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const CotisationsLoading(message: 'Chargement des cotisations non payées...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final cotisations = _getMockCotisations()
|
||||
.where((c) => c.statut == StatutCotisation.nonPayee)
|
||||
.toList();
|
||||
|
||||
final total = cotisations.length;
|
||||
final totalPages = (total / event.size).ceil();
|
||||
|
||||
emit(CotisationsLoaded(
|
||||
cotisations: cotisations,
|
||||
total: total,
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
totalPages: totalPages,
|
||||
));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(CotisationsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Charger les cotisations en retard
|
||||
Future<void> _onLoadCotisationsEnRetard(
|
||||
LoadCotisationsEnRetard event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const CotisationsLoading(message: 'Chargement des cotisations en retard...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final cotisations = _getMockCotisations()
|
||||
.where((c) => c.statut == StatutCotisation.enRetard)
|
||||
.toList();
|
||||
|
||||
final total = cotisations.length;
|
||||
final totalPages = (total / event.size).ceil();
|
||||
|
||||
emit(CotisationsLoaded(
|
||||
cotisations: cotisations,
|
||||
total: total,
|
||||
page: event.page,
|
||||
size: event.size,
|
||||
totalPages: totalPages,
|
||||
));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(CotisationsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Enregistrer un paiement
|
||||
Future<void> _onEnregistrerPaiement(
|
||||
EnregistrerPaiement event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
AppLogger.blocEvent('CotisationsBloc', 'EnregistrerPaiement');
|
||||
|
||||
emit(const CotisationsLoading(message: 'Enregistrement du paiement...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final cotisations = _getMockCotisations();
|
||||
final cotisation = cotisations.firstWhere((c) => c.id == event.cotisationId);
|
||||
|
||||
final updatedCotisation = cotisation.copyWith(
|
||||
montantPaye: event.montant,
|
||||
datePaiement: event.datePaiement,
|
||||
methodePaiement: event.methodePaiement,
|
||||
numeroPaiement: event.numeroPaiement,
|
||||
referencePaiement: event.referencePaiement,
|
||||
statut: event.montant >= cotisation.montant
|
||||
? StatutCotisation.payee
|
||||
: StatutCotisation.partielle,
|
||||
dateModification: DateTime.now(),
|
||||
);
|
||||
|
||||
emit(PaiementEnregistre(cotisation: updatedCotisation));
|
||||
|
||||
AppLogger.blocState('CotisationsBloc', 'PaiementEnregistre');
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(CotisationsError(message: 'Erreur lors de l\'enregistrement du paiement', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Charger les statistiques
|
||||
Future<void> _onLoadCotisationsStats(
|
||||
LoadCotisationsStats event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const CotisationsLoading(message: 'Chargement des statistiques...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final cotisations = _getMockCotisations();
|
||||
|
||||
final stats = {
|
||||
'total': cotisations.length,
|
||||
'payees': cotisations.where((c) => c.statut == StatutCotisation.payee).length,
|
||||
'nonPayees': cotisations.where((c) => c.statut == StatutCotisation.nonPayee).length,
|
||||
'enRetard': cotisations.where((c) => c.statut == StatutCotisation.enRetard).length,
|
||||
'partielles': cotisations.where((c) => c.statut == StatutCotisation.partielle).length,
|
||||
'montantTotal': cotisations.fold<double>(0, (sum, c) => sum + c.montant),
|
||||
'montantPaye': cotisations.fold<double>(0, (sum, c) => sum + (c.montantPaye ?? 0)),
|
||||
'montantRestant': cotisations.fold<double>(0, (sum, c) => sum + c.montantRestant),
|
||||
'tauxRecouvrement': 0.0,
|
||||
};
|
||||
|
||||
if (stats['montantTotal']! > 0) {
|
||||
stats['tauxRecouvrement'] = (stats['montantPaye']! / stats['montantTotal']!) * 100;
|
||||
}
|
||||
|
||||
emit(CotisationsStatsLoaded(stats: stats));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(CotisationsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Générer les cotisations annuelles
|
||||
Future<void> _onGenererCotisationsAnnuelles(
|
||||
GenererCotisationsAnnuelles event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const CotisationsLoading(message: 'Génération des cotisations...'));
|
||||
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
// Simuler la génération de 50 cotisations
|
||||
emit(const CotisationsGenerees(nombreGenere: 50));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(CotisationsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoyer un rappel de paiement
|
||||
Future<void> _onEnvoyerRappelPaiement(
|
||||
EnvoyerRappelPaiement event,
|
||||
Emitter<CotisationsState> emit,
|
||||
) async {
|
||||
try {
|
||||
emit(const CotisationsLoading(message: 'Envoi du rappel...'));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
emit(RappelEnvoye(cotisationId: event.cotisationId));
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error('Erreur', error: e, stackTrace: stackTrace);
|
||||
emit(CotisationsError(message: 'Erreur', error: e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Données mock pour les tests
|
||||
List<CotisationModel> _getMockCotisations() {
|
||||
final now = DateTime.now();
|
||||
return [
|
||||
CotisationModel(
|
||||
id: 'cot_001',
|
||||
membreId: 'mbr_001',
|
||||
membreNom: 'Dupont',
|
||||
membrePrenom: 'Jean',
|
||||
montant: 50000,
|
||||
dateEcheance: DateTime(now.year, 12, 31),
|
||||
annee: now.year,
|
||||
statut: StatutCotisation.payee,
|
||||
montantPaye: 50000,
|
||||
datePaiement: DateTime(now.year, 1, 15),
|
||||
methodePaiement: MethodePaiement.virement,
|
||||
),
|
||||
CotisationModel(
|
||||
id: 'cot_002',
|
||||
membreId: 'mbr_002',
|
||||
membreNom: 'Martin',
|
||||
membrePrenom: 'Marie',
|
||||
montant: 50000,
|
||||
dateEcheance: DateTime(now.year, 12, 31),
|
||||
annee: now.year,
|
||||
statut: StatutCotisation.nonPayee,
|
||||
),
|
||||
CotisationModel(
|
||||
id: 'cot_003',
|
||||
membreId: 'mbr_003',
|
||||
membreNom: 'Bernard',
|
||||
membrePrenom: 'Pierre',
|
||||
montant: 50000,
|
||||
dateEcheance: DateTime(now.year - 1, 12, 31),
|
||||
annee: now.year - 1,
|
||||
statut: StatutCotisation.enRetard,
|
||||
),
|
||||
CotisationModel(
|
||||
id: 'cot_004',
|
||||
membreId: 'mbr_004',
|
||||
membreNom: 'Dubois',
|
||||
membrePrenom: 'Sophie',
|
||||
montant: 50000,
|
||||
dateEcheance: DateTime(now.year, 12, 31),
|
||||
annee: now.year,
|
||||
statut: StatutCotisation.partielle,
|
||||
montantPaye: 25000,
|
||||
datePaiement: DateTime(now.year, 2, 10),
|
||||
methodePaiement: MethodePaiement.especes,
|
||||
),
|
||||
CotisationModel(
|
||||
id: 'cot_005',
|
||||
membreId: 'mbr_005',
|
||||
membreNom: 'Petit',
|
||||
membrePrenom: 'Luc',
|
||||
montant: 50000,
|
||||
dateEcheance: DateTime(now.year, 12, 31),
|
||||
annee: now.year,
|
||||
statut: StatutCotisation.payee,
|
||||
montantPaye: 50000,
|
||||
datePaiement: DateTime(now.year, 3, 5),
|
||||
methodePaiement: MethodePaiement.mobileMoney,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
/// Événements pour le BLoC des cotisations
|
||||
library cotisations_event;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../data/models/cotisation_model.dart';
|
||||
|
||||
/// Classe de base pour tous les événements de cotisations
|
||||
abstract class CotisationsEvent extends Equatable {
|
||||
const CotisationsEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// Charger la liste des cotisations
|
||||
class LoadCotisations extends CotisationsEvent {
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const LoadCotisations({
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size];
|
||||
}
|
||||
|
||||
/// Charger une cotisation par ID
|
||||
class LoadCotisationById extends CotisationsEvent {
|
||||
final String id;
|
||||
|
||||
const LoadCotisationById({required this.id});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Créer une nouvelle cotisation
|
||||
class CreateCotisation extends CotisationsEvent {
|
||||
final CotisationModel cotisation;
|
||||
|
||||
const CreateCotisation({required this.cotisation});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisation];
|
||||
}
|
||||
|
||||
/// Mettre à jour une cotisation
|
||||
class UpdateCotisation extends CotisationsEvent {
|
||||
final String id;
|
||||
final CotisationModel cotisation;
|
||||
|
||||
const UpdateCotisation({
|
||||
required this.id,
|
||||
required this.cotisation,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, cotisation];
|
||||
}
|
||||
|
||||
/// Supprimer une cotisation
|
||||
class DeleteCotisation extends CotisationsEvent {
|
||||
final String id;
|
||||
|
||||
const DeleteCotisation({required this.id});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Rechercher des cotisations
|
||||
class SearchCotisations extends CotisationsEvent {
|
||||
final String? membreId;
|
||||
final StatutCotisation? statut;
|
||||
final TypeCotisation? type;
|
||||
final int? annee;
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const SearchCotisations({
|
||||
this.membreId,
|
||||
this.statut,
|
||||
this.type,
|
||||
this.annee,
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membreId, statut, type, annee, page, size];
|
||||
}
|
||||
|
||||
/// Charger les cotisations d'un membre
|
||||
class LoadCotisationsByMembre extends CotisationsEvent {
|
||||
final String membreId;
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const LoadCotisationsByMembre({
|
||||
required this.membreId,
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [membreId, page, size];
|
||||
}
|
||||
|
||||
/// Charger les cotisations payées
|
||||
class LoadCotisationsPayees extends CotisationsEvent {
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const LoadCotisationsPayees({
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size];
|
||||
}
|
||||
|
||||
/// Charger les cotisations non payées
|
||||
class LoadCotisationsNonPayees extends CotisationsEvent {
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const LoadCotisationsNonPayees({
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size];
|
||||
}
|
||||
|
||||
/// Charger les cotisations en retard
|
||||
class LoadCotisationsEnRetard extends CotisationsEvent {
|
||||
final int page;
|
||||
final int size;
|
||||
|
||||
const LoadCotisationsEnRetard({
|
||||
this.page = 0,
|
||||
this.size = 20,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [page, size];
|
||||
}
|
||||
|
||||
/// Enregistrer un paiement
|
||||
class EnregistrerPaiement extends CotisationsEvent {
|
||||
final String cotisationId;
|
||||
final double montant;
|
||||
final MethodePaiement methodePaiement;
|
||||
final String? numeroPaiement;
|
||||
final String? referencePaiement;
|
||||
final DateTime datePaiement;
|
||||
final String? notes;
|
||||
final String? reference;
|
||||
|
||||
const EnregistrerPaiement({
|
||||
required this.cotisationId,
|
||||
required this.montant,
|
||||
required this.methodePaiement,
|
||||
this.numeroPaiement,
|
||||
this.referencePaiement,
|
||||
required this.datePaiement,
|
||||
this.notes,
|
||||
this.reference,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
cotisationId,
|
||||
montant,
|
||||
methodePaiement,
|
||||
numeroPaiement,
|
||||
referencePaiement,
|
||||
datePaiement,
|
||||
notes,
|
||||
reference,
|
||||
];
|
||||
}
|
||||
|
||||
/// Charger les statistiques des cotisations
|
||||
class LoadCotisationsStats extends CotisationsEvent {
|
||||
final int? annee;
|
||||
|
||||
const LoadCotisationsStats({this.annee});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [annee];
|
||||
}
|
||||
|
||||
/// Générer les cotisations annuelles
|
||||
class GenererCotisationsAnnuelles extends CotisationsEvent {
|
||||
final int annee;
|
||||
final double montant;
|
||||
final DateTime dateEcheance;
|
||||
|
||||
const GenererCotisationsAnnuelles({
|
||||
required this.annee,
|
||||
required this.montant,
|
||||
required this.dateEcheance,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [annee, montant, dateEcheance];
|
||||
}
|
||||
|
||||
/// Envoyer un rappel de paiement
|
||||
class EnvoyerRappelPaiement extends CotisationsEvent {
|
||||
final String cotisationId;
|
||||
|
||||
const EnvoyerRappelPaiement({required this.cotisationId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisationId];
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
/// États pour le BLoC des cotisations
|
||||
library cotisations_state;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../data/models/cotisation_model.dart';
|
||||
|
||||
/// Classe de base pour tous les états de cotisations
|
||||
abstract class CotisationsState extends Equatable {
|
||||
const CotisationsState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
/// État initial
|
||||
class CotisationsInitial extends CotisationsState {
|
||||
const CotisationsInitial();
|
||||
}
|
||||
|
||||
/// État de chargement
|
||||
class CotisationsLoading extends CotisationsState {
|
||||
final String? message;
|
||||
|
||||
const CotisationsLoading({this.message});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// État de rafraîchissement
|
||||
class CotisationsRefreshing extends CotisationsState {
|
||||
const CotisationsRefreshing();
|
||||
}
|
||||
|
||||
/// État chargé avec succès
|
||||
class CotisationsLoaded extends CotisationsState {
|
||||
final List<CotisationModel> cotisations;
|
||||
final int total;
|
||||
final int page;
|
||||
final int size;
|
||||
final int totalPages;
|
||||
|
||||
const CotisationsLoaded({
|
||||
required this.cotisations,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.size,
|
||||
required this.totalPages,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisations, total, page, size, totalPages];
|
||||
}
|
||||
|
||||
/// État détail d'une cotisation chargé
|
||||
class CotisationDetailLoaded extends CotisationsState {
|
||||
final CotisationModel cotisation;
|
||||
|
||||
const CotisationDetailLoaded({required this.cotisation});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisation];
|
||||
}
|
||||
|
||||
/// État cotisation créée
|
||||
class CotisationCreated extends CotisationsState {
|
||||
final CotisationModel cotisation;
|
||||
|
||||
const CotisationCreated({required this.cotisation});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisation];
|
||||
}
|
||||
|
||||
/// État cotisation mise à jour
|
||||
class CotisationUpdated extends CotisationsState {
|
||||
final CotisationModel cotisation;
|
||||
|
||||
const CotisationUpdated({required this.cotisation});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisation];
|
||||
}
|
||||
|
||||
/// État cotisation supprimée
|
||||
class CotisationDeleted extends CotisationsState {
|
||||
final String id;
|
||||
|
||||
const CotisationDeleted({required this.id});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// État paiement enregistré
|
||||
class PaiementEnregistre extends CotisationsState {
|
||||
final CotisationModel cotisation;
|
||||
|
||||
const PaiementEnregistre({required this.cotisation});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisation];
|
||||
}
|
||||
|
||||
/// État statistiques chargées
|
||||
class CotisationsStatsLoaded extends CotisationsState {
|
||||
final Map<String, dynamic> stats;
|
||||
|
||||
const CotisationsStatsLoaded({required this.stats});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [stats];
|
||||
}
|
||||
|
||||
/// État cotisations générées
|
||||
class CotisationsGenerees extends CotisationsState {
|
||||
final int nombreGenere;
|
||||
|
||||
const CotisationsGenerees({required this.nombreGenere});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [nombreGenere];
|
||||
}
|
||||
|
||||
/// État rappel envoyé
|
||||
class RappelEnvoye extends CotisationsState {
|
||||
final String cotisationId;
|
||||
|
||||
const RappelEnvoye({required this.cotisationId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cotisationId];
|
||||
}
|
||||
|
||||
/// État d'erreur générique
|
||||
class CotisationsError extends CotisationsState {
|
||||
final String message;
|
||||
final dynamic error;
|
||||
|
||||
const CotisationsError({
|
||||
required this.message,
|
||||
this.error,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, error];
|
||||
}
|
||||
|
||||
/// État d'erreur réseau
|
||||
class CotisationsNetworkError extends CotisationsState {
|
||||
final String message;
|
||||
|
||||
const CotisationsNetworkError({required this.message});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
|
||||
/// État d'erreur de validation
|
||||
class CotisationsValidationError extends CotisationsState {
|
||||
final String message;
|
||||
final Map<String, String>? fieldErrors;
|
||||
|
||||
const CotisationsValidationError({
|
||||
required this.message,
|
||||
this.fieldErrors,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, fieldErrors];
|
||||
}
|
||||
|
||||
@@ -1,572 +0,0 @@
|
||||
/// Dialogue de création de cotisation
|
||||
library create_cotisation_dialog;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../bloc/cotisations_bloc.dart';
|
||||
import '../../bloc/cotisations_event.dart';
|
||||
import '../../data/models/cotisation_model.dart';
|
||||
import '../../../members/bloc/membres_bloc.dart';
|
||||
import '../../../members/bloc/membres_event.dart';
|
||||
import '../../../members/bloc/membres_state.dart';
|
||||
import '../../../members/data/models/membre_complete_model.dart';
|
||||
|
||||
class CreateCotisationDialog extends StatefulWidget {
|
||||
const CreateCotisationDialog({super.key});
|
||||
|
||||
@override
|
||||
State<CreateCotisationDialog> createState() => _CreateCotisationDialogState();
|
||||
}
|
||||
|
||||
class _CreateCotisationDialogState extends State<CreateCotisationDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _montantController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
MembreCompletModel? _selectedMembre;
|
||||
TypeCotisation _selectedType = TypeCotisation.annuelle;
|
||||
DateTime _dateEcheance = DateTime.now().add(const Duration(days: 30));
|
||||
int _annee = DateTime.now().year;
|
||||
int? _mois;
|
||||
int? _trimestre;
|
||||
int? _semestre;
|
||||
List<MembreCompletModel> _membresDisponibles = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<MembresBloc>().add(const LoadActiveMembres());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_montantController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width * 0.9,
|
||||
constraints: const BoxConstraints(maxHeight: 600),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle('Membre'),
|
||||
const SizedBox(height: 12),
|
||||
_buildMembreSelector(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildSectionTitle('Type de cotisation'),
|
||||
const SizedBox(height: 12),
|
||||
_buildTypeDropdown(),
|
||||
const SizedBox(height: 12),
|
||||
_buildPeriodeFields(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildSectionTitle('Montant'),
|
||||
const SizedBox(height: 12),
|
||||
_buildMontantField(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildSectionTitle('Échéance'),
|
||||
const SizedBox(height: 12),
|
||||
_buildDateEcheanceField(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildSectionTitle('Description (optionnel)'),
|
||||
const SizedBox(height: 12),
|
||||
_buildDescriptionField(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildActionButtons(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFEF4444),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(4),
|
||||
topRight: Radius.circular(4),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.add_card, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Créer une cotisation',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFEF4444),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMembreSelector() {
|
||||
return BlocBuilder<MembresBloc, MembresState>(
|
||||
builder: (context, state) {
|
||||
if (state is MembresLoaded) {
|
||||
_membresDisponibles = state.membres;
|
||||
}
|
||||
|
||||
if (_selectedMembre != null) {
|
||||
return _buildSelectedMembre();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSearchField(),
|
||||
const SizedBox(height: 12),
|
||||
if (_membresDisponibles.isNotEmpty) _buildMembresList(),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchField() {
|
||||
return TextFormField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Rechercher un membre *',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
context.read<MembresBloc>().add(const LoadActiveMembres());
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (value) {
|
||||
if (value.isNotEmpty) {
|
||||
context.read<MembresBloc>().add(LoadMembres(recherche: value));
|
||||
} else {
|
||||
context.read<MembresBloc>().add(const LoadActiveMembres());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMembresList() {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _membresDisponibles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final membre = _membresDisponibles[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(child: Text(membre.initiales)),
|
||||
title: Text(membre.nomComplet),
|
||||
subtitle: Text(membre.email),
|
||||
onTap: () => setState(() => _selectedMembre = membre),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectedMembre() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green[50],
|
||||
border: Border.all(color: Colors.green[300]!),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(child: Text(_selectedMembre!.initiales)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_selectedMembre!.nomComplet,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
_selectedMembre!.email,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.red),
|
||||
onPressed: () => setState(() => _selectedMembre = null),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeDropdown() {
|
||||
return DropdownButtonFormField<TypeCotisation>(
|
||||
value: _selectedType,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Type *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.category),
|
||||
),
|
||||
items: TypeCotisation.values.map((type) {
|
||||
return DropdownMenuItem(
|
||||
value: type,
|
||||
child: Text(_getTypeLabel(type)),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedType = value!;
|
||||
_updatePeriodeFields();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMontantField() {
|
||||
return TextFormField(
|
||||
controller: _montantController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Montant *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.attach_money),
|
||||
suffixText: 'XOF',
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Le montant est obligatoire';
|
||||
}
|
||||
final montant = double.tryParse(value);
|
||||
if (montant == null || montant <= 0) {
|
||||
return 'Le montant doit être supérieur à 0';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPeriodeFields() {
|
||||
switch (_selectedType) {
|
||||
case TypeCotisation.mensuelle:
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<int>(
|
||||
value: _mois,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Mois *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: List.generate(12, (index) {
|
||||
final mois = index + 1;
|
||||
return DropdownMenuItem(
|
||||
value: mois,
|
||||
child: Text(_getNomMois(mois)),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) => setState(() => _mois = value),
|
||||
validator: (value) => value == null ? 'Le mois est obligatoire' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
initialValue: _annee.toString(),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Année *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) => _annee = int.tryParse(value) ?? DateTime.now().year,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
case TypeCotisation.trimestrielle:
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<int>(
|
||||
value: _trimestre,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Trimestre *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 1, child: Text('T1 (Jan-Mar)')),
|
||||
DropdownMenuItem(value: 2, child: Text('T2 (Avr-Juin)')),
|
||||
DropdownMenuItem(value: 3, child: Text('T3 (Juil-Sep)')),
|
||||
DropdownMenuItem(value: 4, child: Text('T4 (Oct-Déc)')),
|
||||
],
|
||||
onChanged: (value) => setState(() => _trimestre = value),
|
||||
validator: (value) => value == null ? 'Le trimestre est obligatoire' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
initialValue: _annee.toString(),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Année *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) => _annee = int.tryParse(value) ?? DateTime.now().year,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
case TypeCotisation.semestrielle:
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<int>(
|
||||
value: _semestre,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Semestre *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 1, child: Text('S1 (Jan-Juin)')),
|
||||
DropdownMenuItem(value: 2, child: Text('S2 (Juil-Déc)')),
|
||||
],
|
||||
onChanged: (value) => setState(() => _semestre = value),
|
||||
validator: (value) => value == null ? 'Le semestre est obligatoire' : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
initialValue: _annee.toString(),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Année *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) => _annee = int.tryParse(value) ?? DateTime.now().year,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
case TypeCotisation.annuelle:
|
||||
case TypeCotisation.exceptionnelle:
|
||||
return TextFormField(
|
||||
initialValue: _annee.toString(),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Année *',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (value) => _annee = int.tryParse(value) ?? DateTime.now().year,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDateEcheanceField() {
|
||||
return InkWell(
|
||||
onTap: () => _selectDateEcheance(context),
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Date d\'échéance *',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.calendar_today),
|
||||
),
|
||||
child: Text(DateFormat('dd/MM/yyyy').format(_dateEcheance)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDescriptionField() {
|
||||
return TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.notes),
|
||||
),
|
||||
maxLines: 3,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
border: Border(top: BorderSide(color: Colors.grey[300]!)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: _submitForm,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFEF4444),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Créer la cotisation'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getTypeLabel(TypeCotisation type) {
|
||||
switch (type) {
|
||||
case TypeCotisation.annuelle:
|
||||
return 'Annuelle';
|
||||
case TypeCotisation.mensuelle:
|
||||
return 'Mensuelle';
|
||||
case TypeCotisation.trimestrielle:
|
||||
return 'Trimestrielle';
|
||||
case TypeCotisation.semestrielle:
|
||||
return 'Semestrielle';
|
||||
case TypeCotisation.exceptionnelle:
|
||||
return 'Exceptionnelle';
|
||||
}
|
||||
}
|
||||
|
||||
String _getNomMois(int mois) {
|
||||
const moisFr = [
|
||||
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
|
||||
];
|
||||
return (mois >= 1 && mois <= 12) ? moisFr[mois - 1] : 'Mois $mois';
|
||||
}
|
||||
|
||||
void _updatePeriodeFields() {
|
||||
_mois = null;
|
||||
_trimestre = null;
|
||||
_semestre = null;
|
||||
|
||||
final now = DateTime.now();
|
||||
switch (_selectedType) {
|
||||
case TypeCotisation.mensuelle:
|
||||
_mois = now.month;
|
||||
break;
|
||||
case TypeCotisation.trimestrielle:
|
||||
_trimestre = ((now.month - 1) ~/ 3) + 1;
|
||||
break;
|
||||
case TypeCotisation.semestrielle:
|
||||
_semestre = now.month <= 6 ? 1 : 2;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectDateEcheance(BuildContext context) async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _dateEcheance,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
|
||||
);
|
||||
if (picked != null && picked != _dateEcheance) {
|
||||
setState(() => _dateEcheance = picked);
|
||||
}
|
||||
}
|
||||
|
||||
void _submitForm() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
if (_selectedMembre == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Veuillez sélectionner un membre'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final cotisation = CotisationModel(
|
||||
membreId: _selectedMembre!.id!,
|
||||
membreNom: _selectedMembre!.nom,
|
||||
membrePrenom: _selectedMembre!.prenom,
|
||||
type: _selectedType,
|
||||
montant: double.parse(_montantController.text),
|
||||
dateEcheance: _dateEcheance,
|
||||
annee: _annee,
|
||||
mois: _mois,
|
||||
trimestre: _trimestre,
|
||||
semestre: _semestre,
|
||||
description: _descriptionController.text.isNotEmpty ? _descriptionController.text : null,
|
||||
statut: StatutCotisation.nonPayee,
|
||||
);
|
||||
|
||||
context.read<CotisationsBloc>().add(CreateCotisation(cotisation: cotisation));
|
||||
Navigator.pop(context);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Cotisation créée avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/// Configuration globale du Dashboard UnionFlow
|
||||
class DashboardConfig {
|
||||
// Version du dashboard
|
||||
static const String version = '1.0.0';
|
||||
static const String buildNumber = '2024.10.06.001';
|
||||
|
||||
// Configuration des couleurs
|
||||
static const bool useCustomTheme = true;
|
||||
static const String primaryColorHex = '#4169E1'; // Bleu Roi
|
||||
static const String secondaryColorHex = '#008B8B'; // Bleu Pétrole
|
||||
|
||||
// Configuration des données
|
||||
static const bool useMockData = false;
|
||||
static const String apiBaseUrl = 'http://localhost:8080';
|
||||
static const Duration networkTimeout = Duration(seconds: 30);
|
||||
|
||||
// Configuration du rafraîchissement
|
||||
static const Duration autoRefreshInterval = Duration(minutes: 5);
|
||||
static const Duration cacheExpiration = Duration(minutes: 10);
|
||||
static const bool enableAutoRefresh = true;
|
||||
static const bool enablePullToRefresh = true;
|
||||
|
||||
// Configuration des animations
|
||||
static const bool enableAnimations = true;
|
||||
static const Duration animationDuration = Duration(milliseconds: 300);
|
||||
static const Duration chartAnimationDuration = Duration(milliseconds: 1500);
|
||||
static const Duration counterAnimationDuration = Duration(milliseconds: 2000);
|
||||
|
||||
// Configuration des widgets
|
||||
static const int maxRecentActivities = 10;
|
||||
static const int maxUpcomingEvents = 5;
|
||||
static const int maxNotifications = 5;
|
||||
static const int maxShortcuts = 6;
|
||||
|
||||
// Configuration des graphiques
|
||||
static const bool enableCharts = true;
|
||||
static const bool enableInteractiveCharts = true;
|
||||
static const double chartHeight = 200.0;
|
||||
static const double largeChartHeight = 300.0;
|
||||
|
||||
// Configuration des métriques temps réel
|
||||
static const bool enableRealTimeMetrics = true;
|
||||
static const Duration metricsUpdateInterval = Duration(seconds: 30);
|
||||
static const bool enableMetricsAnimations = true;
|
||||
|
||||
// Configuration des notifications
|
||||
static const bool enableNotifications = true;
|
||||
static const bool enableUrgentNotifications = true;
|
||||
static const int maxUrgentNotifications = 3;
|
||||
|
||||
// Configuration de la recherche
|
||||
static const bool enableSearch = true;
|
||||
static const int maxSearchSuggestions = 5;
|
||||
static const Duration searchDebounceDelay = Duration(milliseconds: 300);
|
||||
|
||||
// Configuration des raccourcis
|
||||
static const bool enableShortcuts = true;
|
||||
static const bool enableShortcutBadges = true;
|
||||
static const bool enableShortcutCustomization = true;
|
||||
|
||||
// Configuration du logging
|
||||
static const bool enableLogging = true;
|
||||
static const bool enableVerboseLogging = false;
|
||||
static const bool enableErrorReporting = true;
|
||||
|
||||
// Configuration de la performance
|
||||
static const bool enablePerformanceMonitoring = true;
|
||||
static const Duration performanceCheckInterval = Duration(minutes: 1);
|
||||
static const double memoryWarningThreshold = 500.0; // MB
|
||||
static const double cpuWarningThreshold = 80.0; // %
|
||||
|
||||
// Configuration de l'accessibilité
|
||||
static const bool enableAccessibility = true;
|
||||
static const bool enableHighContrast = false;
|
||||
static const bool enableLargeText = false;
|
||||
|
||||
// Configuration des fonctionnalités expérimentales
|
||||
static const bool enableExperimentalFeatures = false;
|
||||
static const bool enableBetaWidgets = false;
|
||||
static const bool enableAdvancedAnalytics = false;
|
||||
|
||||
// Seuils d'alerte
|
||||
static const Map<String, dynamic> alertThresholds = {
|
||||
'memoryUsage': 400.0, // MB
|
||||
'cpuUsage': 70.0, // %
|
||||
'networkLatency': 1000, // ms
|
||||
'frameRate': 30.0, // fps
|
||||
'batteryLevel': 20.0, // %
|
||||
'errorRate': 5.0, // %
|
||||
'crashRate': 1.0, // %
|
||||
};
|
||||
|
||||
// Configuration des endpoints API
|
||||
static const Map<String, String> apiEndpoints = {
|
||||
'dashboard': '/api/v1/dashboard/data',
|
||||
'stats': '/api/v1/dashboard/stats',
|
||||
'activities': '/api/v1/dashboard/activities',
|
||||
'events': '/api/v1/dashboard/events/upcoming',
|
||||
'refresh': '/api/v1/dashboard/refresh',
|
||||
'health': '/api/v1/dashboard/health',
|
||||
};
|
||||
|
||||
// Configuration des préférences utilisateur par défaut
|
||||
static const Map<String, dynamic> defaultUserPreferences = {
|
||||
'theme': 'royal_teal',
|
||||
'language': 'fr',
|
||||
'notifications': true,
|
||||
'autoRefresh': true,
|
||||
'refreshInterval': 300, // 5 minutes
|
||||
'enableAnimations': true,
|
||||
'enableCharts': true,
|
||||
'enableRealTimeMetrics': true,
|
||||
'maxRecentActivities': 10,
|
||||
'maxUpcomingEvents': 5,
|
||||
'enableShortcuts': true,
|
||||
'shortcuts': [
|
||||
'new_member',
|
||||
'create_event',
|
||||
'add_contribution',
|
||||
'send_message',
|
||||
'generate_report',
|
||||
'settings',
|
||||
],
|
||||
};
|
||||
|
||||
// Configuration des widgets par défaut
|
||||
static const Map<String, dynamic> defaultWidgetConfig = {
|
||||
'statsCards': {
|
||||
'enabled': true,
|
||||
'columns': 2,
|
||||
'aspectRatio': 1.2,
|
||||
'showSubtitle': true,
|
||||
'showIcon': true,
|
||||
},
|
||||
'charts': {
|
||||
'enabled': true,
|
||||
'showLegend': true,
|
||||
'showGrid': true,
|
||||
'enableInteraction': true,
|
||||
'animationDuration': 1500,
|
||||
},
|
||||
'activities': {
|
||||
'enabled': true,
|
||||
'showAvatar': true,
|
||||
'showTimeAgo': true,
|
||||
'maxItems': 10,
|
||||
'enableActions': true,
|
||||
},
|
||||
'events': {
|
||||
'enabled': true,
|
||||
'showProgress': true,
|
||||
'showTags': true,
|
||||
'maxItems': 5,
|
||||
'enableNavigation': true,
|
||||
},
|
||||
'notifications': {
|
||||
'enabled': true,
|
||||
'showBadges': true,
|
||||
'enableActions': true,
|
||||
'maxItems': 5,
|
||||
'autoHide': false,
|
||||
},
|
||||
'search': {
|
||||
'enabled': true,
|
||||
'showSuggestions': true,
|
||||
'enableHistory': true,
|
||||
'maxSuggestions': 5,
|
||||
'debounceDelay': 300,
|
||||
},
|
||||
'shortcuts': {
|
||||
'enabled': true,
|
||||
'columns': 3,
|
||||
'showBadges': true,
|
||||
'enableCustomization': true,
|
||||
'maxItems': 6,
|
||||
},
|
||||
'metrics': {
|
||||
'enabled': true,
|
||||
'enableAnimations': true,
|
||||
'updateInterval': 30,
|
||||
'showProgress': true,
|
||||
'enableAlerts': true,
|
||||
},
|
||||
};
|
||||
|
||||
// Configuration des couleurs du thème
|
||||
static const Map<String, String> themeColors = {
|
||||
'royalBlue': '#4169E1',
|
||||
'royalBlueLight': '#6A8EF7',
|
||||
'royalBlueDark': '#2E4BC6',
|
||||
'tealBlue': '#008B8B',
|
||||
'tealBlueLight': '#20B2AA',
|
||||
'tealBlueDark': '#006666',
|
||||
'success': '#10B981',
|
||||
'warning': '#F59E0B',
|
||||
'error': '#EF4444',
|
||||
'info': '#3B82F6',
|
||||
'grey50': '#F9FAFB',
|
||||
'grey100': '#F3F4F6',
|
||||
'grey200': '#E5E7EB',
|
||||
'grey300': '#D1D5DB',
|
||||
'grey400': '#9CA3AF',
|
||||
'grey500': '#6B7280',
|
||||
'grey600': '#4B5563',
|
||||
'grey700': '#374151',
|
||||
'grey800': '#1F2937',
|
||||
'grey900': '#111827',
|
||||
'white': '#FFFFFF',
|
||||
'black': '#000000',
|
||||
};
|
||||
|
||||
// Configuration des espacements
|
||||
static const Map<String, double> spacing = {
|
||||
'spacing2': 2.0,
|
||||
'spacing4': 4.0,
|
||||
'spacing6': 6.0,
|
||||
'spacing8': 8.0,
|
||||
'spacing12': 12.0,
|
||||
'spacing16': 16.0,
|
||||
'spacing20': 20.0,
|
||||
'spacing24': 24.0,
|
||||
'spacing32': 32.0,
|
||||
'spacing40': 40.0,
|
||||
};
|
||||
|
||||
// Configuration des bordures
|
||||
static const Map<String, double> borderRadius = {
|
||||
'borderRadiusSmall': 4.0,
|
||||
'borderRadius': 8.0,
|
||||
'borderRadiusLarge': 16.0,
|
||||
'borderRadiusXLarge': 24.0,
|
||||
};
|
||||
|
||||
// Configuration des ombres
|
||||
static const Map<String, Map<String, dynamic>> shadows = {
|
||||
'subtleShadow': {
|
||||
'color': '#00000010',
|
||||
'blurRadius': 4.0,
|
||||
'offset': {'dx': 0.0, 'dy': 2.0},
|
||||
},
|
||||
'elevatedShadow': {
|
||||
'color': '#00000020',
|
||||
'blurRadius': 8.0,
|
||||
'offset': {'dx': 0.0, 'dy': 4.0},
|
||||
},
|
||||
};
|
||||
|
||||
// Configuration des polices
|
||||
static const Map<String, Map<String, dynamic>> typography = {
|
||||
'titleLarge': {
|
||||
'fontSize': 24.0,
|
||||
'fontWeight': 'bold',
|
||||
'letterSpacing': 0.0,
|
||||
},
|
||||
'titleMedium': {
|
||||
'fontSize': 20.0,
|
||||
'fontWeight': 'w600',
|
||||
'letterSpacing': 0.0,
|
||||
},
|
||||
'titleSmall': {
|
||||
'fontSize': 16.0,
|
||||
'fontWeight': 'w600',
|
||||
'letterSpacing': 0.0,
|
||||
},
|
||||
'bodyLarge': {
|
||||
'fontSize': 16.0,
|
||||
'fontWeight': 'normal',
|
||||
'letterSpacing': 0.0,
|
||||
},
|
||||
'bodyMedium': {
|
||||
'fontSize': 14.0,
|
||||
'fontWeight': 'normal',
|
||||
'letterSpacing': 0.0,
|
||||
},
|
||||
'bodySmall': {
|
||||
'fontSize': 12.0,
|
||||
'fontWeight': 'normal',
|
||||
'letterSpacing': 0.0,
|
||||
},
|
||||
};
|
||||
|
||||
// Méthodes utilitaires
|
||||
static bool get isDevelopment => useMockData;
|
||||
static bool get isProduction => !useMockData;
|
||||
|
||||
static String get fullVersion => '$version+$buildNumber';
|
||||
|
||||
static Duration get effectiveRefreshInterval =>
|
||||
enableAutoRefresh ? autoRefreshInterval : Duration.zero;
|
||||
|
||||
static Map<String, dynamic> getUserPreference(String key) {
|
||||
return defaultUserPreferences[key] ?? {};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> getWidgetConfig(String widget) {
|
||||
return defaultWidgetConfig[widget] ?? {};
|
||||
}
|
||||
|
||||
static String getApiEndpoint(String endpoint) {
|
||||
final path = apiEndpoints[endpoint] ?? '';
|
||||
return '$apiBaseUrl$path';
|
||||
}
|
||||
|
||||
static double getAlertThreshold(String metric) {
|
||||
return alertThresholds[metric]?.toDouble() ?? 0.0;
|
||||
}
|
||||
}
|
||||
400
unionflow-mobile-apps/lib/features/dashboard/data/cache/dashboard_cache_manager.dart
vendored
Normal file
400
unionflow-mobile-apps/lib/features/dashboard/data/cache/dashboard_cache_manager.dart
vendored
Normal file
@@ -0,0 +1,400 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/dashboard_stats_model.dart';
|
||||
import '../../config/dashboard_config.dart';
|
||||
|
||||
/// Gestionnaire de cache avancé pour le Dashboard
|
||||
class DashboardCacheManager {
|
||||
static const String _keyPrefix = 'dashboard_cache_';
|
||||
static const String _keyDashboardData = '${_keyPrefix}data';
|
||||
static const String _keyDashboardStats = '${_keyPrefix}stats';
|
||||
static const String _keyRecentActivities = '${_keyPrefix}activities';
|
||||
static const String _keyUpcomingEvents = '${_keyPrefix}events';
|
||||
static const String _keyLastUpdate = '${_keyPrefix}last_update';
|
||||
static const String _keyUserPreferences = '${_keyPrefix}user_prefs';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
final Map<String, dynamic> _memoryCache = {};
|
||||
final Map<String, DateTime> _cacheTimestamps = {};
|
||||
Timer? _cleanupTimer;
|
||||
|
||||
/// Initialise le gestionnaire de cache
|
||||
Future<void> initialize() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_startCleanupTimer();
|
||||
await _loadMemoryCache();
|
||||
}
|
||||
|
||||
/// Démarre le timer de nettoyage automatique
|
||||
void _startCleanupTimer() {
|
||||
_cleanupTimer = Timer.periodic(
|
||||
const Duration(minutes: 30),
|
||||
(_) => _cleanupExpiredCache(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Charge le cache en mémoire au démarrage
|
||||
Future<void> _loadMemoryCache() async {
|
||||
if (_prefs == null) return;
|
||||
|
||||
final keys = _prefs!.getKeys().where((key) => key.startsWith(_keyPrefix));
|
||||
|
||||
for (final key in keys) {
|
||||
final value = _prefs!.getString(key);
|
||||
if (value != null) {
|
||||
try {
|
||||
final data = jsonDecode(value);
|
||||
_memoryCache[key] = data;
|
||||
|
||||
// Charger le timestamp si disponible
|
||||
final timestampKey = '${key}_timestamp';
|
||||
final timestamp = _prefs!.getInt(timestampKey);
|
||||
if (timestamp != null) {
|
||||
_cacheTimestamps[key] = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
}
|
||||
} catch (e) {
|
||||
// Supprimer les données corrompues
|
||||
await _prefs!.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sauvegarde les données complètes du dashboard
|
||||
Future<void> cacheDashboardData(
|
||||
DashboardDataModel data,
|
||||
String organizationId,
|
||||
String userId,
|
||||
) async {
|
||||
final key = '${_keyDashboardData}_${organizationId}_$userId';
|
||||
await _cacheData(key, data.toJson());
|
||||
}
|
||||
|
||||
/// Récupère les données complètes du dashboard
|
||||
Future<DashboardDataModel?> getCachedDashboardData(
|
||||
String organizationId,
|
||||
String userId,
|
||||
) async {
|
||||
final key = '${_keyDashboardData}_${organizationId}_$userId';
|
||||
final data = await _getCachedData(key);
|
||||
|
||||
if (data != null) {
|
||||
try {
|
||||
return DashboardDataModel.fromJson(data);
|
||||
} catch (e) {
|
||||
// Supprimer les données corrompues
|
||||
await _removeCachedData(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Sauvegarde les statistiques du dashboard
|
||||
Future<void> cacheDashboardStats(
|
||||
DashboardStatsModel stats,
|
||||
String organizationId,
|
||||
String userId,
|
||||
) async {
|
||||
final key = '${_keyDashboardStats}_${organizationId}_$userId';
|
||||
await _cacheData(key, stats.toJson());
|
||||
}
|
||||
|
||||
/// Récupère les statistiques du dashboard
|
||||
Future<DashboardStatsModel?> getCachedDashboardStats(
|
||||
String organizationId,
|
||||
String userId,
|
||||
) async {
|
||||
final key = '${_keyDashboardStats}_${organizationId}_$userId';
|
||||
final data = await _getCachedData(key);
|
||||
|
||||
if (data != null) {
|
||||
try {
|
||||
return DashboardStatsModel.fromJson(data);
|
||||
} catch (e) {
|
||||
await _removeCachedData(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Sauvegarde les activités récentes
|
||||
Future<void> cacheRecentActivities(
|
||||
List<RecentActivityModel> activities,
|
||||
String organizationId,
|
||||
String userId,
|
||||
) async {
|
||||
final key = '${_keyRecentActivities}_${organizationId}_$userId';
|
||||
final data = activities.map((activity) => activity.toJson()).toList();
|
||||
await _cacheData(key, data);
|
||||
}
|
||||
|
||||
/// Récupère les activités récentes
|
||||
Future<List<RecentActivityModel>?> getCachedRecentActivities(
|
||||
String organizationId,
|
||||
String userId,
|
||||
) async {
|
||||
final key = '${_keyRecentActivities}_${organizationId}_$userId';
|
||||
final data = await _getCachedData(key);
|
||||
|
||||
if (data != null && data is List) {
|
||||
try {
|
||||
return data
|
||||
.map((item) => RecentActivityModel.fromJson(item))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
await _removeCachedData(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Sauvegarde les événements à venir
|
||||
Future<void> cacheUpcomingEvents(
|
||||
List<UpcomingEventModel> events,
|
||||
String organizationId,
|
||||
String userId,
|
||||
) async {
|
||||
final key = '${_keyUpcomingEvents}_${organizationId}_$userId';
|
||||
final data = events.map((event) => event.toJson()).toList();
|
||||
await _cacheData(key, data);
|
||||
}
|
||||
|
||||
/// Récupère les événements à venir
|
||||
Future<List<UpcomingEventModel>?> getCachedUpcomingEvents(
|
||||
String organizationId,
|
||||
String userId,
|
||||
) async {
|
||||
final key = '${_keyUpcomingEvents}_${organizationId}_$userId';
|
||||
final data = await _getCachedData(key);
|
||||
|
||||
if (data != null && data is List) {
|
||||
try {
|
||||
return data
|
||||
.map((item) => UpcomingEventModel.fromJson(item))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
await _removeCachedData(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Sauvegarde les préférences utilisateur
|
||||
Future<void> cacheUserPreferences(
|
||||
Map<String, dynamic> preferences,
|
||||
String userId,
|
||||
) async {
|
||||
final key = '${_keyUserPreferences}_$userId';
|
||||
await _cacheData(key, preferences);
|
||||
}
|
||||
|
||||
/// Récupère les préférences utilisateur
|
||||
Future<Map<String, dynamic>?> getCachedUserPreferences(String userId) async {
|
||||
final key = '${_keyUserPreferences}_$userId';
|
||||
final data = await _getCachedData(key);
|
||||
|
||||
if (data != null && data is Map<String, dynamic>) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Méthode générique pour sauvegarder des données
|
||||
Future<void> _cacheData(String key, dynamic data) async {
|
||||
if (_prefs == null) return;
|
||||
|
||||
try {
|
||||
final jsonString = jsonEncode(data);
|
||||
await _prefs!.setString(key, jsonString);
|
||||
|
||||
// Sauvegarder le timestamp
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
await _prefs!.setInt('${key}_timestamp', timestamp);
|
||||
|
||||
// Mettre à jour le cache mémoire
|
||||
_memoryCache[key] = data;
|
||||
_cacheTimestamps[key] = DateTime.now();
|
||||
|
||||
} catch (e) {
|
||||
// Erreur de sérialisation, ignorer
|
||||
}
|
||||
}
|
||||
|
||||
/// Méthode générique pour récupérer des données
|
||||
Future<dynamic> _getCachedData(String key) async {
|
||||
// Vérifier d'abord le cache mémoire
|
||||
if (_memoryCache.containsKey(key)) {
|
||||
if (_isCacheValid(key)) {
|
||||
return _memoryCache[key];
|
||||
} else {
|
||||
// Cache expiré, le supprimer
|
||||
await _removeCachedData(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier le cache persistant
|
||||
if (_prefs == null) return null;
|
||||
|
||||
final jsonString = _prefs!.getString(key);
|
||||
if (jsonString != null) {
|
||||
try {
|
||||
final data = jsonDecode(jsonString);
|
||||
|
||||
// Vérifier la validité du cache
|
||||
if (_isCacheValid(key)) {
|
||||
// Charger en mémoire pour les prochains accès
|
||||
_memoryCache[key] = data;
|
||||
return data;
|
||||
} else {
|
||||
// Cache expiré, le supprimer
|
||||
await _removeCachedData(key);
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
// Données corrompues, les supprimer
|
||||
await _removeCachedData(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Vérifie si le cache est encore valide
|
||||
bool _isCacheValid(String key) {
|
||||
final timestamp = _cacheTimestamps[key];
|
||||
if (timestamp == null) {
|
||||
// Essayer de récupérer le timestamp depuis SharedPreferences
|
||||
final timestampMs = _prefs?.getInt('${key}_timestamp');
|
||||
if (timestampMs != null) {
|
||||
final cacheTime = DateTime.fromMillisecondsSinceEpoch(timestampMs);
|
||||
_cacheTimestamps[key] = cacheTime;
|
||||
return DateTime.now().difference(cacheTime) < DashboardConfig.cacheExpiration;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return DateTime.now().difference(timestamp) < DashboardConfig.cacheExpiration;
|
||||
}
|
||||
|
||||
/// Supprime des données du cache
|
||||
Future<void> _removeCachedData(String key) async {
|
||||
_memoryCache.remove(key);
|
||||
_cacheTimestamps.remove(key);
|
||||
|
||||
if (_prefs != null) {
|
||||
await _prefs!.remove(key);
|
||||
await _prefs!.remove('${key}_timestamp');
|
||||
}
|
||||
}
|
||||
|
||||
/// Nettoie le cache expiré
|
||||
Future<void> _cleanupExpiredCache() async {
|
||||
final keysToRemove = <String>[];
|
||||
|
||||
for (final key in _cacheTimestamps.keys) {
|
||||
if (!_isCacheValid(key)) {
|
||||
keysToRemove.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (final key in keysToRemove) {
|
||||
await _removeCachedData(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Vide tout le cache
|
||||
Future<void> clearCache() async {
|
||||
_memoryCache.clear();
|
||||
_cacheTimestamps.clear();
|
||||
|
||||
if (_prefs != null) {
|
||||
final keys = _prefs!.getKeys().where((key) => key.startsWith(_keyPrefix));
|
||||
for (final key in keys) {
|
||||
await _prefs!.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vide le cache pour un utilisateur spécifique
|
||||
Future<void> clearUserCache(String organizationId, String userId) async {
|
||||
final userKeys = [
|
||||
'${_keyDashboardData}_${organizationId}_$userId',
|
||||
'${_keyDashboardStats}_${organizationId}_$userId',
|
||||
'${_keyRecentActivities}_${organizationId}_$userId',
|
||||
'${_keyUpcomingEvents}_${organizationId}_$userId',
|
||||
'${_keyUserPreferences}_$userId',
|
||||
];
|
||||
|
||||
for (final key in userKeys) {
|
||||
await _removeCachedData(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient les statistiques du cache
|
||||
Map<String, dynamic> getCacheStats() {
|
||||
final totalKeys = _memoryCache.length;
|
||||
final validKeys = _cacheTimestamps.keys.where(_isCacheValid).length;
|
||||
final expiredKeys = totalKeys - validKeys;
|
||||
|
||||
return {
|
||||
'totalKeys': totalKeys,
|
||||
'validKeys': validKeys,
|
||||
'expiredKeys': expiredKeys,
|
||||
'memoryUsage': _calculateMemoryUsage(),
|
||||
'oldestEntry': _getOldestEntryAge(),
|
||||
'newestEntry': _getNewestEntryAge(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Calcule l'utilisation mémoire approximative
|
||||
int _calculateMemoryUsage() {
|
||||
int totalSize = 0;
|
||||
for (final data in _memoryCache.values) {
|
||||
try {
|
||||
totalSize += jsonEncode(data).length;
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de sérialisation
|
||||
}
|
||||
}
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
/// Obtient l'âge de l'entrée la plus ancienne
|
||||
Duration? _getOldestEntryAge() {
|
||||
if (_cacheTimestamps.isEmpty) return null;
|
||||
|
||||
final oldestTimestamp = _cacheTimestamps.values
|
||||
.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||
|
||||
return DateTime.now().difference(oldestTimestamp);
|
||||
}
|
||||
|
||||
/// Obtient l'âge de l'entrée la plus récente
|
||||
Duration? _getNewestEntryAge() {
|
||||
if (_cacheTimestamps.isEmpty) return null;
|
||||
|
||||
final newestTimestamp = _cacheTimestamps.values
|
||||
.reduce((a, b) => a.isAfter(b) ? a : b);
|
||||
|
||||
return DateTime.now().difference(newestTimestamp);
|
||||
}
|
||||
|
||||
/// Libère les ressources
|
||||
void dispose() {
|
||||
_cleanupTimer?.cancel();
|
||||
_memoryCache.clear();
|
||||
_cacheTimestamps.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../models/dashboard_stats_model.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../core/error/exceptions.dart';
|
||||
|
||||
abstract class DashboardRemoteDataSource {
|
||||
Future<DashboardDataModel> getDashboardData(String organizationId, String userId);
|
||||
Future<DashboardStatsModel> getDashboardStats(String organizationId, String userId);
|
||||
Future<List<RecentActivityModel>> getRecentActivities(String organizationId, String userId, {int limit = 10});
|
||||
Future<List<UpcomingEventModel>> getUpcomingEvents(String organizationId, String userId, {int limit = 5});
|
||||
}
|
||||
|
||||
class DashboardRemoteDataSourceImpl implements DashboardRemoteDataSource {
|
||||
final DioClient dioClient;
|
||||
|
||||
DashboardRemoteDataSourceImpl({required this.dioClient});
|
||||
|
||||
@override
|
||||
Future<DashboardDataModel> getDashboardData(String organizationId, String userId) async {
|
||||
try {
|
||||
final response = await dioClient.get(
|
||||
'/api/v1/dashboard/data',
|
||||
queryParameters: {
|
||||
'organizationId': organizationId,
|
||||
'userId': userId,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return DashboardDataModel.fromJson(response.data);
|
||||
} else {
|
||||
throw ServerException('Failed to load dashboard data: ${response.statusCode}');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
throw ServerException('Network error: ${e.message}');
|
||||
} catch (e) {
|
||||
throw ServerException('Unexpected error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DashboardStatsModel> getDashboardStats(String organizationId, String userId) async {
|
||||
try {
|
||||
final response = await dioClient.get(
|
||||
'/api/v1/dashboard/stats',
|
||||
queryParameters: {
|
||||
'organizationId': organizationId,
|
||||
'userId': userId,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return DashboardStatsModel.fromJson(response.data);
|
||||
} else {
|
||||
throw ServerException('Failed to load dashboard stats: ${response.statusCode}');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
throw ServerException('Network error: ${e.message}');
|
||||
} catch (e) {
|
||||
throw ServerException('Unexpected error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<RecentActivityModel>> getRecentActivities(
|
||||
String organizationId,
|
||||
String userId, {
|
||||
int limit = 10,
|
||||
}) async {
|
||||
try {
|
||||
final response = await dioClient.get(
|
||||
'/api/v1/dashboard/activities',
|
||||
queryParameters: {
|
||||
'organizationId': organizationId,
|
||||
'userId': userId,
|
||||
'limit': limit,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data['activities'] ?? [];
|
||||
return data.map((json) => RecentActivityModel.fromJson(json)).toList();
|
||||
} else {
|
||||
throw ServerException('Failed to load recent activities: ${response.statusCode}');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
throw ServerException('Network error: ${e.message}');
|
||||
} catch (e) {
|
||||
throw ServerException('Unexpected error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<UpcomingEventModel>> getUpcomingEvents(
|
||||
String organizationId,
|
||||
String userId, {
|
||||
int limit = 5,
|
||||
}) async {
|
||||
try {
|
||||
final response = await dioClient.get(
|
||||
'/api/v1/dashboard/events/upcoming',
|
||||
queryParameters: {
|
||||
'organizationId': organizationId,
|
||||
'userId': userId,
|
||||
'limit': limit,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final List<dynamic> data = response.data['events'] ?? [];
|
||||
return data.map((json) => UpcomingEventModel.fromJson(json)).toList();
|
||||
} else {
|
||||
throw ServerException('Failed to load upcoming events: ${response.statusCode}');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
throw ServerException('Network error: ${e.message}');
|
||||
} catch (e) {
|
||||
throw ServerException('Unexpected error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'dashboard_stats_model.g.dart';
|
||||
|
||||
/// Modèle pour les statistiques du dashboard
|
||||
@JsonSerializable()
|
||||
class DashboardStatsModel extends Equatable {
|
||||
final int totalMembers;
|
||||
final int activeMembers;
|
||||
final int totalEvents;
|
||||
final int upcomingEvents;
|
||||
final int totalContributions;
|
||||
final double totalContributionAmount;
|
||||
final int pendingRequests;
|
||||
final int completedProjects;
|
||||
final double monthlyGrowth;
|
||||
final double engagementRate;
|
||||
final DateTime lastUpdated;
|
||||
|
||||
const DashboardStatsModel({
|
||||
required this.totalMembers,
|
||||
required this.activeMembers,
|
||||
required this.totalEvents,
|
||||
required this.upcomingEvents,
|
||||
required this.totalContributions,
|
||||
required this.totalContributionAmount,
|
||||
required this.pendingRequests,
|
||||
required this.completedProjects,
|
||||
required this.monthlyGrowth,
|
||||
required this.engagementRate,
|
||||
required this.lastUpdated,
|
||||
});
|
||||
|
||||
factory DashboardStatsModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$DashboardStatsModelFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$DashboardStatsModelToJson(this);
|
||||
|
||||
// Getters calculés
|
||||
String get formattedContributionAmount {
|
||||
return '${totalContributionAmount.toStringAsFixed(2)} €';
|
||||
}
|
||||
|
||||
bool get hasGrowth => monthlyGrowth > 0;
|
||||
|
||||
bool get isHighEngagement => engagementRate > 0.7;
|
||||
|
||||
double get activeMemberPercentage {
|
||||
return totalMembers > 0 ? (activeMembers / totalMembers) : 0.0;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
totalMembers,
|
||||
activeMembers,
|
||||
totalEvents,
|
||||
upcomingEvents,
|
||||
totalContributions,
|
||||
totalContributionAmount,
|
||||
pendingRequests,
|
||||
completedProjects,
|
||||
monthlyGrowth,
|
||||
engagementRate,
|
||||
lastUpdated,
|
||||
];
|
||||
}
|
||||
|
||||
/// Modèle pour les activités récentes
|
||||
@JsonSerializable()
|
||||
class RecentActivityModel extends Equatable {
|
||||
final String id;
|
||||
final String type;
|
||||
final String title;
|
||||
final String description;
|
||||
final String? userAvatar;
|
||||
final String userName;
|
||||
final DateTime timestamp;
|
||||
final String? actionUrl;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
const RecentActivityModel({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.description,
|
||||
this.userAvatar,
|
||||
required this.userName,
|
||||
required this.timestamp,
|
||||
this.actionUrl,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
factory RecentActivityModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$RecentActivityModelFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$RecentActivityModelToJson(this);
|
||||
|
||||
// Getter calculé pour l'affichage du temps
|
||||
String get timeAgo {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(timestamp);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return 'il y a ${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
|
||||
} else if (difference.inHours > 0) {
|
||||
return 'il y a ${difference.inHours} heure${difference.inHours > 1 ? 's' : ''}';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return 'il y a ${difference.inMinutes} minute${difference.inMinutes > 1 ? 's' : ''}';
|
||||
} else {
|
||||
return 'à l\'instant';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
userAvatar,
|
||||
userName,
|
||||
timestamp,
|
||||
actionUrl,
|
||||
metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/// Modèle pour les événements à venir
|
||||
@JsonSerializable()
|
||||
class UpcomingEventModel extends Equatable {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final DateTime startDate;
|
||||
final DateTime? endDate;
|
||||
final String location;
|
||||
final int maxParticipants;
|
||||
final int currentParticipants;
|
||||
final String status;
|
||||
final String? imageUrl;
|
||||
final List<String> tags;
|
||||
|
||||
const UpcomingEventModel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.startDate,
|
||||
this.endDate,
|
||||
required this.location,
|
||||
required this.maxParticipants,
|
||||
required this.currentParticipants,
|
||||
required this.status,
|
||||
this.imageUrl,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
factory UpcomingEventModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$UpcomingEventModelFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$UpcomingEventModelToJson(this);
|
||||
|
||||
bool get isAlmostFull => currentParticipants >= (maxParticipants * 0.8);
|
||||
bool get isFull => currentParticipants >= maxParticipants;
|
||||
double get fillPercentage => maxParticipants > 0 ? currentParticipants / maxParticipants : 0.0;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
startDate,
|
||||
endDate,
|
||||
location,
|
||||
maxParticipants,
|
||||
currentParticipants,
|
||||
status,
|
||||
imageUrl,
|
||||
tags,
|
||||
];
|
||||
}
|
||||
|
||||
/// Modèle pour les données du dashboard complet
|
||||
@JsonSerializable()
|
||||
class DashboardDataModel extends Equatable {
|
||||
final DashboardStatsModel stats;
|
||||
final List<RecentActivityModel> recentActivities;
|
||||
final List<UpcomingEventModel> upcomingEvents;
|
||||
final Map<String, dynamic> userPreferences;
|
||||
final String organizationId;
|
||||
final String userId;
|
||||
|
||||
const DashboardDataModel({
|
||||
required this.stats,
|
||||
required this.recentActivities,
|
||||
required this.upcomingEvents,
|
||||
required this.userPreferences,
|
||||
required this.organizationId,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
factory DashboardDataModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$DashboardDataModelFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$DashboardDataModelToJson(this);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
stats,
|
||||
recentActivities,
|
||||
upcomingEvents,
|
||||
userPreferences,
|
||||
organizationId,
|
||||
userId,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'dashboard_stats_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
DashboardStatsModel _$DashboardStatsModelFromJson(Map<String, dynamic> json) =>
|
||||
DashboardStatsModel(
|
||||
totalMembers: (json['totalMembers'] as num).toInt(),
|
||||
activeMembers: (json['activeMembers'] as num).toInt(),
|
||||
totalEvents: (json['totalEvents'] as num).toInt(),
|
||||
upcomingEvents: (json['upcomingEvents'] as num).toInt(),
|
||||
totalContributions: (json['totalContributions'] as num).toInt(),
|
||||
totalContributionAmount:
|
||||
(json['totalContributionAmount'] as num).toDouble(),
|
||||
pendingRequests: (json['pendingRequests'] as num).toInt(),
|
||||
completedProjects: (json['completedProjects'] as num).toInt(),
|
||||
monthlyGrowth: (json['monthlyGrowth'] as num).toDouble(),
|
||||
engagementRate: (json['engagementRate'] as num).toDouble(),
|
||||
lastUpdated: DateTime.parse(json['lastUpdated'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$DashboardStatsModelToJson(
|
||||
DashboardStatsModel instance) =>
|
||||
<String, dynamic>{
|
||||
'totalMembers': instance.totalMembers,
|
||||
'activeMembers': instance.activeMembers,
|
||||
'totalEvents': instance.totalEvents,
|
||||
'upcomingEvents': instance.upcomingEvents,
|
||||
'totalContributions': instance.totalContributions,
|
||||
'totalContributionAmount': instance.totalContributionAmount,
|
||||
'pendingRequests': instance.pendingRequests,
|
||||
'completedProjects': instance.completedProjects,
|
||||
'monthlyGrowth': instance.monthlyGrowth,
|
||||
'engagementRate': instance.engagementRate,
|
||||
'lastUpdated': instance.lastUpdated.toIso8601String(),
|
||||
};
|
||||
|
||||
RecentActivityModel _$RecentActivityModelFromJson(Map<String, dynamic> json) =>
|
||||
RecentActivityModel(
|
||||
id: json['id'] as String,
|
||||
type: json['type'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String,
|
||||
userAvatar: json['userAvatar'] as String?,
|
||||
userName: json['userName'] as String,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
actionUrl: json['actionUrl'] as String?,
|
||||
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$RecentActivityModelToJson(
|
||||
RecentActivityModel instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'type': instance.type,
|
||||
'title': instance.title,
|
||||
'description': instance.description,
|
||||
'userAvatar': instance.userAvatar,
|
||||
'userName': instance.userName,
|
||||
'timestamp': instance.timestamp.toIso8601String(),
|
||||
'actionUrl': instance.actionUrl,
|
||||
'metadata': instance.metadata,
|
||||
};
|
||||
|
||||
UpcomingEventModel _$UpcomingEventModelFromJson(Map<String, dynamic> json) =>
|
||||
UpcomingEventModel(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String,
|
||||
startDate: DateTime.parse(json['startDate'] as String),
|
||||
endDate: json['endDate'] == null
|
||||
? null
|
||||
: DateTime.parse(json['endDate'] as String),
|
||||
location: json['location'] as String,
|
||||
maxParticipants: (json['maxParticipants'] as num).toInt(),
|
||||
currentParticipants: (json['currentParticipants'] as num).toInt(),
|
||||
status: json['status'] as String,
|
||||
imageUrl: json['imageUrl'] as String?,
|
||||
tags: (json['tags'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UpcomingEventModelToJson(UpcomingEventModel instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'description': instance.description,
|
||||
'startDate': instance.startDate.toIso8601String(),
|
||||
'endDate': instance.endDate?.toIso8601String(),
|
||||
'location': instance.location,
|
||||
'maxParticipants': instance.maxParticipants,
|
||||
'currentParticipants': instance.currentParticipants,
|
||||
'status': instance.status,
|
||||
'imageUrl': instance.imageUrl,
|
||||
'tags': instance.tags,
|
||||
};
|
||||
|
||||
DashboardDataModel _$DashboardDataModelFromJson(Map<String, dynamic> json) =>
|
||||
DashboardDataModel(
|
||||
stats:
|
||||
DashboardStatsModel.fromJson(json['stats'] as Map<String, dynamic>),
|
||||
recentActivities: (json['recentActivities'] as List<dynamic>)
|
||||
.map((e) => RecentActivityModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
upcomingEvents: (json['upcomingEvents'] as List<dynamic>)
|
||||
.map((e) => UpcomingEventModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
userPreferences: json['userPreferences'] as Map<String, dynamic>,
|
||||
organizationId: json['organizationId'] as String,
|
||||
userId: json['userId'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$DashboardDataModelToJson(DashboardDataModel instance) =>
|
||||
<String, dynamic>{
|
||||
'stats': instance.stats,
|
||||
'recentActivities': instance.recentActivities,
|
||||
'upcomingEvents': instance.upcomingEvents,
|
||||
'userPreferences': instance.userPreferences,
|
||||
'organizationId': instance.organizationId,
|
||||
'userId': instance.userId,
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import '../../domain/entities/dashboard_entity.dart';
|
||||
import '../../domain/repositories/dashboard_repository.dart';
|
||||
import '../datasources/dashboard_remote_datasource.dart';
|
||||
import '../models/dashboard_stats_model.dart';
|
||||
import '../../../../core/error/exceptions.dart';
|
||||
import '../../../../core/error/failures.dart';
|
||||
import '../../../../core/network/network_info.dart';
|
||||
|
||||
class DashboardRepositoryImpl implements DashboardRepository {
|
||||
final DashboardRemoteDataSource remoteDataSource;
|
||||
final NetworkInfo networkInfo;
|
||||
|
||||
DashboardRepositoryImpl({
|
||||
required this.remoteDataSource,
|
||||
required this.networkInfo,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<Either<Failure, DashboardEntity>> getDashboardData(
|
||||
String organizationId,
|
||||
String userId,
|
||||
) async {
|
||||
if (await networkInfo.isConnected) {
|
||||
try {
|
||||
final dashboardData = await remoteDataSource.getDashboardData(organizationId, userId);
|
||||
return Right(_mapToEntity(dashboardData));
|
||||
} on ServerException catch (e) {
|
||||
return Left(ServerFailure(e.message));
|
||||
} catch (e) {
|
||||
return Left(ServerFailure('Unexpected error: $e'));
|
||||
}
|
||||
} else {
|
||||
return const Left(NetworkFailure('No internet connection'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, DashboardStatsEntity>> getDashboardStats(
|
||||
String organizationId,
|
||||
String userId,
|
||||
) async {
|
||||
if (await networkInfo.isConnected) {
|
||||
try {
|
||||
final stats = await remoteDataSource.getDashboardStats(organizationId, userId);
|
||||
return Right(_mapStatsToEntity(stats));
|
||||
} on ServerException catch (e) {
|
||||
return Left(ServerFailure(e.message));
|
||||
} catch (e) {
|
||||
return Left(ServerFailure('Unexpected error: $e'));
|
||||
}
|
||||
} else {
|
||||
return const Left(NetworkFailure('No internet connection'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<RecentActivityEntity>>> getRecentActivities(
|
||||
String organizationId,
|
||||
String userId, {
|
||||
int limit = 10,
|
||||
}) async {
|
||||
if (await networkInfo.isConnected) {
|
||||
try {
|
||||
final activities = await remoteDataSource.getRecentActivities(
|
||||
organizationId,
|
||||
userId,
|
||||
limit: limit,
|
||||
);
|
||||
return Right(activities.map(_mapActivityToEntity).toList());
|
||||
} on ServerException catch (e) {
|
||||
return Left(ServerFailure(e.message));
|
||||
} catch (e) {
|
||||
return Left(ServerFailure('Unexpected error: $e'));
|
||||
}
|
||||
} else {
|
||||
return const Left(NetworkFailure('No internet connection'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<UpcomingEventEntity>>> getUpcomingEvents(
|
||||
String organizationId,
|
||||
String userId, {
|
||||
int limit = 5,
|
||||
}) async {
|
||||
if (await networkInfo.isConnected) {
|
||||
try {
|
||||
final events = await remoteDataSource.getUpcomingEvents(
|
||||
organizationId,
|
||||
userId,
|
||||
limit: limit,
|
||||
);
|
||||
return Right(events.map(_mapEventToEntity).toList());
|
||||
} on ServerException catch (e) {
|
||||
return Left(ServerFailure(e.message));
|
||||
} catch (e) {
|
||||
return Left(ServerFailure('Unexpected error: $e'));
|
||||
}
|
||||
} else {
|
||||
return const Left(NetworkFailure('No internet connection'));
|
||||
}
|
||||
}
|
||||
|
||||
// Mappers
|
||||
DashboardEntity _mapToEntity(DashboardDataModel model) {
|
||||
return DashboardEntity(
|
||||
stats: _mapStatsToEntity(model.stats),
|
||||
recentActivities: model.recentActivities.map(_mapActivityToEntity).toList(),
|
||||
upcomingEvents: model.upcomingEvents.map(_mapEventToEntity).toList(),
|
||||
userPreferences: model.userPreferences,
|
||||
organizationId: model.organizationId,
|
||||
userId: model.userId,
|
||||
);
|
||||
}
|
||||
|
||||
DashboardStatsEntity _mapStatsToEntity(DashboardStatsModel model) {
|
||||
return DashboardStatsEntity(
|
||||
totalMembers: model.totalMembers,
|
||||
activeMembers: model.activeMembers,
|
||||
totalEvents: model.totalEvents,
|
||||
upcomingEvents: model.upcomingEvents,
|
||||
totalContributions: model.totalContributions,
|
||||
totalContributionAmount: model.totalContributionAmount,
|
||||
pendingRequests: model.pendingRequests,
|
||||
completedProjects: model.completedProjects,
|
||||
monthlyGrowth: model.monthlyGrowth,
|
||||
engagementRate: model.engagementRate,
|
||||
lastUpdated: model.lastUpdated,
|
||||
);
|
||||
}
|
||||
|
||||
RecentActivityEntity _mapActivityToEntity(RecentActivityModel model) {
|
||||
return RecentActivityEntity(
|
||||
id: model.id,
|
||||
type: model.type,
|
||||
title: model.title,
|
||||
description: model.description,
|
||||
userAvatar: model.userAvatar,
|
||||
userName: model.userName,
|
||||
timestamp: model.timestamp,
|
||||
actionUrl: model.actionUrl,
|
||||
metadata: model.metadata,
|
||||
);
|
||||
}
|
||||
|
||||
UpcomingEventEntity _mapEventToEntity(UpcomingEventModel model) {
|
||||
return UpcomingEventEntity(
|
||||
id: model.id,
|
||||
title: model.title,
|
||||
description: model.description,
|
||||
startDate: model.startDate,
|
||||
endDate: model.endDate,
|
||||
location: model.location,
|
||||
maxParticipants: model.maxParticipants,
|
||||
currentParticipants: model.currentParticipants,
|
||||
status: model.status,
|
||||
imageUrl: model.imageUrl,
|
||||
tags: model.tags,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
import 'dart:io';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../models/dashboard_stats_model.dart';
|
||||
|
||||
/// Service d'export de rapports PDF pour le Dashboard
|
||||
class DashboardExportService {
|
||||
static const String _reportsFolder = 'UnionFlow_Reports';
|
||||
|
||||
/// Exporte un rapport complet du dashboard en PDF
|
||||
Future<String> exportDashboardReport({
|
||||
required DashboardDataModel dashboardData,
|
||||
required String organizationName,
|
||||
required String reportTitle,
|
||||
bool includeCharts = true,
|
||||
bool includeActivities = true,
|
||||
bool includeEvents = true,
|
||||
}) async {
|
||||
final pdf = pw.Document();
|
||||
|
||||
// Charger les polices personnalisées si disponibles
|
||||
final font = await _loadFont();
|
||||
|
||||
// Page 1: Couverture et statistiques principales
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
theme: _createTheme(font),
|
||||
build: (context) => [
|
||||
_buildHeader(organizationName, reportTitle),
|
||||
pw.SizedBox(height: 20),
|
||||
_buildStatsSection(dashboardData.stats),
|
||||
pw.SizedBox(height: 20),
|
||||
_buildSummarySection(dashboardData),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Page 2: Activités récentes (si incluses)
|
||||
if (includeActivities && dashboardData.recentActivities.isNotEmpty) {
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
theme: _createTheme(font),
|
||||
build: (context) => [
|
||||
_buildSectionTitle('Activités Récentes'),
|
||||
pw.SizedBox(height: 10),
|
||||
_buildActivitiesSection(dashboardData.recentActivities),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Page 3: Événements à venir (si inclus)
|
||||
if (includeEvents && dashboardData.upcomingEvents.isNotEmpty) {
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
theme: _createTheme(font),
|
||||
build: (context) => [
|
||||
_buildSectionTitle('Événements à Venir'),
|
||||
pw.SizedBox(height: 10),
|
||||
_buildEventsSection(dashboardData.upcomingEvents),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Page 4: Graphiques et analyses (si inclus)
|
||||
if (includeCharts) {
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
theme: _createTheme(font),
|
||||
build: (context) => [
|
||||
_buildSectionTitle('Analyses et Tendances'),
|
||||
pw.SizedBox(height: 10),
|
||||
_buildAnalyticsSection(dashboardData.stats),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Sauvegarder le PDF
|
||||
final fileName = _generateFileName(reportTitle);
|
||||
final filePath = await _savePdf(pdf, fileName);
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/// Exporte uniquement les statistiques en PDF
|
||||
Future<String> exportStatsReport({
|
||||
required DashboardStatsModel stats,
|
||||
required String organizationName,
|
||||
String? customTitle,
|
||||
}) async {
|
||||
final pdf = pw.Document();
|
||||
final font = await _loadFont();
|
||||
final title = customTitle ?? 'Rapport Statistiques - ${DateTime.now().day}/${DateTime.now().month}/${DateTime.now().year}';
|
||||
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
theme: _createTheme(font),
|
||||
build: (context) => [
|
||||
_buildHeader(organizationName, title),
|
||||
pw.SizedBox(height: 30),
|
||||
_buildStatsSection(stats),
|
||||
pw.SizedBox(height: 30),
|
||||
_buildStatsAnalysis(stats),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final fileName = _generateFileName('Stats_${DateTime.now().millisecondsSinceEpoch}');
|
||||
final filePath = await _savePdf(pdf, fileName);
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/// Charge une police personnalisée
|
||||
Future<pw.Font?> _loadFont() async {
|
||||
try {
|
||||
final fontData = await rootBundle.load('assets/fonts/Inter-Regular.ttf');
|
||||
return pw.Font.ttf(fontData);
|
||||
} catch (e) {
|
||||
// Police par défaut si la police personnalisée n'est pas disponible
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée le thème PDF
|
||||
pw.ThemeData _createTheme(pw.Font? font) {
|
||||
return pw.ThemeData.withFont(
|
||||
base: font ?? pw.Font.helvetica(),
|
||||
bold: font ?? pw.Font.helveticaBold(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit l'en-tête du rapport
|
||||
pw.Widget _buildHeader(String organizationName, String reportTitle) {
|
||||
return pw.Container(
|
||||
width: double.infinity,
|
||||
padding: const pw.EdgeInsets.all(20),
|
||||
decoration: pw.BoxDecoration(
|
||||
gradient: pw.LinearGradient(
|
||||
colors: [
|
||||
PdfColor.fromHex('#4169E1'), // Bleu Roi
|
||||
PdfColor.fromHex('#008B8B'), // Bleu Pétrole
|
||||
],
|
||||
),
|
||||
borderRadius: pw.BorderRadius.circular(10),
|
||||
),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
organizationName,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColors.white,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 5),
|
||||
pw.Text(
|
||||
reportTitle,
|
||||
style: const pw.TextStyle(
|
||||
fontSize: 16,
|
||||
color: PdfColors.white,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 10),
|
||||
pw.Text(
|
||||
'Généré le ${DateTime.now().day}/${DateTime.now().month}/${DateTime.now().year} à ${DateTime.now().hour}:${DateTime.now().minute.toString().padLeft(2, '0')}',
|
||||
style: const pw.TextStyle(
|
||||
fontSize: 12,
|
||||
color: PdfColors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit la section des statistiques
|
||||
pw.Widget _buildStatsSection(DashboardStatsModel stats) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle('Statistiques Principales'),
|
||||
pw.SizedBox(height: 15),
|
||||
pw.Row(
|
||||
children: [
|
||||
pw.Expanded(
|
||||
child: _buildStatCard('Membres Total', stats.totalMembers.toString(), PdfColor.fromHex('#4169E1')),
|
||||
),
|
||||
pw.SizedBox(width: 10),
|
||||
pw.Expanded(
|
||||
child: _buildStatCard('Membres Actifs', stats.activeMembers.toString(), PdfColor.fromHex('#10B981')),
|
||||
),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 10),
|
||||
pw.Row(
|
||||
children: [
|
||||
pw.Expanded(
|
||||
child: _buildStatCard('Événements', stats.totalEvents.toString(), PdfColor.fromHex('#008B8B')),
|
||||
),
|
||||
pw.SizedBox(width: 10),
|
||||
pw.Expanded(
|
||||
child: _buildStatCard('Contributions', stats.formattedContributionAmount, PdfColor.fromHex('#F59E0B')),
|
||||
),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 10),
|
||||
pw.Row(
|
||||
children: [
|
||||
pw.Expanded(
|
||||
child: _buildStatCard('Croissance', '${stats.monthlyGrowth.toStringAsFixed(1)}%',
|
||||
stats.hasGrowth ? PdfColor.fromHex('#10B981') : PdfColor.fromHex('#EF4444')),
|
||||
),
|
||||
pw.SizedBox(width: 10),
|
||||
pw.Expanded(
|
||||
child: _buildStatCard('Engagement', '${(stats.engagementRate * 100).toStringAsFixed(1)}%',
|
||||
stats.isHighEngagement ? PdfColor.fromHex('#10B981') : PdfColor.fromHex('#F59E0B')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit une carte de statistique
|
||||
pw.Widget _buildStatCard(String title, String value, PdfColor color) {
|
||||
return pw.Container(
|
||||
padding: const pw.EdgeInsets.all(15),
|
||||
decoration: pw.BoxDecoration(
|
||||
border: pw.Border.all(color: color, width: 2),
|
||||
borderRadius: pw.BorderRadius.circular(8),
|
||||
),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
title,
|
||||
style: const pw.TextStyle(
|
||||
fontSize: 12,
|
||||
color: PdfColors.grey700,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 5),
|
||||
pw.Text(
|
||||
value,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un titre de section
|
||||
pw.Widget _buildSectionTitle(String title) {
|
||||
return pw.Text(
|
||||
title,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColor.fromHex('#1F2937'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit la section de résumé
|
||||
pw.Widget _buildSummarySection(DashboardDataModel data) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle('Résumé Exécutif'),
|
||||
pw.SizedBox(height: 10),
|
||||
pw.Text(
|
||||
'Ce rapport présente un aperçu complet de l\'activité de l\'organisation. '
|
||||
'Avec ${data.stats.totalMembers} membres dont ${data.stats.activeMembers} actifs '
|
||||
'(${data.stats.activeMemberPercentage.toStringAsFixed(1)}%), l\'organisation maintient '
|
||||
'un niveau d\'engagement de ${(data.stats.engagementRate * 100).toStringAsFixed(1)}%.',
|
||||
style: const pw.TextStyle(fontSize: 12, lineSpacing: 1.5),
|
||||
),
|
||||
pw.SizedBox(height: 10),
|
||||
pw.Text(
|
||||
'La croissance mensuelle de ${data.stats.monthlyGrowth.toStringAsFixed(1)}% '
|
||||
'${data.stats.hasGrowth ? 'indique une tendance positive' : 'nécessite une attention particulière'}. '
|
||||
'Les contributions totales s\'élèvent à ${data.stats.formattedContributionAmount} XOF.',
|
||||
style: const pw.TextStyle(fontSize: 12, lineSpacing: 1.5),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit la section des activités
|
||||
pw.Widget _buildActivitiesSection(List<RecentActivityModel> activities) {
|
||||
return pw.Table(
|
||||
border: pw.TableBorder.all(color: PdfColors.grey300),
|
||||
children: [
|
||||
// En-tête
|
||||
pw.TableRow(
|
||||
decoration: pw.BoxDecoration(color: PdfColor.fromHex('#F3F4F6')),
|
||||
children: [
|
||||
_buildTableHeader('Type'),
|
||||
_buildTableHeader('Description'),
|
||||
_buildTableHeader('Utilisateur'),
|
||||
_buildTableHeader('Date'),
|
||||
],
|
||||
),
|
||||
// Données
|
||||
...activities.take(10).map((activity) => pw.TableRow(
|
||||
children: [
|
||||
_buildTableCell(activity.type),
|
||||
_buildTableCell(activity.title),
|
||||
_buildTableCell(activity.userName),
|
||||
_buildTableCell(activity.timeAgo),
|
||||
],
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit la section des événements
|
||||
pw.Widget _buildEventsSection(List<UpcomingEventModel> events) {
|
||||
return pw.Table(
|
||||
border: pw.TableBorder.all(color: PdfColors.grey300),
|
||||
children: [
|
||||
// En-tête
|
||||
pw.TableRow(
|
||||
decoration: pw.BoxDecoration(color: PdfColor.fromHex('#F3F4F6')),
|
||||
children: [
|
||||
_buildTableHeader('Événement'),
|
||||
_buildTableHeader('Date'),
|
||||
_buildTableHeader('Lieu'),
|
||||
_buildTableHeader('Participants'),
|
||||
],
|
||||
),
|
||||
// Données
|
||||
...events.take(10).map((event) => pw.TableRow(
|
||||
children: [
|
||||
_buildTableCell(event.title),
|
||||
_buildTableCell('${event.startDate.day}/${event.startDate.month}'),
|
||||
_buildTableCell(event.location),
|
||||
_buildTableCell('${event.currentParticipants}/${event.maxParticipants}'),
|
||||
],
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit l'en-tête de tableau
|
||||
pw.Widget _buildTableHeader(String text) {
|
||||
return pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Text(
|
||||
text,
|
||||
style: pw.TextStyle(
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit une cellule de tableau
|
||||
pw.Widget _buildTableCell(String text) {
|
||||
return pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Text(
|
||||
text,
|
||||
style: const pw.TextStyle(fontSize: 9),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit la section d'analyse des statistiques
|
||||
pw.Widget _buildStatsAnalysis(DashboardStatsModel stats) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle('Analyse des Performances'),
|
||||
pw.SizedBox(height: 10),
|
||||
_buildAnalysisPoint('Taux d\'activité des membres',
|
||||
'${stats.activeMemberPercentage.toStringAsFixed(1)}%',
|
||||
stats.activeMemberPercentage > 70 ? 'Excellent' : 'À améliorer'),
|
||||
_buildAnalysisPoint('Croissance mensuelle',
|
||||
'${stats.monthlyGrowth.toStringAsFixed(1)}%',
|
||||
stats.hasGrowth ? 'Positive' : 'Négative'),
|
||||
_buildAnalysisPoint('Niveau d\'engagement',
|
||||
'${(stats.engagementRate * 100).toStringAsFixed(1)}%',
|
||||
stats.isHighEngagement ? 'Élevé' : 'Modéré'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un point d'analyse
|
||||
pw.Widget _buildAnalysisPoint(String metric, String value, String assessment) {
|
||||
return pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 5),
|
||||
child: pw.Row(
|
||||
children: [
|
||||
pw.Expanded(flex: 2, child: pw.Text(metric, style: const pw.TextStyle(fontSize: 11))),
|
||||
pw.Expanded(flex: 1, child: pw.Text(value, style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold))),
|
||||
pw.Expanded(flex: 1, child: pw.Text(assessment, style: const pw.TextStyle(fontSize: 11))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit la section d'analytics
|
||||
pw.Widget _buildAnalyticsSection(DashboardStatsModel stats) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text('Tendances et Projections', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 15),
|
||||
pw.Text('Basé sur les données actuelles, voici les principales tendances observées:', style: const pw.TextStyle(fontSize: 11)),
|
||||
pw.SizedBox(height: 10),
|
||||
pw.Bullet(text: 'Évolution du nombre de membres: ${stats.hasGrowth ? 'Croissance' : 'Déclin'} de ${stats.monthlyGrowth.abs().toStringAsFixed(1)}% ce mois'),
|
||||
pw.Bullet(text: 'Participation aux événements: ${stats.upcomingEvents} événements programmés'),
|
||||
pw.Bullet(text: 'Volume des contributions: ${stats.formattedContributionAmount} XOF collectés'),
|
||||
pw.Bullet(text: 'Demandes en attente: ${stats.pendingRequests} nécessitent un traitement'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Génère un nom de fichier unique
|
||||
String _generateFileName(String baseName) {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final cleanName = baseName.replaceAll(RegExp(r'[^\w\s-]'), '').replaceAll(' ', '_');
|
||||
return '${cleanName}_$timestamp.pdf';
|
||||
}
|
||||
|
||||
/// Sauvegarde le PDF et retourne le chemin
|
||||
Future<String> _savePdf(pw.Document pdf, String fileName) async {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final reportsDir = Directory('${directory.path}/$_reportsFolder');
|
||||
|
||||
if (!await reportsDir.exists()) {
|
||||
await reportsDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final file = File('${reportsDir.path}/$fileName');
|
||||
await file.writeAsBytes(await pdf.save());
|
||||
|
||||
return file.path;
|
||||
}
|
||||
|
||||
/// Partage un rapport PDF
|
||||
Future<void> shareReport(String filePath, {String? subject}) async {
|
||||
await Share.shareXFiles(
|
||||
[XFile(filePath)],
|
||||
subject: subject ?? 'Rapport Dashboard UnionFlow',
|
||||
text: 'Rapport généré par l\'application UnionFlow',
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtient la liste des rapports sauvegardés
|
||||
Future<List<File>> getSavedReports() async {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final reportsDir = Directory('${directory.path}/$_reportsFolder');
|
||||
|
||||
if (!await reportsDir.exists()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final files = await reportsDir.list().where((entity) =>
|
||||
entity is File && entity.path.endsWith('.pdf')).cast<File>().toList();
|
||||
|
||||
// Trier par date de modification (plus récent en premier)
|
||||
files.sort((a, b) => b.lastModifiedSync().compareTo(a.lastModifiedSync()));
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/// Supprime un rapport
|
||||
Future<void> deleteReport(String filePath) async {
|
||||
final file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime tous les rapports anciens (plus de 30 jours)
|
||||
Future<void> cleanupOldReports() async {
|
||||
final reports = await getSavedReports();
|
||||
final cutoffDate = DateTime.now().subtract(const Duration(days: 30));
|
||||
|
||||
for (final report in reports) {
|
||||
final lastModified = await report.lastModified();
|
||||
if (lastModified.isBefore(cutoffDate)) {
|
||||
await report.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import '../models/dashboard_stats_model.dart';
|
||||
import '../../config/dashboard_config.dart';
|
||||
|
||||
/// Service de notifications temps réel pour le Dashboard
|
||||
class DashboardNotificationService {
|
||||
static const String _wsEndpoint = 'ws://localhost:8080/ws/dashboard';
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription? _subscription;
|
||||
Timer? _reconnectTimer;
|
||||
Timer? _heartbeatTimer;
|
||||
|
||||
bool _isConnected = false;
|
||||
bool _shouldReconnect = true;
|
||||
int _reconnectAttempts = 0;
|
||||
static const int _maxReconnectAttempts = 5;
|
||||
static const Duration _reconnectDelay = Duration(seconds: 5);
|
||||
static const Duration _heartbeatInterval = Duration(seconds: 30);
|
||||
|
||||
// Streams pour les différents types de notifications
|
||||
final StreamController<DashboardStatsModel> _statsController =
|
||||
StreamController<DashboardStatsModel>.broadcast();
|
||||
final StreamController<RecentActivityModel> _activityController =
|
||||
StreamController<RecentActivityModel>.broadcast();
|
||||
final StreamController<UpcomingEventModel> _eventController =
|
||||
StreamController<UpcomingEventModel>.broadcast();
|
||||
final StreamController<DashboardNotification> _notificationController =
|
||||
StreamController<DashboardNotification>.broadcast();
|
||||
final StreamController<ConnectionStatus> _connectionController =
|
||||
StreamController<ConnectionStatus>.broadcast();
|
||||
|
||||
// Getters pour les streams
|
||||
Stream<DashboardStatsModel> get statsStream => _statsController.stream;
|
||||
Stream<RecentActivityModel> get activityStream => _activityController.stream;
|
||||
Stream<UpcomingEventModel> get eventStream => _eventController.stream;
|
||||
Stream<DashboardNotification> get notificationStream => _notificationController.stream;
|
||||
Stream<ConnectionStatus> get connectionStream => _connectionController.stream;
|
||||
|
||||
/// Initialise le service de notifications
|
||||
Future<void> initialize(String organizationId, String userId) async {
|
||||
if (!DashboardConfig.enableNotifications) {
|
||||
debugPrint('📱 Notifications désactivées dans la configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('📱 Initialisation du service de notifications...');
|
||||
await _connect(organizationId, userId);
|
||||
}
|
||||
|
||||
/// Établit la connexion WebSocket
|
||||
Future<void> _connect(String organizationId, String userId) async {
|
||||
if (_isConnected) return;
|
||||
|
||||
try {
|
||||
final uri = Uri.parse('$_wsEndpoint?orgId=$organizationId&userId=$userId');
|
||||
_channel = WebSocketChannel.connect(uri);
|
||||
|
||||
debugPrint('📱 Connexion WebSocket en cours...');
|
||||
_connectionController.add(ConnectionStatus.connecting);
|
||||
|
||||
// Écouter les messages
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleMessage,
|
||||
onError: _handleError,
|
||||
onDone: _handleDisconnection,
|
||||
);
|
||||
|
||||
_isConnected = true;
|
||||
_reconnectAttempts = 0;
|
||||
_connectionController.add(ConnectionStatus.connected);
|
||||
|
||||
// Démarrer le heartbeat
|
||||
_startHeartbeat();
|
||||
|
||||
debugPrint('✅ Connexion WebSocket établie');
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur de connexion WebSocket: $e');
|
||||
_connectionController.add(ConnectionStatus.error);
|
||||
_scheduleReconnect(organizationId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère les messages reçus
|
||||
void _handleMessage(dynamic message) {
|
||||
try {
|
||||
final data = jsonDecode(message as String);
|
||||
final type = data['type'] as String?;
|
||||
final payload = data['payload'];
|
||||
|
||||
debugPrint('📨 Message reçu: $type');
|
||||
|
||||
switch (type) {
|
||||
case 'stats_update':
|
||||
final stats = DashboardStatsModel.fromJson(payload);
|
||||
_statsController.add(stats);
|
||||
break;
|
||||
|
||||
case 'new_activity':
|
||||
final activity = RecentActivityModel.fromJson(payload);
|
||||
_activityController.add(activity);
|
||||
break;
|
||||
|
||||
case 'event_update':
|
||||
final event = UpcomingEventModel.fromJson(payload);
|
||||
_eventController.add(event);
|
||||
break;
|
||||
|
||||
case 'notification':
|
||||
final notification = DashboardNotification.fromJson(payload);
|
||||
_notificationController.add(notification);
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// Réponse au heartbeat
|
||||
debugPrint('💓 Heartbeat reçu');
|
||||
break;
|
||||
|
||||
default:
|
||||
debugPrint('⚠️ Type de message inconnu: $type');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur de parsing du message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère les erreurs de connexion
|
||||
void _handleError(error) {
|
||||
debugPrint('❌ Erreur WebSocket: $error');
|
||||
_isConnected = false;
|
||||
_connectionController.add(ConnectionStatus.error);
|
||||
}
|
||||
|
||||
/// Gère la déconnexion
|
||||
void _handleDisconnection() {
|
||||
debugPrint('🔌 Connexion WebSocket fermée');
|
||||
_isConnected = false;
|
||||
_connectionController.add(ConnectionStatus.disconnected);
|
||||
|
||||
if (_shouldReconnect) {
|
||||
// Programmer une reconnexion
|
||||
_scheduleReconnect('', ''); // Les IDs seront récupérés du contexte
|
||||
}
|
||||
}
|
||||
|
||||
/// Programme une tentative de reconnexion
|
||||
void _scheduleReconnect(String organizationId, String userId) {
|
||||
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
||||
debugPrint('❌ Nombre maximum de tentatives de reconnexion atteint');
|
||||
_connectionController.add(ConnectionStatus.failed);
|
||||
return;
|
||||
}
|
||||
|
||||
_reconnectAttempts++;
|
||||
final delay = _reconnectDelay * _reconnectAttempts;
|
||||
|
||||
debugPrint('🔄 Reconnexion programmée dans ${delay.inSeconds}s (tentative $_reconnectAttempts)');
|
||||
|
||||
_reconnectTimer = Timer(delay, () {
|
||||
if (_shouldReconnect) {
|
||||
_connect(organizationId, userId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Démarre le heartbeat
|
||||
void _startHeartbeat() {
|
||||
_heartbeatTimer = Timer.periodic(_heartbeatInterval, (timer) {
|
||||
if (_isConnected && _channel != null) {
|
||||
try {
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'ping',
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
}));
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de l\'envoi du heartbeat: $e');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Envoie une demande de rafraîchissement
|
||||
void requestRefresh(String organizationId, String userId) {
|
||||
if (_isConnected && _channel != null) {
|
||||
try {
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'refresh_request',
|
||||
'payload': {
|
||||
'organizationId': organizationId,
|
||||
'userId': userId,
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
},
|
||||
}));
|
||||
debugPrint('📤 Demande de rafraîchissement envoyée');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de l\'envoi de la demande: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// S'abonne aux notifications pour un type spécifique
|
||||
void subscribeToNotifications(List<String> notificationTypes) {
|
||||
if (_isConnected && _channel != null) {
|
||||
try {
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'subscribe',
|
||||
'payload': {
|
||||
'notificationTypes': notificationTypes,
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
},
|
||||
}));
|
||||
debugPrint('📋 Abonnement aux notifications: $notificationTypes');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de l\'abonnement: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Se désabonne des notifications
|
||||
void unsubscribeFromNotifications(List<String> notificationTypes) {
|
||||
if (_isConnected && _channel != null) {
|
||||
try {
|
||||
_channel!.sink.add(jsonEncode({
|
||||
'type': 'unsubscribe',
|
||||
'payload': {
|
||||
'notificationTypes': notificationTypes,
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
},
|
||||
}));
|
||||
debugPrint('📋 Désabonnement des notifications: $notificationTypes');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors du désabonnement: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient le statut de la connexion
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
/// Obtient le nombre de tentatives de reconnexion
|
||||
int get reconnectAttempts => _reconnectAttempts;
|
||||
|
||||
/// Force une reconnexion
|
||||
Future<void> reconnect(String organizationId, String userId) async {
|
||||
await disconnect();
|
||||
_reconnectAttempts = 0;
|
||||
await _connect(organizationId, userId);
|
||||
}
|
||||
|
||||
/// Déconnecte le service
|
||||
Future<void> disconnect() async {
|
||||
_shouldReconnect = false;
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_heartbeatTimer?.cancel();
|
||||
|
||||
if (_channel != null) {
|
||||
await _channel!.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
|
||||
_isConnected = false;
|
||||
_connectionController.add(ConnectionStatus.disconnected);
|
||||
|
||||
debugPrint('🔌 Service de notifications déconnecté');
|
||||
}
|
||||
|
||||
/// Libère les ressources
|
||||
void dispose() {
|
||||
disconnect();
|
||||
|
||||
_statsController.close();
|
||||
_activityController.close();
|
||||
_eventController.close();
|
||||
_notificationController.close();
|
||||
_connectionController.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Statut de la connexion
|
||||
enum ConnectionStatus {
|
||||
disconnected,
|
||||
connecting,
|
||||
connected,
|
||||
error,
|
||||
failed,
|
||||
}
|
||||
|
||||
/// Notification du dashboard
|
||||
class DashboardNotification {
|
||||
final String id;
|
||||
final String type;
|
||||
final String title;
|
||||
final String message;
|
||||
final NotificationPriority priority;
|
||||
final DateTime timestamp;
|
||||
final Map<String, dynamic>? data;
|
||||
final String? actionUrl;
|
||||
final bool isRead;
|
||||
|
||||
const DashboardNotification({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.priority,
|
||||
required this.timestamp,
|
||||
this.data,
|
||||
this.actionUrl,
|
||||
this.isRead = false,
|
||||
});
|
||||
|
||||
factory DashboardNotification.fromJson(Map<String, dynamic> json) {
|
||||
return DashboardNotification(
|
||||
id: json['id'] as String,
|
||||
type: json['type'] as String,
|
||||
title: json['title'] as String,
|
||||
message: json['message'] as String,
|
||||
priority: NotificationPriority.values.firstWhere(
|
||||
(p) => p.name == json['priority'],
|
||||
orElse: () => NotificationPriority.normal,
|
||||
),
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
data: json['data'] as Map<String, dynamic>?,
|
||||
actionUrl: json['actionUrl'] as String?,
|
||||
isRead: json['isRead'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'type': type,
|
||||
'title': title,
|
||||
'message': message,
|
||||
'priority': priority.name,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'data': data,
|
||||
'actionUrl': actionUrl,
|
||||
'isRead': isRead,
|
||||
};
|
||||
}
|
||||
|
||||
/// Obtient l'icône pour le type de notification
|
||||
String get icon {
|
||||
switch (type) {
|
||||
case 'new_member':
|
||||
return '👤';
|
||||
case 'new_event':
|
||||
return '📅';
|
||||
case 'contribution':
|
||||
return '💰';
|
||||
case 'urgent':
|
||||
return '🚨';
|
||||
case 'system':
|
||||
return '⚙️';
|
||||
default:
|
||||
return '📢';
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient la couleur pour la priorité
|
||||
String get priorityColor {
|
||||
switch (priority) {
|
||||
case NotificationPriority.low:
|
||||
return '#6B7280';
|
||||
case NotificationPriority.normal:
|
||||
return '#3B82F6';
|
||||
case NotificationPriority.high:
|
||||
return '#F59E0B';
|
||||
case NotificationPriority.urgent:
|
||||
return '#EF4444';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Priorité des notifications
|
||||
enum NotificationPriority {
|
||||
low,
|
||||
normal,
|
||||
high,
|
||||
urgent,
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import '../models/dashboard_stats_model.dart';
|
||||
import '../cache/dashboard_cache_manager.dart';
|
||||
|
||||
/// Service de mode hors ligne avec synchronisation pour le Dashboard
|
||||
class DashboardOfflineService {
|
||||
static const String _offlineQueueKey = 'dashboard_offline_queue';
|
||||
static const String _lastSyncKey = 'dashboard_last_sync';
|
||||
static const String _offlineModeKey = 'dashboard_offline_mode';
|
||||
|
||||
final DashboardCacheManager _cacheManager;
|
||||
final Connectivity _connectivity = Connectivity();
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
|
||||
Timer? _syncTimer;
|
||||
|
||||
final StreamController<OfflineStatus> _statusController =
|
||||
StreamController<OfflineStatus>.broadcast();
|
||||
final StreamController<SyncProgress> _syncController =
|
||||
StreamController<SyncProgress>.broadcast();
|
||||
|
||||
final List<OfflineAction> _pendingActions = [];
|
||||
bool _isOnline = true;
|
||||
bool _isSyncing = false;
|
||||
DateTime? _lastSyncTime;
|
||||
|
||||
// Streams publics
|
||||
Stream<OfflineStatus> get statusStream => _statusController.stream;
|
||||
Stream<SyncProgress> get syncStream => _syncController.stream;
|
||||
|
||||
DashboardOfflineService(this._cacheManager);
|
||||
|
||||
/// Initialise le service hors ligne
|
||||
Future<void> initialize() async {
|
||||
debugPrint('📱 Initialisation du service hors ligne...');
|
||||
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Charger les actions en attente
|
||||
await _loadPendingActions();
|
||||
|
||||
// Charger la dernière synchronisation
|
||||
_loadLastSyncTime();
|
||||
|
||||
// Vérifier la connectivité initiale
|
||||
final connectivityResult = await _connectivity.checkConnectivity();
|
||||
_updateConnectivityStatus(connectivityResult);
|
||||
|
||||
// Écouter les changements de connectivité
|
||||
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(
|
||||
(List<ConnectivityResult> results) => _updateConnectivityStatus(results),
|
||||
);
|
||||
|
||||
// Démarrer la synchronisation automatique
|
||||
_startAutoSync();
|
||||
|
||||
debugPrint('✅ Service hors ligne initialisé');
|
||||
}
|
||||
|
||||
/// Met à jour le statut de connectivité
|
||||
void _updateConnectivityStatus(dynamic result) {
|
||||
final wasOnline = _isOnline;
|
||||
if (result is List<ConnectivityResult>) {
|
||||
_isOnline = result.any((r) => r != ConnectivityResult.none);
|
||||
} else if (result is ConnectivityResult) {
|
||||
_isOnline = result != ConnectivityResult.none;
|
||||
} else {
|
||||
_isOnline = false;
|
||||
}
|
||||
|
||||
debugPrint('🌐 Connectivité: ${_isOnline ? 'En ligne' : 'Hors ligne'}');
|
||||
|
||||
_statusController.add(OfflineStatus(
|
||||
isOnline: _isOnline,
|
||||
pendingActionsCount: _pendingActions.length,
|
||||
lastSyncTime: _lastSyncTime,
|
||||
));
|
||||
|
||||
// Si on revient en ligne, synchroniser
|
||||
if (!wasOnline && _isOnline && _pendingActions.isNotEmpty) {
|
||||
_syncPendingActions();
|
||||
}
|
||||
}
|
||||
|
||||
/// Démarre la synchronisation automatique
|
||||
void _startAutoSync() {
|
||||
_syncTimer = Timer.periodic(
|
||||
const Duration(minutes: 5),
|
||||
(_) {
|
||||
if (_isOnline && _pendingActions.isNotEmpty) {
|
||||
_syncPendingActions();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Ajoute une action à la queue hors ligne
|
||||
Future<void> queueAction(OfflineAction action) async {
|
||||
_pendingActions.add(action);
|
||||
await _savePendingActions();
|
||||
|
||||
debugPrint('📝 Action mise en queue: ${action.type} (${_pendingActions.length} en attente)');
|
||||
|
||||
_statusController.add(OfflineStatus(
|
||||
isOnline: _isOnline,
|
||||
pendingActionsCount: _pendingActions.length,
|
||||
lastSyncTime: _lastSyncTime,
|
||||
));
|
||||
|
||||
// Si en ligne, essayer de synchroniser immédiatement
|
||||
if (_isOnline) {
|
||||
_syncPendingActions();
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronise les actions en attente
|
||||
Future<void> _syncPendingActions() async {
|
||||
if (_isSyncing || _pendingActions.isEmpty || !_isOnline) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isSyncing = true;
|
||||
debugPrint('🔄 Début de la synchronisation (${_pendingActions.length} actions)');
|
||||
|
||||
_syncController.add(SyncProgress(
|
||||
isActive: true,
|
||||
totalActions: _pendingActions.length,
|
||||
completedActions: 0,
|
||||
currentAction: _pendingActions.first.type.toString(),
|
||||
));
|
||||
|
||||
final actionsToSync = List<OfflineAction>.from(_pendingActions);
|
||||
int completedCount = 0;
|
||||
|
||||
for (final action in actionsToSync) {
|
||||
try {
|
||||
await _executeAction(action);
|
||||
_pendingActions.remove(action);
|
||||
completedCount++;
|
||||
|
||||
_syncController.add(SyncProgress(
|
||||
isActive: true,
|
||||
totalActions: actionsToSync.length,
|
||||
completedActions: completedCount,
|
||||
currentAction: completedCount < actionsToSync.length
|
||||
? actionsToSync[completedCount].type.toString()
|
||||
: null,
|
||||
));
|
||||
|
||||
debugPrint('✅ Action synchronisée: ${action.type}');
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de la synchronisation de ${action.type}: $e');
|
||||
|
||||
// Marquer l'action comme échouée si trop de tentatives
|
||||
action.retryCount++;
|
||||
if (action.retryCount >= 3) {
|
||||
_pendingActions.remove(action);
|
||||
debugPrint('🗑️ Action abandonnée après 3 tentatives: ${action.type}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _savePendingActions();
|
||||
_lastSyncTime = DateTime.now();
|
||||
await _saveLastSyncTime();
|
||||
|
||||
_syncController.add(SyncProgress(
|
||||
isActive: false,
|
||||
totalActions: actionsToSync.length,
|
||||
completedActions: completedCount,
|
||||
currentAction: null,
|
||||
));
|
||||
|
||||
_statusController.add(OfflineStatus(
|
||||
isOnline: _isOnline,
|
||||
pendingActionsCount: _pendingActions.length,
|
||||
lastSyncTime: _lastSyncTime,
|
||||
));
|
||||
|
||||
_isSyncing = false;
|
||||
debugPrint('✅ Synchronisation terminée ($completedCount/${actionsToSync.length} réussies)');
|
||||
}
|
||||
|
||||
/// Exécute une action spécifique
|
||||
Future<void> _executeAction(OfflineAction action) async {
|
||||
switch (action.type) {
|
||||
case OfflineActionType.refreshDashboard:
|
||||
await _syncDashboardData(action);
|
||||
break;
|
||||
case OfflineActionType.updatePreferences:
|
||||
await _syncUserPreferences(action);
|
||||
break;
|
||||
case OfflineActionType.markActivityRead:
|
||||
await _syncActivityRead(action);
|
||||
break;
|
||||
case OfflineActionType.joinEvent:
|
||||
await _syncEventJoin(action);
|
||||
break;
|
||||
case OfflineActionType.exportReport:
|
||||
await _syncReportExport(action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronise les données du dashboard
|
||||
Future<void> _syncDashboardData(OfflineAction action) async {
|
||||
// TODO: Implémenter la synchronisation des données
|
||||
await Future.delayed(const Duration(milliseconds: 500)); // Simulation
|
||||
}
|
||||
|
||||
/// Synchronise les préférences utilisateur
|
||||
Future<void> _syncUserPreferences(OfflineAction action) async {
|
||||
// TODO: Implémenter la synchronisation des préférences
|
||||
await Future.delayed(const Duration(milliseconds: 300)); // Simulation
|
||||
}
|
||||
|
||||
/// Synchronise le marquage d'activité comme lue
|
||||
Future<void> _syncActivityRead(OfflineAction action) async {
|
||||
// TODO: Implémenter la synchronisation du marquage
|
||||
await Future.delayed(const Duration(milliseconds: 200)); // Simulation
|
||||
}
|
||||
|
||||
/// Synchronise l'inscription à un événement
|
||||
Future<void> _syncEventJoin(OfflineAction action) async {
|
||||
// TODO: Implémenter la synchronisation d'inscription
|
||||
await Future.delayed(const Duration(milliseconds: 400)); // Simulation
|
||||
}
|
||||
|
||||
/// Synchronise l'export de rapport
|
||||
Future<void> _syncReportExport(OfflineAction action) async {
|
||||
// TODO: Implémenter la synchronisation d'export
|
||||
await Future.delayed(const Duration(milliseconds: 800)); // Simulation
|
||||
}
|
||||
|
||||
/// Sauvegarde les actions en attente
|
||||
Future<void> _savePendingActions() async {
|
||||
if (_prefs == null) return;
|
||||
|
||||
final actionsJson = _pendingActions
|
||||
.map((action) => action.toJson())
|
||||
.toList();
|
||||
|
||||
await _prefs!.setString(_offlineQueueKey, jsonEncode(actionsJson));
|
||||
}
|
||||
|
||||
/// Charge les actions en attente
|
||||
Future<void> _loadPendingActions() async {
|
||||
if (_prefs == null) return;
|
||||
|
||||
final actionsJsonString = _prefs!.getString(_offlineQueueKey);
|
||||
if (actionsJsonString != null) {
|
||||
try {
|
||||
final actionsJson = jsonDecode(actionsJsonString) as List;
|
||||
_pendingActions.clear();
|
||||
_pendingActions.addAll(
|
||||
actionsJson.map((json) => OfflineAction.fromJson(json)),
|
||||
);
|
||||
|
||||
debugPrint('📋 ${_pendingActions.length} actions chargées depuis le cache');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors du chargement des actions: $e');
|
||||
await _prefs!.remove(_offlineQueueKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sauvegarde l'heure de dernière synchronisation
|
||||
Future<void> _saveLastSyncTime() async {
|
||||
if (_prefs == null || _lastSyncTime == null) return;
|
||||
|
||||
await _prefs!.setInt(_lastSyncKey, _lastSyncTime!.millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
/// Charge l'heure de dernière synchronisation
|
||||
void _loadLastSyncTime() {
|
||||
if (_prefs == null) return;
|
||||
|
||||
final lastSyncMs = _prefs!.getInt(_lastSyncKey);
|
||||
if (lastSyncMs != null) {
|
||||
_lastSyncTime = DateTime.fromMillisecondsSinceEpoch(lastSyncMs);
|
||||
}
|
||||
}
|
||||
|
||||
/// Force une synchronisation manuelle
|
||||
Future<void> forcSync() async {
|
||||
if (!_isOnline) {
|
||||
throw Exception('Impossible de synchroniser hors ligne');
|
||||
}
|
||||
|
||||
await _syncPendingActions();
|
||||
}
|
||||
|
||||
/// Obtient les données en mode hors ligne
|
||||
Future<DashboardDataModel?> getOfflineData(
|
||||
String organizationId,
|
||||
String userId,
|
||||
) async {
|
||||
return await _cacheManager.getCachedDashboardData(organizationId, userId);
|
||||
}
|
||||
|
||||
/// Vérifie si des données sont disponibles hors ligne
|
||||
Future<bool> hasOfflineData(String organizationId, String userId) async {
|
||||
final data = await getOfflineData(organizationId, userId);
|
||||
return data != null;
|
||||
}
|
||||
|
||||
/// Obtient les statistiques du mode hors ligne
|
||||
OfflineStats getStats() {
|
||||
return OfflineStats(
|
||||
isOnline: _isOnline,
|
||||
pendingActionsCount: _pendingActions.length,
|
||||
lastSyncTime: _lastSyncTime,
|
||||
isSyncing: _isSyncing,
|
||||
cacheStats: _cacheManager.getCacheStats(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Nettoie les anciennes actions
|
||||
Future<void> cleanupOldActions() async {
|
||||
final cutoffTime = DateTime.now().subtract(const Duration(days: 7));
|
||||
|
||||
_pendingActions.removeWhere((action) =>
|
||||
action.timestamp.isBefore(cutoffTime));
|
||||
|
||||
await _savePendingActions();
|
||||
}
|
||||
|
||||
/// Libère les ressources
|
||||
void dispose() {
|
||||
_connectivitySubscription?.cancel();
|
||||
_syncTimer?.cancel();
|
||||
_statusController.close();
|
||||
_syncController.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Action hors ligne
|
||||
class OfflineAction {
|
||||
final String id;
|
||||
final OfflineActionType type;
|
||||
final Map<String, dynamic> data;
|
||||
final DateTime timestamp;
|
||||
int retryCount;
|
||||
|
||||
OfflineAction({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.data,
|
||||
required this.timestamp,
|
||||
this.retryCount = 0,
|
||||
});
|
||||
|
||||
factory OfflineAction.fromJson(Map<String, dynamic> json) {
|
||||
return OfflineAction(
|
||||
id: json['id'] as String,
|
||||
type: OfflineActionType.values.firstWhere(
|
||||
(t) => t.name == json['type'],
|
||||
),
|
||||
data: json['data'] as Map<String, dynamic>,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
retryCount: json['retryCount'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'type': type.name,
|
||||
'data': data,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'retryCount': retryCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Types d'actions hors ligne
|
||||
enum OfflineActionType {
|
||||
refreshDashboard,
|
||||
updatePreferences,
|
||||
markActivityRead,
|
||||
joinEvent,
|
||||
exportReport,
|
||||
}
|
||||
|
||||
/// Statut hors ligne
|
||||
class OfflineStatus {
|
||||
final bool isOnline;
|
||||
final int pendingActionsCount;
|
||||
final DateTime? lastSyncTime;
|
||||
|
||||
const OfflineStatus({
|
||||
required this.isOnline,
|
||||
required this.pendingActionsCount,
|
||||
this.lastSyncTime,
|
||||
});
|
||||
|
||||
String get statusText {
|
||||
if (isOnline) {
|
||||
if (pendingActionsCount > 0) {
|
||||
return 'En ligne - $pendingActionsCount actions en attente';
|
||||
} else {
|
||||
return 'En ligne - Synchronisé';
|
||||
}
|
||||
} else {
|
||||
return 'Hors ligne - Mode cache activé';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Progression de synchronisation
|
||||
class SyncProgress {
|
||||
final bool isActive;
|
||||
final int totalActions;
|
||||
final int completedActions;
|
||||
final String? currentAction;
|
||||
|
||||
const SyncProgress({
|
||||
required this.isActive,
|
||||
required this.totalActions,
|
||||
required this.completedActions,
|
||||
this.currentAction,
|
||||
});
|
||||
|
||||
double get progress {
|
||||
if (totalActions == 0) return 1.0;
|
||||
return completedActions / totalActions;
|
||||
}
|
||||
|
||||
String get progressText {
|
||||
if (!isActive) return 'Synchronisation terminée';
|
||||
if (currentAction != null) {
|
||||
return 'Synchronisation: $currentAction ($completedActions/$totalActions)';
|
||||
}
|
||||
return 'Synchronisation en cours... ($completedActions/$totalActions)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistiques du mode hors ligne
|
||||
class OfflineStats {
|
||||
final bool isOnline;
|
||||
final int pendingActionsCount;
|
||||
final DateTime? lastSyncTime;
|
||||
final bool isSyncing;
|
||||
final Map<String, dynamic> cacheStats;
|
||||
|
||||
const OfflineStats({
|
||||
required this.isOnline,
|
||||
required this.pendingActionsCount,
|
||||
this.lastSyncTime,
|
||||
required this.isSyncing,
|
||||
required this.cacheStats,
|
||||
});
|
||||
|
||||
String get lastSyncText {
|
||||
if (lastSyncTime == null) return 'Jamais synchronisé';
|
||||
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(lastSyncTime!);
|
||||
|
||||
if (diff.inMinutes < 1) return 'Synchronisé à l\'instant';
|
||||
if (diff.inMinutes < 60) return 'Synchronisé il y a ${diff.inMinutes}min';
|
||||
if (diff.inHours < 24) return 'Synchronisé il y a ${diff.inHours}h';
|
||||
return 'Synchronisé il y a ${diff.inDays}j';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../config/dashboard_config.dart';
|
||||
|
||||
/// Moniteur de performances avancé pour le Dashboard
|
||||
class DashboardPerformanceMonitor {
|
||||
static const String _channelName = 'dashboard_performance';
|
||||
static const MethodChannel _channel = MethodChannel(_channelName);
|
||||
|
||||
Timer? _monitoringTimer;
|
||||
Timer? _reportTimer;
|
||||
final List<PerformanceSnapshot> _snapshots = [];
|
||||
final StreamController<PerformanceMetrics> _metricsController =
|
||||
StreamController<PerformanceMetrics>.broadcast();
|
||||
final StreamController<PerformanceAlert> _alertController =
|
||||
StreamController<PerformanceAlert>.broadcast();
|
||||
|
||||
bool _isMonitoring = false;
|
||||
DateTime _startTime = DateTime.now();
|
||||
|
||||
// Seuils d'alerte configurables
|
||||
final double _memoryThreshold = DashboardConfig.getAlertThreshold('memoryUsage');
|
||||
final double _cpuThreshold = DashboardConfig.getAlertThreshold('cpuUsage');
|
||||
final int _networkLatencyThreshold = DashboardConfig.getAlertThreshold('networkLatency').toInt();
|
||||
final double _frameRateThreshold = DashboardConfig.getAlertThreshold('frameRate');
|
||||
|
||||
// Streams publics
|
||||
Stream<PerformanceMetrics> get metricsStream => _metricsController.stream;
|
||||
Stream<PerformanceAlert> get alertStream => _alertController.stream;
|
||||
|
||||
/// Démarre le monitoring des performances
|
||||
Future<void> startMonitoring() async {
|
||||
if (_isMonitoring) return;
|
||||
|
||||
debugPrint('🔍 Démarrage du monitoring des performances...');
|
||||
|
||||
_isMonitoring = true;
|
||||
_startTime = DateTime.now();
|
||||
|
||||
// Timer pour collecter les métriques
|
||||
_monitoringTimer = Timer.periodic(
|
||||
DashboardConfig.performanceCheckInterval,
|
||||
(_) => _collectMetrics(),
|
||||
);
|
||||
|
||||
// Timer pour générer les rapports
|
||||
_reportTimer = Timer.periodic(
|
||||
const Duration(minutes: 5),
|
||||
(_) => _generateReport(),
|
||||
);
|
||||
|
||||
// Collecte initiale
|
||||
await _collectMetrics();
|
||||
|
||||
debugPrint('✅ Monitoring des performances démarré');
|
||||
}
|
||||
|
||||
/// Arrête le monitoring
|
||||
void stopMonitoring() {
|
||||
if (!_isMonitoring) return;
|
||||
|
||||
_isMonitoring = false;
|
||||
_monitoringTimer?.cancel();
|
||||
_reportTimer?.cancel();
|
||||
|
||||
debugPrint('🛑 Monitoring des performances arrêté');
|
||||
}
|
||||
|
||||
/// Collecte les métriques de performance
|
||||
Future<void> _collectMetrics() async {
|
||||
try {
|
||||
final metrics = await _gatherMetrics();
|
||||
final snapshot = PerformanceSnapshot(
|
||||
timestamp: DateTime.now(),
|
||||
metrics: metrics,
|
||||
);
|
||||
|
||||
_snapshots.add(snapshot);
|
||||
|
||||
// Garder seulement les 1000 derniers snapshots
|
||||
if (_snapshots.length > 1000) {
|
||||
_snapshots.removeAt(0);
|
||||
}
|
||||
|
||||
// Émettre les métriques
|
||||
_metricsController.add(metrics);
|
||||
|
||||
// Vérifier les seuils d'alerte
|
||||
_checkAlerts(metrics);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de la collecte des métriques: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Rassemble toutes les métriques
|
||||
Future<PerformanceMetrics> _gatherMetrics() async {
|
||||
final memoryUsage = await _getMemoryUsage();
|
||||
final cpuUsage = await _getCpuUsage();
|
||||
final networkLatency = await _getNetworkLatency();
|
||||
final frameRate = await _getFrameRate();
|
||||
final batteryLevel = await _getBatteryLevel();
|
||||
final diskUsage = await _getDiskUsage();
|
||||
final networkUsage = await _getNetworkUsage();
|
||||
|
||||
return PerformanceMetrics(
|
||||
timestamp: DateTime.now(),
|
||||
memoryUsage: memoryUsage,
|
||||
cpuUsage: cpuUsage,
|
||||
networkLatency: networkLatency,
|
||||
frameRate: frameRate,
|
||||
batteryLevel: batteryLevel,
|
||||
diskUsage: diskUsage,
|
||||
networkUsage: networkUsage,
|
||||
uptime: DateTime.now().difference(_startTime),
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtient l'utilisation mémoire
|
||||
Future<double> _getMemoryUsage() async {
|
||||
try {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
final result = await _channel.invokeMethod('getMemoryUsage');
|
||||
return (result as num).toDouble();
|
||||
} else {
|
||||
// Simulation pour les autres plateformes
|
||||
return _simulateMemoryUsage();
|
||||
}
|
||||
} catch (e) {
|
||||
return _simulateMemoryUsage();
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient l'utilisation CPU
|
||||
Future<double> _getCpuUsage() async {
|
||||
try {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
final result = await _channel.invokeMethod('getCpuUsage');
|
||||
return (result as num).toDouble();
|
||||
} else {
|
||||
return _simulateCpuUsage();
|
||||
}
|
||||
} catch (e) {
|
||||
return _simulateCpuUsage();
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient la latence réseau
|
||||
Future<int> _getNetworkLatency() async {
|
||||
try {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
// Ping vers le serveur de l'API
|
||||
final socket = await Socket.connect('localhost', 8080)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
|
||||
stopwatch.stop();
|
||||
await socket.close();
|
||||
|
||||
return stopwatch.elapsedMilliseconds;
|
||||
} catch (e) {
|
||||
return _simulateNetworkLatency();
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient le frame rate
|
||||
Future<double> _getFrameRate() async {
|
||||
try {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
final result = await _channel.invokeMethod('getFrameRate');
|
||||
return (result as num).toDouble();
|
||||
} else {
|
||||
return _simulateFrameRate();
|
||||
}
|
||||
} catch (e) {
|
||||
return _simulateFrameRate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient le niveau de batterie
|
||||
Future<double> _getBatteryLevel() async {
|
||||
try {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
final result = await _channel.invokeMethod('getBatteryLevel');
|
||||
return (result as num).toDouble();
|
||||
} else {
|
||||
return _simulateBatteryLevel();
|
||||
}
|
||||
} catch (e) {
|
||||
return _simulateBatteryLevel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient l'utilisation disque
|
||||
Future<double> _getDiskUsage() async {
|
||||
try {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
final result = await _channel.invokeMethod('getDiskUsage');
|
||||
return (result as num).toDouble();
|
||||
} else {
|
||||
return _simulateDiskUsage();
|
||||
}
|
||||
} catch (e) {
|
||||
return _simulateDiskUsage();
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient l'utilisation réseau
|
||||
Future<NetworkUsage> _getNetworkUsage() async {
|
||||
try {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
final result = await _channel.invokeMethod('getNetworkUsage');
|
||||
return NetworkUsage(
|
||||
bytesReceived: (result['bytesReceived'] as num).toDouble(),
|
||||
bytesSent: (result['bytesSent'] as num).toDouble(),
|
||||
);
|
||||
} else {
|
||||
return _simulateNetworkUsage();
|
||||
}
|
||||
} catch (e) {
|
||||
return _simulateNetworkUsage();
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie les seuils d'alerte
|
||||
void _checkAlerts(PerformanceMetrics metrics) {
|
||||
// Alerte mémoire
|
||||
if (metrics.memoryUsage > _memoryThreshold) {
|
||||
_alertController.add(PerformanceAlert(
|
||||
type: AlertType.memory,
|
||||
severity: AlertSeverity.warning,
|
||||
message: 'Utilisation mémoire élevée: ${metrics.memoryUsage.toStringAsFixed(1)}MB',
|
||||
value: metrics.memoryUsage,
|
||||
threshold: _memoryThreshold,
|
||||
timestamp: DateTime.now(),
|
||||
));
|
||||
}
|
||||
|
||||
// Alerte CPU
|
||||
if (metrics.cpuUsage > _cpuThreshold) {
|
||||
_alertController.add(PerformanceAlert(
|
||||
type: AlertType.cpu,
|
||||
severity: AlertSeverity.warning,
|
||||
message: 'Utilisation CPU élevée: ${metrics.cpuUsage.toStringAsFixed(1)}%',
|
||||
value: metrics.cpuUsage,
|
||||
threshold: _cpuThreshold,
|
||||
timestamp: DateTime.now(),
|
||||
));
|
||||
}
|
||||
|
||||
// Alerte latence réseau
|
||||
if (metrics.networkLatency > _networkLatencyThreshold) {
|
||||
_alertController.add(PerformanceAlert(
|
||||
type: AlertType.network,
|
||||
severity: AlertSeverity.error,
|
||||
message: 'Latence réseau élevée: ${metrics.networkLatency}ms',
|
||||
value: metrics.networkLatency.toDouble(),
|
||||
threshold: _networkLatencyThreshold.toDouble(),
|
||||
timestamp: DateTime.now(),
|
||||
));
|
||||
}
|
||||
|
||||
// Alerte frame rate
|
||||
if (metrics.frameRate < _frameRateThreshold) {
|
||||
_alertController.add(PerformanceAlert(
|
||||
type: AlertType.performance,
|
||||
severity: AlertSeverity.warning,
|
||||
message: 'Frame rate faible: ${metrics.frameRate.toStringAsFixed(1)}fps',
|
||||
value: metrics.frameRate,
|
||||
threshold: _frameRateThreshold,
|
||||
timestamp: DateTime.now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Génère un rapport de performance
|
||||
void _generateReport() {
|
||||
if (_snapshots.isEmpty) return;
|
||||
|
||||
final recentSnapshots = _snapshots.where((snapshot) =>
|
||||
DateTime.now().difference(snapshot.timestamp).inMinutes <= 5).toList();
|
||||
|
||||
if (recentSnapshots.isEmpty) return;
|
||||
|
||||
final report = PerformanceReport.fromSnapshots(recentSnapshots);
|
||||
|
||||
debugPrint('📊 RAPPORT DE PERFORMANCE (5 min)');
|
||||
debugPrint('Mémoire: ${report.averageMemoryUsage.toStringAsFixed(1)}MB (max: ${report.maxMemoryUsage.toStringAsFixed(1)}MB)');
|
||||
debugPrint('CPU: ${report.averageCpuUsage.toStringAsFixed(1)}% (max: ${report.maxCpuUsage.toStringAsFixed(1)}%)');
|
||||
debugPrint('Latence: ${report.averageNetworkLatency.toStringAsFixed(0)}ms (max: ${report.maxNetworkLatency.toStringAsFixed(0)}ms)');
|
||||
debugPrint('FPS: ${report.averageFrameRate.toStringAsFixed(1)}fps (min: ${report.minFrameRate.toStringAsFixed(1)}fps)');
|
||||
}
|
||||
|
||||
/// Obtient les statistiques de performance
|
||||
PerformanceStats getStats() {
|
||||
if (_snapshots.isEmpty) {
|
||||
return PerformanceStats.empty();
|
||||
}
|
||||
|
||||
return PerformanceStats.fromSnapshots(_snapshots);
|
||||
}
|
||||
|
||||
/// Méthodes de simulation pour le développement
|
||||
double _simulateMemoryUsage() {
|
||||
const base = 200.0;
|
||||
final variation = 100.0 * (DateTime.now().millisecond / 1000.0);
|
||||
return base + variation;
|
||||
}
|
||||
|
||||
double _simulateCpuUsage() {
|
||||
const base = 30.0;
|
||||
final variation = 40.0 * (DateTime.now().second / 60.0);
|
||||
return (base + variation).clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
int _simulateNetworkLatency() {
|
||||
const base = 150;
|
||||
final variation = (200 * (DateTime.now().millisecond / 1000.0)).round();
|
||||
return base + variation;
|
||||
}
|
||||
|
||||
double _simulateFrameRate() {
|
||||
const base = 58.0;
|
||||
final variation = 5.0 * (DateTime.now().millisecond / 1000.0);
|
||||
return (base + variation).clamp(30.0, 60.0);
|
||||
}
|
||||
|
||||
double _simulateBatteryLevel() {
|
||||
final elapsed = DateTime.now().difference(_startTime).inMinutes;
|
||||
return (100.0 - elapsed * 0.1).clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
double _simulateDiskUsage() {
|
||||
return 45.0 + (10.0 * (DateTime.now().millisecond / 1000.0));
|
||||
}
|
||||
|
||||
NetworkUsage _simulateNetworkUsage() {
|
||||
const base = 1024.0;
|
||||
final variation = 512.0 * (DateTime.now().millisecond / 1000.0);
|
||||
return NetworkUsage(
|
||||
bytesReceived: base + variation,
|
||||
bytesSent: (base + variation) * 0.3,
|
||||
);
|
||||
}
|
||||
|
||||
/// Libère les ressources
|
||||
void dispose() {
|
||||
stopMonitoring();
|
||||
_metricsController.close();
|
||||
_alertController.close();
|
||||
_snapshots.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Métriques de performance
|
||||
class PerformanceMetrics {
|
||||
final DateTime timestamp;
|
||||
final double memoryUsage; // MB
|
||||
final double cpuUsage; // %
|
||||
final int networkLatency; // ms
|
||||
final double frameRate; // fps
|
||||
final double batteryLevel; // %
|
||||
final double diskUsage; // %
|
||||
final NetworkUsage networkUsage;
|
||||
final Duration uptime;
|
||||
|
||||
const PerformanceMetrics({
|
||||
required this.timestamp,
|
||||
required this.memoryUsage,
|
||||
required this.cpuUsage,
|
||||
required this.networkLatency,
|
||||
required this.frameRate,
|
||||
required this.batteryLevel,
|
||||
required this.diskUsage,
|
||||
required this.networkUsage,
|
||||
required this.uptime,
|
||||
});
|
||||
}
|
||||
|
||||
/// Utilisation réseau
|
||||
class NetworkUsage {
|
||||
final double bytesReceived;
|
||||
final double bytesSent;
|
||||
|
||||
const NetworkUsage({
|
||||
required this.bytesReceived,
|
||||
required this.bytesSent,
|
||||
});
|
||||
|
||||
double get totalBytes => bytesReceived + bytesSent;
|
||||
}
|
||||
|
||||
/// Snapshot de performance
|
||||
class PerformanceSnapshot {
|
||||
final DateTime timestamp;
|
||||
final PerformanceMetrics metrics;
|
||||
|
||||
const PerformanceSnapshot({
|
||||
required this.timestamp,
|
||||
required this.metrics,
|
||||
});
|
||||
}
|
||||
|
||||
/// Alerte de performance
|
||||
class PerformanceAlert {
|
||||
final AlertType type;
|
||||
final AlertSeverity severity;
|
||||
final String message;
|
||||
final double value;
|
||||
final double threshold;
|
||||
final DateTime timestamp;
|
||||
|
||||
const PerformanceAlert({
|
||||
required this.type,
|
||||
required this.severity,
|
||||
required this.message,
|
||||
required this.value,
|
||||
required this.threshold,
|
||||
required this.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
/// Type d'alerte
|
||||
enum AlertType { memory, cpu, network, performance, battery, disk }
|
||||
|
||||
/// Sévérité d'alerte
|
||||
enum AlertSeverity { info, warning, error, critical }
|
||||
|
||||
/// Rapport de performance
|
||||
class PerformanceReport {
|
||||
final DateTime startTime;
|
||||
final DateTime endTime;
|
||||
final double averageMemoryUsage;
|
||||
final double maxMemoryUsage;
|
||||
final double averageCpuUsage;
|
||||
final double maxCpuUsage;
|
||||
final double averageNetworkLatency;
|
||||
final double maxNetworkLatency;
|
||||
final double averageFrameRate;
|
||||
final double minFrameRate;
|
||||
|
||||
const PerformanceReport({
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.averageMemoryUsage,
|
||||
required this.maxMemoryUsage,
|
||||
required this.averageCpuUsage,
|
||||
required this.maxCpuUsage,
|
||||
required this.averageNetworkLatency,
|
||||
required this.maxNetworkLatency,
|
||||
required this.averageFrameRate,
|
||||
required this.minFrameRate,
|
||||
});
|
||||
|
||||
factory PerformanceReport.fromSnapshots(List<PerformanceSnapshot> snapshots) {
|
||||
if (snapshots.isEmpty) {
|
||||
throw ArgumentError('Cannot create report from empty snapshots');
|
||||
}
|
||||
|
||||
final metrics = snapshots.map((s) => s.metrics).toList();
|
||||
|
||||
return PerformanceReport(
|
||||
startTime: snapshots.first.timestamp,
|
||||
endTime: snapshots.last.timestamp,
|
||||
averageMemoryUsage: metrics.map((m) => m.memoryUsage).reduce((a, b) => a + b) / metrics.length,
|
||||
maxMemoryUsage: metrics.map((m) => m.memoryUsage).reduce((a, b) => a > b ? a : b),
|
||||
averageCpuUsage: metrics.map((m) => m.cpuUsage).reduce((a, b) => a + b) / metrics.length,
|
||||
maxCpuUsage: metrics.map((m) => m.cpuUsage).reduce((a, b) => a > b ? a : b),
|
||||
averageNetworkLatency: metrics.map((m) => m.networkLatency.toDouble()).reduce((a, b) => a + b) / metrics.length,
|
||||
maxNetworkLatency: metrics.map((m) => m.networkLatency.toDouble()).reduce((a, b) => a > b ? a : b),
|
||||
averageFrameRate: metrics.map((m) => m.frameRate).reduce((a, b) => a + b) / metrics.length,
|
||||
minFrameRate: metrics.map((m) => m.frameRate).reduce((a, b) => a < b ? a : b),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistiques de performance
|
||||
class PerformanceStats {
|
||||
final int totalSnapshots;
|
||||
final Duration totalUptime;
|
||||
final double averageMemoryUsage;
|
||||
final double peakMemoryUsage;
|
||||
final double averageCpuUsage;
|
||||
final double peakCpuUsage;
|
||||
final int alertsGenerated;
|
||||
|
||||
const PerformanceStats({
|
||||
required this.totalSnapshots,
|
||||
required this.totalUptime,
|
||||
required this.averageMemoryUsage,
|
||||
required this.peakMemoryUsage,
|
||||
required this.averageCpuUsage,
|
||||
required this.peakCpuUsage,
|
||||
required this.alertsGenerated,
|
||||
});
|
||||
|
||||
factory PerformanceStats.empty() {
|
||||
return const PerformanceStats(
|
||||
totalSnapshots: 0,
|
||||
totalUptime: Duration.zero,
|
||||
averageMemoryUsage: 0.0,
|
||||
peakMemoryUsage: 0.0,
|
||||
averageCpuUsage: 0.0,
|
||||
peakCpuUsage: 0.0,
|
||||
alertsGenerated: 0,
|
||||
);
|
||||
}
|
||||
|
||||
factory PerformanceStats.fromSnapshots(List<PerformanceSnapshot> snapshots) {
|
||||
if (snapshots.isEmpty) return PerformanceStats.empty();
|
||||
|
||||
final metrics = snapshots.map((s) => s.metrics).toList();
|
||||
|
||||
return PerformanceStats(
|
||||
totalSnapshots: snapshots.length,
|
||||
totalUptime: snapshots.last.timestamp.difference(snapshots.first.timestamp),
|
||||
averageMemoryUsage: metrics.map((m) => m.memoryUsage).reduce((a, b) => a + b) / metrics.length,
|
||||
peakMemoryUsage: metrics.map((m) => m.memoryUsage).reduce((a, b) => a > b ? a : b),
|
||||
averageCpuUsage: metrics.map((m) => m.cpuUsage).reduce((a, b) => a + b) / metrics.length,
|
||||
peakCpuUsage: metrics.map((m) => m.cpuUsage).reduce((a, b) => a > b ? a : b),
|
||||
alertsGenerated: 0, // À implémenter si nécessaire
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../data/datasources/dashboard_remote_datasource.dart';
|
||||
import '../data/repositories/dashboard_repository_impl.dart';
|
||||
import '../domain/repositories/dashboard_repository.dart';
|
||||
import '../domain/usecases/get_dashboard_data.dart';
|
||||
import '../presentation/bloc/dashboard_bloc.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import '../../../core/network/network_info.dart';
|
||||
|
||||
/// Configuration de l'injection de dépendances pour le module Dashboard
|
||||
class DashboardDI {
|
||||
static final GetIt _getIt = GetIt.instance;
|
||||
|
||||
/// Enregistre toutes les dépendances du module Dashboard
|
||||
static void registerDependencies() {
|
||||
// Data Sources
|
||||
_getIt.registerLazySingleton<DashboardRemoteDataSource>(
|
||||
() => DashboardRemoteDataSourceImpl(
|
||||
dioClient: _getIt<DioClient>(),
|
||||
),
|
||||
);
|
||||
|
||||
// Repositories
|
||||
_getIt.registerLazySingleton<DashboardRepository>(
|
||||
() => DashboardRepositoryImpl(
|
||||
remoteDataSource: _getIt<DashboardRemoteDataSource>(),
|
||||
networkInfo: _getIt<NetworkInfo>(),
|
||||
),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
_getIt.registerLazySingleton(() => GetDashboardData(_getIt<DashboardRepository>()));
|
||||
_getIt.registerLazySingleton(() => GetDashboardStats(_getIt<DashboardRepository>()));
|
||||
_getIt.registerLazySingleton(() => GetRecentActivities(_getIt<DashboardRepository>()));
|
||||
_getIt.registerLazySingleton(() => GetUpcomingEvents(_getIt<DashboardRepository>()));
|
||||
|
||||
// BLoC
|
||||
_getIt.registerFactory(
|
||||
() => DashboardBloc(
|
||||
getDashboardData: _getIt<GetDashboardData>(),
|
||||
getDashboardStats: _getIt<GetDashboardStats>(),
|
||||
getRecentActivities: _getIt<GetRecentActivities>(),
|
||||
getUpcomingEvents: _getIt<GetUpcomingEvents>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Nettoie les dépendances du module Dashboard
|
||||
static void unregisterDependencies() {
|
||||
_getIt.unregister<DashboardBloc>();
|
||||
_getIt.unregister<GetUpcomingEvents>();
|
||||
_getIt.unregister<GetRecentActivities>();
|
||||
_getIt.unregister<GetDashboardStats>();
|
||||
_getIt.unregister<GetDashboardData>();
|
||||
_getIt.unregister<DashboardRepository>();
|
||||
_getIt.unregister<DashboardRemoteDataSource>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Entité pour les statistiques du dashboard
|
||||
class DashboardStatsEntity extends Equatable {
|
||||
final int totalMembers;
|
||||
final int activeMembers;
|
||||
final int totalEvents;
|
||||
final int upcomingEvents;
|
||||
final int totalContributions;
|
||||
final double totalContributionAmount;
|
||||
final int pendingRequests;
|
||||
final int completedProjects;
|
||||
final double monthlyGrowth;
|
||||
final double engagementRate;
|
||||
final DateTime lastUpdated;
|
||||
|
||||
const DashboardStatsEntity({
|
||||
required this.totalMembers,
|
||||
required this.activeMembers,
|
||||
required this.totalEvents,
|
||||
required this.upcomingEvents,
|
||||
required this.totalContributions,
|
||||
required this.totalContributionAmount,
|
||||
required this.pendingRequests,
|
||||
required this.completedProjects,
|
||||
required this.monthlyGrowth,
|
||||
required this.engagementRate,
|
||||
required this.lastUpdated,
|
||||
});
|
||||
|
||||
// Méthodes utilitaires
|
||||
double get memberActivityRate => totalMembers > 0 ? activeMembers / totalMembers : 0.0;
|
||||
bool get hasGrowth => monthlyGrowth > 0;
|
||||
bool get isHighEngagement => engagementRate > 0.7;
|
||||
|
||||
String get formattedContributionAmount {
|
||||
if (totalContributionAmount >= 1000000) {
|
||||
return '${(totalContributionAmount / 1000000).toStringAsFixed(1)}M';
|
||||
} else if (totalContributionAmount >= 1000) {
|
||||
return '${(totalContributionAmount / 1000).toStringAsFixed(1)}K';
|
||||
}
|
||||
return totalContributionAmount.toStringAsFixed(0);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
totalMembers,
|
||||
activeMembers,
|
||||
totalEvents,
|
||||
upcomingEvents,
|
||||
totalContributions,
|
||||
totalContributionAmount,
|
||||
pendingRequests,
|
||||
completedProjects,
|
||||
monthlyGrowth,
|
||||
engagementRate,
|
||||
lastUpdated,
|
||||
];
|
||||
}
|
||||
|
||||
/// Entité pour les activités récentes
|
||||
class RecentActivityEntity extends Equatable {
|
||||
final String id;
|
||||
final String type;
|
||||
final String title;
|
||||
final String description;
|
||||
final String? userAvatar;
|
||||
final String userName;
|
||||
final DateTime timestamp;
|
||||
final String? actionUrl;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
const RecentActivityEntity({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.description,
|
||||
this.userAvatar,
|
||||
required this.userName,
|
||||
required this.timestamp,
|
||||
this.actionUrl,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
// Méthodes utilitaires
|
||||
String get timeAgo {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(timestamp);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return '${difference.inDays}j';
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours}h';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes}min';
|
||||
} else {
|
||||
return 'maintenant';
|
||||
}
|
||||
}
|
||||
|
||||
bool get isRecent => DateTime.now().difference(timestamp).inHours < 24;
|
||||
bool get hasAction => actionUrl != null && actionUrl!.isNotEmpty;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
userAvatar,
|
||||
userName,
|
||||
timestamp,
|
||||
actionUrl,
|
||||
metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/// Entité pour les événements à venir
|
||||
class UpcomingEventEntity extends Equatable {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final DateTime startDate;
|
||||
final DateTime? endDate;
|
||||
final String location;
|
||||
final int maxParticipants;
|
||||
final int currentParticipants;
|
||||
final String status;
|
||||
final String? imageUrl;
|
||||
final List<String> tags;
|
||||
|
||||
const UpcomingEventEntity({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.startDate,
|
||||
this.endDate,
|
||||
required this.location,
|
||||
required this.maxParticipants,
|
||||
required this.currentParticipants,
|
||||
required this.status,
|
||||
this.imageUrl,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
// Méthodes utilitaires
|
||||
bool get isAlmostFull => currentParticipants >= (maxParticipants * 0.8);
|
||||
bool get isFull => currentParticipants >= maxParticipants;
|
||||
double get fillPercentage => maxParticipants > 0 ? currentParticipants / maxParticipants : 0.0;
|
||||
|
||||
String get daysUntilEvent {
|
||||
final now = DateTime.now();
|
||||
final difference = startDate.difference(now);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return '${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours}h';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes}min';
|
||||
} else {
|
||||
return 'En cours';
|
||||
}
|
||||
}
|
||||
|
||||
bool get isToday {
|
||||
final now = DateTime.now();
|
||||
return startDate.year == now.year &&
|
||||
startDate.month == now.month &&
|
||||
startDate.day == now.day;
|
||||
}
|
||||
|
||||
bool get isTomorrow {
|
||||
final tomorrow = DateTime.now().add(const Duration(days: 1));
|
||||
return startDate.year == tomorrow.year &&
|
||||
startDate.month == tomorrow.month &&
|
||||
startDate.day == tomorrow.day;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
startDate,
|
||||
endDate,
|
||||
location,
|
||||
maxParticipants,
|
||||
currentParticipants,
|
||||
status,
|
||||
imageUrl,
|
||||
tags,
|
||||
];
|
||||
}
|
||||
|
||||
/// Entité principale du dashboard
|
||||
class DashboardEntity extends Equatable {
|
||||
final DashboardStatsEntity stats;
|
||||
final List<RecentActivityEntity> recentActivities;
|
||||
final List<UpcomingEventEntity> upcomingEvents;
|
||||
final Map<String, dynamic> userPreferences;
|
||||
final String organizationId;
|
||||
final String userId;
|
||||
|
||||
const DashboardEntity({
|
||||
required this.stats,
|
||||
required this.recentActivities,
|
||||
required this.upcomingEvents,
|
||||
required this.userPreferences,
|
||||
required this.organizationId,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
// Méthodes utilitaires
|
||||
bool get hasRecentActivity => recentActivities.isNotEmpty;
|
||||
bool get hasUpcomingEvents => upcomingEvents.isNotEmpty;
|
||||
int get todayEventsCount => upcomingEvents.where((e) => e.isToday).length;
|
||||
int get tomorrowEventsCount => upcomingEvents.where((e) => e.isTomorrow).length;
|
||||
int get recentActivitiesCount => recentActivities.length;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
stats,
|
||||
recentActivities,
|
||||
upcomingEvents,
|
||||
userPreferences,
|
||||
organizationId,
|
||||
userId,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import '../entities/dashboard_entity.dart';
|
||||
import '../../../../core/error/failures.dart';
|
||||
|
||||
abstract class DashboardRepository {
|
||||
Future<Either<Failure, DashboardEntity>> getDashboardData(
|
||||
String organizationId,
|
||||
String userId,
|
||||
);
|
||||
|
||||
Future<Either<Failure, DashboardStatsEntity>> getDashboardStats(
|
||||
String organizationId,
|
||||
String userId,
|
||||
);
|
||||
|
||||
Future<Either<Failure, List<RecentActivityEntity>>> getRecentActivities(
|
||||
String organizationId,
|
||||
String userId, {
|
||||
int limit = 10,
|
||||
});
|
||||
|
||||
Future<Either<Failure, List<UpcomingEventEntity>>> getUpcomingEvents(
|
||||
String organizationId,
|
||||
String userId, {
|
||||
int limit = 5,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../entities/dashboard_entity.dart';
|
||||
import '../repositories/dashboard_repository.dart';
|
||||
import '../../../../core/error/failures.dart';
|
||||
import '../../../../core/usecases/usecase.dart';
|
||||
|
||||
class GetDashboardData implements UseCase<DashboardEntity, GetDashboardDataParams> {
|
||||
final DashboardRepository repository;
|
||||
|
||||
GetDashboardData(this.repository);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, DashboardEntity>> call(GetDashboardDataParams params) async {
|
||||
return await repository.getDashboardData(
|
||||
params.organizationId,
|
||||
params.userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GetDashboardDataParams extends Equatable {
|
||||
final String organizationId;
|
||||
final String userId;
|
||||
|
||||
const GetDashboardDataParams({
|
||||
required this.organizationId,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object> get props => [organizationId, userId];
|
||||
}
|
||||
|
||||
class GetDashboardStats implements UseCase<DashboardStatsEntity, GetDashboardStatsParams> {
|
||||
final DashboardRepository repository;
|
||||
|
||||
GetDashboardStats(this.repository);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, DashboardStatsEntity>> call(GetDashboardStatsParams params) async {
|
||||
return await repository.getDashboardStats(
|
||||
params.organizationId,
|
||||
params.userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GetDashboardStatsParams extends Equatable {
|
||||
final String organizationId;
|
||||
final String userId;
|
||||
|
||||
const GetDashboardStatsParams({
|
||||
required this.organizationId,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object> get props => [organizationId, userId];
|
||||
}
|
||||
|
||||
class GetRecentActivities implements UseCase<List<RecentActivityEntity>, GetRecentActivitiesParams> {
|
||||
final DashboardRepository repository;
|
||||
|
||||
GetRecentActivities(this.repository);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<RecentActivityEntity>>> call(GetRecentActivitiesParams params) async {
|
||||
return await repository.getRecentActivities(
|
||||
params.organizationId,
|
||||
params.userId,
|
||||
limit: params.limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GetRecentActivitiesParams extends Equatable {
|
||||
final String organizationId;
|
||||
final String userId;
|
||||
final int limit;
|
||||
|
||||
const GetRecentActivitiesParams({
|
||||
required this.organizationId,
|
||||
required this.userId,
|
||||
this.limit = 10,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object> get props => [organizationId, userId, limit];
|
||||
}
|
||||
|
||||
class GetUpcomingEvents implements UseCase<List<UpcomingEventEntity>, GetUpcomingEventsParams> {
|
||||
final DashboardRepository repository;
|
||||
|
||||
GetUpcomingEvents(this.repository);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<UpcomingEventEntity>>> call(GetUpcomingEventsParams params) async {
|
||||
return await repository.getUpcomingEvents(
|
||||
params.organizationId,
|
||||
params.userId,
|
||||
limit: params.limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GetUpcomingEventsParams extends Equatable {
|
||||
final String organizationId;
|
||||
final String userId;
|
||||
final int limit;
|
||||
|
||||
const GetUpcomingEventsParams({
|
||||
required this.organizationId,
|
||||
required this.userId,
|
||||
this.limit = 5,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object> get props => [organizationId, userId, limit];
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../domain/entities/dashboard_entity.dart';
|
||||
import '../../domain/usecases/get_dashboard_data.dart';
|
||||
import '../../../../core/error/failures.dart';
|
||||
|
||||
part 'dashboard_event.dart';
|
||||
part 'dashboard_state.dart';
|
||||
|
||||
class DashboardBloc extends Bloc<DashboardEvent, DashboardState> {
|
||||
final GetDashboardData getDashboardData;
|
||||
final GetDashboardStats getDashboardStats;
|
||||
final GetRecentActivities getRecentActivities;
|
||||
final GetUpcomingEvents getUpcomingEvents;
|
||||
|
||||
DashboardBloc({
|
||||
required this.getDashboardData,
|
||||
required this.getDashboardStats,
|
||||
required this.getRecentActivities,
|
||||
required this.getUpcomingEvents,
|
||||
}) : super(DashboardInitial()) {
|
||||
on<LoadDashboardData>(_onLoadDashboardData);
|
||||
on<RefreshDashboardData>(_onRefreshDashboardData);
|
||||
on<LoadDashboardStats>(_onLoadDashboardStats);
|
||||
on<LoadRecentActivities>(_onLoadRecentActivities);
|
||||
on<LoadUpcomingEvents>(_onLoadUpcomingEvents);
|
||||
}
|
||||
|
||||
Future<void> _onLoadDashboardData(
|
||||
LoadDashboardData event,
|
||||
Emitter<DashboardState> emit,
|
||||
) async {
|
||||
emit(DashboardLoading());
|
||||
|
||||
final result = await getDashboardData(
|
||||
GetDashboardDataParams(
|
||||
organizationId: event.organizationId,
|
||||
userId: event.userId,
|
||||
),
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(DashboardError(_mapFailureToMessage(failure))),
|
||||
(dashboardData) => emit(DashboardLoaded(dashboardData)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onRefreshDashboardData(
|
||||
RefreshDashboardData event,
|
||||
Emitter<DashboardState> emit,
|
||||
) async {
|
||||
// Garde l'état actuel pendant le refresh
|
||||
if (state is DashboardLoaded) {
|
||||
emit(DashboardRefreshing((state as DashboardLoaded).dashboardData));
|
||||
} else {
|
||||
emit(DashboardLoading());
|
||||
}
|
||||
|
||||
final result = await getDashboardData(
|
||||
GetDashboardDataParams(
|
||||
organizationId: event.organizationId,
|
||||
userId: event.userId,
|
||||
),
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(DashboardError(_mapFailureToMessage(failure))),
|
||||
(dashboardData) => emit(DashboardLoaded(dashboardData)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onLoadDashboardStats(
|
||||
LoadDashboardStats event,
|
||||
Emitter<DashboardState> emit,
|
||||
) async {
|
||||
final result = await getDashboardStats(
|
||||
GetDashboardStatsParams(
|
||||
organizationId: event.organizationId,
|
||||
userId: event.userId,
|
||||
),
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(DashboardError(_mapFailureToMessage(failure))),
|
||||
(stats) {
|
||||
if (state is DashboardLoaded) {
|
||||
final currentData = (state as DashboardLoaded).dashboardData;
|
||||
final updatedData = DashboardEntity(
|
||||
stats: stats,
|
||||
recentActivities: currentData.recentActivities,
|
||||
upcomingEvents: currentData.upcomingEvents,
|
||||
userPreferences: currentData.userPreferences,
|
||||
organizationId: currentData.organizationId,
|
||||
userId: currentData.userId,
|
||||
);
|
||||
emit(DashboardLoaded(updatedData));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onLoadRecentActivities(
|
||||
LoadRecentActivities event,
|
||||
Emitter<DashboardState> emit,
|
||||
) async {
|
||||
final result = await getRecentActivities(
|
||||
GetRecentActivitiesParams(
|
||||
organizationId: event.organizationId,
|
||||
userId: event.userId,
|
||||
limit: event.limit,
|
||||
),
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(DashboardError(_mapFailureToMessage(failure))),
|
||||
(activities) {
|
||||
if (state is DashboardLoaded) {
|
||||
final currentData = (state as DashboardLoaded).dashboardData;
|
||||
final updatedData = DashboardEntity(
|
||||
stats: currentData.stats,
|
||||
recentActivities: activities,
|
||||
upcomingEvents: currentData.upcomingEvents,
|
||||
userPreferences: currentData.userPreferences,
|
||||
organizationId: currentData.organizationId,
|
||||
userId: currentData.userId,
|
||||
);
|
||||
emit(DashboardLoaded(updatedData));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onLoadUpcomingEvents(
|
||||
LoadUpcomingEvents event,
|
||||
Emitter<DashboardState> emit,
|
||||
) async {
|
||||
final result = await getUpcomingEvents(
|
||||
GetUpcomingEventsParams(
|
||||
organizationId: event.organizationId,
|
||||
userId: event.userId,
|
||||
limit: event.limit,
|
||||
),
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(DashboardError(_mapFailureToMessage(failure))),
|
||||
(events) {
|
||||
if (state is DashboardLoaded) {
|
||||
final currentData = (state as DashboardLoaded).dashboardData;
|
||||
final updatedData = DashboardEntity(
|
||||
stats: currentData.stats,
|
||||
recentActivities: currentData.recentActivities,
|
||||
upcomingEvents: events,
|
||||
userPreferences: currentData.userPreferences,
|
||||
organizationId: currentData.organizationId,
|
||||
userId: currentData.userId,
|
||||
);
|
||||
emit(DashboardLoaded(updatedData));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _mapFailureToMessage(Failure failure) {
|
||||
switch (failure.runtimeType) {
|
||||
case ServerFailure:
|
||||
return 'Erreur serveur. Veuillez réessayer.';
|
||||
case NetworkFailure:
|
||||
return 'Pas de connexion internet. Vérifiez votre connexion.';
|
||||
default:
|
||||
return 'Une erreur inattendue s\'est produite.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
part of 'dashboard_bloc.dart';
|
||||
|
||||
abstract class DashboardEvent extends Equatable {
|
||||
const DashboardEvent();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
class LoadDashboardData extends DashboardEvent {
|
||||
final String organizationId;
|
||||
final String userId;
|
||||
|
||||
const LoadDashboardData({
|
||||
required this.organizationId,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object> get props => [organizationId, userId];
|
||||
}
|
||||
|
||||
class RefreshDashboardData extends DashboardEvent {
|
||||
final String organizationId;
|
||||
final String userId;
|
||||
|
||||
const RefreshDashboardData({
|
||||
required this.organizationId,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object> get props => [organizationId, userId];
|
||||
}
|
||||
|
||||
class LoadDashboardStats extends DashboardEvent {
|
||||
final String organizationId;
|
||||
final String userId;
|
||||
|
||||
const LoadDashboardStats({
|
||||
required this.organizationId,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object> get props => [organizationId, userId];
|
||||
}
|
||||
|
||||
class LoadRecentActivities extends DashboardEvent {
|
||||
final String organizationId;
|
||||
final String userId;
|
||||
final int limit;
|
||||
|
||||
const LoadRecentActivities({
|
||||
required this.organizationId,
|
||||
required this.userId,
|
||||
this.limit = 10,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object> get props => [organizationId, userId, limit];
|
||||
}
|
||||
|
||||
class LoadUpcomingEvents extends DashboardEvent {
|
||||
final String organizationId;
|
||||
final String userId;
|
||||
final int limit;
|
||||
|
||||
const LoadUpcomingEvents({
|
||||
required this.organizationId,
|
||||
required this.userId,
|
||||
this.limit = 5,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object> get props => [organizationId, userId, limit];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
part of 'dashboard_bloc.dart';
|
||||
|
||||
abstract class DashboardState extends Equatable {
|
||||
const DashboardState();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
class DashboardInitial extends DashboardState {}
|
||||
|
||||
class DashboardLoading extends DashboardState {}
|
||||
|
||||
class DashboardLoaded extends DashboardState {
|
||||
final DashboardEntity dashboardData;
|
||||
|
||||
const DashboardLoaded(this.dashboardData);
|
||||
|
||||
@override
|
||||
List<Object> get props => [dashboardData];
|
||||
}
|
||||
|
||||
class DashboardRefreshing extends DashboardState {
|
||||
final DashboardEntity dashboardData;
|
||||
|
||||
const DashboardRefreshing(this.dashboardData);
|
||||
|
||||
@override
|
||||
List<Object> get props => [dashboardData];
|
||||
}
|
||||
|
||||
class DashboardError extends DashboardState {
|
||||
final String message;
|
||||
|
||||
const DashboardError(this.message);
|
||||
|
||||
@override
|
||||
List<Object> get props => [message];
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Carte de performance système réutilisable
|
||||
///
|
||||
/// Widget spécialisé pour afficher les métriques de performance
|
||||
/// avec barres de progression et indicateurs colorés.
|
||||
class PerformanceCard extends StatelessWidget {
|
||||
/// Titre de la carte
|
||||
final String title;
|
||||
|
||||
/// Sous-titre optionnel
|
||||
final String? subtitle;
|
||||
|
||||
/// Liste des métriques de performance
|
||||
final List<PerformanceMetric> metrics;
|
||||
|
||||
/// Style de la carte
|
||||
final PerformanceCardStyle style;
|
||||
|
||||
/// Callback lors du tap sur la carte
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Afficher ou non les valeurs numériques
|
||||
final bool showValues;
|
||||
|
||||
/// Afficher ou non les barres de progression
|
||||
final bool showProgressBars;
|
||||
|
||||
const PerformanceCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.metrics,
|
||||
this.style = PerformanceCardStyle.elevated,
|
||||
this.onTap,
|
||||
this.showValues = true,
|
||||
this.showProgressBars = true,
|
||||
});
|
||||
|
||||
/// Constructeur pour les métriques serveur
|
||||
const PerformanceCard.server({
|
||||
super.key,
|
||||
this.onTap,
|
||||
}) : title = 'Performance Serveur',
|
||||
subtitle = 'Métriques temps réel',
|
||||
metrics = const [
|
||||
PerformanceMetric(
|
||||
label: 'CPU',
|
||||
value: 67.3,
|
||||
unit: '%',
|
||||
color: Colors.orange,
|
||||
threshold: 80,
|
||||
),
|
||||
PerformanceMetric(
|
||||
label: 'RAM',
|
||||
value: 78.5,
|
||||
unit: '%',
|
||||
color: Colors.blue,
|
||||
threshold: 85,
|
||||
),
|
||||
PerformanceMetric(
|
||||
label: 'Disque',
|
||||
value: 45.2,
|
||||
unit: '%',
|
||||
color: Colors.green,
|
||||
threshold: 90,
|
||||
),
|
||||
],
|
||||
style = PerformanceCardStyle.elevated,
|
||||
showValues = true,
|
||||
showProgressBars = true;
|
||||
|
||||
/// Constructeur pour les métriques réseau
|
||||
const PerformanceCard.network({
|
||||
super.key,
|
||||
this.onTap,
|
||||
}) : title = 'Réseau',
|
||||
subtitle = 'Trafic et latence',
|
||||
metrics = const [
|
||||
PerformanceMetric(
|
||||
label: 'Bande passante',
|
||||
value: 23.4,
|
||||
unit: 'MB/s',
|
||||
color: Color(0xFF6C5CE7),
|
||||
threshold: 100,
|
||||
),
|
||||
PerformanceMetric(
|
||||
label: 'Latence',
|
||||
value: 12.7,
|
||||
unit: 'ms',
|
||||
color: Color(0xFF00B894),
|
||||
threshold: 50,
|
||||
),
|
||||
PerformanceMetric(
|
||||
label: 'Paquets perdus',
|
||||
value: 0.02,
|
||||
unit: '%',
|
||||
color: Colors.red,
|
||||
threshold: 1,
|
||||
),
|
||||
],
|
||||
style = PerformanceCardStyle.elevated,
|
||||
showValues = true,
|
||||
showProgressBars = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: _getDecoration(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 12),
|
||||
_buildMetrics(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// En-tête de la carte
|
||||
Widget _buildHeader() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF6C5CE7),
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction des métriques
|
||||
Widget _buildMetrics() {
|
||||
return Column(
|
||||
children: metrics.map((metric) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _buildMetricRow(metric),
|
||||
)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Ligne de métrique
|
||||
Widget _buildMetricRow(PerformanceMetric metric) {
|
||||
final isWarning = metric.value > metric.threshold * 0.8;
|
||||
final isCritical = metric.value > metric.threshold;
|
||||
|
||||
Color effectiveColor = metric.color;
|
||||
if (isCritical) {
|
||||
effectiveColor = Colors.red;
|
||||
} else if (isWarning) {
|
||||
effectiveColor = Colors.orange;
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: effectiveColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
metric.label,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showValues)
|
||||
Text(
|
||||
'${metric.value.toStringAsFixed(1)}${metric.unit}',
|
||||
style: TextStyle(
|
||||
color: effectiveColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showProgressBars) ...[
|
||||
const SizedBox(height: 4),
|
||||
_buildProgressBar(metric, effectiveColor),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Barre de progression
|
||||
Widget _buildProgressBar(PerformanceMetric metric, Color color) {
|
||||
final progress = (metric.value / metric.threshold).clamp(0.0, 1.0);
|
||||
|
||||
return Container(
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: progress,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Décoration selon le style
|
||||
BoxDecoration _getDecoration() {
|
||||
switch (style) {
|
||||
case PerformanceCardStyle.elevated:
|
||||
return BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
);
|
||||
case PerformanceCardStyle.outlined:
|
||||
return BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF6C5CE7).withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
);
|
||||
case PerformanceCardStyle.minimal:
|
||||
return BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Modèle de données pour une métrique de performance
|
||||
class PerformanceMetric {
|
||||
final String label;
|
||||
final double value;
|
||||
final String unit;
|
||||
final Color color;
|
||||
final double threshold;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
const PerformanceMetric({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.unit,
|
||||
required this.color,
|
||||
required this.threshold,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
/// Constructeur pour une métrique CPU
|
||||
const PerformanceMetric.cpu(double value)
|
||||
: label = 'CPU',
|
||||
value = value,
|
||||
unit = '%',
|
||||
color = Colors.orange,
|
||||
threshold = 80,
|
||||
metadata = null;
|
||||
|
||||
/// Constructeur pour une métrique RAM
|
||||
const PerformanceMetric.memory(double value)
|
||||
: label = 'Mémoire',
|
||||
value = value,
|
||||
unit = '%',
|
||||
color = Colors.blue,
|
||||
threshold = 85,
|
||||
metadata = null;
|
||||
|
||||
/// Constructeur pour une métrique disque
|
||||
const PerformanceMetric.disk(double value)
|
||||
: label = 'Disque',
|
||||
value = value,
|
||||
unit = '%',
|
||||
color = Colors.green,
|
||||
threshold = 90,
|
||||
metadata = null;
|
||||
|
||||
/// Constructeur pour une métrique réseau
|
||||
PerformanceMetric.network(double value, String unit)
|
||||
: label = 'Réseau',
|
||||
value = value,
|
||||
unit = unit,
|
||||
color = const Color(0xFF6C5CE7),
|
||||
threshold = 100,
|
||||
metadata = null;
|
||||
|
||||
/// Niveau de criticité de la métrique
|
||||
MetricLevel get level {
|
||||
if (value > threshold) return MetricLevel.critical;
|
||||
if (value > threshold * 0.8) return MetricLevel.warning;
|
||||
if (value > threshold * 0.6) return MetricLevel.normal;
|
||||
return MetricLevel.good;
|
||||
}
|
||||
|
||||
/// Couleur selon le niveau
|
||||
Color get levelColor {
|
||||
switch (level) {
|
||||
case MetricLevel.good:
|
||||
return Colors.green;
|
||||
case MetricLevel.normal:
|
||||
return color;
|
||||
case MetricLevel.warning:
|
||||
return Colors.orange;
|
||||
case MetricLevel.critical:
|
||||
return Colors.red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Niveaux de métrique
|
||||
enum MetricLevel {
|
||||
good,
|
||||
normal,
|
||||
warning,
|
||||
critical,
|
||||
}
|
||||
|
||||
/// Styles de carte de performance
|
||||
enum PerformanceCardStyle {
|
||||
elevated,
|
||||
outlined,
|
||||
minimal,
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
/// Dashboard Adaptatif Principal - Orchestrateur Intelligent
|
||||
/// Sélectionne et affiche le dashboard approprié selon le rôle utilisateur
|
||||
library adaptive_dashboard_page;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../core/auth/bloc/auth_bloc.dart';
|
||||
import '../../../../core/auth/models/user_role.dart';
|
||||
import '../../../../core/widgets/adaptive_widget.dart';
|
||||
import 'role_dashboards/super_admin_dashboard.dart';
|
||||
import 'role_dashboards/org_admin_dashboard.dart';
|
||||
import 'role_dashboards/moderator_dashboard.dart';
|
||||
import 'role_dashboards/active_member_dashboard.dart';
|
||||
import 'role_dashboards/simple_member_dashboard.dart';
|
||||
import 'role_dashboards/visitor_dashboard.dart';
|
||||
|
||||
/// Page Dashboard Adaptatif - Le cœur du système morphique
|
||||
///
|
||||
/// Cette page utilise l'AdaptiveWidget pour afficher automatiquement
|
||||
/// le dashboard approprié selon le rôle de l'utilisateur connecté.
|
||||
///
|
||||
/// Fonctionnalités :
|
||||
/// - Morphing automatique entre les dashboards
|
||||
/// - Animations fluides lors des changements de rôle
|
||||
/// - Gestion des états de chargement et d'erreur
|
||||
/// - Fallback gracieux pour les rôles non supportés
|
||||
class AdaptiveDashboardPage extends StatefulWidget {
|
||||
const AdaptiveDashboardPage({super.key});
|
||||
|
||||
@override
|
||||
State<AdaptiveDashboardPage> createState() => _AdaptiveDashboardPageState();
|
||||
}
|
||||
|
||||
class _AdaptiveDashboardPageState extends State<AdaptiveDashboardPage>
|
||||
with TickerProviderStateMixin {
|
||||
|
||||
/// Contrôleur d'animation pour les transitions
|
||||
late AnimationController _transitionController;
|
||||
|
||||
/// Animation de fade pour les transitions
|
||||
late Animation<double> _fadeAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeAnimations();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_transitionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Initialise les animations de transition
|
||||
void _initializeAnimations() {
|
||||
_transitionController = AnimationController(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _transitionController,
|
||||
curve: Curves.easeInOutCubic,
|
||||
));
|
||||
|
||||
// Démarrer l'animation initiale
|
||||
_transitionController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
// Déclencher l'animation lors des changements d'état
|
||||
if (state is AuthAuthenticated) {
|
||||
_transitionController.reset();
|
||||
_transitionController.forward();
|
||||
}
|
||||
},
|
||||
child: AnimatedBuilder(
|
||||
animation: _fadeAnimation,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: _fadeAnimation.value,
|
||||
child: _buildAdaptiveDashboard(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit le dashboard adaptatif selon le rôle
|
||||
Widget _buildAdaptiveDashboard() {
|
||||
return AdaptiveWidget(
|
||||
// Mapping des rôles vers leurs dashboards spécifiques
|
||||
roleWidgets: {
|
||||
UserRole.superAdmin: () => const SuperAdminDashboard(),
|
||||
UserRole.orgAdmin: () => const OrgAdminDashboard(),
|
||||
UserRole.moderator: () => const ModeratorDashboard(),
|
||||
UserRole.activeMember: () => const ActiveMemberDashboard(),
|
||||
UserRole.simpleMember: () => const SimpleMemberDashboard(),
|
||||
UserRole.visitor: () => const VisitorDashboard(),
|
||||
},
|
||||
|
||||
// Permissions requises pour accéder au dashboard
|
||||
requiredPermissions: const [
|
||||
'dashboard.view.own',
|
||||
],
|
||||
|
||||
// Widget affiché si les permissions sont insuffisantes
|
||||
fallbackWidget: _buildUnauthorizedDashboard(),
|
||||
|
||||
// Widget affiché pendant le chargement
|
||||
loadingWidget: _buildLoadingDashboard(),
|
||||
|
||||
// Configuration des animations
|
||||
enableMorphing: true,
|
||||
morphingDuration: const Duration(milliseconds: 800),
|
||||
animationCurve: Curves.easeInOutCubic,
|
||||
|
||||
// Audit trail activé
|
||||
auditLog: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Dashboard affiché en cas d'accès non autorisé
|
||||
Widget _buildUnauthorizedDashboard() {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFF8F9FA),
|
||||
Color(0xFFE9ECEF),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Icône d'accès refusé
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.lock_outline,
|
||||
size: 60,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Titre
|
||||
Text(
|
||||
'Accès Non Autorisé',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Description
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
'Vous n\'avez pas les permissions nécessaires pour accéder au dashboard. Veuillez contacter un administrateur.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Bouton de contact
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _onContactSupport(),
|
||||
icon: const Icon(Icons.support_agent),
|
||||
label: const Text('Contacter le Support'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Dashboard affiché pendant le chargement
|
||||
Widget _buildLoadingDashboard() {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFF6C5CE7),
|
||||
Color(0xFF5A4FCF),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Logo animé
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: const Duration(seconds: 2),
|
||||
builder: (context, value, child) {
|
||||
return Transform.rotate(
|
||||
angle: value * 2 * 3.14159,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.dashboard,
|
||||
color: Colors.white,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Titre
|
||||
Text(
|
||||
'UnionFlow',
|
||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Indicateur de chargement
|
||||
const SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
strokeWidth: 3,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Message de chargement
|
||||
Text(
|
||||
'Préparation de votre dashboard...',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Gère le contact avec le support
|
||||
void _onContactSupport() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Contacter le Support'),
|
||||
content: const Text(
|
||||
'Pour obtenir de l\'aide, veuillez envoyer un email à :\n\nsupport@unionflow.com\n\nOu appelez le :\n+33 1 23 45 67 89',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// Ici, on pourrait ouvrir l'app email ou téléphone
|
||||
},
|
||||
child: const Text('Envoyer Email'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension pour faciliter la navigation vers le dashboard adaptatif
|
||||
extension AdaptiveDashboardNavigation on BuildContext {
|
||||
/// Navigue vers le dashboard adaptatif
|
||||
void navigateToAdaptiveDashboard() {
|
||||
Navigator.of(this).pushReplacement(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
const AdaptiveDashboardPage(),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0.0, 0.1),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeInOutCubic,
|
||||
)),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 600),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mixin pour les dashboards qui ont besoin de fonctionnalités communes
|
||||
mixin DashboardMixin<T extends StatefulWidget> on State<T> {
|
||||
/// Affiche une notification de succès
|
||||
void showSuccessNotification(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(message)),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche une notification d'erreur
|
||||
void showErrorNotification(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(message)),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche une boîte de dialogue de confirmation
|
||||
Future<bool> showConfirmationDialog(String title, String message) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Confirmer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return result ?? false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../bloc/dashboard_bloc.dart';
|
||||
import '../widgets/connected/connected_stats_card.dart';
|
||||
import '../widgets/connected/connected_recent_activities.dart';
|
||||
import '../widgets/connected/connected_upcoming_events.dart';
|
||||
import '../widgets/charts/dashboard_chart_widget.dart';
|
||||
import '../widgets/metrics/real_time_metrics_widget.dart';
|
||||
import '../widgets/notifications/dashboard_notifications_widget.dart';
|
||||
import '../../../../shared/design_system/dashboard_theme.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
|
||||
/// Page dashboard avancée avec graphiques et analytics
|
||||
class AdvancedDashboardPage extends StatefulWidget {
|
||||
final String organizationId;
|
||||
final String userId;
|
||||
|
||||
const AdvancedDashboardPage({
|
||||
super.key,
|
||||
required this.organizationId,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AdvancedDashboardPage> createState() => _AdvancedDashboardPageState();
|
||||
}
|
||||
|
||||
class _AdvancedDashboardPageState extends State<AdvancedDashboardPage>
|
||||
with TickerProviderStateMixin {
|
||||
late DashboardBloc _dashboardBloc;
|
||||
late TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_dashboardBloc = sl<DashboardBloc>();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
_loadDashboardData();
|
||||
}
|
||||
|
||||
void _loadDashboardData() {
|
||||
_dashboardBloc.add(LoadDashboardData(
|
||||
organizationId: widget.organizationId,
|
||||
userId: widget.userId,
|
||||
));
|
||||
}
|
||||
|
||||
void _refreshDashboardData() {
|
||||
_dashboardBloc.add(RefreshDashboardData(
|
||||
organizationId: widget.organizationId,
|
||||
userId: widget.userId,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => _dashboardBloc,
|
||||
child: Scaffold(
|
||||
body: NestedScrollView(
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
||||
_buildSliverAppBar(),
|
||||
],
|
||||
body: Column(
|
||||
children: [
|
||||
_buildTabBar(),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildOverviewTab(),
|
||||
_buildAnalyticsTab(),
|
||||
_buildReportsTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: _buildFloatingActionButton(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSliverAppBar() {
|
||||
return SliverAppBar(
|
||||
expandedHeight: 200,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Container(
|
||||
decoration: DashboardTheme.headerDecoration,
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing12),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.dashboard,
|
||||
color: DashboardTheme.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Dashboard Avancé',
|
||||
style: DashboardTheme.titleLarge.copyWith(
|
||||
color: DashboardTheme.white,
|
||||
fontSize: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Text(
|
||||
'Analytics & Insights',
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.white.withOpacity(0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
BlocBuilder<DashboardBloc, DashboardState>(
|
||||
builder: (context, state) {
|
||||
if (state is DashboardLoaded || state is DashboardRefreshing) {
|
||||
final data = state is DashboardLoaded
|
||||
? state.dashboardData
|
||||
: (state as DashboardRefreshing).dashboardData;
|
||||
return Row(
|
||||
children: [
|
||||
_buildQuickStat(
|
||||
'Membres',
|
||||
'${data.stats.activeMembers}/${data.stats.totalMembers}',
|
||||
Icons.people,
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing16),
|
||||
_buildQuickStat(
|
||||
'Événements',
|
||||
'${data.stats.upcomingEvents}',
|
||||
Icons.event,
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing16),
|
||||
_buildQuickStat(
|
||||
'Croissance',
|
||||
'${data.stats.monthlyGrowth.toStringAsFixed(1)}%',
|
||||
Icons.trending_up,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _refreshDashboardData,
|
||||
icon: const Icon(
|
||||
Icons.refresh,
|
||||
color: DashboardTheme.white,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
// TODO: Ouvrir les paramètres
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.settings,
|
||||
color: DashboardTheme.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickStat(String label, String value, IconData icon) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DashboardTheme.spacing12,
|
||||
vertical: DashboardTheme.spacing8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: DashboardTheme.white,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.white.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabBar() {
|
||||
return Container(
|
||||
color: DashboardTheme.white,
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: DashboardTheme.royalBlue,
|
||||
unselectedLabelColor: DashboardTheme.grey500,
|
||||
indicatorColor: DashboardTheme.royalBlue,
|
||||
tabs: const [
|
||||
Tab(text: 'Vue d\'ensemble', icon: Icon(Icons.dashboard)),
|
||||
Tab(text: 'Analytics', icon: Icon(Icons.analytics)),
|
||||
Tab(text: 'Rapports', icon: Icon(Icons.assessment)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOverviewTab() {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => _refreshDashboardData(),
|
||||
color: DashboardTheme.royalBlue,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Métriques temps réel
|
||||
RealTimeMetricsWidget(
|
||||
organizationId: widget.organizationId,
|
||||
userId: widget.userId,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing24),
|
||||
|
||||
// Grille de statistiques
|
||||
_buildStatsGrid(),
|
||||
const SizedBox(height: DashboardTheme.spacing24),
|
||||
|
||||
// Notifications
|
||||
const DashboardNotificationsWidget(maxNotifications: 3),
|
||||
const SizedBox(height: DashboardTheme.spacing24),
|
||||
|
||||
// Activités et événements
|
||||
const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ConnectedRecentActivities(maxItems: 3),
|
||||
),
|
||||
SizedBox(width: DashboardTheme.spacing16),
|
||||
Expanded(
|
||||
child: ConnectedUpcomingEvents(maxItems: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnalyticsTab() {
|
||||
return const SingleChildScrollView(
|
||||
padding: EdgeInsets.all(DashboardTheme.spacing16),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DashboardChartWidget(
|
||||
title: 'Activité des Membres',
|
||||
chartType: DashboardChartType.memberActivity,
|
||||
height: 250,
|
||||
),
|
||||
),
|
||||
SizedBox(width: DashboardTheme.spacing16),
|
||||
Expanded(
|
||||
child: DashboardChartWidget(
|
||||
title: 'Croissance Mensuelle',
|
||||
chartType: DashboardChartType.monthlyGrowth,
|
||||
height: 250,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: DashboardTheme.spacing24),
|
||||
DashboardChartWidget(
|
||||
title: 'Tendance des Contributions',
|
||||
chartType: DashboardChartType.contributionTrend,
|
||||
height: 300,
|
||||
),
|
||||
SizedBox(height: DashboardTheme.spacing24),
|
||||
DashboardChartWidget(
|
||||
title: 'Participation aux Événements',
|
||||
chartType: DashboardChartType.eventParticipation,
|
||||
height: 250,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportsTab() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildReportCard(
|
||||
'Rapport Mensuel',
|
||||
'Synthèse complète des activités du mois',
|
||||
Icons.calendar_month,
|
||||
DashboardTheme.royalBlue,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
_buildReportCard(
|
||||
'Rapport Financier',
|
||||
'État des contributions et finances',
|
||||
Icons.account_balance,
|
||||
DashboardTheme.tealBlue,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
_buildReportCard(
|
||||
'Rapport d\'Activité',
|
||||
'Analyse de l\'engagement des membres',
|
||||
Icons.trending_up,
|
||||
DashboardTheme.success,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
_buildReportCard(
|
||||
'Rapport Événements',
|
||||
'Statistiques des événements organisés',
|
||||
Icons.event_note,
|
||||
DashboardTheme.warning,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatsGrid() {
|
||||
return GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisSpacing: DashboardTheme.spacing16,
|
||||
mainAxisSpacing: DashboardTheme.spacing16,
|
||||
childAspectRatio: 1.2,
|
||||
children: [
|
||||
ConnectedStatsCard(
|
||||
title: 'Membres totaux',
|
||||
icon: Icons.people,
|
||||
valueExtractor: (stats) => stats.totalMembers.toString(),
|
||||
subtitleExtractor: (stats) => '${stats.activeMembers} actifs',
|
||||
customColor: DashboardTheme.royalBlue,
|
||||
),
|
||||
ConnectedStatsCard(
|
||||
title: 'Contributions',
|
||||
icon: Icons.payment,
|
||||
valueExtractor: (stats) => stats.formattedContributionAmount,
|
||||
subtitleExtractor: (stats) => '${stats.totalContributions} versements',
|
||||
customColor: DashboardTheme.tealBlue,
|
||||
),
|
||||
ConnectedStatsCard(
|
||||
title: 'Événements',
|
||||
icon: Icons.event,
|
||||
valueExtractor: (stats) => stats.totalEvents.toString(),
|
||||
subtitleExtractor: (stats) => '${stats.upcomingEvents} à venir',
|
||||
customColor: DashboardTheme.success,
|
||||
),
|
||||
ConnectedStatsCard(
|
||||
title: 'Engagement',
|
||||
icon: Icons.favorite,
|
||||
valueExtractor: (stats) => '${(stats.engagementRate * 100).toStringAsFixed(0)}%',
|
||||
subtitleExtractor: (stats) => stats.isHighEngagement ? 'Excellent' : 'Moyen',
|
||||
customColor: DashboardTheme.warning,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportCard(String title, String description, IconData icon, Color color) {
|
||||
return Container(
|
||||
decoration: DashboardTheme.cardDecoration,
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: DashboardTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Text(
|
||||
description,
|
||||
style: DashboardTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
// TODO: Générer le rapport
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.download,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFloatingActionButton() {
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
// TODO: Actions rapides
|
||||
},
|
||||
backgroundColor: DashboardTheme.royalBlue,
|
||||
foregroundColor: DashboardTheme.white,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Action'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_dashboardBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../bloc/dashboard_bloc.dart';
|
||||
import '../widgets/connected/connected_stats_card.dart';
|
||||
import '../widgets/connected/connected_recent_activities.dart';
|
||||
import '../widgets/connected/connected_upcoming_events.dart';
|
||||
import '../../../../shared/design_system/dashboard_theme.dart';
|
||||
|
||||
/// Page dashboard connectée au backend
|
||||
class ConnectedDashboardPage extends StatefulWidget {
|
||||
final String organizationId;
|
||||
final String userId;
|
||||
|
||||
const ConnectedDashboardPage({
|
||||
super.key,
|
||||
required this.organizationId,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ConnectedDashboardPage> createState() => _ConnectedDashboardPageState();
|
||||
}
|
||||
|
||||
class _ConnectedDashboardPageState extends State<ConnectedDashboardPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Charger les données du dashboard
|
||||
context.read<DashboardBloc>().add(LoadDashboardData(
|
||||
organizationId: widget.organizationId,
|
||||
userId: widget.userId,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: DashboardTheme.grey50,
|
||||
appBar: AppBar(
|
||||
title: const Text('Dashboard'),
|
||||
backgroundColor: DashboardTheme.royalBlue,
|
||||
foregroundColor: DashboardTheme.white,
|
||||
elevation: 0,
|
||||
),
|
||||
body: BlocBuilder<DashboardBloc, DashboardState>(
|
||||
builder: (context, state) {
|
||||
if (state is DashboardLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: DashboardTheme.royalBlue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is DashboardError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: DashboardTheme.error,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
const Text(
|
||||
'Erreur de chargement',
|
||||
style: DashboardTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
state.message,
|
||||
style: DashboardTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<DashboardBloc>().add(LoadDashboardData(
|
||||
organizationId: widget.organizationId,
|
||||
userId: widget.userId,
|
||||
));
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: DashboardTheme.royalBlue,
|
||||
foregroundColor: DashboardTheme.white,
|
||||
),
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is DashboardLoaded) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<DashboardBloc>().add(LoadDashboardData(
|
||||
organizationId: widget.organizationId,
|
||||
userId: widget.userId,
|
||||
));
|
||||
},
|
||||
color: DashboardTheme.royalBlue,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Statistiques
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ConnectedStatsCard(
|
||||
title: 'Membres',
|
||||
icon: Icons.people,
|
||||
valueExtractor: (stats) => stats.totalMembers.toString(),
|
||||
subtitleExtractor: (stats) => '${stats.activeMembers} actifs',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing16),
|
||||
Expanded(
|
||||
child: ConnectedStatsCard(
|
||||
title: 'Événements',
|
||||
icon: Icons.event,
|
||||
valueExtractor: (stats) => stats.totalEvents.toString(),
|
||||
subtitleExtractor: (stats) => '${stats.upcomingEvents} à venir',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing24),
|
||||
|
||||
// Activités récentes et événements à venir
|
||||
const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ConnectedRecentActivities(),
|
||||
),
|
||||
SizedBox(width: DashboardTheme.spacing16),
|
||||
Expanded(
|
||||
child: ConnectedUpcomingEvents(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Page principale du tableau de bord - Version simple
|
||||
class DashboardPage extends StatefulWidget {
|
||||
const DashboardPage({super.key});
|
||||
|
||||
@override
|
||||
State<DashboardPage> createState() => _DashboardPageState();
|
||||
}
|
||||
|
||||
class _DashboardPageState extends State<DashboardPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('UnionFlow - Tableau de bord'),
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.notifications_outlined),
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Notifications - Fonctionnalité à venir'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Paramètres - Fonctionnalité à venir'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _refreshDashboard,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Message de bienvenue
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Bienvenue sur UnionFlow',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Votre plateforme de gestion d\'union familiale',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Statistiques rapides
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Membres',
|
||||
'25',
|
||||
Icons.people,
|
||||
Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Cotisations',
|
||||
'15',
|
||||
Icons.payment,
|
||||
Colors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Événements',
|
||||
'8',
|
||||
Icons.event,
|
||||
Colors.orange,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Solidarité',
|
||||
'3',
|
||||
Icons.favorite,
|
||||
Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Actions rapides
|
||||
Text(
|
||||
'Actions rapides',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 1.5,
|
||||
children: [
|
||||
_buildActionCard(
|
||||
'Nouveau membre',
|
||||
Icons.person_add,
|
||||
Colors.blue,
|
||||
() => _showComingSoon('Nouveau membre'),
|
||||
),
|
||||
_buildActionCard(
|
||||
'Nouvelle cotisation',
|
||||
Icons.add_card,
|
||||
Colors.green,
|
||||
() => _showComingSoon('Nouvelle cotisation'),
|
||||
),
|
||||
_buildActionCard(
|
||||
'Nouvel événement',
|
||||
Icons.event_available,
|
||||
Colors.orange,
|
||||
() => _showComingSoon('Nouvel événement'),
|
||||
),
|
||||
_buildActionCard(
|
||||
'Demande d\'aide',
|
||||
Icons.help_outline,
|
||||
Colors.red,
|
||||
() => _showComingSoon('Demande d\'aide'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatCard(String title, String value, IconData icon, Color color) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 24),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionCard(String title, IconData icon, Color color, VoidCallback onTap) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showComingSoon(String feature) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$feature - Fonctionnalité à venir'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshDashboard() async {
|
||||
// Simuler un délai de rafraîchissement
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Tableau de bord actualisé'),
|
||||
duration: Duration(seconds: 2),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/// Dashboard Page Stable - Redirecteur vers Dashboard Adaptatif
|
||||
/// Redirige automatiquement vers le nouveau système de dashboard adaptatif
|
||||
library dashboard_page_stable;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'adaptive_dashboard_page.dart';
|
||||
|
||||
/// Page Dashboard Stable - Maintenant un redirecteur
|
||||
///
|
||||
/// Cette page redirige automatiquement vers le nouveau système
|
||||
/// de dashboard adaptatif basé sur les rôles utilisateurs.
|
||||
class DashboardPageStable extends StatefulWidget {
|
||||
const DashboardPageStable({super.key});
|
||||
|
||||
@override
|
||||
State<DashboardPageStable> createState() => _DashboardPageStableState();
|
||||
}
|
||||
|
||||
class _DashboardPageStableState extends State<DashboardPageStable> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Rediriger automatiquement vers le dashboard adaptatif
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_redirectToAdaptiveDashboard();
|
||||
});
|
||||
}
|
||||
|
||||
/// Redirige vers le dashboard adaptatif
|
||||
void _redirectToAdaptiveDashboard() {
|
||||
Navigator.of(context).pushReplacement(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
const AdaptiveDashboardPage(),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0.0, 0.1),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeInOutCubic,
|
||||
)),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 600),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Afficher un écran de chargement pendant la redirection
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFF6C5CE7),
|
||||
Color(0xFF5A4FCF),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Logo
|
||||
Icon(
|
||||
Icons.dashboard,
|
||||
color: Colors.white,
|
||||
size: 80,
|
||||
),
|
||||
|
||||
SizedBox(height: 24),
|
||||
|
||||
// Titre
|
||||
Text(
|
||||
'UnionFlow',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 16),
|
||||
|
||||
// Indicateur de chargement
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
strokeWidth: 3,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 16),
|
||||
|
||||
// Message
|
||||
Text(
|
||||
'Chargement de votre dashboard...',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../widgets/dashboard_widgets.dart';
|
||||
|
||||
/// Exemple de dashboard refactorisé utilisant les nouveaux composants
|
||||
///
|
||||
/// Ce fichier démontre comment créer un dashboard sophistiqué
|
||||
/// en utilisant les composants modulaires créés lors de la refactorisation.
|
||||
class ExampleRefactoredDashboard extends StatelessWidget {
|
||||
const ExampleRefactoredDashboard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F9FA),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// En-tête avec informations système et actions
|
||||
DashboardHeader.superAdmin(
|
||||
actions: [
|
||||
DashboardAction(
|
||||
icon: Icons.refresh,
|
||||
tooltip: 'Actualiser',
|
||||
onPressed: () => _handleRefresh(context),
|
||||
),
|
||||
DashboardAction(
|
||||
icon: Icons.settings,
|
||||
tooltip: 'Paramètres',
|
||||
onPressed: () => _handleSettings(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Section des KPIs système
|
||||
QuickStatsSection.systemKPIs(
|
||||
onStatTap: (stat) => _handleStatTap(context, stat),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Carte de performance serveur
|
||||
PerformanceCard.server(
|
||||
onTap: () => _handlePerformanceTap(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Section des alertes récentes
|
||||
RecentActivitiesSection.alerts(
|
||||
onActivityTap: (activity) => _handleActivityTap(context, activity),
|
||||
onViewAll: () => _handleViewAllAlerts(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Section des activités système
|
||||
RecentActivitiesSection.system(
|
||||
onActivityTap: (activity) => _handleActivityTap(context, activity),
|
||||
onViewAll: () => _handleViewAllActivities(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Section des événements à venir
|
||||
UpcomingEventsSection.systemTasks(
|
||||
onEventTap: (event) => _handleEventTap(context, event),
|
||||
onViewAll: () => _handleViewAllEvents(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Exemple de section personnalisée avec composants individuels
|
||||
_buildCustomSection(context),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Exemple de métriques de performance réseau
|
||||
PerformanceCard.network(
|
||||
onTap: () => _handleNetworkTap(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Section personnalisée utilisant les composants de base
|
||||
Widget _buildCustomSection(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionHeader.section(
|
||||
title: 'Section Personnalisée',
|
||||
subtitle: 'Exemple d\'utilisation des composants de base',
|
||||
icon: Icons.extension,
|
||||
),
|
||||
|
||||
// Grille de statistiques personnalisées
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
childAspectRatio: 1.4,
|
||||
children: [
|
||||
StatCard(
|
||||
title: 'Connexions',
|
||||
value: '1,247',
|
||||
subtitle: 'Actives maintenant',
|
||||
icon: Icons.wifi,
|
||||
color: const Color(0xFF6C5CE7),
|
||||
onTap: () => _showSnackBar(context, 'Connexions tappées'),
|
||||
),
|
||||
StatCard(
|
||||
title: 'Erreurs',
|
||||
value: '3',
|
||||
subtitle: 'Dernière heure',
|
||||
icon: Icons.error_outline,
|
||||
color: Colors.red,
|
||||
onTap: () => _showSnackBar(context, 'Erreurs tappées'),
|
||||
),
|
||||
StatCard(
|
||||
title: 'Succès',
|
||||
value: '98.7%',
|
||||
subtitle: 'Taux de réussite',
|
||||
icon: Icons.check_circle_outline,
|
||||
color: const Color(0xFF00B894),
|
||||
onTap: () => _showSnackBar(context, 'Succès tappés'),
|
||||
),
|
||||
StatCard(
|
||||
title: 'Latence',
|
||||
value: '12ms',
|
||||
subtitle: 'Moyenne',
|
||||
icon: Icons.speed,
|
||||
color: Colors.orange,
|
||||
onTap: () => _showSnackBar(context, 'Latence tappée'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Liste d'activités personnalisées
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionHeader.subsection(
|
||||
title: 'Activités Personnalisées',
|
||||
),
|
||||
ActivityItem.system(
|
||||
title: 'Configuration mise à jour',
|
||||
description: 'Paramètres de sécurité modifiés',
|
||||
timestamp: 'il y a 10min',
|
||||
onTap: () => _showSnackBar(context, 'Configuration tappée'),
|
||||
),
|
||||
ActivityItem.user(
|
||||
title: 'Nouvel administrateur',
|
||||
description: 'Jean Dupont ajouté comme admin',
|
||||
timestamp: 'il y a 1h',
|
||||
onTap: () => _showSnackBar(context, 'Administrateur tappé'),
|
||||
),
|
||||
ActivityItem.success(
|
||||
title: 'Sauvegarde terminée',
|
||||
description: 'Sauvegarde automatique réussie',
|
||||
timestamp: 'il y a 2h',
|
||||
onTap: () => _showSnackBar(context, 'Sauvegarde tappée'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Gestionnaires d'événements
|
||||
void _handleRefresh(BuildContext context) {
|
||||
_showSnackBar(context, 'Actualisation en cours...');
|
||||
}
|
||||
|
||||
void _handleSettings(BuildContext context) {
|
||||
_showSnackBar(context, 'Ouverture des paramètres...');
|
||||
}
|
||||
|
||||
void _handleStatTap(BuildContext context, QuickStat stat) {
|
||||
_showSnackBar(context, 'Statistique tappée: ${stat.title}');
|
||||
}
|
||||
|
||||
void _handlePerformanceTap(BuildContext context) {
|
||||
_showSnackBar(context, 'Ouverture des détails de performance...');
|
||||
}
|
||||
|
||||
void _handleActivityTap(BuildContext context, RecentActivity activity) {
|
||||
_showSnackBar(context, 'Activité tappée: ${activity.title}');
|
||||
}
|
||||
|
||||
void _handleEventTap(BuildContext context, UpcomingEvent event) {
|
||||
_showSnackBar(context, 'Événement tappé: ${event.title}');
|
||||
}
|
||||
|
||||
void _handleViewAllAlerts(BuildContext context) {
|
||||
_showSnackBar(context, 'Affichage de toutes les alertes...');
|
||||
}
|
||||
|
||||
void _handleViewAllActivities(BuildContext context) {
|
||||
_showSnackBar(context, 'Affichage de toutes les activités...');
|
||||
}
|
||||
|
||||
void _handleViewAllEvents(BuildContext context) {
|
||||
_showSnackBar(context, 'Affichage de tous les événements...');
|
||||
}
|
||||
|
||||
void _handleNetworkTap(BuildContext context) {
|
||||
_showSnackBar(context, 'Ouverture des métriques réseau...');
|
||||
}
|
||||
|
||||
void _showSnackBar(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: const Color(0xFF6C5CE7),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget de démonstration pour tester les composants
|
||||
class DashboardComponentsDemo extends StatelessWidget {
|
||||
const DashboardComponentsDemo({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Démo Composants Dashboard'),
|
||||
backgroundColor: const Color(0xFF6C5CE7),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: const SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionHeader.primary(
|
||||
title: 'Démonstration des Composants',
|
||||
subtitle: 'Tous les widgets refactorisés',
|
||||
icon: Icons.widgets,
|
||||
),
|
||||
|
||||
SectionHeader.section(
|
||||
title: 'En-têtes de Dashboard',
|
||||
),
|
||||
DashboardHeader.superAdmin(),
|
||||
SizedBox(height: 16),
|
||||
DashboardHeader.orgAdmin(),
|
||||
SizedBox(height: 16),
|
||||
DashboardHeader.member(),
|
||||
SizedBox(height: 24),
|
||||
|
||||
SectionHeader.section(
|
||||
title: 'Sections de Statistiques',
|
||||
),
|
||||
QuickStatsSection.systemKPIs(),
|
||||
SizedBox(height: 16),
|
||||
QuickStatsSection.organizationStats(),
|
||||
SizedBox(height: 24),
|
||||
|
||||
SectionHeader.section(
|
||||
title: 'Cartes de Performance',
|
||||
),
|
||||
PerformanceCard.server(),
|
||||
SizedBox(height: 16),
|
||||
PerformanceCard.network(),
|
||||
SizedBox(height: 24),
|
||||
|
||||
SectionHeader.section(
|
||||
title: 'Sections d\'Activités',
|
||||
),
|
||||
RecentActivitiesSection.system(),
|
||||
SizedBox(height: 16),
|
||||
RecentActivitiesSection.alerts(),
|
||||
SizedBox(height: 24),
|
||||
|
||||
SectionHeader.section(
|
||||
title: 'Événements à Venir',
|
||||
),
|
||||
UpcomingEventsSection.organization(),
|
||||
SizedBox(height: 16),
|
||||
UpcomingEventsSection.systemTasks(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
library moderator_dashboard;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../core/design_system/tokens/tokens.dart';
|
||||
import '../../widgets/widgets.dart';
|
||||
import '../../../../../shared/design_system/unionflow_design_system.dart';
|
||||
import '../../widgets/dashboard_widgets.dart';
|
||||
|
||||
/// Dashboard Management Hub pour Modérateur
|
||||
class ModeratorDashboard extends StatelessWidget {
|
||||
@@ -81,34 +81,30 @@ class ModeratorDashboard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
DashboardStatsGrid(
|
||||
stats: [
|
||||
stats: const [
|
||||
DashboardStat(
|
||||
icon: Icons.flag,
|
||||
value: '12',
|
||||
title: 'Signalements',
|
||||
color: const Color(0xFFE17055),
|
||||
onTap: () {},
|
||||
color: Color(0xFFE17055),
|
||||
),
|
||||
DashboardStat(
|
||||
icon: Icons.pending_actions,
|
||||
value: '8',
|
||||
title: 'En Attente',
|
||||
color: const Color(0xFFD63031),
|
||||
onTap: () {},
|
||||
color: Color(0xFFD63031),
|
||||
),
|
||||
DashboardStat(
|
||||
icon: Icons.check_circle,
|
||||
value: '45',
|
||||
title: 'Résolus',
|
||||
color: const Color(0xFF00B894),
|
||||
onTap: () {},
|
||||
color: Color(0xFF00B894),
|
||||
),
|
||||
DashboardStat(
|
||||
icon: Icons.people,
|
||||
value: '156',
|
||||
title: 'Membres',
|
||||
color: const Color(0xFF0984E3),
|
||||
onTap: () {},
|
||||
color: Color(0xFF0984E3),
|
||||
),
|
||||
],
|
||||
onStatTap: (type) {},
|
||||
@@ -127,37 +123,36 @@ class ModeratorDashboard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
DashboardQuickActionsGrid(
|
||||
actions: [
|
||||
children: [
|
||||
DashboardQuickAction(
|
||||
icon: Icons.gavel,
|
||||
title: 'Modérer',
|
||||
subtitle: 'Contenu signalé',
|
||||
|
||||
color: const Color(0xFFE17055),
|
||||
onTap: () {},
|
||||
),
|
||||
DashboardQuickAction(
|
||||
icon: Icons.person_remove,
|
||||
title: 'Suspendre',
|
||||
subtitle: 'Membre problématique',
|
||||
|
||||
color: const Color(0xFFD63031),
|
||||
onTap: () {},
|
||||
),
|
||||
DashboardQuickAction(
|
||||
icon: Icons.message,
|
||||
title: 'Communiquer',
|
||||
subtitle: 'Envoyer message',
|
||||
|
||||
color: const Color(0xFF0984E3),
|
||||
onTap: () {},
|
||||
),
|
||||
DashboardQuickAction(
|
||||
icon: Icons.report,
|
||||
title: 'Rapport',
|
||||
subtitle: 'Activité modération',
|
||||
|
||||
color: const Color(0xFF6C5CE7),
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
onActionTap: (type) {},
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -213,8 +208,8 @@ class ModeratorDashboard extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildRecentActivity() {
|
||||
return DashboardRecentActivitySection(
|
||||
activities: const [
|
||||
return const DashboardRecentActivitySection(
|
||||
children: [
|
||||
DashboardActivity(
|
||||
title: 'Signalement traité',
|
||||
subtitle: 'Contenu supprimé',
|
||||
@@ -230,7 +225,6 @@ class ModeratorDashboard extends StatelessWidget {
|
||||
time: 'Il y a 3h',
|
||||
),
|
||||
],
|
||||
onActivityTap: (id) {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
library org_admin_dashboard;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../core/design_system/tokens/tokens.dart';
|
||||
import '../../widgets/dashboard_widgets.dart';
|
||||
import '../../../../../shared/design_system/unionflow_design_system.dart';
|
||||
|
||||
|
||||
/// Dashboard Control Panel pour Administrateur d'Organisation
|
||||
@@ -236,7 +235,31 @@ class _OrgAdminDashboardState extends State<OrgAdminDashboard> {
|
||||
|
||||
/// Section métriques organisation
|
||||
Widget _buildOrganizationMetricsSection() {
|
||||
return const QuickStatsSection.organizationStats();
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Métriques Organisation',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text('Statistiques de l\'organisation à implémenter'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Section actions rapides admin
|
||||
@@ -482,8 +505,32 @@ class _OrgAdminDashboardState extends State<OrgAdminDashboard> {
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Remplacé par PerformanceCard pour les métriques
|
||||
const PerformanceCard.server(),
|
||||
// Métriques serveur
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Performance Serveur',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('Métriques serveur à implémenter'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -501,8 +548,32 @@ class _OrgAdminDashboardState extends State<OrgAdminDashboard> {
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Remplacé par RecentActivitiesSection
|
||||
const RecentActivitiesSection.organization(),
|
||||
// Activités récentes de l'organisation
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Activités Récentes',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('Activités de l\'organisation à implémenter'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
library simple_member_dashboard;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../core/design_system/tokens/tokens.dart';
|
||||
import '../../widgets/widgets.dart';
|
||||
import '../../../../../shared/design_system/unionflow_design_system.dart';
|
||||
import '../../widgets/dashboard_widgets.dart';
|
||||
|
||||
/// Dashboard Personal Space pour Membre Simple
|
||||
class SimpleMemberDashboard extends StatelessWidget {
|
||||
@@ -148,38 +148,33 @@ class SimpleMemberDashboard extends StatelessWidget {
|
||||
style: TypographyTokens.headlineMedium.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
DashboardStatsGrid(
|
||||
const DashboardStatsGrid(
|
||||
stats: [
|
||||
DashboardStat(
|
||||
icon: Icons.payment,
|
||||
value: 'À jour',
|
||||
title: 'Cotisations',
|
||||
color: const Color(0xFF00B894),
|
||||
onTap: () {},
|
||||
color: Color(0xFF00B894),
|
||||
),
|
||||
DashboardStat(
|
||||
icon: Icons.event,
|
||||
value: '2',
|
||||
title: 'Événements',
|
||||
color: const Color(0xFF00CEC9),
|
||||
onTap: () {},
|
||||
color: Color(0xFF00CEC9),
|
||||
),
|
||||
DashboardStat(
|
||||
icon: Icons.account_circle,
|
||||
value: '100%',
|
||||
title: 'Profil',
|
||||
color: const Color(0xFF0984E3),
|
||||
onTap: () {},
|
||||
color: Color(0xFF0984E3),
|
||||
),
|
||||
DashboardStat(
|
||||
icon: Icons.notifications,
|
||||
value: '3',
|
||||
title: 'Notifications',
|
||||
color: const Color(0xFFE17055),
|
||||
onTap: () {},
|
||||
color: Color(0xFFE17055),
|
||||
),
|
||||
],
|
||||
onStatTap: (type) {},
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -195,37 +190,32 @@ class SimpleMemberDashboard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
DashboardQuickActionsGrid(
|
||||
actions: [
|
||||
children: [
|
||||
DashboardQuickAction(
|
||||
icon: Icons.edit,
|
||||
title: 'Modifier Profil',
|
||||
subtitle: 'Mes informations',
|
||||
color: const Color(0xFF00CEC9),
|
||||
onTap: () {},
|
||||
),
|
||||
DashboardQuickAction(
|
||||
icon: Icons.payment,
|
||||
title: 'Mes Cotisations',
|
||||
subtitle: 'Historique paiements',
|
||||
color: const Color(0xFF0984E3),
|
||||
onTap: () {},
|
||||
),
|
||||
DashboardQuickAction(
|
||||
icon: Icons.event,
|
||||
title: 'Événements',
|
||||
subtitle: 'Voir les événements',
|
||||
color: const Color(0xFF00B894),
|
||||
onTap: () {},
|
||||
),
|
||||
DashboardQuickAction(
|
||||
icon: Icons.help,
|
||||
title: 'Aide',
|
||||
subtitle: 'Support & FAQ',
|
||||
color: const Color(0xFFE17055),
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
onActionTap: (type) {},
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -339,8 +329,8 @@ class SimpleMemberDashboard extends StatelessWidget {
|
||||
style: TypographyTokens.headlineMedium.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
DashboardRecentActivitySection(
|
||||
activities: const [
|
||||
const DashboardRecentActivitySection(
|
||||
children: [
|
||||
DashboardActivity(
|
||||
title: 'Cotisation payée',
|
||||
subtitle: 'Décembre 2024',
|
||||
@@ -363,7 +353,6 @@ class SimpleMemberDashboard extends StatelessWidget {
|
||||
time: 'Il y a 2 sem',
|
||||
),
|
||||
],
|
||||
onActivityTap: (id) {},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../widgets/dashboard_widgets.dart';
|
||||
|
||||
|
||||
|
||||
@@ -39,23 +38,131 @@ class _SuperAdminDashboardState extends State<SuperAdminDashboard> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header avec informations système
|
||||
const DashboardHeader.superAdmin(),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.shade200),
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Super Admin Dashboard',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.red),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('Accès complet au système'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// KPIs système en temps réel
|
||||
const QuickStatsSection.systemKPIs(),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'KPIs Système',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('Indicateurs système à implémenter'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Performance serveur
|
||||
const PerformanceCard.server(),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Performance Serveur',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('Métriques serveur à implémenter'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Alertes importantes
|
||||
const RecentActivitiesSection.alerts(),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.shade200),
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Alertes Système',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.orange),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('Alertes importantes à implémenter'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Activité récente
|
||||
const RecentActivitiesSection.system(),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Activité Système',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('Activités système à implémenter'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Actions rapides système
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
library visitor_dashboard;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../core/design_system/tokens/tokens.dart';
|
||||
import '../../../../../shared/design_system/tokens/color_tokens.dart';
|
||||
import '../../../../../shared/design_system/tokens/radius_tokens.dart';
|
||||
import '../../../../../shared/design_system/tokens/spacing_tokens.dart';
|
||||
import '../../../../../shared/design_system/tokens/typography_tokens.dart';
|
||||
|
||||
/// Dashboard Landing Experience pour Visiteur
|
||||
class VisitorDashboard extends StatelessWidget {
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
# 🚀 Widgets Dashboard Améliorés - UnionFlow Mobile
|
||||
|
||||
## 📋 Vue d'ensemble
|
||||
|
||||
Cette documentation présente les **3 widgets dashboard améliorés** avec des fonctionnalités avancées, des styles multiples et une architecture moderne.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Widgets Améliorés
|
||||
|
||||
### 1. **DashboardQuickActionButton** - Boutons d'Action Sophistiqués
|
||||
|
||||
#### ✨ Nouvelles Fonctionnalités :
|
||||
- **7 types d'actions** : `primary`, `secondary`, `success`, `warning`, `error`, `info`, `custom`
|
||||
- **6 styles** : `elevated`, `filled`, `outlined`, `text`, `gradient`, `minimal`
|
||||
- **4 tailles** : `small`, `medium`, `large`, `extraLarge`
|
||||
- **5 états** : `enabled`, `disabled`, `loading`, `success`, `error`
|
||||
- **Animations fluides** avec contrôle granulaire
|
||||
- **Feedback haptique** configurable
|
||||
- **Badges et indicateurs** visuels
|
||||
- **Icônes secondaires** pour plus de contexte
|
||||
- **Tooltips** avec descriptions détaillées
|
||||
- **Support long press** pour actions avancées
|
||||
|
||||
#### 🎨 Constructeurs Spécialisés :
|
||||
```dart
|
||||
// Action primaire
|
||||
DashboardQuickAction.primary(
|
||||
icon: Icons.person_add,
|
||||
title: 'Ajouter Membre',
|
||||
subtitle: 'Nouveau',
|
||||
badge: '+',
|
||||
onTap: () => handleAction(),
|
||||
)
|
||||
|
||||
// Action avec gradient
|
||||
DashboardQuickAction.gradient(
|
||||
icon: Icons.star,
|
||||
title: 'Premium',
|
||||
gradient: LinearGradient(...),
|
||||
onTap: () => handlePremium(),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. **DashboardQuickActionsGrid** - Grilles Flexibles et Responsives
|
||||
|
||||
#### ✨ Nouvelles Fonctionnalités :
|
||||
- **7 layouts** : `grid2x2`, `grid3x2`, `grid4x2`, `horizontal`, `vertical`, `staggered`, `carousel`
|
||||
- **5 styles** : `standard`, `compact`, `expanded`, `minimal`, `card`
|
||||
- **Animations d'apparition** avec délais configurables
|
||||
- **Filtrage par permissions** utilisateur
|
||||
- **Limitation du nombre d'actions** affichées
|
||||
- **Support "Voir tout"** pour navigation
|
||||
- **Mode debug** pour développement
|
||||
- **Responsive design** adaptatif
|
||||
|
||||
#### 🎨 Constructeurs Spécialisés :
|
||||
```dart
|
||||
// Grille compacte
|
||||
DashboardQuickActionsGrid.compact(
|
||||
title: 'Actions Rapides',
|
||||
onActionTap: (type) => handleAction(type),
|
||||
)
|
||||
|
||||
// Carrousel horizontal
|
||||
DashboardQuickActionsGrid.carousel(
|
||||
title: 'Actions Populaires',
|
||||
animated: true,
|
||||
)
|
||||
|
||||
// Grille étendue avec "Voir tout"
|
||||
DashboardQuickActionsGrid.expanded(
|
||||
title: 'Toutes les Actions',
|
||||
subtitle: 'Accès complet',
|
||||
onSeeAll: () => navigateToAllActions(),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. **DashboardStatsCard** - Cartes de Statistiques Avancées
|
||||
|
||||
#### ✨ Nouvelles Fonctionnalités :
|
||||
- **7 types de stats** : `count`, `percentage`, `currency`, `duration`, `rate`, `score`, `custom`
|
||||
- **7 styles** : `standard`, `minimal`, `elevated`, `outlined`, `gradient`, `compact`, `detailed`
|
||||
- **4 tailles** : `small`, `medium`, `large`, `extraLarge`
|
||||
- **Indicateurs de tendance** : `up`, `down`, `stable`, `unknown`
|
||||
- **Comparaisons temporelles** avec pourcentages de changement
|
||||
- **Graphiques miniatures** (sparklines)
|
||||
- **Badges et notifications** visuels
|
||||
- **Formatage automatique** des valeurs
|
||||
- **Animations d'apparition** sophistiquées
|
||||
|
||||
#### 🎨 Constructeurs Spécialisés :
|
||||
```dart
|
||||
// Statistique de comptage
|
||||
DashboardStat.count(
|
||||
icon: Icons.people,
|
||||
value: '1,247',
|
||||
title: 'Membres Actifs',
|
||||
changePercentage: 12.5,
|
||||
trend: StatTrend.up,
|
||||
period: 'ce mois',
|
||||
)
|
||||
|
||||
// Statistique avec devise
|
||||
DashboardStat.currency(
|
||||
icon: Icons.euro,
|
||||
value: '45,230',
|
||||
title: 'Revenus',
|
||||
sparklineData: [100, 120, 110, 140, 135, 160],
|
||||
style: StatCardStyle.detailed,
|
||||
)
|
||||
|
||||
// Statistique avec gradient
|
||||
DashboardStat.gradient(
|
||||
icon: Icons.star,
|
||||
value: '4.8',
|
||||
title: 'Satisfaction',
|
||||
gradient: LinearGradient(...),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Utilisation Pratique
|
||||
|
||||
### Import des Widgets :
|
||||
```dart
|
||||
import 'dashboard_quick_action_button.dart';
|
||||
import 'dashboard_quick_actions_grid.dart';
|
||||
import 'dashboard_stats_card.dart';
|
||||
```
|
||||
|
||||
### Exemple d'Intégration :
|
||||
```dart
|
||||
Column(
|
||||
children: [
|
||||
// Grille d'actions rapides
|
||||
DashboardQuickActionsGrid.expanded(
|
||||
title: 'Actions Principales',
|
||||
onActionTap: (type) => _handleQuickAction(type),
|
||||
userPermissions: currentUser.permissions,
|
||||
),
|
||||
|
||||
SizedBox(height: 20),
|
||||
|
||||
// Statistiques en grille
|
||||
GridView.count(
|
||||
crossAxisCount: 2,
|
||||
children: [
|
||||
DashboardStatsCard(
|
||||
stat: DashboardStat.count(
|
||||
icon: Icons.people,
|
||||
value: '${memberCount}',
|
||||
title: 'Membres',
|
||||
changePercentage: memberGrowth,
|
||||
trend: memberTrend,
|
||||
),
|
||||
),
|
||||
// ... autres stats
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design System
|
||||
|
||||
### Couleurs Utilisées :
|
||||
- **Primary** : `#6C5CE7` (Violet principal)
|
||||
- **Success** : `#00B894` (Vert succès)
|
||||
- **Warning** : `#FDCB6E` (Orange alerte)
|
||||
- **Error** : `#E17055` (Rouge erreur)
|
||||
|
||||
### Espacements :
|
||||
- **Small** : `8px`
|
||||
- **Medium** : `16px`
|
||||
- **Large** : `24px`
|
||||
- **Extra Large** : `32px`
|
||||
|
||||
### Animations :
|
||||
- **Durée standard** : `200ms`
|
||||
- **Courbe** : `Curves.easeOutBack`
|
||||
- **Délai entre éléments** : `100ms`
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Test et Démonstration
|
||||
|
||||
### Page de Test :
|
||||
```dart
|
||||
import 'test_improved_widgets.dart';
|
||||
|
||||
// Navigation vers la page de test
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TestImprovedWidgetsPage(),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
### Fonctionnalités Testées :
|
||||
- ✅ Tous les styles et tailles
|
||||
- ✅ Animations et transitions
|
||||
- ✅ Feedback haptique
|
||||
- ✅ Gestion des états
|
||||
- ✅ Responsive design
|
||||
- ✅ Accessibilité
|
||||
|
||||
---
|
||||
|
||||
## 📊 Métriques d'Amélioration
|
||||
|
||||
### Performance :
|
||||
- **Réduction du code** : -60% de duplication
|
||||
- **Temps de développement** : -75% pour nouveaux dashboards
|
||||
- **Maintenance** : +80% plus facile
|
||||
|
||||
### Fonctionnalités :
|
||||
- **Styles disponibles** : 6x plus qu'avant
|
||||
- **Layouts supportés** : 7 types différents
|
||||
- **États gérés** : 5 états interactifs
|
||||
- **Animations** : 100% fluides et configurables
|
||||
|
||||
### Dimensions Optimisées :
|
||||
- **Largeur des boutons** : Réduite de 50% (140px → 100px)
|
||||
- **Hauteur des boutons** : Optimisée (100px → 70px)
|
||||
- **Format rectangulaire** : Ratio d'aspect 1.6 au lieu de 2.2
|
||||
- **Bordures** : Moins arrondies (12px → 6px)
|
||||
- **Espacement** : Réduit pour plus de compacité
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Prochaines Étapes
|
||||
|
||||
1. **Tests unitaires** complets
|
||||
2. **Documentation API** détaillée
|
||||
3. **Exemples d'usage** avancés
|
||||
4. **Intégration** dans tous les dashboards
|
||||
5. **Optimisations** de performance
|
||||
|
||||
---
|
||||
|
||||
**Les widgets dashboard UnionFlow Mobile sont maintenant de niveau professionnel avec une architecture moderne et des fonctionnalités avancées !** 🎯✨
|
||||
@@ -0,0 +1,410 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../domain/entities/dashboard_entity.dart';
|
||||
import '../../bloc/dashboard_bloc.dart';
|
||||
import '../../../../../shared/design_system/dashboard_theme.dart';
|
||||
|
||||
/// Widget de graphique pour le dashboard
|
||||
class DashboardChartWidget extends StatelessWidget {
|
||||
final String title;
|
||||
final DashboardChartType chartType;
|
||||
final double height;
|
||||
|
||||
const DashboardChartWidget({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.chartType,
|
||||
this.height = 200,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: DashboardTheme.cardDecoration,
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
SizedBox(
|
||||
height: height,
|
||||
child: BlocBuilder<DashboardBloc, DashboardState>(
|
||||
builder: (context, state) {
|
||||
if (state is DashboardLoading) {
|
||||
return _buildLoadingChart();
|
||||
} else if (state is DashboardLoaded || state is DashboardRefreshing) {
|
||||
final data = state is DashboardLoaded
|
||||
? state.dashboardData
|
||||
: (state as DashboardRefreshing).dashboardData;
|
||||
return _buildChart(data);
|
||||
} else if (state is DashboardError) {
|
||||
return _buildErrorChart();
|
||||
}
|
||||
return _buildEmptyChart();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing8),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.royalBlue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: Icon(
|
||||
_getChartIcon(),
|
||||
color: DashboardTheme.royalBlue,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: DashboardTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChart(DashboardEntity data) {
|
||||
switch (chartType) {
|
||||
case DashboardChartType.memberActivity:
|
||||
return _buildMemberActivityChart(data.stats);
|
||||
case DashboardChartType.contributionTrend:
|
||||
return _buildContributionTrendChart(data.stats);
|
||||
case DashboardChartType.eventParticipation:
|
||||
return _buildEventParticipationChart(data.upcomingEvents);
|
||||
case DashboardChartType.monthlyGrowth:
|
||||
return _buildMonthlyGrowthChart(data.stats);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMemberActivityChart(DashboardStatsEntity stats) {
|
||||
return PieChart(
|
||||
PieChartData(
|
||||
sectionsSpace: 2,
|
||||
centerSpaceRadius: 40,
|
||||
sections: [
|
||||
PieChartSectionData(
|
||||
color: DashboardTheme.success,
|
||||
value: stats.activeMembers.toDouble(),
|
||||
title: '${stats.activeMembers}',
|
||||
radius: 50,
|
||||
titleStyle: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
PieChartSectionData(
|
||||
color: DashboardTheme.grey300,
|
||||
value: (stats.totalMembers - stats.activeMembers).toDouble(),
|
||||
title: '${stats.totalMembers - stats.activeMembers}',
|
||||
radius: 45,
|
||||
titleStyle: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.grey700,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContributionTrendChart(DashboardStatsEntity stats) {
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: stats.totalContributionAmount / 4,
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return const FlLine(
|
||||
color: DashboardTheme.grey200,
|
||||
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', 'Jun'];
|
||||
if (value.toInt() >= 0 && value.toInt() < months.length) {
|
||||
return Text(
|
||||
months[value.toInt()],
|
||||
style: DashboardTheme.bodySmall,
|
||||
);
|
||||
}
|
||||
return const Text('');
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
interval: stats.totalContributionAmount / 4,
|
||||
reservedSize: 60,
|
||||
getTitlesWidget: (double value, TitleMeta meta) {
|
||||
return Text(
|
||||
'${(value / 1000).toStringAsFixed(0)}K',
|
||||
style: DashboardTheme.bodySmall,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
minX: 0,
|
||||
maxX: 5,
|
||||
minY: 0,
|
||||
maxY: stats.totalContributionAmount,
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: _generateContributionSpots(stats),
|
||||
isCurved: true,
|
||||
gradient: const LinearGradient(
|
||||
colors: [
|
||||
DashboardTheme.tealBlue,
|
||||
DashboardTheme.royalBlue,
|
||||
],
|
||||
),
|
||||
barWidth: 3,
|
||||
isStrokeCapRound: true,
|
||||
dotData: const FlDotData(show: true),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
DashboardTheme.tealBlue.withOpacity(0.3),
|
||||
DashboardTheme.royalBlue.withOpacity(0.1),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEventParticipationChart(List<UpcomingEventEntity> events) {
|
||||
if (events.isEmpty) {
|
||||
return _buildEmptyChart();
|
||||
}
|
||||
|
||||
return BarChart(
|
||||
BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: events.map((e) => e.maxParticipants).reduce((a, b) => a > b ? a : b).toDouble(),
|
||||
barTouchData: BarTouchData(enabled: false),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (double value, TitleMeta meta) {
|
||||
if (value.toInt() < events.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
events[value.toInt()].title.length > 8
|
||||
? '${events[value.toInt()].title.substring(0, 8)}...'
|
||||
: events[value.toInt()].title,
|
||||
style: DashboardTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Text('');
|
||||
},
|
||||
reservedSize: 40,
|
||||
),
|
||||
),
|
||||
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
barGroups: events.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final event = entry.value;
|
||||
return BarChartGroupData(
|
||||
x: index,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: event.currentParticipants.toDouble(),
|
||||
color: event.isFull
|
||||
? DashboardTheme.error
|
||||
: event.isAlmostFull
|
||||
? DashboardTheme.warning
|
||||
: DashboardTheme.success,
|
||||
width: 16,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(4),
|
||||
topRight: Radius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthlyGrowthChart(DashboardStatsEntity stats) {
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
gridData: const FlGridData(show: false),
|
||||
titlesData: const FlTitlesData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
minX: 0,
|
||||
maxX: 11,
|
||||
minY: -5,
|
||||
maxY: 20,
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: _generateGrowthSpots(stats.monthlyGrowth),
|
||||
isCurved: true,
|
||||
color: stats.hasGrowth ? DashboardTheme.success : DashboardTheme.error,
|
||||
barWidth: 3,
|
||||
isStrokeCapRound: true,
|
||||
dotData: const FlDotData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: (stats.hasGrowth ? DashboardTheme.success : DashboardTheme.error)
|
||||
.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<FlSpot> _generateContributionSpots(DashboardStatsEntity stats) {
|
||||
final baseAmount = stats.totalContributionAmount / 6;
|
||||
return [
|
||||
FlSpot(0, baseAmount * 0.8),
|
||||
FlSpot(1, baseAmount * 1.2),
|
||||
FlSpot(2, baseAmount * 0.9),
|
||||
FlSpot(3, baseAmount * 1.5),
|
||||
FlSpot(4, baseAmount * 1.1),
|
||||
FlSpot(5, baseAmount * 1.3),
|
||||
];
|
||||
}
|
||||
|
||||
List<FlSpot> _generateGrowthSpots(double currentGrowth) {
|
||||
final baseGrowth = currentGrowth;
|
||||
return List.generate(12, (index) {
|
||||
final variation = (index % 3 - 1) * 2.0;
|
||||
return FlSpot(index.toDouble(), baseGrowth + variation);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildLoadingChart() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey100,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: DashboardTheme.royalBlue,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorChart() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: DashboardTheme.error,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
'Erreur de chargement',
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyChart() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey50,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.bar_chart,
|
||||
color: DashboardTheme.grey400,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
'Aucune donnée',
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getChartIcon() {
|
||||
switch (chartType) {
|
||||
case DashboardChartType.memberActivity:
|
||||
return Icons.pie_chart;
|
||||
case DashboardChartType.contributionTrend:
|
||||
return Icons.trending_up;
|
||||
case DashboardChartType.eventParticipation:
|
||||
return Icons.bar_chart;
|
||||
case DashboardChartType.monthlyGrowth:
|
||||
return Icons.show_chart;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DashboardChartType {
|
||||
memberActivity,
|
||||
contributionTrend,
|
||||
eventParticipation,
|
||||
monthlyGrowth,
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../shared/design_system/unionflow_design_system.dart';
|
||||
|
||||
/// Widget réutilisable pour afficher un élément d'activité
|
||||
///
|
||||
///
|
||||
/// Composant standardisé pour les listes d'activités récentes,
|
||||
/// notifications, historiques, etc.
|
||||
///
|
||||
/// REFACTORISÉ pour utiliser le Design System UnionFlow.
|
||||
class ActivityItem extends StatelessWidget {
|
||||
/// Titre principal de l'activité
|
||||
final String title;
|
||||
@@ -53,7 +56,7 @@ class ActivityItem extends StatelessWidget {
|
||||
required this.timestamp,
|
||||
this.onTap,
|
||||
}) : icon = Icons.settings,
|
||||
color = const Color(0xFF6C5CE7),
|
||||
color = ColorTokens.primary,
|
||||
type = ActivityType.system,
|
||||
style = ActivityItemStyle.normal,
|
||||
showStatusIndicator = true;
|
||||
@@ -66,7 +69,7 @@ class ActivityItem extends StatelessWidget {
|
||||
required this.timestamp,
|
||||
this.onTap,
|
||||
}) : icon = Icons.person,
|
||||
color = const Color(0xFF00B894),
|
||||
color = ColorTokens.success,
|
||||
type = ActivityType.user,
|
||||
style = ActivityItemStyle.normal,
|
||||
showStatusIndicator = true;
|
||||
@@ -79,7 +82,7 @@ class ActivityItem extends StatelessWidget {
|
||||
required this.timestamp,
|
||||
this.onTap,
|
||||
}) : icon = Icons.warning,
|
||||
color = Colors.orange,
|
||||
color = ColorTokens.warning,
|
||||
type = ActivityType.alert,
|
||||
style = ActivityItemStyle.alert,
|
||||
showStatusIndicator = true;
|
||||
@@ -339,24 +342,24 @@ class ActivityItem extends StatelessWidget {
|
||||
/// Couleur effective selon le type
|
||||
Color _getEffectiveColor() {
|
||||
if (color != null) return color!;
|
||||
|
||||
|
||||
switch (type) {
|
||||
case ActivityType.system:
|
||||
return const Color(0xFF6C5CE7);
|
||||
return ColorTokens.primary;
|
||||
case ActivityType.user:
|
||||
return const Color(0xFF00B894);
|
||||
return ColorTokens.success;
|
||||
case ActivityType.organization:
|
||||
return const Color(0xFF0984E3);
|
||||
return ColorTokens.info;
|
||||
case ActivityType.event:
|
||||
return const Color(0xFFE17055);
|
||||
return ColorTokens.secondary;
|
||||
case ActivityType.alert:
|
||||
return Colors.orange;
|
||||
return ColorTokens.warning;
|
||||
case ActivityType.error:
|
||||
return Colors.red;
|
||||
return ColorTokens.error;
|
||||
case ActivityType.success:
|
||||
return const Color(0xFF00B894);
|
||||
return ColorTokens.success;
|
||||
case null:
|
||||
return const Color(0xFF6C5CE7);
|
||||
return ColorTokens.primary;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../shared/design_system/unionflow_design_system.dart';
|
||||
|
||||
/// Widget réutilisable pour les en-têtes de section
|
||||
///
|
||||
///
|
||||
/// Composant standardisé pour tous les titres de section dans les dashboards
|
||||
/// avec support pour actions, sous-titres et styles personnalisés.
|
||||
///
|
||||
/// REFACTORISÉ pour utiliser le Design System UnionFlow.
|
||||
class SectionHeader extends StatelessWidget {
|
||||
/// Titre principal de la section
|
||||
final String title;
|
||||
@@ -48,7 +51,7 @@ class SectionHeader extends StatelessWidget {
|
||||
this.subtitle,
|
||||
this.action,
|
||||
this.icon,
|
||||
}) : color = const Color(0xFF6C5CE7),
|
||||
}) : color = ColorTokens.primary,
|
||||
fontSize = 20,
|
||||
style = SectionHeaderStyle.primary,
|
||||
bottomSpacing = 16;
|
||||
@@ -60,7 +63,7 @@ class SectionHeader extends StatelessWidget {
|
||||
this.subtitle,
|
||||
this.action,
|
||||
this.icon,
|
||||
}) : color = const Color(0xFF6C5CE7),
|
||||
}) : color = ColorTokens.primary,
|
||||
fontSize = 16,
|
||||
style = SectionHeaderStyle.normal,
|
||||
bottomSpacing = 12;
|
||||
@@ -100,25 +103,21 @@ class SectionHeader extends StatelessWidget {
|
||||
|
||||
/// En-tête principal avec fond coloré
|
||||
Widget _buildPrimaryHeader() {
|
||||
final effectiveColor = color ?? ColorTokens.primary;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
color ?? const Color(0xFF6C5CE7),
|
||||
(color ?? const Color(0xFF6C5CE7)).withOpacity(0.8),
|
||||
effectiveColor,
|
||||
effectiveColor.withOpacity(0.8),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: (color ?? const Color(0xFF6C5CE7)).withOpacity(0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
|
||||
boxShadow: ShadowTokens.primary,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -175,10 +174,10 @@ class SectionHeader extends StatelessWidget {
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
color: color ?? const Color(0xFF6C5CE7),
|
||||
color: color ?? ColorTokens.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: SpacingTokens.md),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -189,7 +188,7 @@ class SectionHeader extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: fontSize ?? 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color ?? const Color(0xFF6C5CE7),
|
||||
color: color ?? ColorTokens.primary,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
@@ -257,10 +256,10 @@ class SectionHeader extends StatelessWidget {
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
color: color ?? const Color(0xFF6C5CE7),
|
||||
color: color ?? ColorTokens.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: SpacingTokens.md),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -271,7 +270,7 @@ class SectionHeader extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: fontSize ?? 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color ?? const Color(0xFF6C5CE7),
|
||||
color: color ?? ColorTokens.primary,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../../shared/design_system/unionflow_design_system.dart';
|
||||
|
||||
/// Carte de performance système réutilisable
|
||||
///
|
||||
///
|
||||
/// Widget spécialisé pour afficher les métriques de performance
|
||||
/// avec barres de progression et indicateurs colorés.
|
||||
///
|
||||
/// REFACTORISÉ pour utiliser le Design System UnionFlow.
|
||||
class PerformanceCard extends StatelessWidget {
|
||||
/// Titre de la carte
|
||||
final String title;
|
||||
@@ -48,21 +51,21 @@ class PerformanceCard extends StatelessWidget {
|
||||
label: 'CPU',
|
||||
value: 67.3,
|
||||
unit: '%',
|
||||
color: Colors.orange,
|
||||
color: ColorTokens.warning,
|
||||
threshold: 80,
|
||||
),
|
||||
PerformanceMetric(
|
||||
label: 'RAM',
|
||||
value: 78.5,
|
||||
unit: '%',
|
||||
color: Colors.blue,
|
||||
color: ColorTokens.info,
|
||||
threshold: 85,
|
||||
),
|
||||
PerformanceMetric(
|
||||
label: 'Disque',
|
||||
value: 45.2,
|
||||
unit: '%',
|
||||
color: Colors.green,
|
||||
color: ColorTokens.success,
|
||||
threshold: 90,
|
||||
),
|
||||
],
|
||||
@@ -81,21 +84,21 @@ class PerformanceCard extends StatelessWidget {
|
||||
label: 'Latence',
|
||||
value: 12.0,
|
||||
unit: 'ms',
|
||||
color: Color(0xFF00B894),
|
||||
color: ColorTokens.success,
|
||||
threshold: 100.0,
|
||||
),
|
||||
PerformanceMetric(
|
||||
label: 'Débit',
|
||||
value: 85.0,
|
||||
unit: 'Mbps',
|
||||
color: Color(0xFF6C5CE7),
|
||||
color: ColorTokens.primary,
|
||||
threshold: 100.0,
|
||||
),
|
||||
PerformanceMetric(
|
||||
label: 'Paquets perdus',
|
||||
value: 0.2,
|
||||
unit: '%',
|
||||
color: Color(0xFFE17055),
|
||||
color: ColorTokens.secondary,
|
||||
threshold: 5.0,
|
||||
),
|
||||
],
|
||||
@@ -107,14 +110,13 @@ class PerformanceCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: _getDecoration(),
|
||||
child: UFCard(
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: SpacingTokens.lg),
|
||||
_buildMetrics(),
|
||||
],
|
||||
),
|
||||
@@ -129,19 +131,17 @@ class PerformanceCard extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
style: TypographyTokens.titleMedium.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF6C5CE7),
|
||||
color: ColorTokens.primary,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -153,7 +153,7 @@ class PerformanceCard extends StatelessWidget {
|
||||
Widget _buildMetrics() {
|
||||
return Column(
|
||||
children: metrics.map((metric) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.only(bottom: SpacingTokens.md),
|
||||
child: _buildMetricRow(metric),
|
||||
)).toList(),
|
||||
);
|
||||
@@ -163,12 +163,12 @@ class PerformanceCard extends StatelessWidget {
|
||||
Widget _buildMetricRow(PerformanceMetric metric) {
|
||||
final isWarning = metric.value > metric.threshold * 0.8;
|
||||
final isCritical = metric.value > metric.threshold;
|
||||
|
||||
|
||||
Color effectiveColor = metric.color;
|
||||
if (isCritical) {
|
||||
effectiveColor = Colors.red;
|
||||
effectiveColor = ColorTokens.error;
|
||||
} else if (isWarning) {
|
||||
effectiveColor = Colors.orange;
|
||||
effectiveColor = ColorTokens.warning;
|
||||
}
|
||||
|
||||
return Column(
|
||||
@@ -183,28 +183,26 @@ class PerformanceCard extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: SpacingTokens.md),
|
||||
Text(
|
||||
metric.label,
|
||||
style: const TextStyle(
|
||||
style: TypographyTokens.labelMedium.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showValues)
|
||||
Text(
|
||||
'${metric.value.toStringAsFixed(1)}${metric.unit}',
|
||||
style: TextStyle(
|
||||
style: TypographyTokens.labelMedium.copyWith(
|
||||
color: effectiveColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showProgressBars) ...[
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
_buildProgressBar(metric, effectiveColor),
|
||||
],
|
||||
],
|
||||
@@ -214,12 +212,12 @@ class PerformanceCard extends StatelessWidget {
|
||||
/// Barre de progression
|
||||
Widget _buildProgressBar(PerformanceMetric metric, Color color) {
|
||||
final progress = (metric.value / metric.threshold).clamp(0.0, 1.0);
|
||||
|
||||
|
||||
return Container(
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
color: ColorTokens.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusXs),
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
@@ -227,44 +225,14 @@ class PerformanceCard extends StatelessWidget {
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusXs),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Décoration selon le style
|
||||
BoxDecoration _getDecoration() {
|
||||
switch (style) {
|
||||
case PerformanceCardStyle.elevated:
|
||||
return BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
);
|
||||
case PerformanceCardStyle.outlined:
|
||||
return BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF6C5CE7).withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
);
|
||||
case PerformanceCardStyle.minimal:
|
||||
return BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Modèle de données pour une métrique de performance
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../domain/entities/dashboard_entity.dart';
|
||||
import '../../bloc/dashboard_bloc.dart';
|
||||
import '../../../../../shared/design_system/dashboard_theme.dart';
|
||||
|
||||
/// Widget des activités récentes connecté au backend
|
||||
class ConnectedRecentActivities extends StatelessWidget {
|
||||
final int maxItems;
|
||||
final VoidCallback? onSeeAll;
|
||||
|
||||
const ConnectedRecentActivities({
|
||||
super.key,
|
||||
this.maxItems = 5,
|
||||
this.onSeeAll,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: DashboardTheme.cardDecoration,
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
BlocBuilder<DashboardBloc, DashboardState>(
|
||||
builder: (context, state) {
|
||||
if (state is DashboardLoading) {
|
||||
return _buildLoadingList();
|
||||
} else if (state is DashboardLoaded || state is DashboardRefreshing) {
|
||||
final data = state is DashboardLoaded
|
||||
? state.dashboardData
|
||||
: (state as DashboardRefreshing).dashboardData;
|
||||
return _buildActivitiesList(data.recentActivities);
|
||||
} else if (state is DashboardError) {
|
||||
return _buildErrorState(state.message);
|
||||
}
|
||||
return _buildEmptyState();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing8),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.tealBlue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.history,
|
||||
color: DashboardTheme.tealBlue,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Activités récentes',
|
||||
style: DashboardTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (onSeeAll != null)
|
||||
TextButton(
|
||||
onPressed: onSeeAll,
|
||||
child: Text(
|
||||
'Voir tout',
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.royalBlue,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActivitiesList(List<RecentActivityEntity> activities) {
|
||||
if (activities.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
final displayActivities = activities.take(maxItems).toList();
|
||||
|
||||
return Column(
|
||||
children: displayActivities.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final activity = entry.value;
|
||||
final isLast = index == displayActivities.length - 1;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildActivityItem(activity),
|
||||
if (!isLast) const SizedBox(height: DashboardTheme.spacing12),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActivityItem(RecentActivityEntity activity) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Avatar ou icône
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: _getActivityColor(activity.type).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: activity.userAvatar != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Image.network(
|
||||
activity.userAvatar!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
_getActivityIcon(activity.type),
|
||||
color: _getActivityColor(activity.type),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_getActivityIcon(activity.type),
|
||||
color: _getActivityColor(activity.type),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
// Contenu
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
activity.title,
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Text(
|
||||
activity.description,
|
||||
style: DashboardTheme.bodySmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
activity.userName,
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: DashboardTheme.royalBlue,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' • ${activity.timeAgo}',
|
||||
style: DashboardTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Action button si disponible
|
||||
if (activity.hasAction)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
// TODO: Naviguer vers l'action
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 16,
|
||||
color: DashboardTheme.grey400,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingList() {
|
||||
return Column(
|
||||
children: List.generate(3, (index) => Column(
|
||||
children: [
|
||||
_buildLoadingItem(),
|
||||
if (index < 2) const SizedBox(height: DashboardTheme.spacing12),
|
||||
],
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingItem() {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey200,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 16,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey200,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Container(
|
||||
height: 12,
|
||||
width: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Container(
|
||||
height: 12,
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(String message) {
|
||||
return Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: DashboardTheme.error,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
'Erreur de chargement',
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Text(
|
||||
message,
|
||||
style: DashboardTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.history,
|
||||
color: DashboardTheme.grey400,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
'Aucune activité récente',
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.grey500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
const Text(
|
||||
'Les activités apparaîtront ici',
|
||||
style: DashboardTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getActivityIcon(String type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'member':
|
||||
return Icons.person_add;
|
||||
case 'event':
|
||||
return Icons.event;
|
||||
case 'contribution':
|
||||
return Icons.payment;
|
||||
case 'organization':
|
||||
return Icons.business;
|
||||
case 'system':
|
||||
return Icons.settings;
|
||||
default:
|
||||
return Icons.notifications;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getActivityColor(String type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'member':
|
||||
return DashboardTheme.success;
|
||||
case 'event':
|
||||
return DashboardTheme.info;
|
||||
case 'contribution':
|
||||
return DashboardTheme.tealBlue;
|
||||
case 'organization':
|
||||
return DashboardTheme.royalBlue;
|
||||
case 'system':
|
||||
return DashboardTheme.warning;
|
||||
default:
|
||||
return DashboardTheme.grey500;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../domain/entities/dashboard_entity.dart';
|
||||
import '../../bloc/dashboard_bloc.dart';
|
||||
import '../../../../../shared/design_system/dashboard_theme.dart';
|
||||
|
||||
/// Widget de carte de statistiques connecté au backend
|
||||
class ConnectedStatsCard extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final String Function(DashboardStatsEntity) valueExtractor;
|
||||
final String? Function(DashboardStatsEntity)? subtitleExtractor;
|
||||
final Color? customColor;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const ConnectedStatsCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.valueExtractor,
|
||||
this.subtitleExtractor,
|
||||
this.customColor,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<DashboardBloc, DashboardState>(
|
||||
builder: (context, state) {
|
||||
if (state is DashboardLoading) {
|
||||
return _buildLoadingCard();
|
||||
} else if (state is DashboardLoaded || state is DashboardRefreshing) {
|
||||
final data = state is DashboardLoaded
|
||||
? state.dashboardData
|
||||
: (state as DashboardRefreshing).dashboardData;
|
||||
return _buildDataCard(data.stats);
|
||||
} else if (state is DashboardError) {
|
||||
return _buildErrorCard(state.message);
|
||||
}
|
||||
return _buildLoadingCard();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDataCard(DashboardStatsEntity stats) {
|
||||
final value = valueExtractor(stats);
|
||||
final subtitle = subtitleExtractor?.call(stats);
|
||||
final color = customColor ?? DashboardTheme.royalBlue;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: DashboardTheme.cardDecoration,
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: DashboardTheme.titleSmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
Text(
|
||||
value,
|
||||
style: DashboardTheme.metricLarge.copyWith(color: color),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: DashboardTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingCard() {
|
||||
return Container(
|
||||
decoration: DashboardTheme.cardDecoration,
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey200,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey200,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
Container(
|
||||
height: 32,
|
||||
width: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey200,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Container(
|
||||
height: 12,
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorCard(String message) {
|
||||
return Container(
|
||||
decoration: DashboardTheme.cardDecoration,
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing8),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.error_outline,
|
||||
color: DashboardTheme.error,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: DashboardTheme.titleSmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
Text(
|
||||
'--',
|
||||
style: DashboardTheme.metricLarge.copyWith(
|
||||
color: DashboardTheme.grey400,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Text(
|
||||
message,
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../domain/entities/dashboard_entity.dart';
|
||||
import '../../bloc/dashboard_bloc.dart';
|
||||
import '../../../../../shared/design_system/dashboard_theme.dart';
|
||||
|
||||
/// Widget des événements à venir connecté au backend
|
||||
class ConnectedUpcomingEvents extends StatelessWidget {
|
||||
final int maxItems;
|
||||
final VoidCallback? onSeeAll;
|
||||
|
||||
const ConnectedUpcomingEvents({
|
||||
super.key,
|
||||
this.maxItems = 3,
|
||||
this.onSeeAll,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: DashboardTheme.cardDecoration,
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
BlocBuilder<DashboardBloc, DashboardState>(
|
||||
builder: (context, state) {
|
||||
if (state is DashboardLoading) {
|
||||
return _buildLoadingList();
|
||||
} else if (state is DashboardLoaded || state is DashboardRefreshing) {
|
||||
final data = state is DashboardLoaded
|
||||
? state.dashboardData
|
||||
: (state as DashboardRefreshing).dashboardData;
|
||||
return _buildEventsList(data.upcomingEvents);
|
||||
} else if (state is DashboardError) {
|
||||
return _buildErrorState(state.message);
|
||||
}
|
||||
return _buildEmptyState();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing8),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.royalBlue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.event,
|
||||
color: DashboardTheme.royalBlue,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Événements à venir',
|
||||
style: DashboardTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (onSeeAll != null)
|
||||
TextButton(
|
||||
onPressed: onSeeAll,
|
||||
child: Text(
|
||||
'Voir tout',
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.royalBlue,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEventsList(List<UpcomingEventEntity> events) {
|
||||
if (events.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
final displayEvents = events.take(maxItems).toList();
|
||||
|
||||
return Column(
|
||||
children: displayEvents.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final event = entry.value;
|
||||
final isLast = index == displayEvents.length - 1;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildEventCard(event),
|
||||
if (!isLast) const SizedBox(height: DashboardTheme.spacing12),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEventCard(UpcomingEventEntity event) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey50,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
border: Border.all(
|
||||
color: event.isToday
|
||||
? DashboardTheme.success
|
||||
: event.isTomorrow
|
||||
? DashboardTheme.warning
|
||||
: DashboardTheme.grey200,
|
||||
width: event.isToday || event.isTomorrow ? 2 : 1,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// Image ou icône
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.royalBlue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: event.imageUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
child: Image.network(
|
||||
event.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => const Icon(
|
||||
Icons.event,
|
||||
color: DashboardTheme.royalBlue,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.event,
|
||||
color: DashboardTheme.royalBlue,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
// Contenu principal
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
event.title,
|
||||
style: DashboardTheme.titleSmall,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.location_on,
|
||||
size: 14,
|
||||
color: DashboardTheme.grey500,
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
event.location,
|
||||
style: DashboardTheme.bodySmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Badge de temps
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DashboardTheme.spacing8,
|
||||
vertical: DashboardTheme.spacing4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: event.isToday
|
||||
? DashboardTheme.success.withOpacity(0.1)
|
||||
: event.isTomorrow
|
||||
? DashboardTheme.warning.withOpacity(0.1)
|
||||
: DashboardTheme.royalBlue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
event.daysUntilEvent,
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: event.isToday
|
||||
? DashboardTheme.success
|
||||
: event.isTomorrow
|
||||
? DashboardTheme.warning
|
||||
: DashboardTheme.royalBlue,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing12),
|
||||
// Barre de progression des participants
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Participants',
|
||||
style: DashboardTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
'${event.currentParticipants}/${event.maxParticipants}',
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
LinearProgressIndicator(
|
||||
value: event.fillPercentage,
|
||||
backgroundColor: DashboardTheme.grey200,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
event.isFull
|
||||
? DashboardTheme.error
|
||||
: event.isAlmostFull
|
||||
? DashboardTheme.warning
|
||||
: DashboardTheme.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Tags
|
||||
if (event.tags.isNotEmpty) ...[
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Wrap(
|
||||
spacing: DashboardTheme.spacing4,
|
||||
runSpacing: DashboardTheme.spacing4,
|
||||
children: event.tags.take(3).map((tag) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DashboardTheme.spacing8,
|
||||
vertical: DashboardTheme.spacing4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.tealBlue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.tealBlue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
)).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingList() {
|
||||
return Column(
|
||||
children: List.generate(2, (index) => Column(
|
||||
children: [
|
||||
_buildLoadingCard(),
|
||||
if (index < 1) const SizedBox(height: DashboardTheme.spacing12),
|
||||
],
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingCard() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey50,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
border: Border.all(color: DashboardTheme.grey200),
|
||||
),
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing12),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey200,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 16,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey200,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Container(
|
||||
height: 12,
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 60,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey200,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing12),
|
||||
Container(
|
||||
height: 4,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey200,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(String message) {
|
||||
return Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: DashboardTheme.error,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
'Erreur de chargement',
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Text(
|
||||
message,
|
||||
style: DashboardTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.event_busy,
|
||||
color: DashboardTheme.grey400,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
'Aucun événement à venir',
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.grey500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
const Text(
|
||||
'Les événements apparaîtront ici',
|
||||
style: DashboardTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/// Widget de tuile d'activité individuelle
|
||||
/// Affiche une activité récente avec icône, titre et timestamp
|
||||
library dashboard_activity_tile;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/design_system/tokens/color_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/spacing_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/typography_tokens.dart';
|
||||
|
||||
/// Modèle de données pour une activité récente
|
||||
class DashboardActivity {
|
||||
/// Titre principal de l'activité
|
||||
final String title;
|
||||
|
||||
/// Description détaillée de l'activité
|
||||
final String subtitle;
|
||||
|
||||
/// Icône représentative de l'activité
|
||||
final IconData icon;
|
||||
|
||||
/// Couleur thématique de l'activité
|
||||
final Color color;
|
||||
|
||||
/// Timestamp de l'activité
|
||||
final String time;
|
||||
|
||||
/// Callback optionnel lors du tap sur l'activité
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Constructeur du modèle d'activité
|
||||
const DashboardActivity({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.time,
|
||||
this.onTap,
|
||||
});
|
||||
}
|
||||
|
||||
/// Widget de tuile d'activité
|
||||
///
|
||||
/// Affiche une activité récente avec :
|
||||
/// - Avatar coloré avec icône thématique
|
||||
/// - Titre et description de l'activité
|
||||
/// - Timestamp relatif
|
||||
/// - Design compact et lisible
|
||||
/// - Support du tap pour détails
|
||||
class DashboardActivityTile extends StatelessWidget {
|
||||
/// Données de l'activité à afficher
|
||||
final DashboardActivity activity;
|
||||
|
||||
/// Constructeur de la tuile d'activité
|
||||
const DashboardActivityTile({
|
||||
super.key,
|
||||
required this.activity,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
onTap: activity.onTap,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: SpacingTokens.sm,
|
||||
vertical: SpacingTokens.xs,
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: activity.color.withOpacity(0.1),
|
||||
child: Icon(
|
||||
activity.icon,
|
||||
color: activity.color,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
activity.title,
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
activity.subtitle,
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
trailing: SizedBox(
|
||||
width: 60,
|
||||
child: Text(
|
||||
activity.time,
|
||||
style: TypographyTokens.labelSmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
library dashboard_drawer;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/design_system/tokens/color_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/spacing_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/typography_tokens.dart';
|
||||
import '../../../../shared/design_system/tokens/color_tokens.dart';
|
||||
import '../../../../shared/design_system/tokens/spacing_tokens.dart';
|
||||
import '../../../../shared/design_system/tokens/typography_tokens.dart';
|
||||
|
||||
/// Modèle de données pour un élément de menu
|
||||
class DrawerMenuItem {
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'common/section_header.dart';
|
||||
|
||||
/// Widget d'en-tête principal du dashboard
|
||||
///
|
||||
/// Composant réutilisable pour l'en-tête des dashboards avec
|
||||
/// informations système, statut et actions rapides.
|
||||
class DashboardHeader extends StatelessWidget {
|
||||
/// Titre principal du dashboard
|
||||
final String title;
|
||||
|
||||
/// Sous-titre ou description
|
||||
final String? subtitle;
|
||||
|
||||
/// Afficher les informations système
|
||||
final bool showSystemInfo;
|
||||
|
||||
/// Afficher les actions rapides
|
||||
final bool showQuickActions;
|
||||
|
||||
/// Callback pour les actions personnalisées
|
||||
final List<DashboardAction>? actions;
|
||||
|
||||
/// Métriques système à afficher
|
||||
final List<SystemMetric>? systemMetrics;
|
||||
|
||||
/// Style de l'en-tête
|
||||
final DashboardHeaderStyle style;
|
||||
|
||||
const DashboardHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.showSystemInfo = true,
|
||||
this.showQuickActions = true,
|
||||
this.actions,
|
||||
this.systemMetrics,
|
||||
this.style = DashboardHeaderStyle.gradient,
|
||||
});
|
||||
|
||||
/// Constructeur pour un en-tête Super Admin
|
||||
const DashboardHeader.superAdmin({
|
||||
super.key,
|
||||
this.actions,
|
||||
}) : title = 'Administration Système',
|
||||
subtitle = 'Surveillance et gestion globale',
|
||||
showSystemInfo = true,
|
||||
showQuickActions = true,
|
||||
systemMetrics = null,
|
||||
style = DashboardHeaderStyle.gradient;
|
||||
|
||||
/// Constructeur pour un en-tête Admin Organisation
|
||||
const DashboardHeader.orgAdmin({
|
||||
super.key,
|
||||
this.actions,
|
||||
}) : title = 'Administration Organisation',
|
||||
subtitle = 'Gestion de votre organisation',
|
||||
showSystemInfo = false,
|
||||
showQuickActions = true,
|
||||
systemMetrics = null,
|
||||
style = DashboardHeaderStyle.gradient;
|
||||
|
||||
/// Constructeur pour un en-tête Membre
|
||||
const DashboardHeader.member({
|
||||
super.key,
|
||||
this.actions,
|
||||
}) : title = 'Tableau de bord',
|
||||
subtitle = 'Bienvenue dans UnionFlow',
|
||||
showSystemInfo = false,
|
||||
showQuickActions = false,
|
||||
systemMetrics = null,
|
||||
style = DashboardHeaderStyle.simple;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (style) {
|
||||
case DashboardHeaderStyle.gradient:
|
||||
return _buildGradientHeader();
|
||||
case DashboardHeaderStyle.simple:
|
||||
return _buildSimpleHeader();
|
||||
case DashboardHeaderStyle.card:
|
||||
return _buildCardHeader();
|
||||
}
|
||||
}
|
||||
|
||||
/// En-tête avec gradient (style principal)
|
||||
Widget _buildGradientHeader() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF6C5CE7), Color(0xFF5A4FCF)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6C5CE7).withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeaderContent(),
|
||||
if (showSystemInfo && systemMetrics != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildSystemMetrics(),
|
||||
],
|
||||
if (showQuickActions && actions != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildQuickActions(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// En-tête simple sans fond
|
||||
Widget _buildSimpleHeader() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionHeader.primary(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
action: actions?.isNotEmpty == true ? _buildActionsRow() : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// En-tête avec fond de carte
|
||||
Widget _buildCardHeader() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeaderContent(isWhiteBackground: true),
|
||||
if (showSystemInfo && systemMetrics != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildSystemMetrics(isWhiteBackground: true),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Contenu principal de l'en-tête
|
||||
Widget _buildHeaderContent({bool isWhiteBackground = false}) {
|
||||
final textColor = isWhiteBackground ? const Color(0xFF1F2937) : Colors.white;
|
||||
final subtitleColor = isWhiteBackground ? Colors.grey[600] : Colors.white.withOpacity(0.8);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: subtitleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (actions?.isNotEmpty == true) _buildActionsRow(isWhiteBackground: isWhiteBackground),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Métriques système
|
||||
Widget _buildSystemMetrics({bool isWhiteBackground = false}) {
|
||||
if (systemMetrics == null || systemMetrics!.isEmpty) {
|
||||
return _buildDefaultSystemMetrics(isWhiteBackground: isWhiteBackground);
|
||||
}
|
||||
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: systemMetrics!.map((metric) => _buildMetricChip(
|
||||
metric.label,
|
||||
metric.value,
|
||||
metric.icon,
|
||||
isWhiteBackground: isWhiteBackground,
|
||||
)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Métriques système par défaut
|
||||
Widget _buildDefaultSystemMetrics({bool isWhiteBackground = false}) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: _buildMetricChip('Uptime', '99.97%', Icons.trending_up, isWhiteBackground: isWhiteBackground)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _buildMetricChip('CPU', '23%', Icons.memory, isWhiteBackground: isWhiteBackground)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _buildMetricChip('Users', '1,247', Icons.people, isWhiteBackground: isWhiteBackground)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Chip de métrique
|
||||
Widget _buildMetricChip(String label, String value, IconData icon, {bool isWhiteBackground = false}) {
|
||||
final backgroundColor = isWhiteBackground
|
||||
? const Color(0xFF6C5CE7).withOpacity(0.1)
|
||||
: Colors.white.withOpacity(0.15);
|
||||
final textColor = isWhiteBackground ? const Color(0xFF6C5CE7) : Colors.white;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: textColor, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: textColor.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Actions rapides
|
||||
Widget _buildQuickActions({bool isWhiteBackground = false}) {
|
||||
if (actions == null || actions!.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Row(
|
||||
children: actions!.map((action) => Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: _buildActionButton(action, isWhiteBackground: isWhiteBackground),
|
||||
),
|
||||
)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Ligne d'actions
|
||||
Widget _buildActionsRow({bool isWhiteBackground = false}) {
|
||||
if (actions == null || actions!.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: actions!.map((action) => Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: _buildActionButton(action, isWhiteBackground: isWhiteBackground),
|
||||
)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Bouton d'action
|
||||
Widget _buildActionButton(DashboardAction action, {bool isWhiteBackground = false}) {
|
||||
final backgroundColor = isWhiteBackground
|
||||
? Colors.white
|
||||
: Colors.white.withOpacity(0.2);
|
||||
final iconColor = isWhiteBackground ? const Color(0xFF6C5CE7) : Colors.white;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: action.onPressed,
|
||||
icon: Icon(action.icon, color: iconColor),
|
||||
tooltip: action.tooltip,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Action du dashboard
|
||||
class DashboardAction {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const DashboardAction({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
});
|
||||
}
|
||||
|
||||
/// Métrique système
|
||||
class SystemMetric {
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
|
||||
const SystemMetric({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
/// Styles d'en-tête de dashboard
|
||||
enum DashboardHeaderStyle {
|
||||
gradient,
|
||||
simple,
|
||||
card,
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/// Widget de section d'insights du dashboard
|
||||
/// Affiche les métriques de performance dans une carte
|
||||
library dashboard_insights_section;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/design_system/tokens/color_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/spacing_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/typography_tokens.dart';
|
||||
import 'dashboard_metric_row.dart';
|
||||
|
||||
/// Widget de section d'insights
|
||||
///
|
||||
/// Affiche les métriques de performance :
|
||||
/// - Taux de cotisation
|
||||
/// - Participation aux événements
|
||||
/// - Demandes traitées
|
||||
///
|
||||
/// Chaque métrique peut être tapée pour plus de détails
|
||||
class DashboardInsightsSection extends StatelessWidget {
|
||||
/// Callback pour les actions sur les métriques
|
||||
final Function(String metricType)? onMetricTap;
|
||||
|
||||
/// Liste des métriques à afficher
|
||||
final List<DashboardMetric>? metrics;
|
||||
|
||||
/// Constructeur de la section d'insights
|
||||
const DashboardInsightsSection({
|
||||
super.key,
|
||||
this.onMetricTap,
|
||||
this.metrics,
|
||||
});
|
||||
|
||||
/// Génère la liste des métriques par défaut
|
||||
List<DashboardMetric> _getDefaultMetrics() {
|
||||
return [
|
||||
DashboardMetric(
|
||||
label: 'Taux de cotisation',
|
||||
value: '85%',
|
||||
progress: 0.85,
|
||||
color: ColorTokens.success,
|
||||
onTap: () => onMetricTap?.call('cotisation_rate'),
|
||||
),
|
||||
DashboardMetric(
|
||||
label: 'Participation événements',
|
||||
value: '72%',
|
||||
progress: 0.72,
|
||||
color: ColorTokens.primary,
|
||||
onTap: () => onMetricTap?.call('event_participation'),
|
||||
),
|
||||
DashboardMetric(
|
||||
label: 'Demandes traitées',
|
||||
value: '95%',
|
||||
progress: 0.95,
|
||||
color: ColorTokens.tertiary,
|
||||
onTap: () => onMetricTap?.call('requests_processed'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final metricsToShow = metrics ?? _getDefaultMetrics();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Insights',
|
||||
style: TypographyTokens.headlineSmall.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
Card(
|
||||
elevation: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(SpacingTokens.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Performance ce mois-ci',
|
||||
style: TypographyTokens.titleSmall.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
...metricsToShow.map((metric) {
|
||||
final isLast = metric == metricsToShow.last;
|
||||
return Column(
|
||||
children: [
|
||||
DashboardMetricRow(metric: metric),
|
||||
if (!isLast) const SizedBox(height: SpacingTokens.sm),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/// Widget de ligne de métrique avec barre de progression
|
||||
/// Affiche une métrique avec label, valeur et indicateur visuel
|
||||
library dashboard_metric_row;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/design_system/tokens/spacing_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/typography_tokens.dart';
|
||||
|
||||
/// Modèle de données pour une métrique
|
||||
class DashboardMetric {
|
||||
/// Label descriptif de la métrique
|
||||
final String label;
|
||||
|
||||
/// Valeur formatée à afficher
|
||||
final String value;
|
||||
|
||||
/// Progression entre 0.0 et 1.0
|
||||
final double progress;
|
||||
|
||||
/// Couleur thématique de la métrique
|
||||
final Color color;
|
||||
|
||||
/// Callback optionnel lors du tap sur la métrique
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Constructeur du modèle de métrique
|
||||
const DashboardMetric({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.progress,
|
||||
required this.color,
|
||||
this.onTap,
|
||||
});
|
||||
}
|
||||
|
||||
/// Widget de ligne de métrique
|
||||
///
|
||||
/// Affiche une métrique avec :
|
||||
/// - Label et valeur alignés horizontalement
|
||||
/// - Barre de progression colorée
|
||||
/// - Design compact et lisible
|
||||
/// - Support du tap pour détails
|
||||
class DashboardMetricRow extends StatelessWidget {
|
||||
/// Données de la métrique à afficher
|
||||
final DashboardMetric metric;
|
||||
|
||||
/// Constructeur de la ligne de métrique
|
||||
const DashboardMetricRow({
|
||||
super.key,
|
||||
required this.metric,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: metric.onTap,
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusSm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.xs),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
metric.label,
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
metric.value,
|
||||
style: TypographyTokens.labelLarge.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: metric.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
LinearProgressIndicator(
|
||||
value: metric.progress,
|
||||
backgroundColor: metric.color.withOpacity(0.1),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(metric.color),
|
||||
minHeight: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,683 +0,0 @@
|
||||
/// Widget de bouton d'action rapide individuel - Version Améliorée
|
||||
/// Bouton stylisé sophistiqué pour les actions principales du dashboard
|
||||
/// avec support d'animations, badges, états et styles multiples
|
||||
library dashboard_quick_action_button;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../../core/design_system/tokens/spacing_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/color_tokens.dart';
|
||||
|
||||
/// Types d'actions rapides disponibles
|
||||
enum QuickActionType {
|
||||
primary,
|
||||
secondary,
|
||||
success,
|
||||
warning,
|
||||
error,
|
||||
info,
|
||||
custom,
|
||||
}
|
||||
|
||||
/// Styles de boutons d'action rapide
|
||||
enum QuickActionStyle {
|
||||
elevated,
|
||||
filled,
|
||||
outlined,
|
||||
text,
|
||||
gradient,
|
||||
minimal,
|
||||
}
|
||||
|
||||
/// Tailles de boutons d'action rapide
|
||||
enum QuickActionSize {
|
||||
small,
|
||||
medium,
|
||||
large,
|
||||
extraLarge,
|
||||
}
|
||||
|
||||
/// États du bouton d'action rapide
|
||||
enum QuickActionState {
|
||||
enabled,
|
||||
disabled,
|
||||
loading,
|
||||
success,
|
||||
error,
|
||||
}
|
||||
|
||||
/// Modèle de données avancé pour une action rapide
|
||||
class DashboardQuickAction {
|
||||
/// Icône représentative de l'action
|
||||
final IconData icon;
|
||||
|
||||
/// Titre de l'action
|
||||
final String title;
|
||||
|
||||
/// Sous-titre optionnel
|
||||
final String? subtitle;
|
||||
|
||||
/// Description détaillée (tooltip)
|
||||
final String? description;
|
||||
|
||||
/// Couleur thématique du bouton
|
||||
final Color color;
|
||||
|
||||
/// Type d'action (détermine le style par défaut)
|
||||
final QuickActionType type;
|
||||
|
||||
/// Style du bouton
|
||||
final QuickActionStyle style;
|
||||
|
||||
/// Taille du bouton
|
||||
final QuickActionSize size;
|
||||
|
||||
/// État actuel du bouton
|
||||
final QuickActionState state;
|
||||
|
||||
/// Callback lors du tap sur le bouton
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Callback lors du long press
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
/// Badge à afficher (nombre ou texte)
|
||||
final String? badge;
|
||||
|
||||
/// Couleur du badge
|
||||
final Color? badgeColor;
|
||||
|
||||
/// Icône secondaire (affichée en bas à droite)
|
||||
final IconData? secondaryIcon;
|
||||
|
||||
/// Gradient personnalisé
|
||||
final Gradient? gradient;
|
||||
|
||||
/// Animation activée
|
||||
final bool animated;
|
||||
|
||||
/// Feedback haptique activé
|
||||
final bool hapticFeedback;
|
||||
|
||||
/// Constructeur du modèle d'action rapide amélioré
|
||||
const DashboardQuickAction({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.description,
|
||||
required this.color,
|
||||
this.type = QuickActionType.primary,
|
||||
this.style = QuickActionStyle.elevated,
|
||||
this.size = QuickActionSize.medium,
|
||||
this.state = QuickActionState.enabled,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.badge,
|
||||
this.badgeColor,
|
||||
this.secondaryIcon,
|
||||
this.gradient,
|
||||
this.animated = true,
|
||||
this.hapticFeedback = true,
|
||||
});
|
||||
|
||||
/// Constructeur pour action primaire
|
||||
const DashboardQuickAction.primary({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.description,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.badge,
|
||||
this.size = QuickActionSize.medium,
|
||||
this.state = QuickActionState.enabled,
|
||||
this.animated = true,
|
||||
this.hapticFeedback = true,
|
||||
}) : color = ColorTokens.primary,
|
||||
type = QuickActionType.primary,
|
||||
style = QuickActionStyle.elevated,
|
||||
badgeColor = null,
|
||||
secondaryIcon = null,
|
||||
gradient = null;
|
||||
|
||||
/// Constructeur pour action de succès
|
||||
const DashboardQuickAction.success({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.description,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.badge,
|
||||
this.size = QuickActionSize.medium,
|
||||
this.state = QuickActionState.enabled,
|
||||
this.animated = true,
|
||||
this.hapticFeedback = true,
|
||||
}) : color = ColorTokens.success,
|
||||
type = QuickActionType.success,
|
||||
style = QuickActionStyle.filled,
|
||||
badgeColor = null,
|
||||
secondaryIcon = null,
|
||||
gradient = null;
|
||||
|
||||
/// Constructeur pour action d'alerte
|
||||
const DashboardQuickAction.warning({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.description,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.badge,
|
||||
this.size = QuickActionSize.medium,
|
||||
this.state = QuickActionState.enabled,
|
||||
this.animated = true,
|
||||
this.hapticFeedback = true,
|
||||
}) : color = ColorTokens.warning,
|
||||
type = QuickActionType.warning,
|
||||
style = QuickActionStyle.outlined,
|
||||
badgeColor = null,
|
||||
secondaryIcon = null,
|
||||
gradient = null;
|
||||
|
||||
/// Constructeur pour action avec gradient
|
||||
const DashboardQuickAction.gradient({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.description,
|
||||
required this.gradient,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.badge,
|
||||
this.size = QuickActionSize.medium,
|
||||
this.state = QuickActionState.enabled,
|
||||
this.animated = true,
|
||||
this.hapticFeedback = true,
|
||||
}) : color = ColorTokens.primary,
|
||||
type = QuickActionType.custom,
|
||||
style = QuickActionStyle.gradient,
|
||||
badgeColor = null,
|
||||
secondaryIcon = null;
|
||||
}
|
||||
|
||||
/// Widget de bouton d'action rapide amélioré
|
||||
///
|
||||
/// Affiche un bouton stylisé sophistiqué avec :
|
||||
/// - Icône thématique avec animations
|
||||
/// - Titre et sous-titre descriptifs
|
||||
/// - Badges et indicateurs visuels
|
||||
/// - Styles multiples (elevated, filled, outlined, gradient)
|
||||
/// - États interactifs (loading, success, error)
|
||||
/// - Feedback haptique et animations
|
||||
/// - Support tooltip et long press
|
||||
/// - Design Material 3 avec bordures arrondies
|
||||
class DashboardQuickActionButton extends StatefulWidget {
|
||||
/// Données de l'action à afficher
|
||||
final DashboardQuickAction action;
|
||||
|
||||
/// Constructeur du bouton d'action rapide amélioré
|
||||
const DashboardQuickActionButton({
|
||||
super.key,
|
||||
required this.action,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DashboardQuickActionButton> createState() => _DashboardQuickActionButtonState();
|
||||
}
|
||||
|
||||
class _DashboardQuickActionButtonState extends State<DashboardQuickActionButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _rotationAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.95,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
_rotationAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 0.1,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Obtient les dimensions selon la taille (format rectangulaire compact)
|
||||
EdgeInsets _getPadding() {
|
||||
switch (widget.action.size) {
|
||||
case QuickActionSize.small:
|
||||
return const EdgeInsets.symmetric(horizontal: SpacingTokens.xs, vertical: SpacingTokens.xs);
|
||||
case QuickActionSize.medium:
|
||||
return const EdgeInsets.symmetric(horizontal: SpacingTokens.sm, vertical: SpacingTokens.sm);
|
||||
case QuickActionSize.large:
|
||||
return const EdgeInsets.symmetric(horizontal: SpacingTokens.md, vertical: SpacingTokens.sm);
|
||||
case QuickActionSize.extraLarge:
|
||||
return const EdgeInsets.symmetric(horizontal: SpacingTokens.md, vertical: SpacingTokens.md);
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient la taille de l'icône selon la taille du bouton (réduite pour format compact)
|
||||
double _getIconSize() {
|
||||
switch (widget.action.size) {
|
||||
case QuickActionSize.small:
|
||||
return 14.0;
|
||||
case QuickActionSize.medium:
|
||||
return 16.0;
|
||||
case QuickActionSize.large:
|
||||
return 18.0;
|
||||
case QuickActionSize.extraLarge:
|
||||
return 20.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient le style de texte pour le titre
|
||||
TextStyle _getTitleStyle() {
|
||||
final baseSize = widget.action.size == QuickActionSize.small ? 11.0 :
|
||||
widget.action.size == QuickActionSize.medium ? 12.0 :
|
||||
widget.action.size == QuickActionSize.large ? 13.0 : 14.0;
|
||||
|
||||
return TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: baseSize,
|
||||
color: _getTextColor(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtient le style de texte pour le sous-titre
|
||||
TextStyle _getSubtitleStyle() {
|
||||
final baseSize = widget.action.size == QuickActionSize.small ? 9.0 :
|
||||
widget.action.size == QuickActionSize.medium ? 10.0 :
|
||||
widget.action.size == QuickActionSize.large ? 11.0 : 12.0;
|
||||
|
||||
return TextStyle(
|
||||
fontSize: baseSize,
|
||||
color: _getTextColor().withOpacity(0.7),
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtient la couleur du texte selon le style
|
||||
Color _getTextColor() {
|
||||
switch (widget.action.style) {
|
||||
case QuickActionStyle.filled:
|
||||
case QuickActionStyle.gradient:
|
||||
return Colors.white;
|
||||
case QuickActionStyle.elevated:
|
||||
case QuickActionStyle.outlined:
|
||||
case QuickActionStyle.text:
|
||||
case QuickActionStyle.minimal:
|
||||
return widget.action.color;
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère le tap avec feedback haptique
|
||||
void _handleTap() {
|
||||
if (widget.action.state != QuickActionState.enabled) return;
|
||||
|
||||
if (widget.action.hapticFeedback) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
if (widget.action.animated) {
|
||||
_animationController.forward().then((_) {
|
||||
_animationController.reverse();
|
||||
});
|
||||
}
|
||||
|
||||
widget.action.onTap?.call();
|
||||
}
|
||||
|
||||
/// Gère le long press
|
||||
void _handleLongPress() {
|
||||
if (widget.action.state != QuickActionState.enabled) return;
|
||||
|
||||
if (widget.action.hapticFeedback) {
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
|
||||
widget.action.onLongPress?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget button = _buildButton();
|
||||
|
||||
// Ajouter tooltip si description fournie
|
||||
if (widget.action.description != null) {
|
||||
button = Tooltip(
|
||||
message: widget.action.description!,
|
||||
child: button,
|
||||
);
|
||||
}
|
||||
|
||||
// Ajouter animation si activée
|
||||
if (widget.action.animated) {
|
||||
button = AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Transform.rotate(
|
||||
angle: _rotationAnimation.value,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: button,
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
/// Construit le bouton selon le style défini
|
||||
Widget _buildButton() {
|
||||
switch (widget.action.style) {
|
||||
case QuickActionStyle.elevated:
|
||||
return _buildElevatedButton();
|
||||
case QuickActionStyle.filled:
|
||||
return _buildFilledButton();
|
||||
case QuickActionStyle.outlined:
|
||||
return _buildOutlinedButton();
|
||||
case QuickActionStyle.text:
|
||||
return _buildTextButton();
|
||||
case QuickActionStyle.gradient:
|
||||
return _buildGradientButton();
|
||||
case QuickActionStyle.minimal:
|
||||
return _buildMinimalButton();
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit un bouton élevé
|
||||
Widget _buildElevatedButton() {
|
||||
return ElevatedButton(
|
||||
onPressed: widget.action.state == QuickActionState.enabled ? _handleTap : null,
|
||||
onLongPress: widget.action.state == QuickActionState.enabled ? _handleLongPress : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: widget.action.color.withOpacity(0.1),
|
||||
foregroundColor: widget.action.color,
|
||||
elevation: widget.action.state == QuickActionState.enabled ? 2 : 0,
|
||||
padding: _getPadding(),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
),
|
||||
),
|
||||
child: _buildButtonContent(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un bouton rempli
|
||||
Widget _buildFilledButton() {
|
||||
return ElevatedButton(
|
||||
onPressed: widget.action.state == QuickActionState.enabled ? _handleTap : null,
|
||||
onLongPress: widget.action.state == QuickActionState.enabled ? _handleLongPress : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: widget.action.color,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: _getPadding(),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
),
|
||||
),
|
||||
child: _buildButtonContent(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un bouton avec contour
|
||||
Widget _buildOutlinedButton() {
|
||||
return OutlinedButton(
|
||||
onPressed: widget.action.state == QuickActionState.enabled ? _handleTap : null,
|
||||
onLongPress: widget.action.state == QuickActionState.enabled ? _handleLongPress : null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: widget.action.color,
|
||||
side: BorderSide(color: widget.action.color, width: 1.5),
|
||||
padding: _getPadding(),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
),
|
||||
),
|
||||
child: _buildButtonContent(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un bouton texte
|
||||
Widget _buildTextButton() {
|
||||
return TextButton(
|
||||
onPressed: widget.action.state == QuickActionState.enabled ? _handleTap : null,
|
||||
onLongPress: widget.action.state == QuickActionState.enabled ? _handleLongPress : null,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: widget.action.color,
|
||||
padding: _getPadding(),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
),
|
||||
),
|
||||
child: _buildButtonContent(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un bouton avec gradient
|
||||
Widget _buildGradientButton() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: widget.action.gradient ?? LinearGradient(
|
||||
colors: [widget.action.color, widget.action.color.withOpacity(0.8)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: widget.action.color.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.action.state == QuickActionState.enabled ? _handleTap : null,
|
||||
onLongPress: widget.action.state == QuickActionState.enabled ? _handleLongPress : null,
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
child: Padding(
|
||||
padding: _getPadding(),
|
||||
child: _buildButtonContent(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un bouton minimal
|
||||
Widget _buildMinimalButton() {
|
||||
return InkWell(
|
||||
onTap: widget.action.state == QuickActionState.enabled ? _handleTap : null,
|
||||
onLongPress: widget.action.state == QuickActionState.enabled ? _handleLongPress : null,
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
child: Container(
|
||||
padding: _getPadding(),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.action.color.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
border: Border.all(
|
||||
color: widget.action.color.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: _buildButtonContent(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit le contenu du bouton (icône, texte, badge)
|
||||
Widget _buildButtonContent() {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildIcon(),
|
||||
const SizedBox(height: 6),
|
||||
_buildTitle(),
|
||||
if (widget.action.subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
_buildSubtitle(),
|
||||
],
|
||||
],
|
||||
),
|
||||
// Badge en haut à droite
|
||||
if (widget.action.badge != null)
|
||||
Positioned(
|
||||
top: -8,
|
||||
right: -8,
|
||||
child: _buildBadge(),
|
||||
),
|
||||
// Icône secondaire en bas à droite
|
||||
if (widget.action.secondaryIcon != null)
|
||||
Positioned(
|
||||
bottom: -4,
|
||||
right: -4,
|
||||
child: _buildSecondaryIcon(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit l'icône principale avec état
|
||||
Widget _buildIcon() {
|
||||
IconData iconToShow = widget.action.icon;
|
||||
|
||||
// Changer l'icône selon l'état
|
||||
switch (widget.action.state) {
|
||||
case QuickActionState.loading:
|
||||
return SizedBox(
|
||||
width: _getIconSize(),
|
||||
height: _getIconSize(),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(_getTextColor()),
|
||||
),
|
||||
);
|
||||
case QuickActionState.success:
|
||||
iconToShow = Icons.check_circle;
|
||||
break;
|
||||
case QuickActionState.error:
|
||||
iconToShow = Icons.error;
|
||||
break;
|
||||
case QuickActionState.disabled:
|
||||
case QuickActionState.enabled:
|
||||
break;
|
||||
}
|
||||
|
||||
return Icon(
|
||||
iconToShow,
|
||||
size: _getIconSize(),
|
||||
color: _getTextColor().withOpacity(
|
||||
widget.action.state == QuickActionState.disabled ? 0.5 : 1.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit le titre
|
||||
Widget _buildTitle() {
|
||||
return Text(
|
||||
widget.action.title,
|
||||
style: _getTitleStyle().copyWith(
|
||||
color: _getTitleStyle().color?.withOpacity(
|
||||
widget.action.state == QuickActionState.disabled ? 0.5 : 1.0,
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit le sous-titre
|
||||
Widget _buildSubtitle() {
|
||||
return Text(
|
||||
widget.action.subtitle!,
|
||||
style: _getSubtitleStyle().copyWith(
|
||||
color: _getSubtitleStyle().color?.withOpacity(
|
||||
widget.action.state == QuickActionState.disabled ? 0.5 : 1.0,
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit le badge
|
||||
Widget _buildBadge() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.action.badgeColor ?? ColorTokens.error,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
widget.action.badge!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit l'icône secondaire
|
||||
Widget _buildSecondaryIcon() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.action.color,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
widget.action.secondaryIcon!,
|
||||
size: 12,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,542 +0,0 @@
|
||||
/// Widget de grille d'actions rapides du dashboard - Version Améliorée
|
||||
/// Affiche les actions principales dans une grille responsive et configurable
|
||||
/// avec support d'animations, layouts multiples et personnalisation avancée
|
||||
library dashboard_quick_actions_grid;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/design_system/tokens/color_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/spacing_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/typography_tokens.dart';
|
||||
import 'dashboard_quick_action_button.dart';
|
||||
|
||||
/// Types de layout pour la grille d'actions
|
||||
enum QuickActionsLayout {
|
||||
grid2x2,
|
||||
grid3x2,
|
||||
grid4x2,
|
||||
horizontal,
|
||||
vertical,
|
||||
staggered,
|
||||
carousel,
|
||||
}
|
||||
|
||||
/// Styles de la grille d'actions
|
||||
enum QuickActionsGridStyle {
|
||||
standard,
|
||||
compact,
|
||||
expanded,
|
||||
minimal,
|
||||
card,
|
||||
}
|
||||
|
||||
/// Widget de grille d'actions rapides amélioré
|
||||
///
|
||||
/// Affiche les actions principales dans différents layouts :
|
||||
/// - Grille 2x2, 3x2, 4x2
|
||||
/// - Layout horizontal ou vertical
|
||||
/// - Grille décalée (staggered)
|
||||
/// - Carrousel horizontal
|
||||
///
|
||||
/// Fonctionnalités avancées :
|
||||
/// - Animations d'apparition
|
||||
/// - Personnalisation complète
|
||||
/// - Gestion des permissions
|
||||
/// - Analytics intégrés
|
||||
/// - Support responsive
|
||||
class DashboardQuickActionsGrid extends StatefulWidget {
|
||||
/// Callback pour les actions rapides
|
||||
final Function(String actionType)? onActionTap;
|
||||
|
||||
/// Liste des actions à afficher
|
||||
final List<DashboardQuickAction>? actions;
|
||||
|
||||
/// Layout de la grille
|
||||
final QuickActionsLayout layout;
|
||||
|
||||
/// Style de la grille
|
||||
final QuickActionsGridStyle style;
|
||||
|
||||
/// Titre de la section
|
||||
final String? title;
|
||||
|
||||
/// Sous-titre de la section
|
||||
final String? subtitle;
|
||||
|
||||
/// Afficher le titre
|
||||
final bool showTitle;
|
||||
|
||||
/// Afficher les animations
|
||||
final bool animated;
|
||||
|
||||
/// Délai entre les animations (en millisecondes)
|
||||
final int animationDelay;
|
||||
|
||||
/// Nombre maximum d'actions à afficher
|
||||
final int? maxActions;
|
||||
|
||||
/// Espacement entre les éléments
|
||||
final double? spacing;
|
||||
|
||||
/// Ratio d'aspect des boutons
|
||||
final double? aspectRatio;
|
||||
|
||||
/// Callback pour voir toutes les actions
|
||||
final VoidCallback? onSeeAll;
|
||||
|
||||
/// Permissions utilisateur (pour filtrer les actions)
|
||||
final List<String>? userPermissions;
|
||||
|
||||
/// Mode de débogage (affiche des infos supplémentaires)
|
||||
final bool debugMode;
|
||||
|
||||
/// Constructeur de la grille d'actions rapides améliorée
|
||||
const DashboardQuickActionsGrid({
|
||||
super.key,
|
||||
this.onActionTap,
|
||||
this.actions,
|
||||
this.layout = QuickActionsLayout.grid2x2,
|
||||
this.style = QuickActionsGridStyle.standard,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.showTitle = true,
|
||||
this.animated = true,
|
||||
this.animationDelay = 100,
|
||||
this.maxActions,
|
||||
this.spacing,
|
||||
this.aspectRatio,
|
||||
this.onSeeAll,
|
||||
this.userPermissions,
|
||||
this.debugMode = false,
|
||||
});
|
||||
|
||||
/// Constructeur pour grille compacte avec format rectangulaire
|
||||
const DashboardQuickActionsGrid.compact({
|
||||
super.key,
|
||||
this.onActionTap,
|
||||
this.actions,
|
||||
this.title,
|
||||
this.userPermissions,
|
||||
}) : layout = QuickActionsLayout.grid2x2,
|
||||
style = QuickActionsGridStyle.compact,
|
||||
subtitle = null,
|
||||
showTitle = true,
|
||||
animated = false,
|
||||
animationDelay = 0,
|
||||
maxActions = 4,
|
||||
spacing = null,
|
||||
aspectRatio = 1.8, // Ratio rectangulaire compact
|
||||
onSeeAll = null,
|
||||
debugMode = false;
|
||||
|
||||
/// Constructeur pour carrousel horizontal avec format rectangulaire
|
||||
const DashboardQuickActionsGrid.carousel({
|
||||
super.key,
|
||||
this.onActionTap,
|
||||
this.actions,
|
||||
this.title,
|
||||
this.animated = true,
|
||||
this.userPermissions,
|
||||
}) : layout = QuickActionsLayout.carousel,
|
||||
style = QuickActionsGridStyle.standard,
|
||||
subtitle = null,
|
||||
showTitle = true,
|
||||
animationDelay = 150,
|
||||
maxActions = null,
|
||||
spacing = 8.0, // Espacement réduit
|
||||
aspectRatio = 1.0, // Ratio plus carré pour format rectangulaire
|
||||
onSeeAll = null,
|
||||
debugMode = false;
|
||||
|
||||
/// Constructeur pour layout étendu avec format rectangulaire
|
||||
const DashboardQuickActionsGrid.expanded({
|
||||
super.key,
|
||||
this.onActionTap,
|
||||
this.actions,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.onSeeAll,
|
||||
this.userPermissions,
|
||||
}) : layout = QuickActionsLayout.grid3x2,
|
||||
style = QuickActionsGridStyle.expanded,
|
||||
showTitle = true,
|
||||
animated = true,
|
||||
animationDelay = 80,
|
||||
maxActions = 6,
|
||||
spacing = null,
|
||||
aspectRatio = 1.5, // Ratio rectangulaire pour layout étendu
|
||||
debugMode = false;
|
||||
|
||||
@override
|
||||
State<DashboardQuickActionsGrid> createState() => _DashboardQuickActionsGridState();
|
||||
}
|
||||
|
||||
class _DashboardQuickActionsGridState extends State<DashboardQuickActionsGrid>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late List<Animation<double>> _itemAnimations;
|
||||
List<DashboardQuickAction> _filteredActions = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupAnimations();
|
||||
_filterActions();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DashboardQuickActionsGrid oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.actions != widget.actions ||
|
||||
oldWidget.userPermissions != widget.userPermissions) {
|
||||
_filterActions();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Configure les animations
|
||||
void _setupAnimations() {
|
||||
_animationController = AnimationController(
|
||||
duration: Duration(milliseconds: widget.animationDelay * 6),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
if (widget.animated) {
|
||||
_animationController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtre les actions selon les permissions
|
||||
void _filterActions() {
|
||||
final actions = widget.actions ?? _getDefaultActions();
|
||||
|
||||
_filteredActions = actions.where((action) {
|
||||
// Filtrer selon les permissions si définies
|
||||
if (widget.userPermissions != null) {
|
||||
// Logique de filtrage basée sur les permissions
|
||||
// À implémenter selon les besoins spécifiques
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
// Limiter le nombre d'actions si spécifié
|
||||
if (widget.maxActions != null && _filteredActions.length > widget.maxActions!) {
|
||||
_filteredActions = _filteredActions.take(widget.maxActions!).toList();
|
||||
}
|
||||
|
||||
// Recréer les animations pour le nouveau nombre d'éléments
|
||||
_itemAnimations = List.generate(
|
||||
_filteredActions.length,
|
||||
(index) => Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Interval(
|
||||
index * 0.1,
|
||||
(index * 0.1) + 0.6,
|
||||
curve: Curves.easeOutBack,
|
||||
),
|
||||
)),
|
||||
);
|
||||
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// Génère la liste des actions rapides par défaut
|
||||
List<DashboardQuickAction> _getDefaultActions() {
|
||||
return [
|
||||
DashboardQuickAction.primary(
|
||||
icon: Icons.person_add,
|
||||
title: 'Ajouter Membre',
|
||||
subtitle: 'Nouveau membre',
|
||||
description: 'Ajouter un nouveau membre à l\'organisation',
|
||||
onTap: () => widget.onActionTap?.call('add_member'),
|
||||
badge: '+',
|
||||
),
|
||||
DashboardQuickAction.success(
|
||||
icon: Icons.payment,
|
||||
title: 'Cotisation',
|
||||
subtitle: 'Enregistrer',
|
||||
description: 'Enregistrer une nouvelle cotisation',
|
||||
onTap: () => widget.onActionTap?.call('add_cotisation'),
|
||||
),
|
||||
DashboardQuickAction(
|
||||
icon: Icons.event_note,
|
||||
title: 'Événement',
|
||||
subtitle: 'Créer',
|
||||
description: 'Créer un nouvel événement',
|
||||
color: ColorTokens.tertiary,
|
||||
type: QuickActionType.info,
|
||||
style: QuickActionStyle.outlined,
|
||||
onTap: () => widget.onActionTap?.call('create_event'),
|
||||
),
|
||||
DashboardQuickAction(
|
||||
icon: Icons.volunteer_activism,
|
||||
title: 'Solidarité',
|
||||
subtitle: 'Demande',
|
||||
description: 'Créer une demande de solidarité',
|
||||
color: ColorTokens.warning,
|
||||
type: QuickActionType.warning,
|
||||
style: QuickActionStyle.outlined,
|
||||
onTap: () => widget.onActionTap?.call('solidarity_request'),
|
||||
secondaryIcon: Icons.favorite,
|
||||
),
|
||||
DashboardQuickAction(
|
||||
icon: Icons.analytics,
|
||||
title: 'Rapports',
|
||||
subtitle: 'Générer',
|
||||
description: 'Générer des rapports analytiques',
|
||||
color: ColorTokens.secondary,
|
||||
type: QuickActionType.secondary,
|
||||
style: QuickActionStyle.minimal,
|
||||
onTap: () => widget.onActionTap?.call('generate_reports'),
|
||||
),
|
||||
DashboardQuickAction.gradient(
|
||||
icon: Icons.settings,
|
||||
title: 'Paramètres',
|
||||
subtitle: 'Configurer',
|
||||
description: 'Accéder aux paramètres système',
|
||||
gradient: const LinearGradient(
|
||||
colors: [ColorTokens.primary, ColorTokens.secondary],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
onTap: () => widget.onActionTap?.call('settings'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_filteredActions.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.showTitle) _buildHeader(),
|
||||
if (widget.showTitle) const SizedBox(height: SpacingTokens.md),
|
||||
_buildActionsLayout(),
|
||||
if (widget.debugMode) _buildDebugInfo(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit l'en-tête de la section
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.title ?? 'Actions rapides',
|
||||
style: TypographyTokens.headlineSmall.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (widget.subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.subtitle!,
|
||||
style: TypographyTokens.bodyMedium.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.onSeeAll != null)
|
||||
TextButton(
|
||||
onPressed: widget.onSeeAll,
|
||||
child: const Text('Voir tout'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit le layout des actions selon le type choisi
|
||||
Widget _buildActionsLayout() {
|
||||
switch (widget.layout) {
|
||||
case QuickActionsLayout.grid2x2:
|
||||
return _buildGridLayout(2);
|
||||
case QuickActionsLayout.grid3x2:
|
||||
return _buildGridLayout(3);
|
||||
case QuickActionsLayout.grid4x2:
|
||||
return _buildGridLayout(4);
|
||||
case QuickActionsLayout.horizontal:
|
||||
return _buildHorizontalLayout();
|
||||
case QuickActionsLayout.vertical:
|
||||
return _buildVerticalLayout();
|
||||
case QuickActionsLayout.staggered:
|
||||
return _buildStaggeredLayout();
|
||||
case QuickActionsLayout.carousel:
|
||||
return _buildCarouselLayout();
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit une grille standard avec format rectangulaire compact
|
||||
Widget _buildGridLayout(int crossAxisCount) {
|
||||
final spacing = widget.spacing ?? SpacingTokens.sm;
|
||||
// Ratio d'aspect plus rectangulaire (largeur réduite de moitié)
|
||||
final aspectRatio = widget.aspectRatio ??
|
||||
(widget.style == QuickActionsGridStyle.compact ? 1.8 : 1.6);
|
||||
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
crossAxisSpacing: spacing,
|
||||
mainAxisSpacing: spacing,
|
||||
childAspectRatio: aspectRatio,
|
||||
),
|
||||
itemCount: _filteredActions.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildAnimatedActionButton(index);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un layout horizontal avec boutons rectangulaires compacts
|
||||
Widget _buildHorizontalLayout() {
|
||||
final spacing = widget.spacing ?? SpacingTokens.sm;
|
||||
|
||||
return SizedBox(
|
||||
height: 80, // Hauteur réduite pour format rectangulaire
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _filteredActions.length,
|
||||
separatorBuilder: (context, index) => SizedBox(width: spacing),
|
||||
itemBuilder: (context, index) {
|
||||
return SizedBox(
|
||||
width: 100, // Largeur réduite de moitié (140 -> 100)
|
||||
child: _buildAnimatedActionButton(index),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un layout vertical
|
||||
Widget _buildVerticalLayout() {
|
||||
final spacing = widget.spacing ?? SpacingTokens.sm;
|
||||
|
||||
return Column(
|
||||
children: _filteredActions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: index < _filteredActions.length - 1 ? spacing : 0),
|
||||
child: _buildAnimatedActionButton(index),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un layout décalé (staggered) avec format rectangulaire
|
||||
Widget _buildStaggeredLayout() {
|
||||
// Implémentation simplifiée du staggered layout avec dimensions réduites
|
||||
return Wrap(
|
||||
spacing: widget.spacing ?? SpacingTokens.sm,
|
||||
runSpacing: widget.spacing ?? SpacingTokens.sm,
|
||||
children: _filteredActions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
return SizedBox(
|
||||
width: (MediaQuery.of(context).size.width - 48 - (widget.spacing ?? SpacingTokens.sm)) / 2,
|
||||
height: index.isEven ? 70 : 85, // Hauteurs alternées réduites
|
||||
child: _buildAnimatedActionButton(index),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un carrousel horizontal avec format rectangulaire compact
|
||||
Widget _buildCarouselLayout() {
|
||||
return SizedBox(
|
||||
height: 90, // Hauteur réduite pour format rectangulaire
|
||||
child: PageView.builder(
|
||||
controller: PageController(viewportFraction: 0.6), // Fraction réduite pour largeur plus petite
|
||||
itemCount: _filteredActions.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: widget.spacing ?? 6.0),
|
||||
child: _buildAnimatedActionButton(index),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un bouton d'action avec animation
|
||||
Widget _buildAnimatedActionButton(int index) {
|
||||
if (!widget.animated || _itemAnimations.isEmpty || index >= _itemAnimations.length) {
|
||||
return DashboardQuickActionButton(action: _filteredActions[index]);
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _itemAnimations[index],
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _itemAnimations[index].value,
|
||||
child: Opacity(
|
||||
opacity: _itemAnimations[index].value,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: DashboardQuickActionButton(action: _filteredActions[index]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit les informations de débogage
|
||||
Widget _buildDebugInfo() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: SpacingTokens.md),
|
||||
padding: const EdgeInsets.all(SpacingTokens.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.warning.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: ColorTokens.warning.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Debug Info:',
|
||||
style: TypographyTokens.labelSmall.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorTokens.warning,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Layout: ${widget.layout.name}',
|
||||
style: TypographyTokens.bodySmall,
|
||||
),
|
||||
Text(
|
||||
'Style: ${widget.style.name}',
|
||||
style: TypographyTokens.bodySmall,
|
||||
),
|
||||
Text(
|
||||
'Actions: ${_filteredActions.length}',
|
||||
style: TypographyTokens.bodySmall,
|
||||
),
|
||||
Text(
|
||||
'Animated: ${widget.animated}',
|
||||
style: TypographyTokens.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/// Widget de section d'activité récente du dashboard
|
||||
/// Affiche les dernières activités dans une liste compacte
|
||||
library dashboard_recent_activity_section;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/design_system/tokens/color_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/spacing_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/typography_tokens.dart';
|
||||
import 'dashboard_activity_tile.dart';
|
||||
|
||||
/// Widget de section d'activité récente
|
||||
///
|
||||
/// Affiche les dernières activités de l'union :
|
||||
/// - Nouveaux membres
|
||||
/// - Cotisations reçues
|
||||
/// - Événements créés
|
||||
/// - Demandes de solidarité
|
||||
///
|
||||
/// Chaque activité peut être tapée pour plus de détails
|
||||
class DashboardRecentActivitySection extends StatelessWidget {
|
||||
/// Callback pour les actions sur les activités
|
||||
final Function(String activityId)? onActivityTap;
|
||||
|
||||
/// Liste des activités à afficher
|
||||
final List<DashboardActivity>? activities;
|
||||
|
||||
/// Constructeur de la section d'activité récente
|
||||
const DashboardRecentActivitySection({
|
||||
super.key,
|
||||
this.onActivityTap,
|
||||
this.activities,
|
||||
});
|
||||
|
||||
/// Génère la liste des activités récentes par défaut
|
||||
List<DashboardActivity> _getDefaultActivities() {
|
||||
return [
|
||||
DashboardActivity(
|
||||
title: 'Nouveau membre ajouté',
|
||||
subtitle: 'Marie Dupont a rejoint l\'union',
|
||||
icon: Icons.person_add,
|
||||
color: ColorTokens.primary,
|
||||
time: 'Il y a 2h',
|
||||
onTap: () => onActivityTap?.call('member_added_001'),
|
||||
),
|
||||
DashboardActivity(
|
||||
title: 'Cotisation reçue',
|
||||
subtitle: 'Paiement de 50€ de Jean Martin',
|
||||
icon: Icons.payment,
|
||||
color: ColorTokens.success,
|
||||
time: 'Il y a 4h',
|
||||
onTap: () => onActivityTap?.call('cotisation_002'),
|
||||
),
|
||||
DashboardActivity(
|
||||
title: 'Événement créé',
|
||||
subtitle: 'Assemblée générale programmée',
|
||||
icon: Icons.event,
|
||||
color: ColorTokens.tertiary,
|
||||
time: 'Hier',
|
||||
onTap: () => onActivityTap?.call('event_003'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activitiesToShow = activities ?? _getDefaultActivities();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Activité récente',
|
||||
style: TypographyTokens.headlineSmall.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
Card(
|
||||
elevation: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm),
|
||||
child: Column(
|
||||
children: activitiesToShow.map((activity) {
|
||||
final isLast = activity == activitiesToShow.last;
|
||||
return Column(
|
||||
children: [
|
||||
DashboardActivityTile(activity: activity),
|
||||
if (!isLast) const Divider(height: 1),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,946 +0,0 @@
|
||||
/// Widget de carte de statistique individuelle - Version Améliorée
|
||||
/// Affiche une métrique sophistiquée avec animations, tendances et comparaisons
|
||||
library dashboard_stats_card;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../../core/design_system/tokens/color_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/spacing_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/typography_tokens.dart';
|
||||
|
||||
/// Types de statistiques disponibles
|
||||
enum StatType {
|
||||
count,
|
||||
percentage,
|
||||
currency,
|
||||
duration,
|
||||
rate,
|
||||
score,
|
||||
custom,
|
||||
}
|
||||
|
||||
/// Styles de cartes de statistiques
|
||||
enum StatCardStyle {
|
||||
standard,
|
||||
minimal,
|
||||
elevated,
|
||||
outlined,
|
||||
gradient,
|
||||
compact,
|
||||
detailed,
|
||||
}
|
||||
|
||||
/// Tailles de cartes de statistiques
|
||||
enum StatCardSize {
|
||||
small,
|
||||
medium,
|
||||
large,
|
||||
extraLarge,
|
||||
}
|
||||
|
||||
/// Tendances des statistiques
|
||||
enum StatTrend {
|
||||
up,
|
||||
down,
|
||||
stable,
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Modèle de données avancé pour une statistique
|
||||
class DashboardStat {
|
||||
/// Icône représentative de la statistique
|
||||
final IconData icon;
|
||||
|
||||
/// Valeur numérique à afficher
|
||||
final String value;
|
||||
|
||||
/// Titre descriptif de la statistique
|
||||
final String title;
|
||||
|
||||
/// Sous-titre ou description
|
||||
final String? subtitle;
|
||||
|
||||
/// Couleur thématique de la carte
|
||||
final Color color;
|
||||
|
||||
/// Type de statistique
|
||||
final StatType type;
|
||||
|
||||
/// Style de la carte
|
||||
final StatCardStyle style;
|
||||
|
||||
/// Taille de la carte
|
||||
final StatCardSize size;
|
||||
|
||||
/// Callback optionnel lors du tap sur la carte
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Callback optionnel lors du long press
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
/// Valeur précédente pour comparaison
|
||||
final String? previousValue;
|
||||
|
||||
/// Pourcentage de changement
|
||||
final double? changePercentage;
|
||||
|
||||
/// Tendance de la statistique
|
||||
final StatTrend trend;
|
||||
|
||||
/// Période de comparaison
|
||||
final String? period;
|
||||
|
||||
/// Icône de tendance personnalisée
|
||||
final IconData? trendIcon;
|
||||
|
||||
/// Gradient personnalisé
|
||||
final Gradient? gradient;
|
||||
|
||||
/// Badge à afficher
|
||||
final String? badge;
|
||||
|
||||
/// Couleur du badge
|
||||
final Color? badgeColor;
|
||||
|
||||
/// Graphique miniature (sparkline)
|
||||
final List<double>? sparklineData;
|
||||
|
||||
/// Animation activée
|
||||
final bool animated;
|
||||
|
||||
/// Feedback haptique activé
|
||||
final bool hapticFeedback;
|
||||
|
||||
/// Formatage personnalisé de la valeur
|
||||
final String Function(String)? valueFormatter;
|
||||
|
||||
/// Constructeur du modèle de statistique amélioré
|
||||
const DashboardStat({
|
||||
required this.icon,
|
||||
required this.value,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.color,
|
||||
this.type = StatType.count,
|
||||
this.style = StatCardStyle.standard,
|
||||
this.size = StatCardSize.medium,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.previousValue,
|
||||
this.changePercentage,
|
||||
this.trend = StatTrend.unknown,
|
||||
this.period,
|
||||
this.trendIcon,
|
||||
this.gradient,
|
||||
this.badge,
|
||||
this.badgeColor,
|
||||
this.sparklineData,
|
||||
this.animated = true,
|
||||
this.hapticFeedback = true,
|
||||
this.valueFormatter,
|
||||
});
|
||||
|
||||
/// Constructeur pour statistique de comptage
|
||||
const DashboardStat.count({
|
||||
required this.icon,
|
||||
required this.value,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.color,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.previousValue,
|
||||
this.changePercentage,
|
||||
this.trend = StatTrend.unknown,
|
||||
this.period,
|
||||
this.badge,
|
||||
this.size = StatCardSize.medium,
|
||||
this.animated = true,
|
||||
this.hapticFeedback = true,
|
||||
}) : type = StatType.count,
|
||||
style = StatCardStyle.standard,
|
||||
trendIcon = null,
|
||||
gradient = null,
|
||||
badgeColor = null,
|
||||
sparklineData = null,
|
||||
valueFormatter = null;
|
||||
|
||||
/// Constructeur pour pourcentage
|
||||
const DashboardStat.percentage({
|
||||
required this.icon,
|
||||
required this.value,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.color,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.changePercentage,
|
||||
this.trend = StatTrend.unknown,
|
||||
this.period,
|
||||
this.size = StatCardSize.medium,
|
||||
this.animated = true,
|
||||
this.hapticFeedback = true,
|
||||
}) : type = StatType.percentage,
|
||||
style = StatCardStyle.elevated,
|
||||
previousValue = null,
|
||||
trendIcon = null,
|
||||
gradient = null,
|
||||
badge = null,
|
||||
badgeColor = null,
|
||||
sparklineData = null,
|
||||
valueFormatter = null;
|
||||
|
||||
/// Constructeur pour devise
|
||||
const DashboardStat.currency({
|
||||
required this.icon,
|
||||
required this.value,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.color,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.previousValue,
|
||||
this.changePercentage,
|
||||
this.trend = StatTrend.unknown,
|
||||
this.period,
|
||||
this.sparklineData,
|
||||
this.size = StatCardSize.medium,
|
||||
this.animated = true,
|
||||
this.hapticFeedback = true,
|
||||
}) : type = StatType.currency,
|
||||
style = StatCardStyle.detailed,
|
||||
trendIcon = null,
|
||||
gradient = null,
|
||||
badge = null,
|
||||
badgeColor = null,
|
||||
valueFormatter = null;
|
||||
|
||||
/// Constructeur avec gradient
|
||||
const DashboardStat.gradient({
|
||||
required this.icon,
|
||||
required this.value,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.gradient,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.changePercentage,
|
||||
this.trend = StatTrend.unknown,
|
||||
this.period,
|
||||
this.size = StatCardSize.medium,
|
||||
this.animated = true,
|
||||
this.hapticFeedback = true,
|
||||
}) : type = StatType.custom,
|
||||
style = StatCardStyle.gradient,
|
||||
color = ColorTokens.primary,
|
||||
previousValue = null,
|
||||
trendIcon = null,
|
||||
badge = null,
|
||||
badgeColor = null,
|
||||
sparklineData = null,
|
||||
valueFormatter = null;
|
||||
}
|
||||
|
||||
/// Widget de carte de statistique amélioré
|
||||
///
|
||||
/// Affiche une métrique sophistiquée avec :
|
||||
/// - Icône colorée thématique avec animations
|
||||
/// - Valeur numérique formatée et mise en évidence
|
||||
/// - Titre et sous-titre descriptifs
|
||||
/// - Indicateurs de tendance et comparaisons
|
||||
/// - Graphiques miniatures (sparklines)
|
||||
/// - Badges et notifications
|
||||
/// - Styles multiples (standard, gradient, minimal)
|
||||
/// - Design Material 3 avec élévation adaptative
|
||||
/// - Support du tap et long press avec feedback haptique
|
||||
class DashboardStatsCard extends StatefulWidget {
|
||||
/// Données de la statistique à afficher
|
||||
final DashboardStat stat;
|
||||
|
||||
/// Constructeur de la carte de statistique améliorée
|
||||
const DashboardStatsCard({
|
||||
super.key,
|
||||
required this.stat,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DashboardStatsCard> createState() => _DashboardStatsCardState();
|
||||
}
|
||||
|
||||
class _DashboardStatsCardState extends State<DashboardStatsCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<double> _slideAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupAnimations();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Configure les animations
|
||||
void _setupAnimations() {
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 800),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.8,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
|
||||
_fadeAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.0, 0.6, curve: Curves.easeOut),
|
||||
));
|
||||
|
||||
_slideAnimation = Tween<double>(
|
||||
begin: 30.0,
|
||||
end: 0.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.2, 0.8, curve: Curves.easeOutCubic),
|
||||
));
|
||||
|
||||
if (widget.stat.animated) {
|
||||
_animationController.forward();
|
||||
} else {
|
||||
_animationController.value = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient les dimensions selon la taille
|
||||
EdgeInsets _getPadding() {
|
||||
switch (widget.stat.size) {
|
||||
case StatCardSize.small:
|
||||
return const EdgeInsets.all(SpacingTokens.sm);
|
||||
case StatCardSize.medium:
|
||||
return const EdgeInsets.all(SpacingTokens.md);
|
||||
case StatCardSize.large:
|
||||
return const EdgeInsets.all(SpacingTokens.lg);
|
||||
case StatCardSize.extraLarge:
|
||||
return const EdgeInsets.all(SpacingTokens.xl);
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient la taille de l'icône selon la taille de la carte
|
||||
double _getIconSize() {
|
||||
switch (widget.stat.size) {
|
||||
case StatCardSize.small:
|
||||
return 20.0;
|
||||
case StatCardSize.medium:
|
||||
return 28.0;
|
||||
case StatCardSize.large:
|
||||
return 36.0;
|
||||
case StatCardSize.extraLarge:
|
||||
return 44.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient le style de texte pour la valeur
|
||||
TextStyle _getValueStyle() {
|
||||
final baseStyle = widget.stat.size == StatCardSize.small
|
||||
? TypographyTokens.headlineSmall
|
||||
: widget.stat.size == StatCardSize.medium
|
||||
? TypographyTokens.headlineMedium
|
||||
: widget.stat.size == StatCardSize.large
|
||||
? TypographyTokens.headlineLarge
|
||||
: TypographyTokens.displaySmall;
|
||||
|
||||
return baseStyle.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _getTextColor(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtient le style de texte pour le titre
|
||||
TextStyle _getTitleStyle() {
|
||||
final baseStyle = widget.stat.size == StatCardSize.small
|
||||
? TypographyTokens.bodySmall
|
||||
: widget.stat.size == StatCardSize.medium
|
||||
? TypographyTokens.bodyMedium
|
||||
: TypographyTokens.bodyLarge;
|
||||
|
||||
return baseStyle.copyWith(
|
||||
color: _getSecondaryTextColor(),
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtient la couleur du texte selon le style
|
||||
Color _getTextColor() {
|
||||
switch (widget.stat.style) {
|
||||
case StatCardStyle.gradient:
|
||||
return Colors.white;
|
||||
case StatCardStyle.standard:
|
||||
case StatCardStyle.minimal:
|
||||
case StatCardStyle.elevated:
|
||||
case StatCardStyle.outlined:
|
||||
case StatCardStyle.compact:
|
||||
case StatCardStyle.detailed:
|
||||
return widget.stat.color;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient la couleur du texte secondaire
|
||||
Color _getSecondaryTextColor() {
|
||||
switch (widget.stat.style) {
|
||||
case StatCardStyle.gradient:
|
||||
return Colors.white.withOpacity(0.9);
|
||||
case StatCardStyle.standard:
|
||||
case StatCardStyle.minimal:
|
||||
case StatCardStyle.elevated:
|
||||
case StatCardStyle.outlined:
|
||||
case StatCardStyle.compact:
|
||||
case StatCardStyle.detailed:
|
||||
return ColorTokens.onSurfaceVariant;
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère le tap avec feedback haptique
|
||||
void _handleTap() {
|
||||
if (widget.stat.hapticFeedback) {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
widget.stat.onTap?.call();
|
||||
}
|
||||
|
||||
/// Gère le long press
|
||||
void _handleLongPress() {
|
||||
if (widget.stat.hapticFeedback) {
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
widget.stat.onLongPress?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.stat.animated) {
|
||||
return _buildCard();
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _slideAnimation.value),
|
||||
child: Opacity(
|
||||
opacity: _fadeAnimation.value,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _buildCard(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit la carte selon le style défini
|
||||
Widget _buildCard() {
|
||||
switch (widget.stat.style) {
|
||||
case StatCardStyle.standard:
|
||||
return _buildStandardCard();
|
||||
case StatCardStyle.minimal:
|
||||
return _buildMinimalCard();
|
||||
case StatCardStyle.elevated:
|
||||
return _buildElevatedCard();
|
||||
case StatCardStyle.outlined:
|
||||
return _buildOutlinedCard();
|
||||
case StatCardStyle.gradient:
|
||||
return _buildGradientCard();
|
||||
case StatCardStyle.compact:
|
||||
return _buildCompactCard();
|
||||
case StatCardStyle.detailed:
|
||||
return _buildDetailedCard();
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit une carte standard
|
||||
Widget _buildStandardCard() {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
child: InkWell(
|
||||
onTap: _handleTap,
|
||||
onLongPress: _handleLongPress,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: _getPadding(),
|
||||
child: _buildCardContent(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit une carte minimale
|
||||
Widget _buildMinimalCard() {
|
||||
return InkWell(
|
||||
onTap: _handleTap,
|
||||
onLongPress: _handleLongPress,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: _getPadding(),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.stat.color.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: widget.stat.color.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: _buildCardContent(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit une carte élevée
|
||||
Widget _buildElevatedCard() {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shadowColor: widget.stat.color.withOpacity(0.3),
|
||||
child: InkWell(
|
||||
onTap: _handleTap,
|
||||
onLongPress: _handleLongPress,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: _getPadding(),
|
||||
child: _buildCardContent(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit une carte avec contour
|
||||
Widget _buildOutlinedCard() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: widget.stat.color,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: _handleTap,
|
||||
onLongPress: _handleLongPress,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: _getPadding(),
|
||||
child: _buildCardContent(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit une carte avec gradient
|
||||
Widget _buildGradientCard() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: widget.stat.gradient ?? LinearGradient(
|
||||
colors: [widget.stat.color, widget.stat.color.withOpacity(0.8)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: widget.stat.color.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _handleTap,
|
||||
onLongPress: _handleLongPress,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: _getPadding(),
|
||||
child: _buildCardContent(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit une carte compacte
|
||||
Widget _buildCompactCard() {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
child: InkWell(
|
||||
onTap: _handleTap,
|
||||
onLongPress: _handleLongPress,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(SpacingTokens.sm),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
widget.stat.icon,
|
||||
size: 24,
|
||||
color: widget.stat.color,
|
||||
),
|
||||
const SizedBox(width: SpacingTokens.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.stat.value,
|
||||
style: TypographyTokens.headlineSmall.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: widget.stat.color,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.stat.title,
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.stat.trend != StatTrend.unknown)
|
||||
_buildTrendIndicator(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit une carte détaillée
|
||||
Widget _buildDetailedCard() {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: InkWell(
|
||||
onTap: _handleTap,
|
||||
onLongPress: _handleLongPress,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: _getPadding(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Icon(
|
||||
widget.stat.icon,
|
||||
size: _getIconSize(),
|
||||
color: widget.stat.color,
|
||||
),
|
||||
if (widget.stat.badge != null) _buildBadge(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
Text(
|
||||
_formatValue(widget.stat.value),
|
||||
style: _getValueStyle(),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
Text(
|
||||
widget.stat.title,
|
||||
style: _getTitleStyle(),
|
||||
),
|
||||
if (widget.stat.subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.stat.subtitle!,
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: _getSecondaryTextColor().withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (widget.stat.changePercentage != null) ...[
|
||||
const SizedBox(height: SpacingTokens.sm),
|
||||
_buildChangeIndicator(),
|
||||
],
|
||||
if (widget.stat.sparklineData != null) ...[
|
||||
const SizedBox(height: SpacingTokens.sm),
|
||||
_buildSparkline(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit le contenu standard de la carte
|
||||
Widget _buildCardContent() {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
widget.stat.icon,
|
||||
size: _getIconSize(),
|
||||
color: _getTextColor(),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.sm),
|
||||
Text(
|
||||
_formatValue(widget.stat.value),
|
||||
style: _getValueStyle(),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
Text(
|
||||
widget.stat.title,
|
||||
style: _getTitleStyle(),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.stat.subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.stat.subtitle!,
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: _getSecondaryTextColor().withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (widget.stat.changePercentage != null) ...[
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
_buildChangeIndicator(),
|
||||
],
|
||||
],
|
||||
),
|
||||
// Badge en haut à droite
|
||||
if (widget.stat.badge != null)
|
||||
Positioned(
|
||||
top: -8,
|
||||
right: -8,
|
||||
child: _buildBadge(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Formate la valeur selon le type
|
||||
String _formatValue(String value) {
|
||||
if (widget.stat.valueFormatter != null) {
|
||||
return widget.stat.valueFormatter!(value);
|
||||
}
|
||||
|
||||
switch (widget.stat.type) {
|
||||
case StatType.percentage:
|
||||
return '$value%';
|
||||
case StatType.currency:
|
||||
return '€$value';
|
||||
case StatType.duration:
|
||||
return '${value}h';
|
||||
case StatType.rate:
|
||||
return '$value/min';
|
||||
case StatType.count:
|
||||
case StatType.score:
|
||||
case StatType.custom:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit l'indicateur de changement
|
||||
Widget _buildChangeIndicator() {
|
||||
if (widget.stat.changePercentage == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final isPositive = widget.stat.changePercentage! > 0;
|
||||
final color = isPositive ? ColorTokens.success : ColorTokens.error;
|
||||
final icon = isPositive ? Icons.trending_up : Icons.trending_down;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
widget.stat.trendIcon ?? icon,
|
||||
size: 14,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${isPositive ? '+' : ''}${widget.stat.changePercentage!.toStringAsFixed(1)}%',
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (widget.stat.period != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.stat.period!,
|
||||
style: TypographyTokens.bodySmall.copyWith(
|
||||
color: _getSecondaryTextColor().withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit l'indicateur de tendance
|
||||
Widget _buildTrendIndicator() {
|
||||
IconData icon;
|
||||
Color color;
|
||||
|
||||
switch (widget.stat.trend) {
|
||||
case StatTrend.up:
|
||||
icon = Icons.trending_up;
|
||||
color = ColorTokens.success;
|
||||
break;
|
||||
case StatTrend.down:
|
||||
icon = Icons.trending_down;
|
||||
color = ColorTokens.error;
|
||||
break;
|
||||
case StatTrend.stable:
|
||||
icon = Icons.trending_flat;
|
||||
color = ColorTokens.warning;
|
||||
break;
|
||||
case StatTrend.unknown:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Icon(
|
||||
widget.stat.trendIcon ?? icon,
|
||||
size: 16,
|
||||
color: color,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit le badge
|
||||
Widget _buildBadge() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.stat.badgeColor ?? ColorTokens.error,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
widget.stat.badge!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un graphique miniature (sparkline)
|
||||
Widget _buildSparkline() {
|
||||
if (widget.stat.sparklineData == null || widget.stat.sparklineData!.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: CustomPaint(
|
||||
painter: SparklinePainter(
|
||||
data: widget.stat.sparklineData!,
|
||||
color: widget.stat.color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Painter pour dessiner un graphique miniature
|
||||
class SparklinePainter extends CustomPainter {
|
||||
final List<double> data;
|
||||
final Color color;
|
||||
|
||||
SparklinePainter({
|
||||
required this.data,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (data.length < 2) return;
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = 2
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
final path = Path();
|
||||
final maxValue = data.reduce((a, b) => a > b ? a : b);
|
||||
final minValue = data.reduce((a, b) => a < b ? a : b);
|
||||
final range = maxValue - minValue;
|
||||
|
||||
if (range == 0) return;
|
||||
|
||||
for (int i = 0; i < data.length; i++) {
|
||||
final x = (i / (data.length - 1)) * size.width;
|
||||
final y = size.height - ((data[i] - minValue) / range) * size.height;
|
||||
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y);
|
||||
} else {
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawPath(path, paint);
|
||||
|
||||
// Dessiner des points aux extrémités
|
||||
final pointPaint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
canvas.drawCircle(
|
||||
Offset(0, size.height - ((data.first - minValue) / range) * size.height),
|
||||
2,
|
||||
pointPaint,
|
||||
);
|
||||
|
||||
canvas.drawCircle(
|
||||
Offset(size.width, size.height - ((data.last - minValue) / range) * size.height),
|
||||
2,
|
||||
pointPaint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
/// Widget de grille de statistiques du dashboard
|
||||
/// Affiche les métriques principales dans une grille responsive
|
||||
library dashboard_stats_grid;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/design_system/tokens/color_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/spacing_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/typography_tokens.dart';
|
||||
import 'dashboard_stats_card.dart';
|
||||
|
||||
/// Widget de grille de statistiques
|
||||
///
|
||||
/// Affiche les statistiques principales dans une grille 2x2 :
|
||||
/// - Membres actifs
|
||||
/// - Cotisations du mois
|
||||
/// - Événements programmés
|
||||
/// - Demandes de solidarité
|
||||
///
|
||||
/// Chaque carte est interactive et peut déclencher une navigation
|
||||
class DashboardStatsGrid extends StatelessWidget {
|
||||
/// Callback pour les actions sur les statistiques
|
||||
final Function(String statType)? onStatTap;
|
||||
|
||||
/// Liste des statistiques à afficher
|
||||
final List<DashboardStat>? stats;
|
||||
|
||||
/// Constructeur de la grille de statistiques
|
||||
const DashboardStatsGrid({
|
||||
super.key,
|
||||
this.onStatTap,
|
||||
this.stats,
|
||||
});
|
||||
|
||||
/// Génère la liste des statistiques par défaut
|
||||
List<DashboardStat> _getDefaultStats() {
|
||||
return [
|
||||
DashboardStat(
|
||||
icon: Icons.people,
|
||||
value: '25',
|
||||
title: 'Membres',
|
||||
color: ColorTokens.primary,
|
||||
onTap: () => onStatTap?.call('members'),
|
||||
),
|
||||
DashboardStat(
|
||||
icon: Icons.account_balance_wallet,
|
||||
value: '15',
|
||||
title: 'Cotisations',
|
||||
color: ColorTokens.success,
|
||||
onTap: () => onStatTap?.call('cotisations'),
|
||||
),
|
||||
DashboardStat(
|
||||
icon: Icons.event,
|
||||
value: '8',
|
||||
title: 'Événements',
|
||||
color: ColorTokens.tertiary,
|
||||
onTap: () => onStatTap?.call('events'),
|
||||
),
|
||||
DashboardStat(
|
||||
icon: Icons.favorite,
|
||||
value: '3',
|
||||
title: 'Solidarité',
|
||||
color: ColorTokens.error,
|
||||
onTap: () => onStatTap?.call('solidarity'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final statsToShow = stats ?? _getDefaultStats();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Statistiques',
|
||||
style: TypographyTokens.headlineSmall.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: SpacingTokens.md,
|
||||
mainAxisSpacing: SpacingTokens.md,
|
||||
childAspectRatio: 1.4,
|
||||
),
|
||||
itemCount: statsToShow.length,
|
||||
itemBuilder: (context, index) {
|
||||
return DashboardStatsCard(stat: statsToShow[index]);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
/// Widget de section de bienvenue du dashboard
|
||||
/// Affiche un message d'accueil avec gradient et design moderne
|
||||
library dashboard_welcome_section;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/design_system/tokens/color_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/spacing_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/typography_tokens.dart';
|
||||
|
||||
/// Widget de section de bienvenue
|
||||
///
|
||||
/// Affiche un message d'accueil personnalisé avec :
|
||||
/// - Gradient de fond élégant
|
||||
/// - Typographie hiérarchisée
|
||||
/// - Design responsive et moderne
|
||||
class DashboardWelcomeSection extends StatelessWidget {
|
||||
/// Titre principal de la section
|
||||
final String title;
|
||||
|
||||
/// Sous-titre descriptif
|
||||
final String subtitle;
|
||||
|
||||
/// Constructeur du widget de bienvenue
|
||||
const DashboardWelcomeSection({
|
||||
super.key,
|
||||
this.title = 'Bienvenue sur UnionFlow',
|
||||
this.subtitle = 'Votre plateforme de gestion d\'union familiale',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
ColorTokens.primary.withOpacity(0.1),
|
||||
ColorTokens.secondary.withOpacity(0.05),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
|
||||
border: Border.all(
|
||||
color: ColorTokens.outline.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TypographyTokens.headlineSmall.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorTokens.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TypographyTokens.bodyMedium.copyWith(
|
||||
color: ColorTokens.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,191 +1,12 @@
|
||||
library dashboard_widgets;
|
||||
|
||||
/// Exports pour tous les widgets du dashboard UnionFlow
|
||||
///
|
||||
/// Ce fichier centralise tous les imports des composants du dashboard
|
||||
/// pour faciliter leur utilisation dans les pages et autres widgets.
|
||||
|
||||
// Widgets communs réutilisables
|
||||
export 'common/stat_card.dart';
|
||||
export 'common/section_header.dart';
|
||||
export 'common/activity_item.dart';
|
||||
|
||||
// Sections principales du dashboard
|
||||
export 'dashboard_header.dart';
|
||||
export 'quick_stats_section.dart';
|
||||
export 'recent_activities_section.dart';
|
||||
export 'upcoming_events_section.dart';
|
||||
|
||||
// Composants spécialisés
|
||||
export 'components/cards/performance_card.dart';
|
||||
|
||||
// Widgets existants (legacy) - gardés pour compatibilité
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/design_system/tokens/tokens.dart';
|
||||
import '../../../../shared/design_system/dashboard_theme.dart';
|
||||
|
||||
/// Widget pour afficher une grille d'actions rapides
|
||||
class DashboardQuickActionsGrid extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
final int crossAxisCount;
|
||||
|
||||
const DashboardQuickActionsGrid({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.crossAxisCount = 2,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: crossAxisCount,
|
||||
childAspectRatio: 1.2,
|
||||
crossAxisSpacing: SpacingTokens.md,
|
||||
mainAxisSpacing: SpacingTokens.md,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget pour une action rapide
|
||||
class DashboardQuickAction extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final Color? color;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const DashboardQuickAction({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
this.color,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(RadiusTokens.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 32,
|
||||
color: color ?? ColorTokens.primary,
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.sm),
|
||||
Text(
|
||||
title,
|
||||
style: TypographyTokens.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget pour afficher une section d'activité récente
|
||||
class DashboardRecentActivitySection extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
final String title;
|
||||
|
||||
const DashboardRecentActivitySection({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.title = 'Activité Récente',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TypographyTokens.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
...children,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget pour une activité
|
||||
class DashboardActivity extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final Color? color;
|
||||
|
||||
const DashboardActivity({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: SpacingTokens.sm),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: color ?? ColorTokens.primary,
|
||||
child: Icon(icon, color: Colors.white),
|
||||
),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget pour une section d'insights
|
||||
class DashboardInsightsSection extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const DashboardInsightsSection({
|
||||
super.key,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Insights',
|
||||
style: TypographyTokens.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
...children,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget pour une statistique
|
||||
/// Widget de statistique simple pour les dashboards de rôle
|
||||
class DashboardStat extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color? color;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const DashboardStat({
|
||||
super.key,
|
||||
@@ -193,59 +14,56 @@ class DashboardStat extends StatelessWidget {
|
||||
required this.value,
|
||||
required this.icon,
|
||||
this.color,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(RadiusTokens.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(SpacingTokens.lg),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
boxShadow: DashboardTheme.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 32,
|
||||
color: color ?? ColorTokens.primary,
|
||||
color: color ?? DashboardTheme.royalBlue,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.sm),
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
style: TypographyTokens.headlineSmall.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color ?? ColorTokens.primary,
|
||||
style: DashboardTheme.titleLarge.copyWith(
|
||||
color: color ?? DashboardTheme.royalBlue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.xs),
|
||||
Text(
|
||||
title,
|
||||
style: TypographyTokens.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
title,
|
||||
style: DashboardTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget pour la grille de statistiques
|
||||
/// Widget de grille de statistiques
|
||||
class DashboardStatsGrid extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
final int crossAxisCount;
|
||||
final List<DashboardStat> stats;
|
||||
final Function(String)? onStatTap;
|
||||
|
||||
const DashboardStatsGrid({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.crossAxisCount = 2,
|
||||
required this.stats,
|
||||
this.onStatTap,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -253,64 +71,182 @@ class DashboardStatsGrid extends StatelessWidget {
|
||||
return GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: crossAxisCount,
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: DashboardTheme.spacing12,
|
||||
crossAxisSpacing: DashboardTheme.spacing12,
|
||||
childAspectRatio: 1.2,
|
||||
crossAxisSpacing: SpacingTokens.md,
|
||||
mainAxisSpacing: SpacingTokens.md,
|
||||
children: stats,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget de grille d'actions rapides
|
||||
class DashboardQuickActionsGrid extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const DashboardQuickActionsGrid({
|
||||
super.key,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: DashboardTheme.spacing12,
|
||||
crossAxisSpacing: DashboardTheme.spacing12,
|
||||
childAspectRatio: 1.5,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget pour le drawer du dashboard
|
||||
class DashboardDrawer extends StatelessWidget {
|
||||
const DashboardDrawer({super.key});
|
||||
/// Widget d'action rapide
|
||||
class DashboardQuickAction extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
final Color? color;
|
||||
|
||||
const DashboardQuickAction({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Drawer(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
const DrawerHeader(
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.primary,
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
boxShadow: DashboardTheme.cardShadow,
|
||||
border: Border.all(
|
||||
color: (color ?? DashboardTheme.royalBlue).withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color ?? DashboardTheme.royalBlue,
|
||||
size: 32,
|
||||
),
|
||||
child: Text(
|
||||
'UnionFlow',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
title,
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget de section d'activités récentes
|
||||
class DashboardRecentActivitySection extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const DashboardRecentActivitySection({
|
||||
super.key,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
boxShadow: DashboardTheme.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Activités récentes',
|
||||
style: DashboardTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget d'activité
|
||||
class DashboardActivity extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String time;
|
||||
final IconData icon;
|
||||
final Color? color;
|
||||
|
||||
const DashboardActivity({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.time,
|
||||
required this.icon,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: DashboardTheme.spacing12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing8),
|
||||
decoration: BoxDecoration(
|
||||
color: (color ?? DashboardTheme.royalBlue).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: color ?? DashboardTheme.royalBlue,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.dashboard),
|
||||
title: const Text('Dashboard'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: DashboardTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.people),
|
||||
title: const Text('Membres'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.event),
|
||||
title: const Text('Événements'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings),
|
||||
title: const Text('Paramètres'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
Text(
|
||||
time,
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'dart:async';
|
||||
import '../../../domain/entities/dashboard_entity.dart';
|
||||
import '../../bloc/dashboard_bloc.dart';
|
||||
import '../../../../../shared/design_system/dashboard_theme.dart';
|
||||
|
||||
/// Widget de métriques en temps réel avec animations
|
||||
class RealTimeMetricsWidget extends StatefulWidget {
|
||||
final String organizationId;
|
||||
final String userId;
|
||||
final Duration refreshInterval;
|
||||
|
||||
const RealTimeMetricsWidget({
|
||||
super.key,
|
||||
required this.organizationId,
|
||||
required this.userId,
|
||||
this.refreshInterval = const Duration(minutes: 5),
|
||||
});
|
||||
|
||||
@override
|
||||
State<RealTimeMetricsWidget> createState() => _RealTimeMetricsWidgetState();
|
||||
}
|
||||
|
||||
class _RealTimeMetricsWidgetState extends State<RealTimeMetricsWidget>
|
||||
with TickerProviderStateMixin {
|
||||
Timer? _refreshTimer;
|
||||
late AnimationController _pulseController;
|
||||
late AnimationController _countController;
|
||||
late Animation<double> _pulseAnimation;
|
||||
late Animation<double> _countAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupAnimations();
|
||||
_startAutoRefresh();
|
||||
}
|
||||
|
||||
void _setupAnimations() {
|
||||
_pulseController = AnimationController(
|
||||
duration: const Duration(seconds: 2),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_countController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_pulseAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 1.1,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _pulseController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
_countAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _countController,
|
||||
curve: Curves.easeOutCubic,
|
||||
));
|
||||
|
||||
_pulseController.repeat(reverse: true);
|
||||
}
|
||||
|
||||
void _startAutoRefresh() {
|
||||
_refreshTimer = Timer.periodic(widget.refreshInterval, (timer) {
|
||||
if (mounted) {
|
||||
context.read<DashboardBloc>().add(RefreshDashboardData(
|
||||
organizationId: widget.organizationId,
|
||||
userId: widget.userId,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: DashboardTheme.gradientCardDecoration,
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const SizedBox(height: DashboardTheme.spacing20),
|
||||
BlocConsumer<DashboardBloc, DashboardState>(
|
||||
listener: (context, state) {
|
||||
if (state is DashboardLoaded) {
|
||||
_countController.forward(from: 0);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is DashboardLoading) {
|
||||
return _buildLoadingMetrics();
|
||||
} else if (state is DashboardLoaded || state is DashboardRefreshing) {
|
||||
final data = state is DashboardLoaded
|
||||
? state.dashboardData
|
||||
: (state as DashboardRefreshing).dashboardData;
|
||||
return _buildMetrics(data);
|
||||
} else if (state is DashboardError) {
|
||||
return _buildErrorMetrics();
|
||||
}
|
||||
return _buildEmptyMetrics();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _pulseAnimation.value,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing8),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.speed,
|
||||
color: DashboardTheme.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Métriques Temps Réel',
|
||||
style: DashboardTheme.titleMedium.copyWith(
|
||||
color: DashboardTheme.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Text(
|
||||
'Mise à jour automatique',
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.white.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildRefreshIndicator(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRefreshIndicator() {
|
||||
return BlocBuilder<DashboardBloc, DashboardState>(
|
||||
builder: (context, state) {
|
||||
if (state is DashboardRefreshing) {
|
||||
return const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(DashboardTheme.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
context.read<DashboardBloc>().add(RefreshDashboardData(
|
||||
organizationId: widget.organizationId,
|
||||
userId: widget.userId,
|
||||
));
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing4),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.refresh,
|
||||
color: DashboardTheme.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetrics(DashboardEntity data) {
|
||||
return AnimatedBuilder(
|
||||
animation: _countAnimation,
|
||||
builder: (context, child) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildMetricItem(
|
||||
'Membres Actifs',
|
||||
(data.stats.activeMembers * _countAnimation.value).round(),
|
||||
data.stats.totalMembers,
|
||||
Icons.people,
|
||||
DashboardTheme.success,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing16),
|
||||
Expanded(
|
||||
child: _buildMetricItem(
|
||||
'Engagement',
|
||||
((data.stats.engagementRate * 100) * _countAnimation.value).round(),
|
||||
100,
|
||||
Icons.favorite,
|
||||
DashboardTheme.warning,
|
||||
suffix: '%',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildMetricItem(
|
||||
'Événements',
|
||||
(data.stats.upcomingEvents * _countAnimation.value).round(),
|
||||
data.stats.totalEvents,
|
||||
Icons.event,
|
||||
DashboardTheme.info,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing16),
|
||||
Expanded(
|
||||
child: _buildMetricItem(
|
||||
'Croissance',
|
||||
(data.stats.monthlyGrowth * _countAnimation.value),
|
||||
null,
|
||||
Icons.trending_up,
|
||||
data.stats.hasGrowth ? DashboardTheme.success : DashboardTheme.error,
|
||||
suffix: '%',
|
||||
isDecimal: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricItem(
|
||||
String label,
|
||||
dynamic value,
|
||||
int? maxValue,
|
||||
IconData icon,
|
||||
Color color, {
|
||||
String suffix = '',
|
||||
bool isDecimal = false,
|
||||
}) {
|
||||
String displayValue;
|
||||
if (isDecimal) {
|
||||
displayValue = value.toStringAsFixed(1) + suffix;
|
||||
} else {
|
||||
displayValue = value.toString() + suffix;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
border: Border.all(
|
||||
color: DashboardTheme.white.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.white.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
displayValue,
|
||||
style: DashboardTheme.titleLarge.copyWith(
|
||||
color: DashboardTheme.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 24,
|
||||
),
|
||||
),
|
||||
if (maxValue != null) ...[
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Text(
|
||||
'sur $maxValue',
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.white.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingMetrics() {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildLoadingMetricItem()),
|
||||
const SizedBox(width: DashboardTheme.spacing16),
|
||||
Expanded(child: _buildLoadingMetricItem()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildLoadingMetricItem()),
|
||||
const SizedBox(width: DashboardTheme.spacing16),
|
||||
Expanded(child: _buildLoadingMetricItem()),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingMetricItem() {
|
||||
return Container(
|
||||
height: 100,
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(DashboardTheme.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorMetrics() {
|
||||
return Container(
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: DashboardTheme.error,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
'Erreur de chargement',
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyMetrics() {
|
||||
return Container(
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.speed,
|
||||
color: DashboardTheme.white.withOpacity(0.5),
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
'Aucune donnée',
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.white.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshTimer?.cancel();
|
||||
_pulseController.dispose();
|
||||
_countController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
import '../../../../../shared/design_system/dashboard_theme.dart';
|
||||
import '../../../data/services/dashboard_performance_monitor.dart';
|
||||
|
||||
/// Widget de monitoring des performances en temps réel
|
||||
class PerformanceMonitorWidget extends StatefulWidget {
|
||||
final bool showDetails;
|
||||
final Duration updateInterval;
|
||||
|
||||
const PerformanceMonitorWidget({
|
||||
super.key,
|
||||
this.showDetails = false,
|
||||
this.updateInterval = const Duration(seconds: 2),
|
||||
});
|
||||
|
||||
@override
|
||||
State<PerformanceMonitorWidget> createState() => _PerformanceMonitorWidgetState();
|
||||
}
|
||||
|
||||
class _PerformanceMonitorWidgetState extends State<PerformanceMonitorWidget>
|
||||
with TickerProviderStateMixin {
|
||||
final DashboardPerformanceMonitor _monitor = DashboardPerformanceMonitor();
|
||||
StreamSubscription<PerformanceMetrics>? _metricsSubscription;
|
||||
StreamSubscription<PerformanceAlert>? _alertSubscription;
|
||||
|
||||
PerformanceMetrics? _currentMetrics;
|
||||
final List<PerformanceAlert> _recentAlerts = [];
|
||||
|
||||
late AnimationController _pulseController;
|
||||
late Animation<double> _pulseAnimation;
|
||||
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupAnimations();
|
||||
_startMonitoring();
|
||||
}
|
||||
|
||||
void _setupAnimations() {
|
||||
_pulseController = AnimationController(
|
||||
duration: const Duration(seconds: 2),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_pulseAnimation = Tween<double>(
|
||||
begin: 0.8,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _pulseController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
_pulseController.repeat(reverse: true);
|
||||
}
|
||||
|
||||
Future<void> _startMonitoring() async {
|
||||
await _monitor.startMonitoring();
|
||||
|
||||
_metricsSubscription = _monitor.metricsStream.listen((metrics) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentMetrics = metrics;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_alertSubscription = _monitor.alertStream.listen((alert) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_recentAlerts.insert(0, alert);
|
||||
if (_recentAlerts.length > 5) {
|
||||
_recentAlerts.removeLast();
|
||||
}
|
||||
});
|
||||
|
||||
// Afficher une notification pour les alertes critiques
|
||||
if (alert.severity == AlertSeverity.error ||
|
||||
alert.severity == AlertSeverity.critical) {
|
||||
_showAlertSnackBar(alert);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showAlertSnackBar(PerformanceAlert alert) {
|
||||
final color = _getAlertColor(alert.severity);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getAlertIcon(alert.type),
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
alert.message,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: color,
|
||||
duration: const Duration(seconds: 4),
|
||||
action: SnackBarAction(
|
||||
label: 'Détails',
|
||||
textColor: Colors.white,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isExpanded = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_currentMetrics == null) {
|
||||
return _buildLoadingWidget();
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(DashboardTheme.spacing8),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
boxShadow: DashboardTheme.subtleShadow,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
if (_isExpanded || widget.showDetails) ...[
|
||||
const Divider(height: 1),
|
||||
_buildDetailedMetrics(),
|
||||
if (_recentAlerts.isNotEmpty) ...[
|
||||
const Divider(height: 1),
|
||||
_buildAlertsSection(),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingWidget() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
boxShadow: DashboardTheme.subtleShadow,
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(DashboardTheme.royalBlue),
|
||||
),
|
||||
),
|
||||
SizedBox(width: DashboardTheme.spacing12),
|
||||
Text(
|
||||
'Initialisation du monitoring...',
|
||||
style: DashboardTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_isExpanded = !_isExpanded;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _pulseAnimation.value,
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: _getOverallHealthColor(),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _getOverallHealthColor().withOpacity(0.5),
|
||||
blurRadius: 4,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Performances Système',
|
||||
style: DashboardTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
_buildQuickMetrics(),
|
||||
const SizedBox(width: DashboardTheme.spacing8),
|
||||
Icon(
|
||||
_isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
color: DashboardTheme.grey600,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickMetrics() {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildQuickMetric(
|
||||
'MEM',
|
||||
'${_currentMetrics!.memoryUsage.toStringAsFixed(0)}MB',
|
||||
_getMetricColor(_currentMetrics!.memoryUsage, 400, 600),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing8),
|
||||
_buildQuickMetric(
|
||||
'CPU',
|
||||
'${_currentMetrics!.cpuUsage.toStringAsFixed(0)}%',
|
||||
_getMetricColor(_currentMetrics!.cpuUsage, 50, 80),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing8),
|
||||
_buildQuickMetric(
|
||||
'NET',
|
||||
'${_currentMetrics!.networkLatency}ms',
|
||||
_getMetricColor(_currentMetrics!.networkLatency.toDouble(), 200, 1000),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickMetric(String label, String value, Color color) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: DashboardTheme.grey600,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailedMetrics() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildMetricRow(
|
||||
'Mémoire',
|
||||
'${_currentMetrics!.memoryUsage.toStringAsFixed(1)} MB',
|
||||
_currentMetrics!.memoryUsage / 1000, // Normaliser sur 1000MB
|
||||
_getMetricColor(_currentMetrics!.memoryUsage, 400, 600),
|
||||
Icons.memory,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing12),
|
||||
_buildMetricRow(
|
||||
'Processeur',
|
||||
'${_currentMetrics!.cpuUsage.toStringAsFixed(1)}%',
|
||||
_currentMetrics!.cpuUsage / 100,
|
||||
_getMetricColor(_currentMetrics!.cpuUsage, 50, 80),
|
||||
Icons.speed,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing12),
|
||||
_buildMetricRow(
|
||||
'Réseau',
|
||||
'${_currentMetrics!.networkLatency} ms',
|
||||
(_currentMetrics!.networkLatency / 2000).clamp(0.0, 1.0),
|
||||
_getMetricColor(_currentMetrics!.networkLatency.toDouble(), 200, 1000),
|
||||
Icons.wifi,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing12),
|
||||
_buildMetricRow(
|
||||
'Images/sec',
|
||||
'${_currentMetrics!.frameRate.toStringAsFixed(1)} fps',
|
||||
_currentMetrics!.frameRate / 60,
|
||||
_getMetricColor(60 - _currentMetrics!.frameRate, 10, 30), // Inversé car plus c'est haut, mieux c'est
|
||||
Icons.videocam,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing12),
|
||||
_buildMetricRow(
|
||||
'Batterie',
|
||||
'${_currentMetrics!.batteryLevel.toStringAsFixed(0)}%',
|
||||
_currentMetrics!.batteryLevel / 100,
|
||||
_getBatteryColor(_currentMetrics!.batteryLevel),
|
||||
Icons.battery_std,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricRow(
|
||||
String label,
|
||||
String value,
|
||||
double progress,
|
||||
Color color,
|
||||
IconData icon,
|
||||
) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: DashboardTheme.spacing8),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
label,
|
||||
style: DashboardTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: LinearProgressIndicator(
|
||||
value: progress.clamp(0.0, 1.0),
|
||||
backgroundColor: DashboardTheme.grey200,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing8),
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlertsSection() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Alertes Récentes',
|
||||
style: DashboardTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
..._recentAlerts.take(3).map((alert) => _buildAlertItem(alert)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlertItem(PerformanceAlert alert) {
|
||||
final color = _getAlertColor(alert.severity);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getAlertIcon(alert.type),
|
||||
size: 16,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
alert.message,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: DashboardTheme.grey700,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatTime(alert.timestamp),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: DashboardTheme.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getOverallHealthColor() {
|
||||
if (_currentMetrics == null) return DashboardTheme.grey400;
|
||||
|
||||
final metrics = _currentMetrics!;
|
||||
|
||||
// Calculer un score de santé global
|
||||
int issues = 0;
|
||||
if (metrics.memoryUsage > 500) issues++;
|
||||
if (metrics.cpuUsage > 70) issues++;
|
||||
if (metrics.networkLatency > 1000) issues++;
|
||||
if (metrics.frameRate < 30) issues++;
|
||||
|
||||
switch (issues) {
|
||||
case 0:
|
||||
return DashboardTheme.success;
|
||||
case 1:
|
||||
return DashboardTheme.warning;
|
||||
default:
|
||||
return DashboardTheme.error;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getMetricColor(double value, double warningThreshold, double errorThreshold) {
|
||||
if (value >= errorThreshold) return DashboardTheme.error;
|
||||
if (value >= warningThreshold) return DashboardTheme.warning;
|
||||
return DashboardTheme.success;
|
||||
}
|
||||
|
||||
Color _getBatteryColor(double batteryLevel) {
|
||||
if (batteryLevel <= 20) return DashboardTheme.error;
|
||||
if (batteryLevel <= 50) return DashboardTheme.warning;
|
||||
return DashboardTheme.success;
|
||||
}
|
||||
|
||||
Color _getAlertColor(AlertSeverity severity) {
|
||||
switch (severity) {
|
||||
case AlertSeverity.info:
|
||||
return DashboardTheme.info;
|
||||
case AlertSeverity.warning:
|
||||
return DashboardTheme.warning;
|
||||
case AlertSeverity.error:
|
||||
return DashboardTheme.error;
|
||||
case AlertSeverity.critical:
|
||||
return DashboardTheme.error;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getAlertIcon(AlertType type) {
|
||||
switch (type) {
|
||||
case AlertType.memory:
|
||||
return Icons.memory;
|
||||
case AlertType.cpu:
|
||||
return Icons.speed;
|
||||
case AlertType.network:
|
||||
return Icons.wifi_off;
|
||||
case AlertType.performance:
|
||||
return Icons.slow_motion_video;
|
||||
case AlertType.battery:
|
||||
return Icons.battery_alert;
|
||||
case AlertType.disk:
|
||||
return Icons.storage;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(time);
|
||||
|
||||
if (diff.inMinutes < 1) return 'maintenant';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}min';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h';
|
||||
return '${diff.inDays}j';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulseController.dispose();
|
||||
_metricsSubscription?.cancel();
|
||||
_alertSubscription?.cancel();
|
||||
_monitor.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../shared/design_system/dashboard_theme.dart';
|
||||
import '../../pages/connected_dashboard_page.dart';
|
||||
import '../../pages/advanced_dashboard_page.dart';
|
||||
|
||||
/// Widget de navigation pour les différents types de dashboard
|
||||
class DashboardNavigation extends StatefulWidget {
|
||||
final String organizationId;
|
||||
final String userId;
|
||||
|
||||
const DashboardNavigation({
|
||||
super.key,
|
||||
required this.organizationId,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DashboardNavigation> createState() => _DashboardNavigationState();
|
||||
}
|
||||
|
||||
class _DashboardNavigationState extends State<DashboardNavigation> {
|
||||
int _currentIndex = 0;
|
||||
|
||||
final List<DashboardTab> _tabs = [
|
||||
const DashboardTab(
|
||||
title: 'Accueil',
|
||||
icon: Icons.home,
|
||||
activeIcon: Icons.home,
|
||||
type: DashboardType.home,
|
||||
),
|
||||
const DashboardTab(
|
||||
title: 'Analytics',
|
||||
icon: Icons.analytics_outlined,
|
||||
activeIcon: Icons.analytics,
|
||||
type: DashboardType.analytics,
|
||||
),
|
||||
const DashboardTab(
|
||||
title: 'Rapports',
|
||||
icon: Icons.assessment_outlined,
|
||||
activeIcon: Icons.assessment,
|
||||
type: DashboardType.reports,
|
||||
),
|
||||
const DashboardTab(
|
||||
title: 'Paramètres',
|
||||
icon: Icons.settings_outlined,
|
||||
activeIcon: Icons.settings,
|
||||
type: DashboardType.settings,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: _buildCurrentPage(),
|
||||
bottomNavigationBar: _buildBottomNavigationBar(),
|
||||
floatingActionButton: _buildFloatingActionButton(),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentPage() {
|
||||
switch (_tabs[_currentIndex].type) {
|
||||
case DashboardType.home:
|
||||
return ConnectedDashboardPage(
|
||||
organizationId: widget.organizationId,
|
||||
userId: widget.userId,
|
||||
);
|
||||
case DashboardType.analytics:
|
||||
return AdvancedDashboardPage(
|
||||
organizationId: widget.organizationId,
|
||||
userId: widget.userId,
|
||||
);
|
||||
case DashboardType.reports:
|
||||
return _buildReportsPage();
|
||||
case DashboardType.settings:
|
||||
return _buildSettingsPage();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBottomNavigationBar() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: DashboardTheme.grey900.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: BottomAppBar(
|
||||
shape: const CircularNotchedRectangle(),
|
||||
notchMargin: 8,
|
||||
color: DashboardTheme.white,
|
||||
elevation: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: DashboardTheme.spacing8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: _tabs.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final tab = entry.value;
|
||||
final isActive = index == _currentIndex;
|
||||
|
||||
// Skip the middle item for FAB space
|
||||
if (index == 2) {
|
||||
return const SizedBox(width: 40);
|
||||
}
|
||||
|
||||
return _buildNavItem(tab, isActive, index);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavItem(DashboardTab tab, bool isActive, int index) {
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _currentIndex = index),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: DashboardTheme.spacing12,
|
||||
horizontal: DashboardTheme.spacing16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isActive ? tab.activeIcon : tab.icon,
|
||||
color: isActive ? DashboardTheme.royalBlue : DashboardTheme.grey400,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Text(
|
||||
tab.title,
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: isActive ? DashboardTheme.royalBlue : DashboardTheme.grey400,
|
||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFloatingActionButton() {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: DashboardTheme.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
boxShadow: DashboardTheme.elevatedShadow,
|
||||
),
|
||||
child: FloatingActionButton(
|
||||
onPressed: _showQuickActions,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
child: const Icon(
|
||||
Icons.add,
|
||||
color: DashboardTheme.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportsPage() {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Rapports'),
|
||||
backgroundColor: DashboardTheme.royalBlue,
|
||||
foregroundColor: DashboardTheme.white,
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.assessment,
|
||||
size: 64,
|
||||
color: DashboardTheme.grey400,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
const Text(
|
||||
'Page Rapports',
|
||||
style: DashboardTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
'Fonctionnalité en cours de développement',
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingsPage() {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Paramètres'),
|
||||
backgroundColor: DashboardTheme.royalBlue,
|
||||
foregroundColor: DashboardTheme.white,
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
children: [
|
||||
_buildSettingsSection(
|
||||
'Apparence',
|
||||
[
|
||||
_buildSettingsTile(
|
||||
'Thème',
|
||||
'Bleu Roi & Pétrole',
|
||||
Icons.palette,
|
||||
() {},
|
||||
),
|
||||
_buildSettingsTile(
|
||||
'Langue',
|
||||
'Français',
|
||||
Icons.language,
|
||||
() {},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing24),
|
||||
_buildSettingsSection(
|
||||
'Notifications',
|
||||
[
|
||||
_buildSettingsTile(
|
||||
'Notifications push',
|
||||
'Activées',
|
||||
Icons.notifications,
|
||||
() {},
|
||||
),
|
||||
_buildSettingsTile(
|
||||
'Emails',
|
||||
'Quotidien',
|
||||
Icons.email,
|
||||
() {},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing24),
|
||||
_buildSettingsSection(
|
||||
'Données',
|
||||
[
|
||||
_buildSettingsTile(
|
||||
'Synchronisation',
|
||||
'Automatique',
|
||||
Icons.sync,
|
||||
() {},
|
||||
),
|
||||
_buildSettingsTile(
|
||||
'Cache',
|
||||
'Vider le cache',
|
||||
Icons.storage,
|
||||
() {},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingsSection(String title, List<Widget> children) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: DashboardTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing12),
|
||||
Container(
|
||||
decoration: DashboardTheme.cardDecoration,
|
||||
child: Column(children: children),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingsTile(
|
||||
String title,
|
||||
String subtitle,
|
||||
IconData icon,
|
||||
VoidCallback onTap,
|
||||
) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: DashboardTheme.royalBlue),
|
||||
title: Text(title, style: DashboardTheme.bodyMedium),
|
||||
subtitle: Text(subtitle, style: DashboardTheme.bodySmall),
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
color: DashboardTheme.grey400,
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
void _showQuickActions() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: DashboardTheme.white,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(DashboardTheme.borderRadiusLarge),
|
||||
topRight: Radius.circular(DashboardTheme.borderRadiusLarge),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey300,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing20),
|
||||
const Text(
|
||||
'Actions Rapides',
|
||||
style: DashboardTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing20),
|
||||
GridView.count(
|
||||
crossAxisCount: 3,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisSpacing: DashboardTheme.spacing16,
|
||||
mainAxisSpacing: DashboardTheme.spacing16,
|
||||
children: [
|
||||
_buildQuickActionItem('Nouveau\nMembre', Icons.person_add, DashboardTheme.success),
|
||||
_buildQuickActionItem('Créer\nÉvénement', Icons.event_available, DashboardTheme.royalBlue),
|
||||
_buildQuickActionItem('Ajouter\nContribution', Icons.payment, DashboardTheme.tealBlue),
|
||||
_buildQuickActionItem('Envoyer\nMessage', Icons.message, DashboardTheme.warning),
|
||||
_buildQuickActionItem('Générer\nRapport', Icons.assessment, DashboardTheme.info),
|
||||
_buildQuickActionItem('Paramètres', Icons.settings, DashboardTheme.grey600),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickActionItem(String title, IconData icon, Color color) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
// TODO: Implémenter l'action
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 24),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
title,
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardTab {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final IconData activeIcon;
|
||||
final DashboardType type;
|
||||
|
||||
const DashboardTab({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.activeIcon,
|
||||
required this.type,
|
||||
});
|
||||
}
|
||||
|
||||
enum DashboardType {
|
||||
home,
|
||||
analytics,
|
||||
reports,
|
||||
settings,
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../domain/entities/dashboard_entity.dart';
|
||||
import '../../bloc/dashboard_bloc.dart';
|
||||
import '../../../../../shared/design_system/dashboard_theme.dart';
|
||||
|
||||
/// Widget de notifications pour le dashboard
|
||||
class DashboardNotificationsWidget extends StatelessWidget {
|
||||
final int maxNotifications;
|
||||
|
||||
const DashboardNotificationsWidget({
|
||||
super.key,
|
||||
this.maxNotifications = 5,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: DashboardTheme.cardDecoration,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
BlocBuilder<DashboardBloc, DashboardState>(
|
||||
builder: (context, state) {
|
||||
if (state is DashboardLoading) {
|
||||
return _buildLoadingNotifications();
|
||||
} else if (state is DashboardLoaded || state is DashboardRefreshing) {
|
||||
final data = state is DashboardLoaded
|
||||
? state.dashboardData
|
||||
: (state as DashboardRefreshing).dashboardData;
|
||||
return _buildNotifications(data);
|
||||
} else if (state is DashboardError) {
|
||||
return _buildErrorNotifications();
|
||||
}
|
||||
return _buildEmptyNotifications();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.royalBlue.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(DashboardTheme.borderRadius),
|
||||
topRight: Radius.circular(DashboardTheme.borderRadius),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing8),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.royalBlue,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.notifications,
|
||||
color: DashboardTheme.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Notifications',
|
||||
style: DashboardTheme.titleMedium.copyWith(
|
||||
color: DashboardTheme.royalBlue,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
BlocBuilder<DashboardBloc, DashboardState>(
|
||||
builder: (context, state) {
|
||||
if (state is DashboardLoaded || state is DashboardRefreshing) {
|
||||
final data = state is DashboardLoaded
|
||||
? state.dashboardData
|
||||
: (state as DashboardRefreshing).dashboardData;
|
||||
final urgentCount = _getUrgentNotificationsCount(data);
|
||||
|
||||
if (urgentCount > 0) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DashboardTheme.spacing8,
|
||||
vertical: DashboardTheme.spacing4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.error,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: Text(
|
||||
urgentCount.toString(),
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotifications(DashboardEntity data) {
|
||||
final notifications = _generateNotifications(data);
|
||||
|
||||
if (notifications.isEmpty) {
|
||||
return _buildEmptyNotifications();
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: notifications.take(maxNotifications).map((notification) {
|
||||
return _buildNotificationItem(notification);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationItem(DashboardNotification notification) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: DashboardTheme.grey200,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing8),
|
||||
decoration: BoxDecoration(
|
||||
color: notification.color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: Icon(
|
||||
notification.icon,
|
||||
color: notification.color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
notification.title,
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (notification.isUrgent) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DashboardTheme.spacing6,
|
||||
vertical: DashboardTheme.spacing2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.error,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: Text(
|
||||
'URGENT',
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Text(
|
||||
notification.message,
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.grey600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
notification.timeAgo,
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.grey500,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (notification.actionLabel != null) ...[
|
||||
GestureDetector(
|
||||
onTap: notification.onAction,
|
||||
child: Text(
|
||||
notification.actionLabel!,
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.royalBlue,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingNotifications() {
|
||||
return Column(
|
||||
children: List.generate(3, (index) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: DashboardTheme.grey200,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey200,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 16,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey200,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Container(
|
||||
height: 12,
|
||||
width: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.grey200,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorNotifications() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: DashboardTheme.error,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
'Erreur de chargement',
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyNotifications() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.notifications_none,
|
||||
color: DashboardTheme.grey400,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
'Aucune notification',
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.grey500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Text(
|
||||
'Vous êtes à jour !',
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.grey400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<DashboardNotification> _generateNotifications(DashboardEntity data) {
|
||||
List<DashboardNotification> notifications = [];
|
||||
|
||||
// Notification pour les demandes en attente
|
||||
if (data.stats.pendingRequests > 0) {
|
||||
notifications.add(DashboardNotification(
|
||||
title: 'Demandes en attente',
|
||||
message: '${data.stats.pendingRequests} demandes nécessitent votre attention',
|
||||
icon: Icons.pending_actions,
|
||||
color: DashboardTheme.warning,
|
||||
timeAgo: '2h',
|
||||
isUrgent: data.stats.pendingRequests > 20,
|
||||
actionLabel: 'Voir',
|
||||
onAction: () {},
|
||||
));
|
||||
}
|
||||
|
||||
// Notification pour les événements aujourd'hui
|
||||
if (data.todayEventsCount > 0) {
|
||||
notifications.add(DashboardNotification(
|
||||
title: 'Événements aujourd\'hui',
|
||||
message: '${data.todayEventsCount} événement(s) programmé(s) aujourd\'hui',
|
||||
icon: Icons.event_available,
|
||||
color: DashboardTheme.info,
|
||||
timeAgo: '30min',
|
||||
isUrgent: false,
|
||||
actionLabel: 'Voir',
|
||||
onAction: () {},
|
||||
));
|
||||
}
|
||||
|
||||
// Notification pour la croissance
|
||||
if (data.stats.hasGrowth) {
|
||||
notifications.add(DashboardNotification(
|
||||
title: 'Croissance positive',
|
||||
message: 'Croissance de ${data.stats.monthlyGrowth.toStringAsFixed(1)}% ce mois',
|
||||
icon: Icons.trending_up,
|
||||
color: DashboardTheme.success,
|
||||
timeAgo: '1j',
|
||||
isUrgent: false,
|
||||
actionLabel: null,
|
||||
onAction: null,
|
||||
));
|
||||
}
|
||||
|
||||
// Notification pour l'engagement faible
|
||||
if (!data.stats.isHighEngagement) {
|
||||
notifications.add(DashboardNotification(
|
||||
title: 'Engagement à améliorer',
|
||||
message: 'Taux d\'engagement: ${(data.stats.engagementRate * 100).toStringAsFixed(0)}%',
|
||||
icon: Icons.trending_down,
|
||||
color: DashboardTheme.error,
|
||||
timeAgo: '3h',
|
||||
isUrgent: data.stats.engagementRate < 0.5,
|
||||
actionLabel: 'Améliorer',
|
||||
onAction: () {},
|
||||
));
|
||||
}
|
||||
|
||||
// Notification pour les nouveaux membres
|
||||
if (data.recentActivitiesCount > 0) {
|
||||
notifications.add(DashboardNotification(
|
||||
title: 'Nouvelles activités',
|
||||
message: '${data.recentActivitiesCount} nouvelles activités aujourd\'hui',
|
||||
icon: Icons.fiber_new,
|
||||
color: DashboardTheme.tealBlue,
|
||||
timeAgo: '15min',
|
||||
isUrgent: false,
|
||||
actionLabel: 'Voir',
|
||||
onAction: () {},
|
||||
));
|
||||
}
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
int _getUrgentNotificationsCount(DashboardEntity data) {
|
||||
final notifications = _generateNotifications(data);
|
||||
return notifications.where((n) => n.isUrgent).length;
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardNotification {
|
||||
final String title;
|
||||
final String message;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String timeAgo;
|
||||
final bool isUrgent;
|
||||
final String? actionLabel;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
const DashboardNotification({
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.timeAgo,
|
||||
required this.isUrgent,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
});
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'common/section_header.dart';
|
||||
import 'common/stat_card.dart';
|
||||
|
||||
/// Section des statistiques rapides du dashboard
|
||||
///
|
||||
/// Widget réutilisable pour afficher les KPIs et métriques principales
|
||||
/// avec différents layouts et styles selon le contexte.
|
||||
class QuickStatsSection extends StatelessWidget {
|
||||
/// Titre de la section
|
||||
final String title;
|
||||
|
||||
/// Sous-titre optionnel
|
||||
final String? subtitle;
|
||||
|
||||
/// Liste des statistiques à afficher
|
||||
final List<QuickStat> stats;
|
||||
|
||||
/// Layout des cartes (grid, row, column)
|
||||
final StatsLayout layout;
|
||||
|
||||
/// Nombre de colonnes pour le layout grid
|
||||
final int gridColumns;
|
||||
|
||||
/// Style des cartes de statistiques
|
||||
final StatCardStyle cardStyle;
|
||||
|
||||
/// Taille des cartes
|
||||
final StatCardSize cardSize;
|
||||
|
||||
/// Callback lors du tap sur une statistique
|
||||
final Function(QuickStat)? onStatTap;
|
||||
|
||||
/// Afficher ou non l'en-tête de section
|
||||
final bool showHeader;
|
||||
|
||||
const QuickStatsSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.stats,
|
||||
this.layout = StatsLayout.grid,
|
||||
this.gridColumns = 2,
|
||||
this.cardStyle = StatCardStyle.elevated,
|
||||
this.cardSize = StatCardSize.compact,
|
||||
this.onStatTap,
|
||||
this.showHeader = true,
|
||||
});
|
||||
|
||||
/// Constructeur pour les KPIs système (Super Admin)
|
||||
const QuickStatsSection.systemKPIs({
|
||||
super.key,
|
||||
this.onStatTap,
|
||||
}) : title = 'Métriques Système',
|
||||
subtitle = null,
|
||||
stats = const [
|
||||
QuickStat(
|
||||
title: 'Organisations',
|
||||
value: '247',
|
||||
subtitle: '+12 ce mois',
|
||||
icon: Icons.business,
|
||||
color: Color(0xFF0984E3),
|
||||
),
|
||||
QuickStat(
|
||||
title: 'Utilisateurs',
|
||||
value: '15,847',
|
||||
subtitle: '+1,234 ce mois',
|
||||
icon: Icons.people,
|
||||
color: Color(0xFF00B894),
|
||||
),
|
||||
QuickStat(
|
||||
title: 'Uptime',
|
||||
value: '99.97%',
|
||||
subtitle: '30 derniers jours',
|
||||
icon: Icons.trending_up,
|
||||
color: Color(0xFF00CEC9),
|
||||
),
|
||||
QuickStat(
|
||||
title: 'Temps Réponse',
|
||||
value: '1.2s',
|
||||
subtitle: 'Moyenne 24h',
|
||||
icon: Icons.speed,
|
||||
color: Color(0xFFE17055),
|
||||
),
|
||||
],
|
||||
layout = StatsLayout.grid,
|
||||
gridColumns = 2,
|
||||
cardStyle = StatCardStyle.elevated,
|
||||
cardSize = StatCardSize.compact,
|
||||
showHeader = true;
|
||||
|
||||
/// Constructeur pour les statistiques d'organisation
|
||||
const QuickStatsSection.organizationStats({
|
||||
super.key,
|
||||
this.onStatTap,
|
||||
}) : title = 'Vue d\'ensemble',
|
||||
subtitle = null,
|
||||
stats = const [
|
||||
QuickStat(
|
||||
title: 'Membres',
|
||||
value: '156',
|
||||
subtitle: '+12 ce mois',
|
||||
icon: Icons.people,
|
||||
color: Color(0xFF00B894),
|
||||
),
|
||||
QuickStat(
|
||||
title: 'Événements',
|
||||
value: '23',
|
||||
subtitle: '8 à venir',
|
||||
icon: Icons.event,
|
||||
color: Color(0xFFE17055),
|
||||
),
|
||||
QuickStat(
|
||||
title: 'Projets',
|
||||
value: '8',
|
||||
subtitle: '3 actifs',
|
||||
icon: Icons.work,
|
||||
color: Color(0xFF0984E3),
|
||||
),
|
||||
QuickStat(
|
||||
title: 'Taux engagement',
|
||||
value: '78%',
|
||||
subtitle: '+5% ce mois',
|
||||
icon: Icons.trending_up,
|
||||
color: Color(0xFF6C5CE7),
|
||||
),
|
||||
],
|
||||
layout = StatsLayout.grid,
|
||||
gridColumns = 2,
|
||||
cardStyle = StatCardStyle.elevated,
|
||||
cardSize = StatCardSize.compact,
|
||||
showHeader = true;
|
||||
|
||||
/// Constructeur pour les métriques de performance
|
||||
const QuickStatsSection.performanceMetrics({
|
||||
super.key,
|
||||
this.onStatTap,
|
||||
}) : title = 'Performance',
|
||||
subtitle = 'Métriques temps réel',
|
||||
stats = const [
|
||||
QuickStat(
|
||||
title: 'CPU',
|
||||
value: '23%',
|
||||
subtitle: 'Normal',
|
||||
icon: Icons.memory,
|
||||
color: Color(0xFF00B894),
|
||||
),
|
||||
QuickStat(
|
||||
title: 'RAM',
|
||||
value: '67%',
|
||||
subtitle: 'Élevé',
|
||||
icon: Icons.storage,
|
||||
color: Color(0xFFE17055),
|
||||
),
|
||||
QuickStat(
|
||||
title: 'Réseau',
|
||||
value: '12 MB/s',
|
||||
subtitle: 'Stable',
|
||||
icon: Icons.network_check,
|
||||
color: Color(0xFF0984E3),
|
||||
),
|
||||
],
|
||||
layout = StatsLayout.row,
|
||||
gridColumns = 3,
|
||||
cardStyle = StatCardStyle.outlined,
|
||||
cardSize = StatCardSize.normal,
|
||||
showHeader = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showHeader) ...[
|
||||
SectionHeader.section(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
),
|
||||
],
|
||||
_buildStatsLayout(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du layout des statistiques
|
||||
Widget _buildStatsLayout() {
|
||||
switch (layout) {
|
||||
case StatsLayout.grid:
|
||||
return _buildGridLayout();
|
||||
case StatsLayout.row:
|
||||
return _buildRowLayout();
|
||||
case StatsLayout.column:
|
||||
return _buildColumnLayout();
|
||||
case StatsLayout.wrap:
|
||||
return _buildWrapLayout();
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout en grille
|
||||
Widget _buildGridLayout() {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: gridColumns,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
childAspectRatio: _getChildAspectRatio(),
|
||||
),
|
||||
itemCount: stats.length,
|
||||
itemBuilder: (context, index) => _buildStatCard(stats[index]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Layout en ligne
|
||||
Widget _buildRowLayout() {
|
||||
return Row(
|
||||
children: stats.map((stat) => Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: _buildStatCard(stat),
|
||||
),
|
||||
)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Layout en colonne
|
||||
Widget _buildColumnLayout() {
|
||||
return Column(
|
||||
children: stats.map((stat) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _buildStatCard(stat),
|
||||
)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Layout wrap (adaptatif)
|
||||
Widget _buildWrapLayout() {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: stats.map((stat) => SizedBox(
|
||||
width: (constraints.maxWidth - 8) / 2, // 2 colonnes avec espacement
|
||||
child: _buildStatCard(stat),
|
||||
)).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction d'une carte de statistique
|
||||
Widget _buildStatCard(QuickStat stat) {
|
||||
return StatCard(
|
||||
title: stat.title,
|
||||
value: stat.value,
|
||||
subtitle: stat.subtitle,
|
||||
icon: stat.icon,
|
||||
color: stat.color,
|
||||
size: cardSize,
|
||||
style: cardStyle,
|
||||
onTap: onStatTap != null ? () => onStatTap!(stat) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Ratio d'aspect selon la taille des cartes
|
||||
double _getChildAspectRatio() {
|
||||
switch (cardSize) {
|
||||
case StatCardSize.compact:
|
||||
return 1.4;
|
||||
case StatCardSize.normal:
|
||||
return 1.2;
|
||||
case StatCardSize.large:
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Modèle de données pour une statistique rapide
|
||||
class QuickStat {
|
||||
final String title;
|
||||
final String value;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
const QuickStat({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
/// Constructeur pour une métrique système
|
||||
const QuickStat.system({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
}) : color = const Color(0xFF6C5CE7),
|
||||
metadata = null;
|
||||
|
||||
/// Constructeur pour une métrique utilisateur
|
||||
const QuickStat.user({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
}) : color = const Color(0xFF00B894),
|
||||
metadata = null;
|
||||
|
||||
/// Constructeur pour une métrique d'organisation
|
||||
const QuickStat.organization({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
}) : color = const Color(0xFF0984E3),
|
||||
metadata = null;
|
||||
|
||||
/// Constructeur pour une métrique d'événement
|
||||
const QuickStat.event({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
}) : color = const Color(0xFFE17055),
|
||||
metadata = null;
|
||||
|
||||
/// Constructeur pour une alerte
|
||||
const QuickStat.alert({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
}) : color = Colors.orange,
|
||||
metadata = null;
|
||||
|
||||
/// Constructeur pour une erreur
|
||||
const QuickStat.error({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
}) : color = Colors.red,
|
||||
metadata = null;
|
||||
}
|
||||
|
||||
/// Types de layout pour les statistiques
|
||||
enum StatsLayout {
|
||||
grid,
|
||||
row,
|
||||
column,
|
||||
wrap,
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'common/activity_item.dart';
|
||||
|
||||
/// Section des activités récentes du dashboard
|
||||
///
|
||||
/// Widget réutilisable pour afficher les dernières activités,
|
||||
/// notifications, logs ou événements selon le contexte.
|
||||
class RecentActivitiesSection extends StatelessWidget {
|
||||
/// Titre de la section
|
||||
final String title;
|
||||
|
||||
/// Sous-titre optionnel
|
||||
final String? subtitle;
|
||||
|
||||
/// Liste des activités à afficher
|
||||
final List<RecentActivity> activities;
|
||||
|
||||
/// Nombre maximum d'activités à afficher
|
||||
final int maxItems;
|
||||
|
||||
/// Style des éléments d'activité
|
||||
final ActivityItemStyle itemStyle;
|
||||
|
||||
/// Callback lors du tap sur une activité
|
||||
final Function(RecentActivity)? onActivityTap;
|
||||
|
||||
/// Callback pour voir toutes les activités
|
||||
final VoidCallback? onViewAll;
|
||||
|
||||
/// Afficher ou non l'en-tête de section
|
||||
final bool showHeader;
|
||||
|
||||
/// Afficher ou non le bouton "Voir tout"
|
||||
final bool showViewAll;
|
||||
|
||||
/// Message à afficher si aucune activité
|
||||
final String? emptyMessage;
|
||||
|
||||
const RecentActivitiesSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.activities,
|
||||
this.maxItems = 5,
|
||||
this.itemStyle = ActivityItemStyle.normal,
|
||||
this.onActivityTap,
|
||||
this.onViewAll,
|
||||
this.showHeader = true,
|
||||
this.showViewAll = true,
|
||||
this.emptyMessage,
|
||||
});
|
||||
|
||||
/// Constructeur pour les activités système (Super Admin)
|
||||
const RecentActivitiesSection.system({
|
||||
super.key,
|
||||
this.onActivityTap,
|
||||
this.onViewAll,
|
||||
}) : title = 'Activité Système',
|
||||
subtitle = 'Événements récents',
|
||||
activities = const [
|
||||
RecentActivity(
|
||||
title: 'Sauvegarde automatique terminée',
|
||||
description: 'Sauvegarde complète réussie (2.3 GB)',
|
||||
timestamp: 'il y a 1h',
|
||||
type: ActivityType.system,
|
||||
),
|
||||
RecentActivity(
|
||||
title: 'Nouvelle organisation créée',
|
||||
description: 'TechCorp a rejoint la plateforme',
|
||||
timestamp: 'il y a 2h',
|
||||
type: ActivityType.organization,
|
||||
),
|
||||
RecentActivity(
|
||||
title: 'Mise à jour système',
|
||||
description: 'Version 2.1.0 déployée avec succès',
|
||||
timestamp: 'il y a 4h',
|
||||
type: ActivityType.system,
|
||||
),
|
||||
RecentActivity(
|
||||
title: 'Alerte CPU résolue',
|
||||
description: 'Charge CPU revenue à la normale',
|
||||
timestamp: 'il y a 6h',
|
||||
type: ActivityType.success,
|
||||
),
|
||||
],
|
||||
maxItems = 4,
|
||||
itemStyle = ActivityItemStyle.normal,
|
||||
showHeader = true,
|
||||
showViewAll = true,
|
||||
emptyMessage = null;
|
||||
|
||||
/// Constructeur pour les activités d'organisation
|
||||
const RecentActivitiesSection.organization({
|
||||
super.key,
|
||||
this.onActivityTap,
|
||||
this.onViewAll,
|
||||
}) : title = 'Activité Récente',
|
||||
subtitle = null,
|
||||
activities = const [
|
||||
RecentActivity(
|
||||
title: 'Nouveau membre inscrit',
|
||||
description: 'Marie Dubois a rejoint l\'organisation',
|
||||
timestamp: 'il y a 30min',
|
||||
type: ActivityType.user,
|
||||
),
|
||||
RecentActivity(
|
||||
title: 'Événement créé',
|
||||
description: 'Réunion mensuelle programmée',
|
||||
timestamp: 'il y a 2h',
|
||||
type: ActivityType.event,
|
||||
),
|
||||
RecentActivity(
|
||||
title: 'Document partagé',
|
||||
description: 'Rapport Q4 2024 publié',
|
||||
timestamp: 'il y a 1j',
|
||||
type: ActivityType.organization,
|
||||
),
|
||||
],
|
||||
maxItems = 3,
|
||||
itemStyle = ActivityItemStyle.normal,
|
||||
showHeader = true,
|
||||
showViewAll = true,
|
||||
emptyMessage = null;
|
||||
|
||||
/// Constructeur pour les alertes système
|
||||
const RecentActivitiesSection.alerts({
|
||||
super.key,
|
||||
this.onActivityTap,
|
||||
this.onViewAll,
|
||||
}) : title = 'Alertes Récentes',
|
||||
subtitle = 'Notifications importantes',
|
||||
activities = const [
|
||||
RecentActivity(
|
||||
title: 'Charge CPU élevée',
|
||||
description: 'Serveur principal à 85%',
|
||||
timestamp: 'il y a 15min',
|
||||
type: ActivityType.alert,
|
||||
),
|
||||
RecentActivity(
|
||||
title: 'Espace disque faible',
|
||||
description: 'Base de données à 90%',
|
||||
timestamp: 'il y a 1h',
|
||||
type: ActivityType.error,
|
||||
),
|
||||
RecentActivity(
|
||||
title: 'Connexions élevées',
|
||||
description: 'Load balancer surchargé',
|
||||
timestamp: 'il y a 2h',
|
||||
type: ActivityType.alert,
|
||||
),
|
||||
],
|
||||
maxItems = 3,
|
||||
itemStyle = ActivityItemStyle.alert,
|
||||
showHeader = true,
|
||||
showViewAll = true,
|
||||
emptyMessage = null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showHeader) _buildHeader(),
|
||||
const SizedBox(height: 12),
|
||||
_buildActivitiesList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// En-tête de la section
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF6C5CE7),
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showViewAll && onViewAll != null)
|
||||
TextButton(
|
||||
onPressed: onViewAll,
|
||||
child: const Text(
|
||||
'Voir tout',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF6C5CE7),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Liste des activités
|
||||
Widget _buildActivitiesList() {
|
||||
if (activities.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
final displayedActivities = activities.take(maxItems).toList();
|
||||
|
||||
return Column(
|
||||
children: displayedActivities.map((activity) => ActivityItem(
|
||||
title: activity.title,
|
||||
description: activity.description,
|
||||
timestamp: activity.timestamp,
|
||||
icon: activity.icon,
|
||||
color: activity.color,
|
||||
type: activity.type,
|
||||
style: itemStyle,
|
||||
onTap: onActivityTap != null ? () => onActivityTap!(activity) : null,
|
||||
)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// État vide
|
||||
Widget _buildEmptyState() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inbox_outlined,
|
||||
size: 48,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
emptyMessage ?? 'Aucune activité récente',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Modèle de données pour une activité récente
|
||||
class RecentActivity {
|
||||
final String title;
|
||||
final String? description;
|
||||
final String timestamp;
|
||||
final IconData? icon;
|
||||
final Color? color;
|
||||
final ActivityType? type;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
const RecentActivity({
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.timestamp,
|
||||
this.icon,
|
||||
this.color,
|
||||
this.type,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
/// Constructeur pour une activité système
|
||||
const RecentActivity.system({
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.timestamp,
|
||||
this.metadata,
|
||||
}) : icon = Icons.settings,
|
||||
color = const Color(0xFF6C5CE7),
|
||||
type = ActivityType.system;
|
||||
|
||||
/// Constructeur pour une activité utilisateur
|
||||
const RecentActivity.user({
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.timestamp,
|
||||
this.metadata,
|
||||
}) : icon = Icons.person,
|
||||
color = const Color(0xFF00B894),
|
||||
type = ActivityType.user;
|
||||
|
||||
/// Constructeur pour une activité d'organisation
|
||||
const RecentActivity.organization({
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.timestamp,
|
||||
this.metadata,
|
||||
}) : icon = Icons.business,
|
||||
color = const Color(0xFF0984E3),
|
||||
type = ActivityType.organization;
|
||||
|
||||
/// Constructeur pour une activité d'événement
|
||||
const RecentActivity.event({
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.timestamp,
|
||||
this.metadata,
|
||||
}) : icon = Icons.event,
|
||||
color = const Color(0xFFE17055),
|
||||
type = ActivityType.event;
|
||||
|
||||
/// Constructeur pour une alerte
|
||||
const RecentActivity.alert({
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.timestamp,
|
||||
this.metadata,
|
||||
}) : icon = Icons.warning,
|
||||
color = Colors.orange,
|
||||
type = ActivityType.alert;
|
||||
|
||||
/// Constructeur pour une erreur
|
||||
const RecentActivity.error({
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.timestamp,
|
||||
this.metadata,
|
||||
}) : icon = Icons.error,
|
||||
color = Colors.red,
|
||||
type = ActivityType.error;
|
||||
|
||||
/// Constructeur pour un succès
|
||||
const RecentActivity.success({
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.timestamp,
|
||||
this.metadata,
|
||||
}) : icon = Icons.check_circle,
|
||||
color = const Color(0xFF00B894),
|
||||
type = ActivityType.success;
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../shared/design_system/dashboard_theme.dart';
|
||||
|
||||
/// Widget de recherche rapide pour le dashboard
|
||||
class DashboardSearchWidget extends StatefulWidget {
|
||||
final Function(String)? onSearch;
|
||||
final String? hintText;
|
||||
final List<SearchSuggestion>? suggestions;
|
||||
|
||||
const DashboardSearchWidget({
|
||||
super.key,
|
||||
this.onSearch,
|
||||
this.hintText,
|
||||
this.suggestions,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DashboardSearchWidget> createState() => _DashboardSearchWidgetState();
|
||||
}
|
||||
|
||||
class _DashboardSearchWidgetState extends State<DashboardSearchWidget>
|
||||
with TickerProviderStateMixin {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
bool _isExpanded = false;
|
||||
List<SearchSuggestion> _filteredSuggestions = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupAnimations();
|
||||
_setupListeners();
|
||||
_filteredSuggestions = widget.suggestions ?? _getDefaultSuggestions();
|
||||
}
|
||||
|
||||
void _setupAnimations() {
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 1.05,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
}
|
||||
|
||||
void _setupListeners() {
|
||||
_focusNode.addListener(() {
|
||||
setState(() {
|
||||
_isExpanded = _focusNode.hasFocus;
|
||||
});
|
||||
|
||||
if (_focusNode.hasFocus) {
|
||||
_animationController.forward();
|
||||
} else {
|
||||
_animationController.reverse();
|
||||
}
|
||||
});
|
||||
|
||||
_searchController.addListener(() {
|
||||
_filterSuggestions(_searchController.text);
|
||||
});
|
||||
}
|
||||
|
||||
void _filterSuggestions(String query) {
|
||||
if (query.isEmpty) {
|
||||
setState(() {
|
||||
_filteredSuggestions = widget.suggestions ?? _getDefaultSuggestions();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final filtered = (widget.suggestions ?? _getDefaultSuggestions())
|
||||
.where((suggestion) =>
|
||||
suggestion.title.toLowerCase().contains(query.toLowerCase()) ||
|
||||
suggestion.subtitle.toLowerCase().contains(query.toLowerCase()))
|
||||
.toList();
|
||||
|
||||
setState(() {
|
||||
_filteredSuggestions = filtered;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildSearchBar(),
|
||||
if (_isExpanded && _filteredSuggestions.isNotEmpty) ...[
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
_buildSuggestions(),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchBar() {
|
||||
return AnimatedBuilder(
|
||||
animation: _scaleAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusLarge),
|
||||
boxShadow: _isExpanded ? DashboardTheme.elevatedShadow : DashboardTheme.subtleShadow,
|
||||
),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _focusNode,
|
||||
onSubmitted: (value) {
|
||||
if (value.isNotEmpty) {
|
||||
widget.onSearch?.call(value);
|
||||
_focusNode.unfocus();
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText ?? 'Rechercher...',
|
||||
hintStyle: DashboardTheme.bodyMedium.copyWith(
|
||||
color: DashboardTheme.grey400,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
color: _isExpanded ? DashboardTheme.royalBlue : DashboardTheme.grey400,
|
||||
),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_focusNode.unfocus();
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.clear,
|
||||
color: DashboardTheme.grey400,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusLarge),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusLarge),
|
||||
borderSide: const BorderSide(
|
||||
color: DashboardTheme.royalBlue,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: DashboardTheme.spacing16,
|
||||
vertical: DashboardTheme.spacing12,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: DashboardTheme.white,
|
||||
),
|
||||
style: DashboardTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuggestions() {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 300),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
boxShadow: DashboardTheme.elevatedShadow,
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _filteredSuggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = _filteredSuggestions[index];
|
||||
return _buildSuggestionItem(suggestion, index == _filteredSuggestions.length - 1);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuggestionItem(SearchSuggestion suggestion, bool isLast) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
_searchController.text = suggestion.title;
|
||||
widget.onSearch?.call(suggestion.title);
|
||||
_focusNode.unfocus();
|
||||
suggestion.onTap?.call();
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
border: isLast
|
||||
? null
|
||||
: const Border(
|
||||
bottom: BorderSide(
|
||||
color: DashboardTheme.grey200,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing8),
|
||||
decoration: BoxDecoration(
|
||||
color: suggestion.color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: Icon(
|
||||
suggestion.icon,
|
||||
color: suggestion.color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
suggestion.title,
|
||||
style: DashboardTheme.bodyMedium.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (suggestion.subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: DashboardTheme.spacing2),
|
||||
Text(
|
||||
suggestion.subtitle,
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.grey600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
color: DashboardTheme.grey400,
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<SearchSuggestion> _getDefaultSuggestions() {
|
||||
return [
|
||||
SearchSuggestion(
|
||||
title: 'Membres',
|
||||
subtitle: 'Rechercher des membres',
|
||||
icon: Icons.people,
|
||||
color: DashboardTheme.royalBlue,
|
||||
onTap: () {},
|
||||
),
|
||||
SearchSuggestion(
|
||||
title: 'Événements',
|
||||
subtitle: 'Trouver des événements',
|
||||
icon: Icons.event,
|
||||
color: DashboardTheme.tealBlue,
|
||||
onTap: () {},
|
||||
),
|
||||
SearchSuggestion(
|
||||
title: 'Contributions',
|
||||
subtitle: 'Historique des paiements',
|
||||
icon: Icons.payment,
|
||||
color: DashboardTheme.success,
|
||||
onTap: () {},
|
||||
),
|
||||
SearchSuggestion(
|
||||
title: 'Rapports',
|
||||
subtitle: 'Consulter les rapports',
|
||||
icon: Icons.assessment,
|
||||
color: DashboardTheme.warning,
|
||||
onTap: () {},
|
||||
),
|
||||
SearchSuggestion(
|
||||
title: 'Paramètres',
|
||||
subtitle: 'Configuration système',
|
||||
icon: Icons.settings,
|
||||
color: DashboardTheme.grey600,
|
||||
onTap: () {},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_focusNode.dispose();
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class SearchSuggestion {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const SearchSuggestion({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
this.onTap,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../shared/design_system/dashboard_theme_manager.dart';
|
||||
import '../../../../../shared/design_system/dashboard_theme.dart';
|
||||
|
||||
/// Widget de sélection de thème pour le Dashboard
|
||||
class ThemeSelectorWidget extends StatefulWidget {
|
||||
final Function(String)? onThemeChanged;
|
||||
|
||||
const ThemeSelectorWidget({
|
||||
super.key,
|
||||
this.onThemeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ThemeSelectorWidget> createState() => _ThemeSelectorWidgetState();
|
||||
}
|
||||
|
||||
class _ThemeSelectorWidgetState extends State<ThemeSelectorWidget> {
|
||||
String _selectedTheme = 'royalTeal';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedTheme = DashboardThemeManager.currentTheme.name == 'Bleu Roi & Pétrole'
|
||||
? 'royalTeal' : 'royalTeal'; // Par défaut
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.white,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
boxShadow: DashboardTheme.subtleShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.palette,
|
||||
color: DashboardTheme.royalBlue,
|
||||
size: 24,
|
||||
),
|
||||
SizedBox(width: DashboardTheme.spacing8),
|
||||
Text(
|
||||
'Thème de l\'interface',
|
||||
style: DashboardTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
|
||||
// Grille des thèmes
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: DashboardTheme.spacing12,
|
||||
mainAxisSpacing: DashboardTheme.spacing12,
|
||||
childAspectRatio: 1.5,
|
||||
),
|
||||
itemCount: DashboardThemeManager.availableThemes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final themeOption = DashboardThemeManager.availableThemes[index];
|
||||
final isSelected = _selectedTheme == themeOption.key;
|
||||
|
||||
return _buildThemeCard(themeOption, isSelected);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
|
||||
// Aperçu du thème sélectionné
|
||||
_buildThemePreview(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemeCard(ThemeOption themeOption, bool isSelected) {
|
||||
return GestureDetector(
|
||||
onTap: () => _selectTheme(themeOption.key),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? themeOption.theme.primaryColor
|
||||
: DashboardTheme.grey300,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: themeOption.theme.primaryColor.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: DashboardTheme.subtleShadow,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Gradient de démonstration
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
themeOption.theme.primaryColor,
|
||||
themeOption.theme.secondaryColor,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(DashboardTheme.borderRadius - 1),
|
||||
topRight: Radius.circular(DashboardTheme.borderRadius - 1),
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
|
||||
// Nom du thème
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing8),
|
||||
decoration: BoxDecoration(
|
||||
color: themeOption.theme.cardColor,
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(DashboardTheme.borderRadius - 1),
|
||||
bottomRight: Radius.circular(DashboardTheme.borderRadius - 1),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
themeOption.name,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: themeOption.theme.textPrimary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemePreview() {
|
||||
final currentTheme = DashboardThemeManager.availableThemes
|
||||
.firstWhere((theme) => theme.key == _selectedTheme);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: currentTheme.theme.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
border: Border.all(color: DashboardTheme.grey300),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Aperçu: ${currentTheme.name}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: currentTheme.theme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing12),
|
||||
|
||||
// Exemple de carte avec le thème
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing12),
|
||||
decoration: BoxDecoration(
|
||||
color: currentTheme.theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: currentTheme.theme.primaryColor.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
gradient: currentTheme.theme.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.dashboard,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Dashboard UnionFlow',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: currentTheme.theme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Exemple avec ce thème',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: currentTheme.theme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DashboardTheme.spacing8,
|
||||
vertical: DashboardTheme.spacing4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: currentTheme.theme.success.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: Text(
|
||||
'Actif',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: currentTheme.theme.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: DashboardTheme.spacing12),
|
||||
|
||||
// Palette de couleurs
|
||||
Row(
|
||||
children: [
|
||||
_buildColorSwatch('Primaire', currentTheme.theme.primaryColor),
|
||||
const SizedBox(width: DashboardTheme.spacing8),
|
||||
_buildColorSwatch('Secondaire', currentTheme.theme.secondaryColor),
|
||||
const SizedBox(width: DashboardTheme.spacing8),
|
||||
_buildColorSwatch('Succès', currentTheme.theme.success),
|
||||
const SizedBox(width: DashboardTheme.spacing8),
|
||||
_buildColorSwatch('Attention', currentTheme.theme.warning),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColorSwatch(String label, Color color) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: DashboardTheme.grey600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _selectTheme(String themeKey) {
|
||||
setState(() {
|
||||
_selectedTheme = themeKey;
|
||||
});
|
||||
|
||||
// Appliquer le thème
|
||||
DashboardThemeManager.setTheme(themeKey);
|
||||
|
||||
// Notifier le changement
|
||||
widget.onThemeChanged?.call(themeKey);
|
||||
|
||||
// Afficher un message de confirmation
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Thème "${DashboardThemeManager.availableThemes.firstWhere((t) => t.key == themeKey).name}" appliqué',
|
||||
),
|
||||
backgroundColor: DashboardThemeManager.currentTheme.success,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../../shared/design_system/dashboard_theme.dart';
|
||||
|
||||
/// Widget de raccourcis rapides pour le dashboard
|
||||
class DashboardShortcutsWidget extends StatelessWidget {
|
||||
final List<DashboardShortcut>? customShortcuts;
|
||||
final int maxShortcuts;
|
||||
|
||||
const DashboardShortcutsWidget({
|
||||
super.key,
|
||||
this.customShortcuts,
|
||||
this.maxShortcuts = 6,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shortcuts = customShortcuts ?? _getDefaultShortcuts();
|
||||
final displayShortcuts = shortcuts.take(maxShortcuts).toList();
|
||||
|
||||
return Container(
|
||||
decoration: DashboardTheme.cardDecoration,
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
const SizedBox(height: DashboardTheme.spacing16),
|
||||
_buildShortcutsGrid(displayShortcuts),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing8),
|
||||
decoration: BoxDecoration(
|
||||
color: DashboardTheme.tealBlue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.flash_on,
|
||||
color: DashboardTheme.tealBlue,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DashboardTheme.spacing12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Actions Rapides',
|
||||
style: DashboardTheme.titleMedium.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// TODO: Personnaliser les raccourcis
|
||||
},
|
||||
child: Text(
|
||||
'Personnaliser',
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.tealBlue,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShortcutsGrid(List<DashboardShortcut> shortcuts) {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: DashboardTheme.spacing12,
|
||||
mainAxisSpacing: DashboardTheme.spacing12,
|
||||
childAspectRatio: 1.0,
|
||||
),
|
||||
itemCount: shortcuts.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildShortcutItem(shortcuts[index]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShortcutItem(DashboardShortcut shortcut) {
|
||||
return GestureDetector(
|
||||
onTap: shortcut.onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: shortcut.color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadius),
|
||||
border: Border.all(
|
||||
color: shortcut.color.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(DashboardTheme.spacing12),
|
||||
decoration: BoxDecoration(
|
||||
color: shortcut.color.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusLarge),
|
||||
),
|
||||
child: Icon(
|
||||
shortcut.icon,
|
||||
color: shortcut.color,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DashboardTheme.spacing8),
|
||||
Text(
|
||||
shortcut.title,
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: shortcut.color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (shortcut.badge != null) ...[
|
||||
const SizedBox(height: DashboardTheme.spacing4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DashboardTheme.spacing6,
|
||||
vertical: DashboardTheme.spacing2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: shortcut.badgeColor ?? DashboardTheme.error,
|
||||
borderRadius: BorderRadius.circular(DashboardTheme.borderRadiusSmall),
|
||||
),
|
||||
child: Text(
|
||||
shortcut.badge!,
|
||||
style: DashboardTheme.bodySmall.copyWith(
|
||||
color: DashboardTheme.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<DashboardShortcut> _getDefaultShortcuts() {
|
||||
return [
|
||||
DashboardShortcut(
|
||||
title: 'Nouveau\nMembre',
|
||||
icon: Icons.person_add,
|
||||
color: DashboardTheme.success,
|
||||
onTap: () {
|
||||
// TODO: Naviguer vers ajout membre
|
||||
},
|
||||
),
|
||||
DashboardShortcut(
|
||||
title: 'Créer\nÉvénement',
|
||||
icon: Icons.event_available,
|
||||
color: DashboardTheme.royalBlue,
|
||||
onTap: () {
|
||||
// TODO: Naviguer vers création événement
|
||||
},
|
||||
),
|
||||
DashboardShortcut(
|
||||
title: 'Ajouter\nContribution',
|
||||
icon: Icons.payment,
|
||||
color: DashboardTheme.tealBlue,
|
||||
onTap: () {
|
||||
// TODO: Naviguer vers ajout contribution
|
||||
},
|
||||
),
|
||||
DashboardShortcut(
|
||||
title: 'Envoyer\nMessage',
|
||||
icon: Icons.message,
|
||||
color: DashboardTheme.warning,
|
||||
badge: '3',
|
||||
badgeColor: DashboardTheme.error,
|
||||
onTap: () {
|
||||
// TODO: Naviguer vers messagerie
|
||||
},
|
||||
),
|
||||
DashboardShortcut(
|
||||
title: 'Générer\nRapport',
|
||||
icon: Icons.assessment,
|
||||
color: DashboardTheme.info,
|
||||
onTap: () {
|
||||
// TODO: Naviguer vers génération rapport
|
||||
},
|
||||
),
|
||||
DashboardShortcut(
|
||||
title: 'Paramètres',
|
||||
icon: Icons.settings,
|
||||
color: DashboardTheme.grey600,
|
||||
onTap: () {
|
||||
// TODO: Naviguer vers paramètres
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardShortcut {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
final String? badge;
|
||||
final Color? badgeColor;
|
||||
|
||||
const DashboardShortcut({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
this.badge,
|
||||
this.badgeColor,
|
||||
});
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
/// Test rapide pour vérifier les boutons rectangulaires compacts
|
||||
/// Démontre les nouvelles dimensions et le format rectangulaire
|
||||
library test_rectangular_buttons;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/design_system/tokens/color_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/spacing_tokens.dart';
|
||||
import '../../../../core/design_system/tokens/typography_tokens.dart';
|
||||
import 'dashboard_quick_action_button.dart';
|
||||
import 'dashboard_quick_actions_grid.dart';
|
||||
|
||||
/// Page de test pour les boutons rectangulaires
|
||||
class TestRectangularButtonsPage extends StatelessWidget {
|
||||
const TestRectangularButtonsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Boutons Rectangulaires - Test'),
|
||||
backgroundColor: ColorTokens.primary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(SpacingTokens.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle('🔲 Boutons Rectangulaires Compacts'),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
_buildIndividualButtons(),
|
||||
|
||||
const SizedBox(height: SpacingTokens.xl),
|
||||
_buildSectionTitle('📊 Grilles avec Format Rectangulaire'),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
_buildGridLayouts(),
|
||||
|
||||
const SizedBox(height: SpacingTokens.xl),
|
||||
_buildSectionTitle('📏 Comparaison des Dimensions'),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
_buildDimensionComparison(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un titre de section
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Text(
|
||||
title,
|
||||
style: TypographyTokens.headlineMedium.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorTokens.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Test des boutons individuels
|
||||
Widget _buildIndividualButtons() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Boutons Individuels - Largeur Réduite de Moitié',
|
||||
style: TypographyTokens.titleMedium.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Ligne de boutons rectangulaires
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100, // Largeur réduite
|
||||
height: 70, // Hauteur rectangulaire
|
||||
child: DashboardQuickActionButton(
|
||||
action: DashboardQuickAction.primary(
|
||||
icon: Icons.add,
|
||||
title: 'Ajouter',
|
||||
subtitle: 'Nouveau',
|
||||
onTap: () => _showMessage('Bouton Ajouter'),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 100,
|
||||
height: 70,
|
||||
child: DashboardQuickActionButton(
|
||||
action: DashboardQuickAction.success(
|
||||
icon: Icons.check,
|
||||
title: 'Valider',
|
||||
subtitle: 'OK',
|
||||
onTap: () => _showMessage('Bouton Valider'),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 100,
|
||||
height: 70,
|
||||
child: DashboardQuickActionButton(
|
||||
action: DashboardQuickAction.warning(
|
||||
icon: Icons.warning,
|
||||
title: 'Alerte',
|
||||
subtitle: 'Urgent',
|
||||
onTap: () => _showMessage('Bouton Alerte'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Test des grilles avec différents layouts
|
||||
Widget _buildGridLayouts() {
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Grille compacte 2x2
|
||||
DashboardQuickActionsGrid.compact(
|
||||
title: 'Grille Compacte 2x2 - Format Rectangulaire',
|
||||
),
|
||||
|
||||
SizedBox(height: SpacingTokens.xl),
|
||||
|
||||
// Grille étendue 3x2
|
||||
DashboardQuickActionsGrid.expanded(
|
||||
title: 'Grille Étendue 3x2 - Boutons Plus Petits',
|
||||
subtitle: 'Ratio d\'aspect 1.5 au lieu de 2.0',
|
||||
),
|
||||
|
||||
SizedBox(height: SpacingTokens.xl),
|
||||
|
||||
// Carrousel horizontal
|
||||
DashboardQuickActionsGrid.carousel(
|
||||
title: 'Carrousel - Hauteur Réduite (90px)',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Comparaison visuelle des dimensions
|
||||
Widget _buildDimensionComparison() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Comparaison Avant/Après',
|
||||
style: TypographyTokens.titleMedium.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Simulation ancien format (plus large)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: ColorTokens.error.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'❌ AVANT - Trop Large (140x100)',
|
||||
style: TypographyTokens.labelMedium.copyWith(
|
||||
color: ColorTokens.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.sm),
|
||||
Container(
|
||||
width: 140,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: ColorTokens.primary.withOpacity(0.3)),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text('Ancien Format\n140x100'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Nouveau format (rectangulaire compact)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.success.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: ColorTokens.success.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'✅ APRÈS - Rectangulaire Compact (100x70)',
|
||||
style: TypographyTokens.labelMedium.copyWith(
|
||||
color: ColorTokens.success,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.sm),
|
||||
SizedBox(
|
||||
width: 100,
|
||||
height: 70,
|
||||
child: DashboardQuickActionButton(
|
||||
action: DashboardQuickAction.success(
|
||||
icon: Icons.thumb_up,
|
||||
title: 'Nouveau',
|
||||
subtitle: '100x70',
|
||||
onTap: () => _showMessage('Nouveau Format!'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Résumé des améliorations
|
||||
Container(
|
||||
padding: const EdgeInsets.all(SpacingTokens.md),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'📊 Améliorations Apportées',
|
||||
style: TypographyTokens.titleSmall.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorTokens.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: SpacingTokens.sm),
|
||||
const Text('• Largeur réduite de 50% (140px → 100px)'),
|
||||
const Text('• Hauteur optimisée (100px → 70px)'),
|
||||
const Text('• Format rectangulaire plus compact'),
|
||||
const Text('• Bordures moins arrondies (12px → 6px)'),
|
||||
const Text('• Espacement réduit entre éléments'),
|
||||
const Text('• Ratio d\'aspect optimisé (2.2 → 1.6)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche un message de test
|
||||
void _showMessage(String message) {
|
||||
// Note: Cette méthode nécessiterait un BuildContext pour afficher un SnackBar
|
||||
// Dans un vrai contexte, on utiliserait ScaffoldMessenger
|
||||
debugPrint('Test: $message');
|
||||
}
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Section des événements à venir du dashboard
|
||||
///
|
||||
/// Widget réutilisable pour afficher les prochains événements,
|
||||
/// réunions, échéances ou tâches selon le contexte.
|
||||
class UpcomingEventsSection extends StatelessWidget {
|
||||
/// Titre de la section
|
||||
final String title;
|
||||
|
||||
/// Sous-titre optionnel
|
||||
final String? subtitle;
|
||||
|
||||
/// Liste des événements à afficher
|
||||
final List<UpcomingEvent> events;
|
||||
|
||||
/// Nombre maximum d'événements à afficher
|
||||
final int maxItems;
|
||||
|
||||
/// Callback lors du tap sur un événement
|
||||
final Function(UpcomingEvent)? onEventTap;
|
||||
|
||||
/// Callback pour voir tous les événements
|
||||
final VoidCallback? onViewAll;
|
||||
|
||||
/// Afficher ou non l'en-tête de section
|
||||
final bool showHeader;
|
||||
|
||||
/// Afficher ou non le bouton "Voir tout"
|
||||
final bool showViewAll;
|
||||
|
||||
/// Message à afficher si aucun événement
|
||||
final String? emptyMessage;
|
||||
|
||||
/// Style de la section
|
||||
final EventsSectionStyle style;
|
||||
|
||||
const UpcomingEventsSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.events,
|
||||
this.maxItems = 3,
|
||||
this.onEventTap,
|
||||
this.onViewAll,
|
||||
this.showHeader = true,
|
||||
this.showViewAll = true,
|
||||
this.emptyMessage,
|
||||
this.style = EventsSectionStyle.card,
|
||||
});
|
||||
|
||||
/// Constructeur pour les événements d'organisation
|
||||
const UpcomingEventsSection.organization({
|
||||
super.key,
|
||||
this.onEventTap,
|
||||
this.onViewAll,
|
||||
}) : title = 'Événements à venir',
|
||||
subtitle = 'Prochaines échéances',
|
||||
events = const [
|
||||
UpcomingEvent(
|
||||
title: 'Réunion mensuelle',
|
||||
description: 'Point équipe et objectifs',
|
||||
date: '15 Jan 2025',
|
||||
time: '14:00',
|
||||
location: 'Salle de conférence',
|
||||
type: EventType.meeting,
|
||||
),
|
||||
UpcomingEvent(
|
||||
title: 'Formation sécurité',
|
||||
description: 'Session obligatoire',
|
||||
date: '18 Jan 2025',
|
||||
time: '09:00',
|
||||
location: 'En ligne',
|
||||
type: EventType.training,
|
||||
),
|
||||
UpcomingEvent(
|
||||
title: 'Assemblée générale',
|
||||
description: 'Vote budget 2025',
|
||||
date: '25 Jan 2025',
|
||||
time: '10:00',
|
||||
location: 'Auditorium',
|
||||
type: EventType.assembly,
|
||||
),
|
||||
],
|
||||
maxItems = 3,
|
||||
showHeader = true,
|
||||
showViewAll = true,
|
||||
emptyMessage = null,
|
||||
style = EventsSectionStyle.card;
|
||||
|
||||
/// Constructeur pour les tâches système
|
||||
const UpcomingEventsSection.systemTasks({
|
||||
super.key,
|
||||
this.onEventTap,
|
||||
this.onViewAll,
|
||||
}) : title = 'Tâches Programmées',
|
||||
subtitle = 'Maintenance et sauvegardes',
|
||||
events = const [
|
||||
UpcomingEvent(
|
||||
title: 'Sauvegarde hebdomadaire',
|
||||
description: 'Sauvegarde complète BDD',
|
||||
date: 'Aujourd\'hui',
|
||||
time: '02:00',
|
||||
location: 'Automatique',
|
||||
type: EventType.maintenance,
|
||||
),
|
||||
UpcomingEvent(
|
||||
title: 'Mise à jour sécurité',
|
||||
description: 'Patches système',
|
||||
date: 'Demain',
|
||||
time: '01:00',
|
||||
location: 'Serveurs',
|
||||
type: EventType.maintenance,
|
||||
),
|
||||
UpcomingEvent(
|
||||
title: 'Nettoyage logs',
|
||||
description: 'Archivage automatique',
|
||||
date: '20 Jan 2025',
|
||||
time: '03:00',
|
||||
location: 'Système',
|
||||
type: EventType.maintenance,
|
||||
),
|
||||
],
|
||||
maxItems = 3,
|
||||
showHeader = true,
|
||||
showViewAll = true,
|
||||
emptyMessage = null,
|
||||
style = EventsSectionStyle.minimal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (style) {
|
||||
case EventsSectionStyle.card:
|
||||
return _buildCardStyle();
|
||||
case EventsSectionStyle.minimal:
|
||||
return _buildMinimalStyle();
|
||||
case EventsSectionStyle.timeline:
|
||||
return _buildTimelineStyle();
|
||||
}
|
||||
}
|
||||
|
||||
/// Style carte avec fond
|
||||
Widget _buildCardStyle() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showHeader) _buildHeader(),
|
||||
const SizedBox(height: 12),
|
||||
_buildEventsList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Style minimal sans fond
|
||||
Widget _buildMinimalStyle() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showHeader) _buildHeader(),
|
||||
const SizedBox(height: 12),
|
||||
_buildEventsList(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Style timeline avec ligne temporelle
|
||||
Widget _buildTimelineStyle() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showHeader) _buildHeader(),
|
||||
const SizedBox(height: 12),
|
||||
_buildTimelineList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// En-tête de la section
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF6C5CE7),
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showViewAll && onViewAll != null)
|
||||
TextButton(
|
||||
onPressed: onViewAll,
|
||||
child: const Text(
|
||||
'Voir tout',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF6C5CE7),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Liste des événements
|
||||
Widget _buildEventsList() {
|
||||
if (events.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
final displayedEvents = events.take(maxItems).toList();
|
||||
|
||||
return Column(
|
||||
children: displayedEvents.map((event) => _buildEventItem(event)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Liste timeline
|
||||
Widget _buildTimelineList() {
|
||||
if (events.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
final displayedEvents = events.take(maxItems).toList();
|
||||
|
||||
return Column(
|
||||
children: displayedEvents.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final event = entry.value;
|
||||
final isLast = index == displayedEvents.length - 1;
|
||||
|
||||
return _buildTimelineItem(event, isLast);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Élément d'événement
|
||||
Widget _buildEventItem(UpcomingEvent event) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: event.type.color.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: event.type.color.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onEventTap != null ? () => onEventTap!(event) : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: event.type.color.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
event.type.icon,
|
||||
color: event.type.color,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
event.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1F2937),
|
||||
),
|
||||
),
|
||||
if (event.description != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
event.description!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.access_time, size: 12, color: Colors.grey[500]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${event.date} à ${event.time}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (event.location != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.location_on, size: 12, color: Colors.grey[500]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
event.location!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Élément timeline
|
||||
Widget _buildTimelineItem(UpcomingEvent event, bool isLast) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: event.type.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
if (!isLast)
|
||||
Container(
|
||||
width: 2,
|
||||
height: 40,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: isLast ? 0 : 16),
|
||||
child: _buildEventItem(event),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// État vide
|
||||
Widget _buildEmptyState() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.event_available,
|
||||
size: 48,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
emptyMessage ?? 'Aucun événement à venir',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Modèle de données pour un événement à venir
|
||||
class UpcomingEvent {
|
||||
final String title;
|
||||
final String? description;
|
||||
final String date;
|
||||
final String time;
|
||||
final String? location;
|
||||
final EventType type;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
const UpcomingEvent({
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.date,
|
||||
required this.time,
|
||||
this.location,
|
||||
required this.type,
|
||||
this.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/// Types d'événement
|
||||
enum EventType {
|
||||
meeting(Icons.meeting_room, Color(0xFF6C5CE7)),
|
||||
training(Icons.school, Color(0xFF00B894)),
|
||||
assembly(Icons.groups, Color(0xFF0984E3)),
|
||||
maintenance(Icons.build, Color(0xFFE17055)),
|
||||
deadline(Icons.schedule, Colors.orange),
|
||||
celebration(Icons.celebration, Color(0xFFE84393));
|
||||
|
||||
const EventType(this.icon, this.color);
|
||||
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
}
|
||||
|
||||
/// Styles de section d'événements
|
||||
enum EventsSectionStyle {
|
||||
card,
|
||||
minimal,
|
||||
timeline,
|
||||
}
|
||||
@@ -1,17 +1,28 @@
|
||||
/// Fichier d'index pour tous les widgets du dashboard
|
||||
/// Facilite les imports et maintient une API propre
|
||||
library dashboard_widgets;
|
||||
// Export des widgets dashboard connectés
|
||||
export 'connected/connected_stats_card.dart';
|
||||
export 'connected/connected_recent_activities.dart';
|
||||
export 'connected/connected_upcoming_events.dart';
|
||||
|
||||
// === WIDGETS DE SECTION ===
|
||||
export 'dashboard_welcome_section.dart';
|
||||
export 'dashboard_stats_grid.dart';
|
||||
export 'dashboard_quick_actions_grid.dart';
|
||||
export 'dashboard_recent_activity_section.dart';
|
||||
export 'dashboard_insights_section.dart';
|
||||
export 'dashboard_drawer.dart';
|
||||
// Export des widgets charts
|
||||
export 'charts/dashboard_chart_widget.dart';
|
||||
|
||||
// === WIDGETS ATOMIQUES ===
|
||||
export 'dashboard_stats_card.dart';
|
||||
export 'dashboard_quick_action_button.dart';
|
||||
export 'dashboard_activity_tile.dart';
|
||||
export 'dashboard_metric_row.dart';
|
||||
// Export des widgets metrics
|
||||
export 'metrics/real_time_metrics_widget.dart';
|
||||
|
||||
// Export des widgets monitoring
|
||||
export 'monitoring/performance_monitor_widget.dart';
|
||||
|
||||
// Export des widgets navigation
|
||||
export 'navigation/dashboard_navigation.dart';
|
||||
|
||||
// Export des widgets notifications
|
||||
export 'notifications/dashboard_notifications_widget.dart';
|
||||
|
||||
// Export des widgets search
|
||||
export 'search/dashboard_search_widget.dart';
|
||||
|
||||
// Export des widgets settings
|
||||
export 'settings/theme_selector_widget.dart';
|
||||
|
||||
// Export des widgets shortcuts
|
||||
export 'shortcuts/dashboard_shortcuts_widget.dart';
|
||||
|
||||
@@ -172,10 +172,10 @@ class EvenementsError extends EvenementsState {
|
||||
/// État d'erreur réseau
|
||||
class EvenementsNetworkError extends EvenementsError {
|
||||
const EvenementsNetworkError({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic error,
|
||||
}) : super(message: message, code: code, error: error);
|
||||
required super.message,
|
||||
super.code,
|
||||
super.error,
|
||||
});
|
||||
}
|
||||
|
||||
/// État d'erreur de validation
|
||||
@@ -183,10 +183,10 @@ class EvenementsValidationError extends EvenementsError {
|
||||
final Map<String, String> validationErrors;
|
||||
|
||||
const EvenementsValidationError({
|
||||
required String message,
|
||||
required super.message,
|
||||
required this.validationErrors,
|
||||
String? code,
|
||||
}) : super(message: message, code: code);
|
||||
super.code,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, code, validationErrors];
|
||||
|
||||
@@ -301,16 +301,21 @@ class EventDetailPage extends StatelessWidget {
|
||||
evenement.participantsActuels;
|
||||
final isComplet = placesRestantes <= 0 && evenement.maxParticipants != null;
|
||||
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: (isInscrit || !isComplet)
|
||||
? () => _showInscriptionDialog(context, isInscrit)
|
||||
: null,
|
||||
backgroundColor: isInscrit ? Colors.red : const Color(0xFF3B82F6),
|
||||
icon: Icon(isInscrit ? Icons.cancel : Icons.check),
|
||||
label: Text(
|
||||
isInscrit ? 'Se désinscrire' : (isComplet ? 'Complet' : 'S\'inscrire'),
|
||||
),
|
||||
);
|
||||
if (!isComplet) {
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: () => _showInscriptionDialog(context, isInscrit),
|
||||
backgroundColor: const Color(0xFF3B82F6),
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('S\'inscrire'),
|
||||
);
|
||||
} else {
|
||||
return const FloatingActionButton.extended(
|
||||
onPressed: null,
|
||||
backgroundColor: Colors.grey,
|
||||
icon: Icon(Icons.block),
|
||||
label: Text('Complet'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showInscriptionDialog(BuildContext context, bool isInscrit) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../core/auth/bloc/auth_bloc.dart';
|
||||
import '../../../../core/auth/models/user_role.dart';
|
||||
import '../../../authentication/presentation/bloc/auth_bloc.dart';
|
||||
|
||||
import '../../../../shared/design_system/tokens/color_tokens.dart';
|
||||
|
||||
/// Page de gestion des événements - Interface sophistiquée et exhaustive
|
||||
///
|
||||
@@ -222,7 +223,7 @@ class _EventsPageState extends State<EventsPage> with TickerProviderStateMixin {
|
||||
);
|
||||
}
|
||||
|
||||
final canManageEvents = _canManageEvents(state.effectiveRole);
|
||||
|
||||
|
||||
return Container(
|
||||
color: const Color(0xFFF8F9FA),
|
||||
@@ -257,12 +258,7 @@ class _EventsPageState extends State<EventsPage> with TickerProviderStateMixin {
|
||||
);
|
||||
}
|
||||
|
||||
/// Vérifie si l'utilisateur peut gérer les événements
|
||||
bool _canManageEvents(UserRole role) {
|
||||
return role == UserRole.superAdmin ||
|
||||
role == UserRole.orgAdmin ||
|
||||
role == UserRole.moderator;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -282,73 +278,42 @@ class _EventsPageState extends State<EventsPage> with TickerProviderStateMixin {
|
||||
sum + (event['currentParticipants'] as int)
|
||||
);
|
||||
|
||||
final averageParticipation = _allEvents.isNotEmpty
|
||||
? (_allEvents.fold<double>(0, (sum, event) {
|
||||
final current = event['currentParticipants'] as int;
|
||||
final max = event['maxParticipants'] as int;
|
||||
return sum + (max > 0 ? (current / max) * 100 : 0);
|
||||
}) / _allEvents.length).round()
|
||||
: 0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: ColorTokens.secondary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Métriques Événements',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF6C5CE7),
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildSimpleKPICard(
|
||||
'À Venir',
|
||||
upcomingEvents.toString(),
|
||||
'+2 ce mois',
|
||||
Icons.event_available,
|
||||
const Color(0xFF10B981),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.event, color: ColorTokens.secondary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _buildSimpleKPICard(
|
||||
'En Cours',
|
||||
ongoingEvents.toString(),
|
||||
'Actifs maintenant',
|
||||
Icons.play_circle_filled,
|
||||
const Color(0xFF3B82F6),
|
||||
),
|
||||
const Text(
|
||||
'Événements',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Créer événement - Fonctionnalité à venir')),
|
||||
);
|
||||
},
|
||||
tooltip: 'Créer un événement',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildSimpleKPICard(
|
||||
'Participants',
|
||||
totalParticipants.toString(),
|
||||
'Total inscrits',
|
||||
Icons.people,
|
||||
const Color(0xFF8B5CF6),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _buildSimpleKPICard(
|
||||
'Taux Moyen',
|
||||
'$averageParticipation%',
|
||||
'Participation',
|
||||
Icons.trending_up,
|
||||
const Color(0xFFF59E0B),
|
||||
),
|
||||
),
|
||||
_buildStatCard('À Venir', upcomingEvents.toString(), ColorTokens.success),
|
||||
_buildStatCard('En Cours', ongoingEvents.toString(), ColorTokens.info),
|
||||
_buildStatCard('Participants', totalParticipants.toString(), ColorTokens.secondary),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -356,62 +321,30 @@ class _EventsPageState extends State<EventsPage> with TickerProviderStateMixin {
|
||||
);
|
||||
}
|
||||
|
||||
/// Carte KPI simple alignée sur le design system
|
||||
Widget _buildSimpleKPICard(String title, String value, String subtitle, IconData icon, Color color) {
|
||||
Widget _buildStatCard(String label, String value, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
border: Border.all(color: color.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 16),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF374151),
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Color(0xFF9CA3AF),
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1295,14 +1228,13 @@ class _EventsPageState extends State<EventsPage> with TickerProviderStateMixin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Créer un nouvel événement
|
||||
void _showCreateEventDialog(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Création d\'événement - Fonctionnalité à implémenter'),
|
||||
backgroundColor: Color(0xFF6C5CE7),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Modifier un événement
|
||||
@@ -1324,31 +1256,4 @@ class _EventsPageState extends State<EventsPage> with TickerProviderStateMixin {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Importer des événements
|
||||
void _showEventImportDialog() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Import d\'événements - Fonctionnalité à implémenter'),
|
||||
backgroundColor: Color(0xFFF59E0B),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Exporter des événements
|
||||
void _showEventExportDialog() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Export d\'événements - Fonctionnalité à implémenter'),
|
||||
backgroundColor: Color(0xFF10B981),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,9 @@ library events_page_connected;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../core/auth/bloc/auth_bloc.dart';
|
||||
import '../../../../core/auth/models/user_role.dart';
|
||||
import '../../../../core/utils/logger.dart';
|
||||
import '../../../authentication/data/models/user_role.dart';
|
||||
import '../../../authentication/presentation/bloc/auth_bloc.dart';
|
||||
|
||||
/// Page de gestion des événements avec données injectées
|
||||
class EventsPageWithData extends StatefulWidget {
|
||||
@@ -20,7 +19,7 @@ class EventsPageWithData extends StatefulWidget {
|
||||
/// Nombre total d'événements
|
||||
final int totalCount;
|
||||
|
||||
/// Page actuelle
|
||||
/// Page actuelle.
|
||||
final int currentPage;
|
||||
|
||||
/// Nombre total de pages
|
||||
@@ -46,7 +45,7 @@ class _EventsPageWithDataState extends State<EventsPageWithData>
|
||||
|
||||
// État
|
||||
String _searchQuery = '';
|
||||
String _selectedFilter = 'Tous';
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
||||
@@ -8,8 +8,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
import '../../../../core/widgets/error_widget.dart';
|
||||
import '../../../../core/widgets/loading_widget.dart';
|
||||
import '../../../../shared/widgets/error_widget.dart';
|
||||
import '../../../../shared/widgets/loading_widget.dart';
|
||||
import '../../../../core/utils/logger.dart';
|
||||
import '../../bloc/evenements_bloc.dart';
|
||||
import '../../bloc/evenements_event.dart';
|
||||
@@ -177,14 +177,14 @@ class EventsPageConnected extends StatelessWidget {
|
||||
'address': evenement.adresse ?? '',
|
||||
'type': _mapTypeToString(evenement.type),
|
||||
'status': _mapStatutToString(evenement.statut),
|
||||
'maxParticipants': evenement.maxParticipants ?? 0,
|
||||
'currentParticipants': evenement.participantsActuels ?? 0,
|
||||
'maxParticipants': evenement.maxParticipants,
|
||||
'currentParticipants': evenement.participantsActuels,
|
||||
'organizer': 'Organisateur', // TODO: Récupérer depuis organisateurId
|
||||
'priority': _mapPrioriteToString(evenement.priorite),
|
||||
'isPublic': evenement.estPublic ?? true,
|
||||
'requiresRegistration': evenement.inscriptionRequise ?? false,
|
||||
'cost': evenement.cout ?? 0.0,
|
||||
'tags': evenement.tags ?? [],
|
||||
'isPublic': evenement.estPublic,
|
||||
'requiresRegistration': evenement.inscriptionRequise,
|
||||
'cost': evenement.cout,
|
||||
'tags': evenement.tags,
|
||||
'createdBy': 'Créateur', // TODO: Récupérer depuis organisateurId
|
||||
'createdAt': DateTime.now(), // TODO: Ajouter au modèle
|
||||
'lastModified': DateTime.now(), // TODO: Ajouter au modèle
|
||||
|
||||
@@ -493,7 +493,7 @@ class _EditEventDialogState extends State<EditEventDialog> {
|
||||
);
|
||||
|
||||
// Envoyer l'événement au BLoC
|
||||
context.read<EvenementsBloc>().add(UpdateEvenement(widget.evenement.id!, evenementUpdated));
|
||||
context.read<EvenementsBloc>().add(UpdateEvenement(widget.evenement.id!.toString(), evenementUpdated));
|
||||
|
||||
// Fermer le dialogue
|
||||
Navigator.pop(context);
|
||||
|
||||
@@ -234,11 +234,11 @@ class _InscriptionEventDialogState extends State<InscriptionEventDialog> {
|
||||
border: Border.all(color: Colors.orange[200]!),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
child: const Row(
|
||||
children: [
|
||||
const Icon(Icons.warning, color: Colors.orange),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
Icon(Icons.warning, color: Colors.orange),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Êtes-vous sûr de vouloir vous désinscrire de cet événement ?',
|
||||
style: TextStyle(fontSize: 14),
|
||||
@@ -292,9 +292,9 @@ class _InscriptionEventDialogState extends State<InscriptionEventDialog> {
|
||||
void _submitForm() {
|
||||
if (widget.isInscrit) {
|
||||
// Désinscription
|
||||
context.read<EvenementsBloc>().add(DesinscrireEvenement(widget.evenement.id!));
|
||||
context.read<EvenementsBloc>().add(DesinscrireEvenement(widget.evenement.id!.toString()));
|
||||
Navigator.pop(context);
|
||||
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Désinscription réussie'),
|
||||
@@ -304,7 +304,7 @@ class _InscriptionEventDialogState extends State<InscriptionEventDialog> {
|
||||
} else {
|
||||
// Inscription
|
||||
context.read<EvenementsBloc>().add(
|
||||
InscrireEvenement(widget.evenement.id!),
|
||||
InscrireEvenement(widget.evenement.id!.toString()),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ library membres_event;
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../data/models/membre_complete_model.dart';
|
||||
import '../../../core/models/membre_search_criteria.dart';
|
||||
import '../../../shared/models/membre_search_criteria.dart';
|
||||
|
||||
/// Classe de base pour tous les événements des membres
|
||||
abstract class MembresEvent extends Equatable {
|
||||
|
||||
@@ -158,10 +158,10 @@ class MembresError extends MembresState {
|
||||
/// État d'erreur réseau
|
||||
class MembresNetworkError extends MembresError {
|
||||
const MembresNetworkError({
|
||||
required String message,
|
||||
String? code,
|
||||
dynamic error,
|
||||
}) : super(message: message, code: code, error: error);
|
||||
required super.message,
|
||||
super.code,
|
||||
super.error,
|
||||
});
|
||||
}
|
||||
|
||||
/// État d'erreur de validation
|
||||
@@ -169,10 +169,10 @@ class MembresValidationError extends MembresError {
|
||||
final Map<String, String> validationErrors;
|
||||
|
||||
const MembresValidationError({
|
||||
required String message,
|
||||
required super.message,
|
||||
required this.validationErrors,
|
||||
String? code,
|
||||
}) : super(message: message, code: code);
|
||||
super.code,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, code, validationErrors];
|
||||
|
||||
@@ -4,8 +4,8 @@ library membre_repository;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import '../models/membre_complete_model.dart';
|
||||
import '../../../../core/models/membre_search_result.dart';
|
||||
import '../../../../core/models/membre_search_criteria.dart';
|
||||
import '../../../../shared/models/membre_search_result.dart';
|
||||
import '../../../../shared/models/membre_search_criteria.dart';
|
||||
|
||||
/// Interface du repository des membres
|
||||
abstract class MembreRepository {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/models/membre_search_criteria.dart';
|
||||
import '../../../../core/models/membre_search_result.dart';
|
||||
import '../../../../shared/models/membre_search_criteria.dart';
|
||||
import '../../../../shared/models/membre_search_result.dart';
|
||||
|
||||
/// Service pour la recherche avancée de membres
|
||||
/// Gère les appels API vers l'endpoint de recherche sophistiquée
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user