Amélioration de la splash_page et du login

This commit is contained in:
d6soft
2025-06-04 16:51:40 +02:00
parent 8c9e9a21c4
commit bcfdbb2c8b
168 changed files with 153842 additions and 6183 deletions

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:go_router/go_router.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/repositories/operation_repository.dart';
@@ -13,7 +13,6 @@ import 'package:geosector_app/core/services/sync_service.dart';
import 'package:geosector_app/core/services/connectivity_service.dart';
import 'package:geosector_app/presentation/auth/splash_page.dart';
import 'package:geosector_app/presentation/auth/login_page.dart';
import 'package:geosector_app/presentation/auth/register_page.dart';
import 'package:geosector_app/presentation/admin/admin_dashboard_page.dart';
import 'package:geosector_app/presentation/user/user_dashboard_page.dart';
@@ -28,239 +27,202 @@ final amicaleRepository = AmicaleRepository(apiService);
final syncService = SyncService(userRepository: userRepository);
final connectivityService = ConnectivityService();
class GeoSectorApp extends StatelessWidget {
const GeoSectorApp({super.key});
class GeosectorApp extends StatelessWidget {
const GeosectorApp({super.key});
@override
Widget build(BuildContext context) {
// Utiliser directement le router sans provider
final router = GoRouter(
return MaterialApp.router(
title: 'GeoSector',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
routerConfig: _createRouter(),
debugShowCheckedModeBanner: false,
);
}
/// Création du routeur avec configuration pour URLs propres
GoRouter _createRouter() {
return GoRouter(
initialLocation: '/',
debugLogDiagnostics: true,
refreshListenable:
userRepository, // Écouter les changements d'état d'authentification
// Gestionnaire de redirection global - intercepte toutes les navigations
redirect: (context, state) {
// Détection manuelle des paramètres d'URL pour le Web
if (kIsWeb && state.uri.path == '/login') {
try {
// Obtenir le paramètre 'type' de l'URL actuelle
final typeParam = state.uri.queryParameters['type'];
// Obtenir l'URL brute du navigateur pour comparer
final rawUri = Uri.parse(Uri.base.toString());
final rawTypeParam = rawUri.queryParameters['type'];
print('APP ROUTER: state.uri = ${state.uri}, type = $typeParam');
print('APP ROUTER: rawUri = $rawUri, type = $rawTypeParam');
// Pas de redirection si on a déjà le paramètre type
if (typeParam != null) {
print('APP ROUTER: Param type déjà présent, pas de redirection');
return null; // Pas de redirection
}
// Si un paramètre type=user est présent dans l'URL brute mais pas dans l'état
if (rawTypeParam == 'user' && typeParam == null) {
print(
'APP ROUTER: Paramètre détecté dans l\'URL brute, redirection vers /login?type=user');
return '/login?type=user';
}
} catch (e) {
print('Erreur lors de la récupération des paramètres d\'URL: $e');
}
}
// Sauvegarder le chemin actuel pour l'utilisateur connecté, sauf pour la page de splash
if (state.uri.toString() != '/' && userRepository.isLoggedIn) {
// Ne pas sauvegarder les chemins de login/register
if (!state.uri.toString().startsWith('/login') &&
!state.uri.toString().startsWith('/register')) {
userRepository.updateLastPath(state.uri.toString());
}
}
// Vérifier si l'utilisateur est sur la page de splash
if (state.uri.toString() == '/') {
// Laisser l'utilisateur sur la page de splash, la redirection sera gérée par SplashPage
return null;
}
// Vérifier si l'utilisateur est sur une page d'authentification
final isLoggedIn = userRepository.isLoggedIn;
final isOnLoginPage = state.uri.toString().startsWith('/login');
final isOnRegisterPage = state.uri.toString() == '/register';
final isOnAdminRegisterPage = state.uri.toString() == '/admin-register';
// Si l'utilisateur n'est pas connecté et n'est pas sur une page d'authentification, rediriger vers la page de connexion
if (!isLoggedIn &&
!isOnLoginPage &&
!isOnRegisterPage &&
!isOnAdminRegisterPage) {
return '/login';
}
// Si l'utilisateur est connecté et se trouve sur une page d'authentification, rediriger vers le tableau de bord approprié
if (isLoggedIn &&
(isOnLoginPage || isOnRegisterPage || isOnAdminRegisterPage)) {
// Récupérer le rôle de l'utilisateur directement
final user = userRepository.getCurrentUser();
if (user != null) {
// Convertir le rôle en int si nécessaire
int roleValue;
if (user.role is String) {
roleValue = int.tryParse(user.role as String) ?? 1;
} else {
roleValue = user.role as int;
}
// Redirection simple basée sur le rôle
if (roleValue > 1) {
debugPrint(
'Router: Redirection vers /admin (rôle $roleValue > 1)');
return '/admin';
} else {
debugPrint(
'Router: Redirection vers /user (rôle $roleValue = 1)');
return '/user';
}
}
}
// Si l'utilisateur est connecté mais essaie d'accéder à la mauvaise page selon son rôle
if (isLoggedIn) {
final user = userRepository.getCurrentUser();
if (user != null) {
// Convertir le rôle en int si nécessaire
int roleValue;
if (user.role is String) {
roleValue = int.tryParse(user.role as String) ?? 1;
} else {
roleValue = user.role as int;
}
// Vérifier si l'utilisateur est sur la bonne page en fonction de son rôle
final isOnUserPage = state.uri.toString().startsWith('/user');
final isOnAdminPage = state.uri.toString().startsWith('/admin');
// Admin (rôle > 1) essayant d'accéder à une page utilisateur
if (roleValue > 1 && isOnUserPage) {
debugPrint(
'Router: Redirection d\'admin (rôle $roleValue) vers /admin');
return '/admin';
}
// Utilisateur standard (rôle = 1) essayant d'accéder à une page admin
if (roleValue == 1 && isOnAdminPage) {
debugPrint(
'Router: Redirection d\'utilisateur (rôle $roleValue) vers /user');
return '/user';
}
}
}
return null;
},
routes: [
// Splash screen
GoRoute(
path: '/',
builder: (context, state) => const SplashPage(),
),
// Page de connexion utilisateur dédiée
GoRoute(
path: '/login/user',
name: 'splash',
builder: (context, state) {
print('ROUTER: Accès direct à la route login user');
return const LoginPage(
key: Key('login_page_user'),
loginType: 'user',
);
debugPrint('GoRoute: Affichage de SplashPage');
return const SplashPage();
},
),
// Pages d'authentification standard
GoRoute(
path: '/login',
name: 'login',
builder: (context, state) {
// Ajouter des logs de débogage détaillés pour comprendre les paramètres
print('ROUTER DEBUG: Uri complète = ${state.uri}');
print('ROUTER DEBUG: Path = ${state.uri.path}');
print('ROUTER DEBUG: Query params = ${state.uri.queryParameters}');
print(
'ROUTER DEBUG: Has type? ${state.uri.queryParameters.containsKey("type")}');
// Récupérer le type depuis les query parameters ou extra data
final type = state.uri.queryParameters['type'] ??
(state.extra as Map<String, dynamic>?)?['type'] as String?;
// Donner la priorité aux paramètres d'URL puis aux extras
String? loginType;
// 1. Essayer d'abord les paramètres d'URL (pour les liens externes)
final queryParams = state.uri.queryParameters;
loginType = queryParams['type'];
print('ROUTER DEBUG: Type from query params = $loginType');
// 2. Si aucun type dans les paramètres d'URL, vérifier les extras (pour la navigation interne)
if (loginType == null &&
state.extra != null &&
state.extra is Map<String, dynamic>) {
final extras = state.extra as Map<String, dynamic>;
loginType = extras['type']?.toString();
print('ROUTER DEBUG: Type from extras = $loginType');
}
// 3. Normaliser et valider le type
if (loginType != null) {
loginType = loginType.trim().toLowerCase();
// Vérifier explicitement que c'est 'user', sinon mettre 'admin'
if (loginType != 'user') {
loginType = 'admin';
}
} else {
// Si aucun type n'est spécifié, retourner la page de splash
print(
'ROUTER: Aucun type spécifié, utilisation de la page splash');
return const SplashPage();
}
print('ROUTER: Type de connexion final: $loginType');
return LoginPage(
key: Key('login_page_${loginType}'),
loginType: loginType,
);
debugPrint('GoRoute: Affichage de LoginPage avec type: $type');
return LoginPage(loginType: type);
},
),
// Routes spécifiques pour chaque type de login
GoRoute(
path: '/login/user',
name: 'login-user',
builder: (context, state) {
debugPrint('GoRoute: Affichage de LoginPage pour utilisateur');
return const LoginPage(loginType: 'user');
},
),
GoRoute(
path: '/login/admin',
name: 'login-admin',
builder: (context, state) {
debugPrint('GoRoute: Affichage de LoginPage pour admin');
return const LoginPage(loginType: 'admin');
},
),
GoRoute(
path: '/register',
builder: (context, state) => const RegisterPage(),
name: 'register',
builder: (context, state) {
debugPrint('GoRoute: Affichage de RegisterPage');
// Retournez votre page d'inscription ici
return const Scaffold(
body: Center(
child: Text('Page d\'inscription - À implémenter'),
),
);
},
),
// Pages administrateur
GoRoute(
path: '/admin',
builder: (context, state) => const AdminDashboardPage(),
routes: [
// Ajouter d'autres routes admin ici
],
),
// Pages utilisateur
GoRoute(
path: '/user',
builder: (context, state) => const UserDashboardPage(),
routes: [
// Ajouter d'autres routes utilisateur ici
],
name: 'user',
builder: (context, state) {
debugPrint('GoRoute: Affichage de UserDashboardPage');
return const UserDashboardPage();
},
),
GoRoute(
path: '/admin',
name: 'admin',
builder: (context, state) {
debugPrint('GoRoute: Affichage de AdminDashboardPage');
return const AdminDashboardPage();
},
),
],
);
redirect: (context, state) {
final currentPath = state.uri.path;
debugPrint('GoRouter.redirect: currentPath = $currentPath');
return MaterialApp.router(
debugShowCheckedModeBanner: false,
title: 'GEOSECTOR',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
routerConfig: router,
// Pour la page racine, toujours autoriser l'affichage de la splash page
if (currentPath == '/') {
debugPrint('GoRouter.redirect: Autorisation splash page');
return null;
}
// Pages publiques qui ne nécessitent pas d'authentification
final publicPaths = [
'/login',
'/login/user',
'/login/admin',
'/register'
];
if (publicPaths.any((path) => currentPath.startsWith(path))) {
debugPrint(
'GoRouter.redirect: Page publique autorisée: $currentPath');
return null;
}
// Vérifier l'authentification pour les pages protégées
try {
final isAuthenticated = userRepository.isLoggedIn;
final currentUser = userRepository.currentUser;
debugPrint('GoRouter.redirect: isAuthenticated = $isAuthenticated');
debugPrint('GoRouter.redirect: currentUser = ${currentUser?.email}');
// Si pas authentifié, rediriger vers la splash page
if (!isAuthenticated) {
debugPrint(
'GoRouter.redirect: Non authentifié, redirection vers /');
return '/';
}
// Vérifier les permissions pour les pages admin
if (currentPath.startsWith('/admin')) {
final userRole = userRepository.getUserRole();
final isAdmin = userRole > 1; // Admin = rôle 2 ou plus
debugPrint(
'GoRouter.redirect: userRole = $userRole, isAdmin = $isAdmin');
if (!isAdmin) {
debugPrint(
'GoRouter.redirect: Pas admin, redirection vers /user');
return '/user';
}
}
// Si on arrive ici, l'utilisateur a les permissions nécessaires
debugPrint('GoRouter.redirect: Accès autorisé à $currentPath');
return null;
} catch (e) {
debugPrint(
'GoRouter.redirect: Erreur lors de la vérification auth: $e');
// En cas d'erreur, rediriger vers la splash page pour sécurité
return '/';
}
},
// Listener pour déboguer les changements de route
refreshListenable:
userRepository, // Écouter les changements dans userRepository
debugLogDiagnostics: true, // Activer les logs de débogage
errorBuilder: (context, state) {
debugPrint('GoRouter.errorBuilder: Erreur pour ${state.uri.path}');
return Scaffold(
appBar: AppBar(
title: const Text('Erreur de navigation'),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(
'Page non trouvée',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Chemin: ${state.uri.path}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () {
debugPrint('GoRouter.errorBuilder: Retour vers /');
context.go('/');
},
icon: const Icon(Icons.home),
label: const Text('Retour à l\'accueil'),
),
],
),
),
),
);
},
);
}
}

View File

@@ -123,7 +123,7 @@ class AppKeys {
},
1: {
'titre': 'Espèce',
'couleur': 0xFFB87333, // Couleur cuivrée
'couleur': 0xFFDAA520, // Goldenrod
'icon_data': Icons.payments_outlined,
},
2: {

View File

@@ -169,21 +169,13 @@ class UserRepository extends ChangeNotifier {
try {
// Vérifier d'abord si la boîte est ouverte
if (!Hive.isBoxOpen(AppKeys.usersBoxName)) {
try {
Hive.openBox<UserModel>(AppKeys.usersBoxName);
} catch (e) {
debugPrint(
'Erreur lors de l\'ouverture de la boîte utilisateurs: $e');
return null;
}
debugPrint('Boîte users non ouverte, tentative d\'ouverture...');
return null; // Retourner null plutôt que d'essayer d'ouvrir ici
}
// Chercher un utilisateur avec une session active - Il suffit qu'il ait un sessionId
// Chercher un utilisateur avec une session active
final activeUsers = _userBox.values
.where((user) =>
user.sessionId != null && // Vérifier que sessionId n'est pas null
user.sessionId!
.isNotEmpty) // Vérifier que sessionId n'est pas vide
.where((user) => user.sessionId != null && user.sessionId!.isNotEmpty)
.toList();
// S'il y a des utilisateurs actifs, retourner le premier
@@ -267,6 +259,16 @@ class UserRepository extends ChangeNotifier {
}
}
/// Navigation après connexion réussie
void navigateAfterLogin(BuildContext context) {
final user = currentUser;
if (user != null && context.mounted) {
final isAdmin = user.role == 1 || user.role == 2;
context.go(isAdmin ? '/admin' : '/user');
}
}
// Méthode d'inscription (uniquement pour les administrateurs)
Future<bool> register(String email, String password, String name,
String amicaleName, String postalCode, String cityName) async {
@@ -1064,28 +1066,8 @@ class UserRepository extends ChangeNotifier {
}
}
/// Méthode de déconnexion avec affichage d'un overlay de chargement
/// et redirection vers la page de démarrage
/// Cette méthode remplace AuthService.logout
Future<bool> logoutWithUI(BuildContext context) async {
final bool result = await LoadingOverlay.show(
context: context,
spinnerSize: 80.0, // Spinner plus grand
strokeWidth: 6.0, // Trait plus épais
future: logout(),
);
// Si la déconnexion a réussi, rediriger vers la page de démarrage
if (result && context.mounted) {
// Utiliser GoRouter pour naviguer vers la page de démarrage
GoRouter.of(context).go('/');
}
return result;
}
// Logout complet (sans UI)
Future<bool> logout() async {
// Méthode de déconnexion unique avec navigation vers / splash_page
Future<bool> logout(BuildContext context) async {
_isLoading = true;
notifyListeners();
@@ -1097,9 +1079,15 @@ class UserRepository extends ChangeNotifier {
final currentUser = getCurrentUser();
if (currentUser == null) {
debugPrint('Aucun utilisateur connecté, déconnexion terminée');
// Nettoyage en profondeur même si aucun utilisateur n'est connecté
await _deepCleanHiveBoxes();
debugPrint('État isLoggedIn après nettoyage: $isLoggedIn');
// Toujours rediriger avec pushAndRemoveUntil pour forcer la navigation
if (context.mounted) {
debugPrint('Redirection forcée vers / après nettoyage');
context.go('/');
}
return true;
}
@@ -1112,13 +1100,9 @@ class UserRepository extends ChangeNotifier {
await logoutAPI();
} catch (e) {
debugPrint('Erreur lors de la déconnexion API, mais on continue: $e');
// Continuer le processus de déconnexion même si l'API échoue
}
}
// Effacer la session de l'utilisateur
debugPrint('Mise à jour de l\'utilisateur pour effacer la session...');
// Supprimer la session API
setSessionId(null);
@@ -1126,37 +1110,47 @@ class UserRepository extends ChangeNotifier {
_cachedCurrentUser = null;
debugPrint('Cache utilisateur réinitialisé (_cachedCurrentUser = null)');
// MODIFICATION IMPORTANTE: Nettoyage complet de toutes les boîtes Hive
// Nettoyage complet de toutes les boîtes Hive
debugPrint('Nettoyage profond des données Hive après déconnexion...');
await _deepCleanHiveBoxes();
// Vérifier l'état après nettoyage
debugPrint('État isLoggedIn après déconnexion: $isLoggedIn');
debugPrint(
'Valeur de currentUser après déconnexion: ${currentUser != null ? "non null" : "null"}');
// Vérifier si des utilisateurs restent dans la boîte
if (Hive.isBoxOpen(AppKeys.usersBoxName)) {
final remainingUsers = _userBox.values.toList();
debugPrint(
'Nombre d\'utilisateurs restants dans la boîte: ${remainingUsers.length}');
}
// Réinitialiser l'état de HiveResetStateService
hiveResetStateService.reset();
debugPrint('État de HiveResetStateService réinitialisé');
debugPrint('Déconnexion terminée avec succès');
// Forcer la navigation avec pushAndRemoveUntil et attendre
if (context.mounted) {
debugPrint('Navigation forcée vers / après déconnexion');
// Attendre que toutes les opérations asynchrones soient terminées
await Future.delayed(const Duration(milliseconds: 200));
// Navigation forcée qui supprime toute la pile de navigation
context.go('/');
// Alternative si pushAndRemoveUntil ne fonctionne pas
// context.pushReplacementNamed('/');
}
notifyListeners();
return true;
} catch (e) {
debugPrint('Erreur de déconnexion: $e');
// Même en cas d'erreur, essayer de naviguer vers la page d'accueil
if (context.mounted) {
debugPrint('Navigation d\'urgence vers / après erreur');
context.go(
'/',
);
}
return false;
} finally {
_isLoading = false;
notifyListeners();
// Vérification finale
debugPrint('État final isLoggedIn: $isLoggedIn');
}
}

View File

@@ -0,0 +1,13 @@
import 'package:package_info_plus/package_info_plus.dart';
class AppInfoService {
static PackageInfo? _packageInfo;
static Future<void> initialize() async {
_packageInfo = await PackageInfo.fromPlatform();
}
static String get version => _packageInfo?.version ?? '0.0.0';
static String get buildNumber => _packageInfo?.buildNumber ?? '0';
static String get fullVersion => 'v$version+$buildNumber';
}

View File

@@ -1,51 +0,0 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/loading_overlay.dart';
/// Service qui gère les opérations d'authentification avec affichage d'un overlay de chargement
class AuthService {
final UserRepository _userRepository;
AuthService(this._userRepository);
/// Méthode de connexion avec affichage d'un overlay de chargement
Future<bool> login(BuildContext context, String username, String password,
{required String type}) async {
return await LoadingOverlay.show(
context: context,
spinnerSize: 80.0, // Spinner plus grand
strokeWidth: 6.0, // Trait plus épais
future: _userRepository.login(username, password, type: type),
);
}
/// Méthode de déconnexion avec affichage d'un overlay de chargement
/// et redirection vers la page de démarrage
Future<bool> logout(BuildContext context) async {
final bool result = await LoadingOverlay.show(
context: context,
spinnerSize: 80.0, // Spinner plus grand
strokeWidth: 6.0, // Trait plus épais
future: _userRepository.logout(),
);
// Si la déconnexion a réussi, rediriger vers la page de démarrage
if (result && context.mounted) {
// Utiliser GoRouter pour naviguer vers la page de démarrage
GoRouter.of(context).go('/');
}
return result;
}
/// Vérifie si un utilisateur est connecté
bool isLoggedIn() {
return _userRepository.isLoggedIn;
}
/// Récupère le rôle de l'utilisateur connecté
int getUserRole() {
return _userRepository.getUserRole();
}
}

View File

@@ -7,18 +7,70 @@ class AppTheme {
static const Color accentColor = Color(0xFF00E09D); // Vert
static const Color errorColor = Color(0xFFE41B13); // Rouge
static const Color warningColor = Color(0xFFF7A278); // Orange
static const Color backgroundLightColor =
Color(0xFFF4F5F6); // Gris très clair
static const Color backgroundLightColor = Color(0xFFF4F5F6); // Gris très clair
static const Color backgroundDarkColor = Color(0xFF111827);
static const Color textLightColor = Color(0xFF000000); // Noir
static const Color textDarkColor = Color(0xFFF9FAFB);
// Couleurs de texte supplémentaires
static const Color textSecondaryColor = Color(0xFF7F8C8D);
static const Color textLightSecondaryColor = Color(0xFFBDC3C7);
// Couleurs des boutons
static const Color buttonSuccessColor = Color(0xFF2ECC71);
static const Color buttonDangerColor = Color(0xFFE74C3C);
// Couleurs des charts
static const List<Color> chartColors = [
primaryColor,
accentColor,
errorColor,
warningColor,
secondaryColor,
Color(0xFF9B59B6),
Color(0xFF1ABC9C),
];
// Ombres
static List<BoxShadow> cardShadow = [
BoxShadow(
color: Colors.black.withOpacity(0.05),
spreadRadius: 1,
blurRadius: 10,
offset: const Offset(0, 3),
),
];
static List<BoxShadow> buttonShadow = [
BoxShadow(
color: Colors.black.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 5,
offset: const Offset(0, 2),
),
];
// Rayons des bordures
static const double borderRadiusSmall = 4.0;
static const double borderRadiusMedium = 8.0;
static const double borderRadiusLarge = 12.0;
static const double borderRadiusXL = 16.0;
static const double borderRadiusRounded = 50.0;
// Espacement
static const double spacingXS = 4.0;
static const double spacingS = 8.0;
static const double spacingM = 16.0;
static const double spacingL = 24.0;
static const double spacingXL = 32.0;
static const double spacingXXL = 48.0;
// Thème clair
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
fontFamily: 'Figtree', // Utilisation directe de la police locale
fontFamily: 'Figtree',
colorScheme: ColorScheme.light(
primary: primaryColor,
secondary: secondaryColor,
@@ -29,24 +81,10 @@ class AppTheme {
onSecondary: Colors.white,
onBackground: textLightColor,
onSurface: textLightColor,
error: errorColor,
),
textTheme: const TextTheme().copyWith(
displayLarge: const TextStyle(fontFamily: 'Figtree'),
displayMedium: const TextStyle(fontFamily: 'Figtree'),
displaySmall: const TextStyle(fontFamily: 'Figtree'),
headlineLarge: const TextStyle(fontFamily: 'Figtree'),
headlineMedium: const TextStyle(fontFamily: 'Figtree'),
headlineSmall: const TextStyle(fontFamily: 'Figtree'),
titleLarge: const TextStyle(fontFamily: 'Figtree'),
titleMedium: const TextStyle(fontFamily: 'Figtree'),
titleSmall: const TextStyle(fontFamily: 'Figtree'),
bodyLarge: const TextStyle(fontFamily: 'Figtree'),
bodyMedium: const TextStyle(fontFamily: 'Figtree'),
bodySmall: const TextStyle(fontFamily: 'Figtree'),
labelLarge: const TextStyle(fontFamily: 'Figtree'),
labelMedium: const TextStyle(fontFamily: 'Figtree'),
labelSmall: const TextStyle(fontFamily: 'Figtree'),
),
scaffoldBackgroundColor: backgroundLightColor,
textTheme: _getTextTheme(textLightColor),
appBarTheme: const AppBarTheme(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
@@ -56,9 +94,10 @@ class AppTheme {
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
padding: const EdgeInsets.symmetric(horizontal: spacingL, vertical: spacingM),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
borderRadius: BorderRadius.circular(borderRadiusRounded),
),
textStyle: const TextStyle(
fontFamily: 'Figtree',
@@ -67,35 +106,56 @@ class AppTheme {
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: primaryColor,
side: const BorderSide(color: primaryColor),
padding: const EdgeInsets.symmetric(horizontal: spacingL, vertical: spacingM),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: primaryColor,
padding: const EdgeInsets.symmetric(horizontal: spacingM, vertical: spacingS),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: backgroundLightColor,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: BorderSide(
color: textLightColor.withOpacity(0.1),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: BorderSide(
color: textLightColor.withOpacity(0.1),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
contentPadding: const EdgeInsets.symmetric(horizontal: spacingM, vertical: spacingM),
),
cardTheme: CardTheme(
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(borderRadiusXL),
),
color: Colors.white,
),
dividerTheme: const DividerThemeData(
color: Color(0xFFECF0F1),
thickness: 1,
space: spacingM,
),
);
}
@@ -105,7 +165,7 @@ class AppTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
fontFamily: 'Figtree', // Utilisation directe de la police locale
fontFamily: 'Figtree',
colorScheme: ColorScheme.dark(
primary: primaryColor,
secondary: secondaryColor,
@@ -116,24 +176,10 @@ class AppTheme {
onSecondary: Colors.white,
onBackground: textDarkColor,
onSurface: textDarkColor,
error: errorColor,
),
textTheme: const TextTheme().copyWith(
displayLarge: const TextStyle(fontFamily: 'Figtree'),
displayMedium: const TextStyle(fontFamily: 'Figtree'),
displaySmall: const TextStyle(fontFamily: 'Figtree'),
headlineLarge: const TextStyle(fontFamily: 'Figtree'),
headlineMedium: const TextStyle(fontFamily: 'Figtree'),
headlineSmall: const TextStyle(fontFamily: 'Figtree'),
titleLarge: const TextStyle(fontFamily: 'Figtree'),
titleMedium: const TextStyle(fontFamily: 'Figtree'),
titleSmall: const TextStyle(fontFamily: 'Figtree'),
bodyLarge: const TextStyle(fontFamily: 'Figtree'),
bodyMedium: const TextStyle(fontFamily: 'Figtree'),
bodySmall: const TextStyle(fontFamily: 'Figtree'),
labelLarge: const TextStyle(fontFamily: 'Figtree'),
labelMedium: const TextStyle(fontFamily: 'Figtree'),
labelSmall: const TextStyle(fontFamily: 'Figtree'),
),
scaffoldBackgroundColor: backgroundDarkColor,
textTheme: _getTextTheme(textDarkColor),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF1F2937),
foregroundColor: Colors.white,
@@ -143,9 +189,10 @@ class AppTheme {
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
padding: const EdgeInsets.symmetric(horizontal: spacingL, vertical: spacingM),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
borderRadius: BorderRadius.circular(borderRadiusRounded),
),
textStyle: const TextStyle(
fontFamily: 'Figtree',
@@ -154,37 +201,78 @@ class AppTheme {
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: primaryColor,
side: const BorderSide(color: primaryColor),
padding: const EdgeInsets.symmetric(horizontal: spacingL, vertical: spacingM),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: primaryColor,
padding: const EdgeInsets.symmetric(horizontal: spacingM, vertical: spacingS),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF374151),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: BorderSide(
color: textDarkColor.withOpacity(0.1),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: BorderSide(
color: textDarkColor.withOpacity(0.1),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
contentPadding: const EdgeInsets.symmetric(horizontal: spacingM, vertical: spacingM),
),
cardTheme: CardTheme(
cardTheme: CardThemeData(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(borderRadiusXL),
),
color: const Color(0xFF1F2937),
),
dividerTheme: DividerThemeData(
color: textDarkColor.withOpacity(0.1),
thickness: 1,
space: spacingM,
),
);
}
}
// Méthode helper pour générer le TextTheme
static TextTheme _getTextTheme(Color textColor) {
return TextTheme(
displayLarge: TextStyle(fontFamily: 'Figtree', color: textColor),
displayMedium: TextStyle(fontFamily: 'Figtree', color: textColor),
displaySmall: TextStyle(fontFamily: 'Figtree', color: textColor),
headlineLarge: TextStyle(fontFamily: 'Figtree', color: textColor),
headlineMedium: TextStyle(fontFamily: 'Figtree', color: textColor),
headlineSmall: TextStyle(fontFamily: 'Figtree', color: textColor),
titleLarge: TextStyle(fontFamily: 'Figtree', color: textColor),
titleMedium: TextStyle(fontFamily: 'Figtree', color: textColor),
titleSmall: TextStyle(fontFamily: 'Figtree', color: textColor),
bodyLarge: TextStyle(fontFamily: 'Figtree', color: textColor),
bodyMedium: TextStyle(fontFamily: 'Figtree', color: textColor),
bodySmall: TextStyle(fontFamily: 'Figtree', color: textColor.withOpacity(0.7)),
labelLarge: TextStyle(fontFamily: 'Figtree', color: textColor),
labelMedium: TextStyle(fontFamily: 'Figtree', color: textColor.withOpacity(0.7)),
labelSmall: TextStyle(fontFamily: 'Figtree', color: textColor.withOpacity(0.7)),
);
}
}

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:geosector_app/core/services/app_info_service.dart';
import 'package:geosector_app/app.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
@@ -13,88 +15,167 @@ import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/core/data/models/user_sector_model.dart';
import 'package:geosector_app/core/data/models/region_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/hive_reset_service.dart';
import 'package:geosector_app/core/services/hive_reset_state_service.dart';
// Import centralisé pour les modèles chat
import 'package:geosector_app/chat/models/chat_adapters.dart';
void main() async {
// IMPORTANT: Configurer l'URL strategy pour éviter les # dans les URLs
usePathUrlStrategy();
WidgetsFlutterBinding.ensureInitialized();
// Configurer le routage par chemin (URLs sans #)
setUrlStrategy(PathUrlStrategy());
// Initialiser les services essentiels
await _initializeServices();
// Initialiser Hive avec gestion des erreurs
bool hiveInitialized = false;
final hiveInitialized = await _initializeHive();
// TEMPORAIREMENT: Ne pas marquer l'erreur pour éviter la redirection
// if (!hiveInitialized) {
// debugPrint('Incompatibilité détectée dans les données Hive. Marquage pour affichage du dialogue...');
// hiveResetStateService.markAsReset();
// }
// Configurer l'orientation de l'application (mobile uniquement)
if (!kIsWeb) {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
}
// Lancer l'application
runApp(const GeosectorApp());
}
/// Initialise les services essentiels
Future<void> _initializeServices() async {
try {
await AppInfoService.initialize();
debugPrint('Services initialisés avec succès');
} catch (e) {
debugPrint('Erreur lors de l\'initialisation des services: $e');
}
}
/// Initialise Hive et les adaptateurs
Future<bool> _initializeHive() async {
try {
// Initialiser Hive
await Hive.initFlutter();
// Enregistrer les adaptateurs Hive pour les modèles principaux
Hive.registerAdapter(UserModelAdapter());
Hive.registerAdapter(AmicaleModelAdapter());
Hive.registerAdapter(ClientModelAdapter());
Hive.registerAdapter(OperationModelAdapter());
Hive.registerAdapter(SectorModelAdapter());
Hive.registerAdapter(PassageModelAdapter());
Hive.registerAdapter(MembreModelAdapter());
Hive.registerAdapter(UserSectorModelAdapter());
// TODO: Décommenter après avoir généré le fichier region_model.g.dart
// Hive.registerAdapter(RegionModelAdapter());
// Enregistrer les adaptateurs Hive pour le chat
Hive.registerAdapter(ConversationModelAdapter());
Hive.registerAdapter(MessageModelAdapter());
Hive.registerAdapter(ParticipantModelAdapter());
Hive.registerAdapter(AnonymousUserModelAdapter());
Hive.registerAdapter(AudienceTargetModelAdapter());
Hive.registerAdapter(NotificationSettingsAdapter());
_registerHiveAdapters();
// Ouvrir uniquement les boîtes essentielles au démarrage
try {
// La boîte des utilisateurs est nécessaire pour vérifier si un utilisateur est déjà connecté
await Hive.openBox<UserModel>(AppKeys.usersBoxName);
// Boîte pour les amicales
await Hive.openBox<AmicaleModel>(AppKeys.amicaleBoxName);
// Boîte pour les clients
await Hive.openBox<ClientModel>(AppKeys.clientsBoxName);
// Boîte pour les préférences utilisateur générales
await Hive.openBox(AppKeys.settingsBoxName);
await _openEssentialHiveBoxes();
// Ouvrir les boîtes de chat également au démarrage pour le cache local
await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
await Hive.openBox<MessageModel>(AppKeys.chatMessagesBoxName);
hiveInitialized = true;
} catch (e) {
debugPrint('Erreur lors de l\'ouverture des boîtes Hive: $e');
// Une erreur s'est produite lors de l'ouverture des boîtes, probablement due à une incompatibilité
// Nous allons réinitialiser Hive
hiveInitialized = false;
}
debugPrint('Hive initialisé avec succès');
return true;
} catch (e) {
debugPrint('Erreur lors de l\'initialisation de Hive: $e');
hiveInitialized = false;
return false;
}
}
/// Enregistre tous les adaptateurs Hive
void _registerHiveAdapters() {
// Vérifier si les adaptateurs sont déjà enregistrés pour éviter les doublons
if (!Hive.isAdapterRegistered(0)) {
Hive.registerAdapter(UserModelAdapter());
}
if (!Hive.isAdapterRegistered(1)) {
Hive.registerAdapter(AmicaleModelAdapter());
}
if (!Hive.isAdapterRegistered(2)) {
Hive.registerAdapter(ClientModelAdapter());
}
if (!Hive.isAdapterRegistered(3)) {
Hive.registerAdapter(OperationModelAdapter());
}
if (!Hive.isAdapterRegistered(4)) {
Hive.registerAdapter(SectorModelAdapter());
}
if (!Hive.isAdapterRegistered(5)) {
Hive.registerAdapter(PassageModelAdapter());
}
if (!Hive.isAdapterRegistered(6)) {
Hive.registerAdapter(MembreModelAdapter());
}
if (!Hive.isAdapterRegistered(7)) {
Hive.registerAdapter(UserSectorModelAdapter());
}
if (!Hive.isAdapterRegistered(8)) {
Hive.registerAdapter(RegionModelAdapter());
}
// Modèles de chat
if (!Hive.isAdapterRegistered(9)) {
Hive.registerAdapter(ConversationModelAdapter());
}
if (!Hive.isAdapterRegistered(10)) {
Hive.registerAdapter(MessageModelAdapter());
}
if (!Hive.isAdapterRegistered(11)) {
Hive.registerAdapter(ParticipantModelAdapter());
}
if (!Hive.isAdapterRegistered(12)) {
Hive.registerAdapter(AnonymousUserModelAdapter());
}
if (!Hive.isAdapterRegistered(13)) {
Hive.registerAdapter(AudienceTargetModelAdapter());
}
if (!Hive.isAdapterRegistered(14)) {
Hive.registerAdapter(NotificationSettingsAdapter());
}
}
/// Ouvre les boîtes Hive essentielles
Future<void> _openEssentialHiveBoxes() async {
final boxesToOpen = [
{'name': AppKeys.usersBoxName, 'type': 'UserModel'},
{'name': AppKeys.amicaleBoxName, 'type': 'AmicaleModel'},
{'name': AppKeys.clientsBoxName, 'type': 'ClientModel'},
{'name': AppKeys.settingsBoxName, 'type': 'dynamic'},
{'name': AppKeys.chatConversationsBoxName, 'type': 'ConversationModel'},
{'name': AppKeys.chatMessagesBoxName, 'type': 'MessageModel'},
];
for (final box in boxesToOpen) {
try {
final boxName = box['name'] as String;
final boxType = box['type'] as String;
// Vérifier si la boîte est déjà ouverte
if (Hive.isBoxOpen(boxName)) {
debugPrint('Boîte $boxName déjà ouverte');
continue;
}
switch (boxType) {
case 'UserModel':
await Hive.openBox<UserModel>(boxName);
break;
case 'AmicaleModel':
await Hive.openBox<AmicaleModel>(boxName);
break;
case 'ClientModel':
await Hive.openBox<ClientModel>(boxName);
break;
case 'ConversationModel':
await Hive.openBox<ConversationModel>(boxName);
break;
case 'MessageModel':
await Hive.openBox<MessageModel>(boxName);
break;
default:
await Hive.openBox(boxName);
}
debugPrint('Boîte $boxName ouverte avec succès');
} catch (e) {
debugPrint('Erreur lors de l\'ouverture de la boîte ${box['name']}: $e');
// Ne pas lancer d'erreur, continuer avec les autres boîtes
}
}
// Si Hive n'a pas été initialisé correctement, marquer l'état pour afficher le dialogue
if (!hiveInitialized) {
debugPrint(
'Incompatibilité détectée dans les données Hive. Marquage pour affichage du dialogue...');
// Marquer Hive comme ayant été réinitialisé pour afficher le dialogue plus tard
hiveResetStateService.markAsReset();
}
// Les autres boîtes (operations, sectors, passages, user_sector) seront ouvertes après connexion
// dans UserRepository.login() via la méthode _ensureBoxIsOpen()
// Définir l'orientation de l'application
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
// Lancer l'application directement sans AppProviders
runApp(const GeoSectorApp());
}

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/shared/app_theme.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/presentation/widgets/chat/chat_sidebar.dart';
import 'package:geosector_app/presentation/widgets/chat/chat_messages.dart';
import 'package:geosector_app/presentation/widgets/chat/chat_input.dart';

View File

@@ -3,18 +3,13 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:math' as math;
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/presentation/widgets/charts/activity_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
import 'package:geosector_app/presentation/widgets/sector_distribution_card.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/operation_model.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/shared/app_theme.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
/// Class pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@@ -281,7 +276,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
0.0,
(sum, passage) =>
sum +
(passage.montant != null && passage.montant.isNotEmpty
(passage.montant.isNotEmpty
? double.tryParse(passage.montant) ?? 0.0
: 0.0));
@@ -310,10 +305,8 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
// Compter les passages par membre
for (final passage in passages) {
if (passage.fkUser != null) {
memberCounts[passage.fkUser!] =
(memberCounts[passage.fkUser!] ?? 0) + 1;
}
memberCounts[passage.fkUser] =
(memberCounts[passage.fkUser] ?? 0) + 1;
}
// Récupérer les informations des membres
@@ -504,7 +497,6 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
key: ValueKey(
'sector_distribution_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
height: 200,
forceRefresh: !isFirstLoad,
),
),
],
@@ -531,7 +523,6 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
key: ValueKey(
'sector_distribution_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
height: 200,
forceRefresh: !isFirstLoad,
),
],
),
@@ -550,12 +541,10 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
key: ValueKey(
'activity_chart_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
height: 350,
loadFromHive: true,
showAllPassages:
true, // Tous les passages, pas seulement ceux de l'utilisateur courant
title: 'Passages réalisés par jour (15 derniers jours)',
daysToShow: 15,
forceRefresh: !isFirstLoad,
),
// Si vous avez besoin de passer l'ID de l'opération en cours, décommentez les lignes suivantes
// child: ActivityChart(
@@ -607,7 +596,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
const Text(
'Actions sur cette opération',
style: TextStyle(
fontWeight: FontWeight.bold,
@@ -624,7 +613,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
context,
'Exporter les données',
Icons.file_download_outlined,
AppTheme.buttonPrimaryColor,
AppTheme.primaryColor,
() {},
),
_buildActionButton(
@@ -705,386 +694,54 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
);
}
Widget _buildChartCard(
BuildContext context,
String title,
Widget chart,
) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(AppTheme.spacingM),
child: Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
chart,
],
),
);
}
// Construit la carte de répartition par type de passage avec liste
Widget _buildPassageTypeCard(BuildContext context) {
return Container(
height: 300, // Hauteur fixe de 300px
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(AppTheme.spacingM),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Répartition par type de passage',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
Text(
'$totalPassages passages',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: AppTheme.primaryColor,
),
),
],
),
),
Expanded(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: AppTheme.spacingM),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Graphique à gauche
Expanded(
flex: 1,
child: SizedBox(
height: 180, // Taille réduite
child: Builder(
builder: (context) {
// Vérifier si nous avons des données de passages
if (passagesByType.isEmpty) {
debugPrint(
'AdminDashboardHomePage: Aucune donnée de passage disponible pour le graphique');
return const Center(
child: Text('Aucune donnée disponible'),
);
}
// Si nous avons des données, afficher le graphique
// Mais d'abord, vérifier si tous les passages sont de type 2 (à finaliser)
// qui est exclu par défaut dans PassagePieChart
bool hasNonType2Passages = passagesByType.entries.any(
(entry) => entry.key != 2 && entry.value > 0);
debugPrint(
'AdminDashboardHomePage: Données pour le graphique: $passagesByType');
// Créer un widget personnalisé pour afficher le graphique ou un message
// selon le contenu des données
if (passagesByType.isEmpty) {
debugPrint(
'AdminDashboardHomePage: Aucune donnée de passage disponible');
return const Center(
child: Text('Aucune donnée disponible'),
);
}
// Vérifier si nous avons des données pour au moins un type
int totalPassages = 0;
passagesByType
.forEach((_, count) => totalPassages += count);
if (totalPassages == 0) {
debugPrint(
'AdminDashboardHomePage: Aucun passage trouvé');
return const Center(
child: Text('Aucun passage trouvé'),
);
}
// Vérifier si tous les passages sont de type 2 (à finaliser)
if (!hasNonType2Passages) {
debugPrint(
'AdminDashboardHomePage: Tous les passages sont de type 2 (à finaliser)');
// Créer un widget personnalisé pour afficher un message
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.info_outline,
color: Colors.orange,
size: 40,
),
const SizedBox(height: 8),
const Text(
'Uniquement des passages à finaliser',
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${passagesByType[2] ?? 0} passages',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.orange,
fontWeight: FontWeight.bold,
),
),
],
);
}
// Sinon, afficher le graphique avec les données
debugPrint(
'AdminDashboardHomePage: Affichage du graphique avec ${passagesByType.length} types');
return PassagePieChart(
size: 180,
passagesByType: passagesByType,
loadFromHive: false,
isDonut: true,
innerRadius: '50%',
showIcons: false,
showLegend: false,
);
},
),
),
),
// Liste des types à droite
Expanded(
flex: 1,
child: Padding(
padding: const EdgeInsets.only(left: AppTheme.spacingM),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.end, // Alignement à droite
mainAxisAlignment: MainAxisAlignment.center,
children: [
...AppKeys.typesPassages.entries.map((entry) {
final int typeId = entry.key;
final Map<String, dynamic> typeInfo = entry.value;
final int count = passagesByType[typeId] ?? 0;
final Color color =
Color(typeInfo['couleur2'] as int);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment
.end, // Alignement à droite
children: [
Expanded(
child: Text(
'$count ${typeInfo['titres']}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: color,
),
textAlign: TextAlign
.right, // Texte aligné à droite
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
],
),
);
}).toList(),
],
),
),
),
],
),
),
),
],
),
return PassageSummaryCard(
title: 'Répartition par type de passage',
titleColor: AppTheme.primaryColor,
titleIcon: Icons.route,
height: 300,
useValueListenable: false, // Utiliser les données statiques
showAllPassages: true,
excludePassageTypes: const [2], // Exclure "À finaliser"
passagesByType: passagesByType,
customTotalDisplay: (total) => '$totalPassages passages',
isDesktop: MediaQuery.of(context).size.width > 800,
backgroundIcon: Icons.route,
backgroundIconColor: AppTheme.primaryColor,
backgroundIconOpacity: 0.07,
backgroundIconSize: 180,
);
}
// Construit la carte de répartition par mode de paiement
Widget _buildPaymentTypeCard(BuildContext context) {
return Container(
height: 300, // Hauteur fixe de 300px
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(AppTheme.spacingM),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Répartition par mode de paiement',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
Text(
'${totalAmounts.toStringAsFixed(2)}',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: AppTheme.buttonSuccessColor,
),
),
],
),
),
Expanded(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: AppTheme.spacingM),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Graphique à gauche
Expanded(
flex: 1,
child: SizedBox(
height: 180, // Taille réduite
child: PaymentPieChart(
size: 180,
payments: paymentData,
isDonut: true,
innerRadius: '50%',
showIcons: false,
showLegend: false,
enable3DEffect:
false, // Désactiver l'effet 3D pour conserver les couleurs originales
effect3DIntensity: 0.0, // Pas d'intensité 3D
enableEnhancedExplode: false, // Désactiver l'explosion
useGradient:
false, // Ne pas utiliser de dégradé pour conserver les couleurs originales
),
),
),
// Liste des types de règlement à droite
Expanded(
flex: 1,
child: Padding(
padding: const EdgeInsets.only(left: AppTheme.spacingM),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.end, // Alignement à droite
mainAxisAlignment: MainAxisAlignment.center,
children: [
...[1, 2, 3].map((typeId) {
// Uniquement les types 1, 2 et 3
if (!AppKeys.typesReglements.containsKey(typeId)) {
return const SizedBox
.shrink(); // Ignorer si le type n'existe pas
}
final Map<String, dynamic> typeInfo =
AppKeys.typesReglements[typeId]!;
// Calculer le montant total pour ce type de règlement
double amount = 0.0;
for (final payment in paymentData) {
if (payment.typeId == typeId) {
amount = payment.amount;
break;
}
}
// Ne pas afficher si le montant est 0
if (amount <= 0) {
return const SizedBox.shrink();
}
final Color color =
Color(typeInfo['couleur'] as int);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment
.end, // Alignement à droite
children: [
Expanded(
child: Text(
'${amount.toStringAsFixed(2)}${typeInfo['titre']}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: color,
),
textAlign: TextAlign
.right, // Texte aligné à droite
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
],
),
);
}).toList(),
],
),
),
),
],
),
),
),
],
),
return PaymentSummaryCard(
title: 'Répartition par mode de paiement',
titleColor: AppTheme.buttonSuccessColor,
titleIcon: Icons.euro,
height: 300,
useValueListenable: false, // Utiliser les données statiques
showAllPayments: true,
paymentsByType: _convertPaymentDataToMap(paymentData),
customTotalDisplay: (total) => '${totalAmounts.toStringAsFixed(2)}',
isDesktop: MediaQuery.of(context).size.width > 800,
backgroundIcon: Icons.euro,
backgroundIconColor: AppTheme.primaryColor,
backgroundIconOpacity: 0.07,
backgroundIconSize: 180,
);
}
// Méthode helper pour convertir les PaymentData en Map
Map<int, double> _convertPaymentDataToMap(List<PaymentData> paymentDataList) {
final Map<int, double> result = {};
for (final payment in paymentDataList) {
result[payment.typeId] = payment.amount;
}
return result;
}
Widget _buildActionButton(
BuildContext context,
String label,

View File

@@ -2,11 +2,7 @@ import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/shared/app_theme.dart';
import 'package:geosector_app/presentation/widgets/loading_progress_overlay.dart';
import 'package:geosector_app/core/models/loading_state.dart';
import 'dart:math' as math;
// Import des pages admin
@@ -54,14 +50,114 @@ class _AdminDashboardPageState extends State<AdminDashboardPage>
// Liste des pages à afficher
late final List<Widget> _pages;
// Index de la page Amicale et membres
static const int entitePageIndex = 5;
// Référence à la boîte Hive pour les paramètres
late Box _settingsBox;
// Overlay pour afficher la progression du chargement
OverlayEntry? _progressOverlay;
// Liste des éléments de navigation de base (toujours visibles)
final List<_NavigationItem> _baseNavigationItems = [
const _NavigationItem(
label: 'Tableau de bord',
icon: Icons.dashboard_outlined,
selectedIcon: Icons.dashboard,
page: AdminDashboardHomePage(),
),
const _NavigationItem(
label: 'Statistiques',
icon: Icons.bar_chart_outlined,
selectedIcon: Icons.bar_chart,
page: AdminStatisticsPage(),
),
const _NavigationItem(
label: 'Historique',
icon: Icons.history_outlined,
selectedIcon: Icons.history,
page: AdminHistoryPage(),
),
const _NavigationItem(
label: 'Messages',
icon: Icons.chat_outlined,
selectedIcon: Icons.chat,
page: AdminCommunicationPage(),
),
const _NavigationItem(
label: 'Carte',
icon: Icons.map_outlined,
selectedIcon: Icons.map,
page: AdminMapPage(),
),
];
// Éléments de navigation supplémentaires pour le rôle 2
final List<_NavigationItem> _adminNavigationItems = [
const _NavigationItem(
label: 'Amicale & membres',
icon: Icons.business_outlined,
selectedIcon: Icons.business,
page: AdminEntitePage(),
requiredRole: 2,
),
const _NavigationItem(
label: 'Opérations',
icon: Icons.calendar_today_outlined,
selectedIcon: Icons.calendar_today,
page: Scaffold(body: Center(child: Text('Page Opérations'))),
requiredRole: 2,
),
];
// Construire la liste des destinations de navigation en fonction du rôle
List<NavigationDestination> _buildNavigationDestinations() {
final destinations = <NavigationDestination>[];
final currentUser = userRepository.getCurrentUser();
// Ajouter les éléments de base
for (final item in _baseNavigationItems) {
destinations.add(
NavigationDestination(
icon: Icon(item.icon),
selectedIcon: Icon(item.selectedIcon),
label: item.label,
),
);
}
// Ajouter les éléments admin si l'utilisateur a le rôle requis
if (currentUser?.role == 2) {
for (final item in _adminNavigationItems) {
if (item.requiredRole == null || item.requiredRole == 2) {
destinations.add(
NavigationDestination(
icon: Icon(item.icon),
selectedIcon: Icon(item.selectedIcon),
label: item.label,
),
);
}
}
}
return destinations;
}
// Construire la liste des pages en fonction du rôle
List<Widget> _buildPages() {
final pages = <Widget>[];
final currentUser = userRepository.getCurrentUser();
// Ajouter les pages de base
pages.addAll(_baseNavigationItems.map((item) => item.page));
// Ajouter les pages admin si l'utilisateur a le rôle requis
if (currentUser?.role == 2) {
for (final item in _adminNavigationItems) {
if (item.requiredRole == null || item.requiredRole == 2) {
pages.add(item.page);
}
}
}
return pages;
}
@override
void initState() {
@@ -72,35 +168,19 @@ class _AdminDashboardPageState extends State<AdminDashboardPage>
debugPrint('Initialisation de AdminDashboardPage');
// Vérifier que userRepository est correctement initialisé
if (userRepository == null) {
debugPrint('ERREUR: userRepository est null dans AdminDashboardPage');
debugPrint('userRepository est correctement initialisé');
final currentUser = userRepository.getCurrentUser();
if (currentUser == null) {
debugPrint(
'ERREUR: Aucun utilisateur connecté dans AdminDashboardPage');
} else {
debugPrint('userRepository est correctement initialisé');
// Vérifier l'utilisateur courant
final currentUser = userRepository.getCurrentUser();
if (currentUser == null) {
debugPrint(
'ERREUR: Aucun utilisateur connecté dans AdminDashboardPage',
);
} else {
debugPrint(
'Utilisateur connecté: ${currentUser.username} (${currentUser.id})',
);
}
// Écouter les changements d'état du UserRepository
userRepository.addListener(_handleUserRepositoryChanges);
debugPrint(
'Utilisateur connecté: ${currentUser.username} (${currentUser.id})');
}
userRepository.addListener(_handleUserRepositoryChanges);
_pages = [
const AdminDashboardHomePage(),
const AdminStatisticsPage(),
const AdminHistoryPage(),
const AdminCommunicationPage(),
const AdminMapPage(),
const AdminEntitePage(),
];
// Initialiser les pages et les destinations
_pages = _buildPages();
// Initialiser et charger les paramètres
_initSettings();
@@ -117,10 +197,7 @@ class _AdminDashboardPageState extends State<AdminDashboardPage>
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
if (userRepository != null) {
userRepository.removeListener(_handleUserRepositoryChanges);
}
_removeProgressOverlay();
userRepository.removeListener(_handleUserRepositoryChanges);
super.dispose();
}
@@ -134,19 +211,6 @@ class _AdminDashboardPageState extends State<AdminDashboardPage>
// La barre de progression est désactivée, ne rien faire
}
// Méthodes pour gérer l'overlay de progression (désactivées)
void _showProgressOverlay(LoadingState state) {
// La barre de progression est désactivée, ne rien faire
}
void _updateProgressOverlay(LoadingState state) {
// La barre de progression est désactivée, ne rien faire
}
void _removeProgressOverlay() {
// La barre de progression est désactivée, ne rien faire
}
// Initialiser la boîte de paramètres et charger les préférences
Future<void> _initSettings() async {
try {
@@ -233,47 +297,21 @@ class _AdminDashboardPageState extends State<AdminDashboardPage>
],
);
}
/// Construit la liste des destinations de navigation
List<NavigationDestination> _buildNavigationDestinations() {
// Destinations de base toujours présentes
final List<NavigationDestination> destinations = [
const NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Tableau de bord',
),
const NavigationDestination(
icon: Icon(Icons.bar_chart_outlined),
selectedIcon: Icon(Icons.bar_chart),
label: 'Statistiques',
),
const NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historique',
),
const NavigationDestination(
icon: Icon(Icons.chat_outlined),
selectedIcon: Icon(Icons.chat),
label: 'Messages',
),
const NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
];
// Ajouter la destination "Amicale et membres"
destinations.add(
const NavigationDestination(
icon: Icon(Icons.business_outlined),
selectedIcon: Icon(Icons.business),
label: 'Amicale',
),
);
return destinations;
}
}
// Classe pour représenter une destination de navigation avec sa page associée
class _NavigationItem {
final String label;
final IconData icon;
final IconData selectedIcon;
final Widget page;
final int? requiredRole; // null si accessible à tous les rôles
const _NavigationItem({
required this.label,
required this.icon,
required this.selectedIcon,
required this.page,
this.requiredRole,
});
}

View File

@@ -7,7 +7,7 @@ import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/location_service.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import '../../shared/app_theme.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
class AdminMapPage extends StatefulWidget {
const AdminMapPage({Key? key}) : super(key: key);

View File

@@ -1,14 +1,6 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:geosector_app/presentation/widgets/charts/activity_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
import 'package:geosector_app/presentation/widgets/charts/combined_chart.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import '../../shared/app_theme.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
import 'dart:math' as math;
/// Class pour dessiner les petits points blancs sur le fond
@@ -186,7 +178,6 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
const SizedBox(height: AppTheme.spacingM),
ActivityChart(
height: 350,
loadFromHive: true,
showAllPassages: true,
title: '',
daysToShow: _daysToShow,
@@ -210,13 +201,21 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
Expanded(
child: _buildChartCard(
'Répartition par type de passage',
PassagePieChart(
size: 300,
loadFromHive: true,
PassageSummaryCard(
title: '',
titleColor: AppTheme.primaryColor,
titleIcon: Icons.pie_chart,
height: 300,
useValueListenable: true,
showAllPassages: true,
excludePassageTypes: const [
2
], // Exclure "À finaliser"
userId: _selectedUser != 'Tous'
? _getUserIdFromName(_selectedUser)
: null,
isDesktop:
MediaQuery.of(context).size.width > 800,
),
),
),
@@ -224,7 +223,7 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
Expanded(
child: _buildChartCard(
'Répartition par mode de paiement',
PaymentPieChart(
const PaymentPieChart(
payments: [
PaymentData(
typeId: 1,
@@ -258,19 +257,26 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
children: [
_buildChartCard(
'Répartition par type de passage',
PassagePieChart(
size: 300,
loadFromHive: true,
PassageSummaryCard(
title: '',
titleColor: AppTheme.primaryColor,
titleIcon: Icons.pie_chart,
height: 300,
useValueListenable: true,
showAllPassages: true,
excludePassageTypes: const [
2
], // Exclure "À finaliser"
userId: _selectedUser != 'Tous'
? _getUserIdFromName(_selectedUser)
: null,
isDesktop: MediaQuery.of(context).size.width > 800,
),
),
const SizedBox(height: AppTheme.spacingM),
_buildChartCard(
'Répartition par mode de paiement',
PaymentPieChart(
const PaymentPieChart(
payments: [
PaymentData(
typeId: 1,
@@ -357,7 +363,7 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
icon: const Icon(Icons.print),
label: const Text('Imprimer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.buttonSecondaryColor,
backgroundColor: AppTheme.secondaryColor,
foregroundColor: Colors.white,
),
),

View File

@@ -1,16 +1,17 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'dart:convert';
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'dart:js' as js;
import 'package:go_router/go_router.dart';
import 'package:go_router/src/state.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:http/http.dart' as http;
import 'package:geosector_app/core/services/app_info_service.dart';
import 'package:geosector_app/presentation/widgets/custom_button.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
import 'package:geosector_app/core/services/location_service.dart';
import 'package:geosector_app/core/services/connectivity_service.dart';
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
class LoginPage extends StatefulWidget {
@@ -51,6 +52,7 @@ class _LoginPageState extends State<LoginPage> {
final _passwordController = TextEditingController();
final _usernameFocusNode = FocusNode();
bool _obscurePassword = true;
String _appVersion = '';
// Type de connexion (utilisateur ou administrateur)
late String _loginType;
@@ -63,6 +65,37 @@ class _LoginPageState extends State<LoginPage> {
// État de la connexion Internet
bool _isConnected = false;
Future<void> _getAppVersion() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_appVersion = packageInfo.version;
});
}
} catch (e) {
debugPrint('Erreur lors de la récupération de la version: $e');
// Fallback sur la version du AppInfoService si elle existe
if (mounted) {
setState(() {
_appVersion = AppInfoService.fullVersion
.split(' ')
.last; // Extraire juste le numéro
});
}
}
}
Future<void> _checkConnectivity() async {
await connectivityService.checkConnectivity();
if (mounted) {
setState(() {
_isConnected = connectivityService.isConnected;
});
}
}
@override
void initState() {
super.initState();
@@ -163,6 +196,12 @@ class _LoginPageState extends State<LoginPage> {
}
});
// Récupérer la version de l'application
_getAppVersion();
// Vérification de connectivité au démarrage
_checkConnectivity();
// Pré-remplir le champ username avec l'identifiant du dernier utilisateur connecté
// seulement si le rôle correspond au type de login
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -538,6 +577,14 @@ class _LoginPageState extends State<LoginPage> {
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
AppInfoService.fullVersion,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary.withOpacity(0.7),
),
),
const SizedBox(height: 8),
// Ajouter un texte de débogage uniquement en mode développement
if (kDebugMode)
Text(
@@ -558,7 +605,38 @@ class _LoginPageState extends State<LoginPage> {
const SizedBox(height: 16),
// Indicateur de connectivité
ConnectivityIndicator(),
const ConnectivityIndicator(),
const SizedBox(height: 16),
if (!kIsWeb && !_isConnected)
Container(
margin: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color:
theme.colorScheme.error.withOpacity(0.3),
),
),
child: Column(
children: [
Icon(Icons.signal_wifi_off,
color: theme.colorScheme.error, size: 32),
const SizedBox(height: 8),
Text('Connexion Internet requise',
style: theme.textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.error)),
const SizedBox(height: 8),
const Text(
'Veuillez vous connecter à Internet (WiFi ou données mobiles) pour pouvoir vous connecter.'),
],
),
),
const SizedBox(height: 16),
// Formulaire de connexion
@@ -689,7 +767,7 @@ class _LoginPageState extends State<LoginPage> {
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
// Naviguer vers la page de récupération de mot de passe
_showForgotPasswordDialog(context);
},
child: Text(
'Mot de passe oublié ?',
@@ -863,10 +941,10 @@ class _LoginPageState extends State<LoginPage> {
onPressed: () {
context.go('/register');
},
child: Text(
child: const Text(
'Inscription Administrateur',
style: TextStyle(
color: theme.colorScheme.tertiary,
color: Colors.blue,
fontWeight: FontWeight.bold,
),
),
@@ -897,8 +975,255 @@ class _LoginPageState extends State<LoginPage> {
),
),
),
// Badge de version en bas à droite
if (_appVersion.isNotEmpty)
Positioned(
bottom: 16,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.primary.withOpacity(0.3),
width: 1,
),
),
child: Text(
'v$_appVersion',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary.withOpacity(0.8),
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
}
// Affiche la boîte de dialogue pour la récupération de mot de passe
void _showForgotPasswordDialog(BuildContext context) {
final emailController = TextEditingController();
final formKey = GlobalKey<FormState>();
bool isLoading = false;
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return StatefulBuilder(builder: (context, setState) {
return AlertDialog(
title: const Row(
children: [
Icon(Icons.lock_reset, color: Colors.blue),
SizedBox(width: 10),
Text('Récupération de mot de passe'),
],
),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Veuillez entrer votre adresse email pour recevoir un nouveau mot de passe.',
style: TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
CustomTextField(
controller: emailController,
label: 'Email',
hintText: 'Entrez votre email',
prefixIcon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre email';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
.hasMatch(value)) {
return 'Veuillez entrer un email valide';
}
return null;
},
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: isLoading
? null
: () async {
if (formKey.currentState!.validate()) {
setState(() {
isLoading = true;
});
try {
// Vérifier la connexion Internet
await connectivityService.checkConnectivity();
if (!connectivityService.isConnected) {
throw Exception('Aucune connexion Internet');
}
// Construire l'URL de l'API
final baseUrl = Uri.base.origin;
final apiUrl = '$baseUrl/api/lostpassword';
print('Envoi de la requête à: $apiUrl');
print('Email: ${emailController.text.trim()}');
http.Response? response;
try {
// Envoyer la requête à l'API
response = await http.post(
Uri.parse(apiUrl),
headers: {'Content-Type': 'application/json'},
body: json.encode({
'email': emailController.text.trim(),
}),
);
print('Réponse reçue: ${response.statusCode}');
print('Corps de la réponse: ${response.body}');
// Si la réponse est 404, c'est peut-être un problème de route
if (response.statusCode == 404) {
// Essayer avec une URL alternative
final alternativeUrl =
'$baseUrl/api/index.php/lostpassword';
print(
'Tentative avec URL alternative: $alternativeUrl');
final alternativeResponse = await http.post(
Uri.parse(alternativeUrl),
headers: {'Content-Type': 'application/json'},
body: json.encode({
'email': emailController.text.trim(),
}),
);
print(
'Réponse alternative reçue: ${alternativeResponse.statusCode}');
print(
'Corps de la réponse alternative: ${alternativeResponse.body}');
// Si la réponse alternative est un succès, utiliser cette réponse
if (alternativeResponse.statusCode == 200) {
response = alternativeResponse;
}
}
} catch (e) {
print(
'Erreur lors de l\'envoi de la requête: $e');
throw Exception('Erreur de connexion: $e');
}
// Traiter la réponse
if (response != null &&
response.statusCode == 200) {
// Modifier le contenu de la boîte de dialogue pour afficher le message de succès
setState(() {
isLoading = false;
});
// Remplacer le contenu de la boîte de dialogue par un message de succès
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
// Fermer automatiquement la boîte de dialogue après 2 secondes
Future.delayed(Duration(seconds: 2), () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
});
return const AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle,
color: Colors.green,
size: 48,
),
SizedBox(height: 16),
Text(
'Vous recevrez un nouveau mot de passe par email',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16),
),
],
),
);
},
);
} else {
// Fermer la boîte de dialogue actuelle
Navigator.of(context).pop();
// Afficher un message d'erreur
final responseData = json.decode(response.body);
throw Exception(responseData['message'] ??
'Erreur lors de la récupération du mot de passe');
}
} catch (e) {
// Afficher un message d'erreur
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e
.toString()
.contains('Exception:')
? e.toString().split('Exception: ')[1]
: 'Erreur lors de la récupération du mot de passe'),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) {
setState(() {
isLoading = false;
});
}
}
}
},
child: isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text('Recevoir un nouveau mot de passe'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
),
],
);
});
},
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:geosector_app/core/services/app_info_service.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
@@ -15,10 +13,11 @@ import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/core/data/models/user_sector_model.dart';
import 'package:geosector_app/chat/models/conversation_model.dart';
import 'package:geosector_app/chat/models/message_model.dart';
import 'package:geosector_app/core/services/hive_reset_state_service.dart';
import 'package:geosector_app/presentation/widgets/clear_cache_dialog.dart';
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
class SplashPage extends StatefulWidget {
const SplashPage({super.key});
@@ -58,12 +57,28 @@ class _SplashPageState extends State<SplashPage>
String _statusMessage = "Initialisation...";
double _progress = 0.0;
bool _showButtons = false;
String _appVersion = '';
final List<String> _initializationSteps = [
"Initialisation des services...",
"Vérification de l'authentification...",
"Chargement des données..."
];
Future<void> _getAppVersion() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_appVersion = packageInfo.version;
});
}
} catch (e) {
debugPrint('Erreur lors de la récupération de la version: $e');
// Fallback sur la version du AppInfoService si elle existe
if (mounted) {
setState(() {
_appVersion = AppInfoService.fullVersion
.split(' ')
.last; // Extraire juste le numéro
});
}
}
}
@override
void initState() {
@@ -89,33 +104,12 @@ class _SplashPageState extends State<SplashPage>
// Démarrer l'animation immédiatement
_animationController.forward();
// Vérifier si Hive a été réinitialisé
_checkHiveReset();
_getAppVersion();
// Simuler le processus d'initialisation
_startInitialization();
}
// Méthode pour vérifier si Hive a été réinitialisé et afficher le dialogue si nécessaire
void _checkHiveReset() {
// Vérifier si Hive a été réinitialisé et si le dialogue n'a pas encore été affiché
if (hiveResetStateService.wasReset && !hiveResetStateService.dialogShown) {
// Attendre que le widget soit complètement construit
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
// Afficher le dialogue de nettoyage du cache
ClearCacheDialog.show(
context,
onClose: () {
// Marquer le dialogue comme ayant été affiché
hiveResetStateService.markDialogAsShown();
},
);
}
});
}
}
@override
void dispose() {
_animationController.dispose();
@@ -123,11 +117,11 @@ class _SplashPageState extends State<SplashPage>
}
void _startInitialization() async {
// Étape 1: Initialisation des services
// Étape 1: Initialisation des boîtes Hive (0% à 75%)
if (mounted) {
setState(() {
_statusMessage = _initializationSteps[0];
_progress = 0.2;
_statusMessage = "Initialisation des données...";
_progress = 0.0;
});
}
@@ -135,20 +129,11 @@ class _SplashPageState extends State<SplashPage>
await _initializeAllHiveBoxes();
await Future.delayed(const Duration(milliseconds: 500));
// Étape 2: Vérification de l'authentification
// Étape 2: Initialisation des services (75% à 100%)
if (mounted) {
setState(() {
_statusMessage = _initializationSteps[1];
_progress = 0.4;
});
}
await Future.delayed(const Duration(milliseconds: 500));
// Étape 3: Chargement des données
if (mounted) {
setState(() {
_statusMessage = _initializationSteps[2];
_progress = 1.0; // Directement à 100% après la 3ème étape
_statusMessage = "Préparation de l'application...";
_progress = 0.75;
});
}
await Future.delayed(const Duration(milliseconds: 500));
@@ -157,15 +142,8 @@ class _SplashPageState extends State<SplashPage>
setState(() {
_isInitializing = false;
_showButtons = true;
_progress = 1.0; // S'assurer que la barre est à 100%
});
// Attendre quelques secondes avant de rediriger automatiquement
// si l'utilisateur est déjà connecté
if (userRepository.isLoggedIn) {
Timer(const Duration(seconds: 2), () {
_redirectToAppropriateScreen();
});
}
}
}
@@ -202,8 +180,8 @@ class _SplashPageState extends State<SplashPage>
},
];
// Calculer l'incrément de progression pour chaque boîte
final progressIncrement = 0.2 / boxesToOpen.length;
// Calculer l'incrément de progression pour chaque boîte (0.75 / nombre de boîtes)
final progressIncrement = 0.75 / boxesToOpen.length;
double currentProgress = 0.0;
// Ouvrir chaque boîte si elle n'est pas déjà ouverte
@@ -212,12 +190,11 @@ class _SplashPageState extends State<SplashPage>
final displayName = boxesToOpen[i]['display'] as String;
// Mettre à jour la barre de progression et le message
currentProgress += progressIncrement;
currentProgress = progressIncrement * (i + 1);
if (mounted) {
setState(() {
_statusMessage = displayName;
_progress =
0.2 * (currentProgress / 0.2); // Normaliser entre 0 et 0.2
_progress = currentProgress;
});
}
@@ -266,10 +243,27 @@ class _SplashPageState extends State<SplashPage>
if (mounted) {
setState(() {
_statusMessage = 'Toutes les boîtes sont prêtes';
_progress = 0.2;
_progress = 0.8;
});
await Future.delayed(const Duration(milliseconds: 500));
}
if (mounted) {
setState(() {
_statusMessage = "Préparation de l'application...";
_progress = 0.9;
});
await Future.delayed(const Duration(milliseconds: 500));
}
// Finalisation
if (mounted) {
setState(() {
_isInitializing = false;
_showButtons = true;
_progress = 1.0;
});
}
debugPrint('Toutes les boîtes Hive sont maintenant ouvertes');
} catch (e) {
debugPrint('Erreur lors de l\'initialisation des boîtes Hive: $e');
@@ -283,33 +277,9 @@ class _SplashPageState extends State<SplashPage>
}
}
void _redirectToAppropriateScreen() {
if (!mounted) return;
// Utiliser l'instance globale de userRepository définie dans app.dart
if (userRepository.isLoggedIn) {
debugPrint('SplashPage: Redirection d\'utilisateur connecté');
// Récupérer directement le rôle utilisateur
final roleValue = userRepository.getUserRole();
debugPrint('SplashPage: Rôle utilisateur = $roleValue');
// Redirection simple basée sur le rôle
if (roleValue > 1) {
debugPrint('SplashPage: Redirection vers /admin (rôle $roleValue > 1)');
context.go('/admin');
} else {
debugPrint('SplashPage: Redirection vers /user (rôle $roleValue = 1)');
context.go('/user');
}
}
// Ne redirige plus vers la landing page
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
return Scaffold(
body: Stack(
@@ -424,7 +394,8 @@ class _SplashPageState extends State<SplashPage>
duration: const Duration(milliseconds: 500),
child: ElevatedButton(
onPressed: () {
context.go('/login', extra: {'type': 'user'});
context.go(
'/login/user'); // Utiliser la route spécifique
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
@@ -449,13 +420,14 @@ class _SplashPageState extends State<SplashPage>
),
const SizedBox(height: 16),
// Bouton Connexion Administrateur
// Bouton Connexion Administrateur
AnimatedOpacity(
opacity: _showButtons ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: ElevatedButton(
onPressed: () {
context.go('/login', extra: {'type': 'admin'});
context.go(
'/login/admin'); // Utiliser la route spécifique
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
@@ -479,18 +451,74 @@ class _SplashPageState extends State<SplashPage>
),
),
const SizedBox(height: 16),
const SizedBox(
height: 32), // 2 espaces sous le bouton précédent
// Lien d'inscription
// Bouton d'inscription
AnimatedOpacity(
opacity: _showButtons ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: TextButton(
child: ElevatedButton(
onPressed: () {
context.go('/register');
},
child: Text(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 40,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
elevation: 2,
),
child: const Text(
'Pas encore inscrit ?',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 16),
// Lien vers le site web
AnimatedOpacity(
opacity: _showButtons ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: TextButton.icon(
onPressed: () {
// Déterminer l'URL du site web en fonction de l'environnement
String webUrl = 'https://geosector.fr';
if (kIsWeb) {
final host = Uri.base.host;
if (host.startsWith('dapp.')) {
webUrl = 'https://dev.geosector.fr';
} else if (host.startsWith('rapp.')) {
webUrl = 'https://rec.geosector.fr';
} else if (host.startsWith('app.')) {
webUrl = 'https://geosector.fr';
}
}
// Ouvrir l'URL dans une nouvelle fenêtre/onglet
launchUrl(
Uri.parse(webUrl),
mode: LaunchMode.externalApplication,
);
},
icon: Icon(
Icons.language,
size: 18,
color: theme.colorScheme.primary,
),
label: Text(
'Site web Geosector',
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
@@ -506,6 +534,38 @@ class _SplashPageState extends State<SplashPage>
),
),
),
// Badge de version en bas à droite
if (_appVersion.isNotEmpty)
Positioned(
bottom: 16,
right: 16,
child: AnimatedOpacity(
opacity: _showButtons ? 0.7 : 0.5,
duration: const Duration(milliseconds: 500),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.primary,
width: 1,
),
),
child: Text(
'v$_appVersion',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
),
),
],
),
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
import 'package:geosector_app/presentation/widgets/passages/passage_form.dart';
// Import des pages utilisateur
import 'user_dashboard_home_page.dart';
@@ -94,73 +94,85 @@ class _UserDashboardPageState extends State<UserDashboardPage> {
final bool shouldShowNoOperationMessage = isStandardUser && !hasOperation;
final bool shouldShowNoSectorMessage = isStandardUser && !hasSectors;
// Définir les actions supplémentaires pour l'AppBar
List<Widget>? additionalActions;
if (shouldShowNoOperationMessage || shouldShowNoSectorMessage) {
additionalActions = [
// Bouton de déconnexion uniquement si l'utilisateur n'a pas d'opération
TextButton.icon(
icon: const Icon(Icons.logout, color: Colors.white),
label: const Text('Se déconnecter',
style: TextStyle(color: Colors.white)),
onPressed: () async {
// Utiliser directement userRepository pour la déconnexion
await userRepository.logoutWithUI(context);
// La redirection est gérée dans logoutWithUI
},
style: TextButton.styleFrom(
backgroundColor: AppTheme.accentColor,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
// Si l'utilisateur n'a pas d'opération ou de secteur, utiliser DashboardLayout avec un body spécial
if (shouldShowNoOperationMessage) {
return DashboardLayout(
title: 'GEOSECTOR',
selectedIndex: 0, // Index par défaut
onDestinationSelected: (index) {
// Ne rien faire car l'utilisateur ne peut pas naviguer
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.warning_outlined),
selectedIcon: Icon(Icons.warning),
label: 'Accès restreint',
),
),
const SizedBox(width: 16), // Espacement à droite
];
],
showNewPassageButton: false,
body: _buildNoOperationMessage(context),
);
}
return shouldShowNoOperationMessage
? _buildNoOperationMessage(context)
: (shouldShowNoSectorMessage
? _buildNoSectorMessage(context)
: DashboardLayout(
title: 'GEOSECTOR',
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() {
_selectedIndex = index;
_saveSettings(); // Sauvegarder l'index de page sélectionné
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Tableau de bord',
),
NavigationDestination(
icon: Icon(Icons.bar_chart_outlined),
selectedIcon: Icon(Icons.bar_chart),
label: 'Stats',
),
NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historique',
),
NavigationDestination(
icon: Icon(Icons.chat_outlined),
selectedIcon: Icon(Icons.chat),
label: 'Messages',
),
NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
],
additionalActions: additionalActions,
onNewPassagePressed: () => _showPassageForm(context),
body: _pages[_selectedIndex],
));
if (shouldShowNoSectorMessage) {
return DashboardLayout(
title: 'GEOSECTOR',
selectedIndex: 0, // Index par défaut
onDestinationSelected: (index) {
// Ne rien faire car l'utilisateur ne peut pas naviguer
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.warning_outlined),
selectedIcon: Icon(Icons.warning),
label: 'Accès restreint',
),
],
showNewPassageButton: false,
body: _buildNoSectorMessage(context),
);
}
// Utilisateur normal avec accès complet
return DashboardLayout(
title: 'GEOSECTOR',
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() {
_selectedIndex = index;
_saveSettings(); // Sauvegarder l'index de page sélectionné
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Tableau de bord',
),
NavigationDestination(
icon: Icon(Icons.bar_chart_outlined),
selectedIcon: Icon(Icons.bar_chart),
label: 'Stats',
),
NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historique',
),
NavigationDestination(
icon: Icon(Icons.chat_outlined),
selectedIcon: Icon(Icons.chat),
label: 'Messages',
),
NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
],
onNewPassagePressed: () => _showPassageForm(context),
body: _pages[_selectedIndex],
);
}
// Message pour les utilisateurs sans opération assignée
@@ -269,110 +281,90 @@ class _UserDashboardPageState extends State<UserDashboardPage> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(
'Nouveau passage',
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
builder: (context) => Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
content: SingleChildScrollView(
child: Container(
constraints: const BoxConstraints(
maxWidth: 600,
maxHeight: 700,
),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: InputDecoration(
labelText: 'Adresse',
prefixIcon: const Icon(Icons.location_on),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
// En-tête de la modale
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Nouveau passage',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
decoration: InputDecoration(
labelText: 'Type de passage',
prefixIcon: const Icon(Icons.category),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
items: const [
DropdownMenuItem(
value: 1,
child: Text('Effectué'),
),
DropdownMenuItem(
value: 2,
child: Text('À finaliser'),
),
DropdownMenuItem(
value: 3,
child: Text('Refusé'),
),
DropdownMenuItem(
value: 4,
child: Text('Don'),
),
DropdownMenuItem(
value: 5,
child: Text('Lot'),
),
DropdownMenuItem(
value: 6,
child: Text('Maison vide'),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
onChanged: (value) {},
),
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: 'Commentaire',
prefixIcon: const Icon(Icons.comment),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
const Divider(),
const SizedBox(height: 16),
// Formulaire de passage
Expanded(
child: SingleChildScrollView(
child: PassageForm(
onSubmit: (formData) {
// Traiter les données du formulaire
_handlePassageSubmission(context, formData);
},
),
),
maxLines: 3,
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(
'Annuler',
style: TextStyle(
color: theme.colorScheme.error,
),
),
),
ElevatedButton(
onPressed: () {
// Enregistrer le passage
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Passage enregistré avec succès'),
backgroundColor: theme.colorScheme.primary,
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
),
child: const Text('Enregistrer'),
),
],
),
);
}
// Traiter la soumission du formulaire de passage
void _handlePassageSubmission(
BuildContext context, Map<String, dynamic> formData) {
// Fermer la modale
Navigator.of(context).pop();
// Ici vous pouvez traiter les données du formulaire
// Par exemple, les envoyer au repository ou à un service
// Pour l'instant, afficher un message de succès
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Passage enregistré avec succès pour ${formData['adresse']}'),
backgroundColor: Theme.of(context).colorScheme.primary,
behavior: SnackBarBehavior.floating,
),
);
// TODO: Intégrer avec votre logique métier
// Exemple :
// try {
// await passageRepository.createPassage(formData);
// // Rafraîchir les données si nécessaire
// } catch (e) {
// ScaffoldMessenger.of(context).showSnackBar(
// SnackBar(
// content: Text('Erreur lors de l\'enregistrement: $e'),
// backgroundColor: Theme.of(context).colorScheme.error,
// ),
// );
// }
}
}

View File

@@ -1,10 +1,7 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:fl_chart/fl_chart.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
class UserStatisticsPage extends StatefulWidget {
const UserStatisticsPage({super.key});
@@ -58,8 +55,9 @@ class _UserStatisticsPageState extends State<UserStatisticsPage> {
const SizedBox(height: 24),
// Résumé par type de règlement
_buildPaymentTypeSummary(theme, isDesktop),
// Résumé par type de règlement
_buildPaymentTypeSummary(theme, isDesktop),
],
),
),
@@ -363,219 +361,34 @@ class _UserStatisticsPageState extends State<UserStatisticsPage> {
// Construction du résumé par type de passage
Widget _buildPassageTypeSummary(ThemeData theme, bool isDesktop) {
// Dans une implémentation réelle, ces données seraient filtrées par secteur
// en fonction de _selectedSectorId
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Répartition par type de passage',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Row(
children: [
// Graphique circulaire
Expanded(
flex: isDesktop ? 1 : 2,
child: SizedBox(
height: 200,
child: PassagePieChart(
passagesByType: {
1: 60, // Effectués
2: 15, // À finaliser
3: 10, // Refusés
4: 8, // Dons
5: 5, // Lots
6: 2, // Maisons vides
},
size: 140,
labelSize: 12,
showPercentage: true,
showIcons: false, // Désactiver les icônes
isDonut: true, // Activer le format donut
innerRadius: '50%' // Rayon interne du donut
),
),
),
// Légende
if (isDesktop)
Expanded(
flex: 1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLegendItem(
'Effectués', '60%', const Color(0xFF4CAF50)),
_buildLegendItem(
'À finaliser', '15%', const Color(0xFFFF9800)),
_buildLegendItem(
'Refusés', '10%', const Color(0xFFF44336)),
_buildLegendItem('Dons', '8%', const Color(0xFF03A9F4)),
_buildLegendItem('Lots', '5%', const Color(0xFF0D47A1)),
_buildLegendItem(
'Maisons vides', '2%', const Color(0xFF9E9E9E)),
],
),
),
],
),
if (!isDesktop)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
_buildLegendItem('Effectués', '60%', const Color(0xFF4CAF50)),
_buildLegendItem(
'À finaliser', '15%', const Color(0xFFFF9800)),
_buildLegendItem('Refusés', '10%', const Color(0xFFF44336)),
_buildLegendItem('Dons', '8%', const Color(0xFF03A9F4)),
_buildLegendItem('Lots', '5%', const Color(0xFF0D47A1)),
_buildLegendItem(
'Maisons vides', '2%', const Color(0xFF9E9E9E)),
],
),
],
),
),
return PassageSummaryCard(
title: 'Répartition par type de passage',
titleColor: theme.colorScheme.primary,
titleIcon: Icons.pie_chart,
height: 300,
useValueListenable: true,
userId: userRepository.getCurrentUser()?.id,
showAllPassages: false,
excludePassageTypes: const [2], // Exclure "À finaliser"
isDesktop: isDesktop,
);
}
// Construction du résumé par type de règlement
Widget _buildPaymentTypeSummary(ThemeData theme, bool isDesktop) {
// Dans une implémentation réelle, ces données seraient filtrées par secteur
// en fonction de _selectedSectorId
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Répartition par type de règlement',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Row(
children: [
// Graphique circulaire
Expanded(
flex: isDesktop ? 1 : 2,
child: SizedBox(
height: 200,
child: PieChart(
PieChartData(
sectionsSpace: 2,
centerSpaceRadius: 40,
sections: [
_buildPieChartSection(
'Espèces', 30, const Color(0xFF4CAF50), 0),
_buildPieChartSection(
'Chèques', 45, const Color(0xFF2196F3), 1),
_buildPieChartSection(
'CB', 25, const Color(0xFFF44336), 2),
],
),
),
),
),
// Légende
if (isDesktop)
Expanded(
flex: 1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLegendItem(
'Espèces', '30%', const Color(0xFF4CAF50)),
_buildLegendItem(
'Chèques', '45%', const Color(0xFF2196F3)),
_buildLegendItem('CB', '25%', const Color(0xFFF44336)),
],
),
),
],
),
if (!isDesktop)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
_buildLegendItem('Espèces', '30%', const Color(0xFF4CAF50)),
_buildLegendItem('Chèques', '45%', const Color(0xFF2196F3)),
_buildLegendItem('CB', '25%', const Color(0xFFF44336)),
],
),
],
),
),
);
}
// Construction d'une section de graphique circulaire
PieChartSectionData _buildPieChartSection(
String title, double value, Color color, int index) {
return PieChartSectionData(
color: color,
value: value,
title: '$value%',
radius: 60,
titleStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.white,
),
);
}
// Construction d'un élément de légende
Widget _buildLegendItem(String title, String value, Color color) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
const Spacer(),
Text(
value,
style: TextStyle(
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}
Widget _buildPaymentTypeSummary(ThemeData theme, bool isDesktop) {
return PaymentSummaryCard(
title: 'Répartition par type de règlement',
titleColor: AppTheme.accentColor,
titleIcon: Icons.pie_chart,
height: 300,
useValueListenable: true,
userId: userRepository.getCurrentUser()?.id,
showAllPayments: false,
isDesktop: isDesktop,
backgroundIcon: Icons.euro_symbol,
backgroundIconColor: Colors.blue,
backgroundIconOpacity: 0.05,
);
}
}

View File

@@ -1,22 +1,20 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/repositories/region_repository.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/presentation/widgets/mapbox_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import 'custom_text_field.dart';
class EntiteForm extends StatefulWidget {
class AmicaleForm extends StatefulWidget {
final AmicaleModel? amicale;
final Function(AmicaleModel)? onSubmit;
final bool readOnly;
const EntiteForm({
const AmicaleForm({
Key? key,
this.amicale,
this.onSubmit,
@@ -24,10 +22,10 @@ class EntiteForm extends StatefulWidget {
}) : super(key: key);
@override
State<EntiteForm> createState() => _EntiteFormState();
State<AmicaleForm> createState() => _AmicaleFormState();
}
class _EntiteFormState extends State<EntiteForm> {
class _AmicaleFormState extends State<AmicaleForm> {
final _formKey = GlobalKey<FormState>();
// Controllers
@@ -52,9 +50,6 @@ class _EntiteFormState extends State<EntiteForm> {
bool _chkActive = true;
bool _chkStripe = false;
// Liste des régions (sera chargée depuis le store)
List<Map<String, dynamic>> _regions = [];
@override
void initState() {
super.initState();
@@ -81,36 +76,6 @@ class _EntiteFormState extends State<EntiteForm> {
_chkAcceptSms = amicale?.chkAcceptSms ?? false;
_chkActive = amicale?.chkActive ?? true;
_chkStripe = amicale?.chkStripe ?? false;
// Charger les régions depuis le repository
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadRegions();
});
}
void _loadRegions() {
try {
final regionRepository =
Provider.of<RegionRepository>(context, listen: false);
if (!regionRepository.isLoaded) {
// Initialiser le repository si ce n'est pas déjà fait
regionRepository.init().then((_) {
setState(() {
_regions = regionRepository.getRegionsForDropdown();
});
});
} else {
setState(() {
_regions = regionRepository.getRegionsForDropdown();
});
}
} catch (e) {
debugPrint('Erreur lors du chargement des régions: $e');
// Utiliser une liste vide en cas d'erreur
setState(() {
_regions = [];
});
}
}
@override
@@ -130,7 +95,7 @@ class _EntiteFormState extends State<EntiteForm> {
}
// Appeler l'API pour mettre à jour l'entité
Future<void> _updateEntite(AmicaleModel amicale) async {
Future<void> _updateAmicale(AmicaleModel amicale) async {
try {
// Afficher un indicateur de chargement
showDialog(
@@ -175,7 +140,7 @@ class _EntiteFormState extends State<EntiteForm> {
try {
// Obtenir l'instance du service API
final apiService = Provider.of<ApiService>(context, listen: false);
// Appeler la méthode post du service API
await apiService.post('/entite/update', data: data);
@@ -185,7 +150,7 @@ class _EntiteFormState extends State<EntiteForm> {
// Afficher un message de succès
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Entité mise à jour avec succès'),
content: Text('Amicale mise à jour avec succès'),
backgroundColor: Colors.green,
),
);
@@ -204,7 +169,8 @@ class _EntiteFormState extends State<EntiteForm> {
// Afficher un message d'erreur
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la mise à jour de l\'entité: $error'),
content:
Text('Erreur lors de la mise à jour de l\'amicale: $error'),
backgroundColor: Colors.red,
),
);
@@ -277,8 +243,8 @@ class _EntiteFormState extends State<EntiteForm> {
chkActive: _chkActive,
);
// Appeler l'API pour mettre à jour l'entité
_updateEntite(amicale);
// Appeler l'API pour mettre à jour l'amicale
_updateAmicale(amicale);
// Appeler la fonction onSubmit si elle existe (pour la compatibilité avec le code existant)
if (widget.onSubmit != null) {
@@ -391,8 +357,8 @@ class _EntiteFormState extends State<EntiteForm> {
width: 20,
height: 20,
child: const Icon(
Icons.location_on,
color: Color(0xFF20335E),
Icons.fireplace_rounded,
color: Color.fromARGB(255, 212, 34, 31),
size: 20,
),
),
@@ -424,6 +390,72 @@ class _EntiteFormState extends State<EntiteForm> {
);
}
// Construire le dropdown pour la région
Widget _buildRegionDropdown(bool restrictedFieldsReadOnly) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Afficher le libellé de la région en lecture seule
if (_libRegion != null && _libRegion!.isNotEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Theme.of(context).inputDecorationTheme.fillColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
_libRegion!,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
),
)
else
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Theme.of(context).inputDecorationTheme.fillColor,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Aucune région définie',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).hintColor,
),
),
),
],
);
}
// Construire une option checkbox
Widget _buildCheckboxOption({
required String label,
required bool value,
required void Function(bool?)? onChanged,
}) {
return Row(
children: [
Checkbox(
value: value,
onChanged: onChanged,
activeColor: Theme.of(context).colorScheme.primary,
),
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
),
],
);
}
// Construire le formulaire principal
Widget _buildMainForm(ThemeData theme, bool restrictedFieldsReadOnly) {
return Column(
@@ -887,4 +919,46 @@ class _EntiteFormState extends State<EntiteForm> {
widget.readOnly || !canEditRestrictedFields;
// Calculer la largeur maximale du formulaire pour les écrans larges
final screenWidth = MediaQuery.of(context
final screenWidth = MediaQuery.of(context).size.width;
final maxFormWidth = screenWidth > 800 ? 800.0 : screenWidth;
return Scaffold(
appBar: AppBar(
title: Text(
widget.readOnly ? 'Détails de l\'amicale' : 'Modifier l\'amicale'),
backgroundColor: theme.appBarTheme.backgroundColor,
foregroundColor: theme.appBarTheme.foregroundColor,
),
body: Center(
child: Container(
width: maxFormWidth,
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header avec logo et minimap
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// Section Logo
_buildLogoSection(),
// Section MiniMap
_buildMiniMap(),
],
),
const SizedBox(height: 24),
// Formulaire principal
_buildMainForm(theme, restrictedFieldsReadOnly),
],
),
),
),
),
),
);
}
}

View File

@@ -4,7 +4,7 @@ import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/region_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/amicale_row_widget.dart';
import 'package:geosector_app/presentation/widgets/entite_form.dart';
import 'package:geosector_app/presentation/widgets/amicale_form.dart';
import 'package:provider/provider.dart';
/// Widget de tableau pour afficher une liste d'amicales
@@ -165,7 +165,7 @@ class AmicaleTableWidget extends StatelessWidget {
),
const SizedBox(height: 16),
// Formulaire EntiteForm en mode lecture seule
EntiteForm(
AmicaleForm(
amicale: amicale,
readOnly: readOnly,
onSubmit: (updatedAmicale) {

View File

@@ -1,18 +1,17 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/foundation.dart' show listEquals;
import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/services/passage_data_service.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
/// Widget de graphique d'activité affichant les passages
class ActivityChart extends StatefulWidget {
/// Liste des données de passage par date et type (si fournie directement)
/// Format attendu: [{"date": String, "type_passage": int, "nb": int}, ...]
/// Si useValueListenable est true, ce paramètre est ignoré
final List<Map<String, dynamic>>? passageData;
/// Type de période (Jour, Semaine, Mois, Année)
@@ -30,9 +29,6 @@ class ActivityChart extends StatefulWidget {
/// Types de passages à exclure (par défaut [2] = "À finaliser")
final List<int> excludePassageTypes;
/// Indique si les données doivent être chargées depuis la Hive box
final bool loadFromHive;
/// Callback appelé lorsque la période change
final Function(int days)? onPeriodChanged;
@@ -51,8 +47,8 @@ class ActivityChart extends StatefulWidget {
/// Si vrai, n'applique aucun filtrage par utilisateur (affiche tous les passages)
final bool showAllPassages;
/// Si vrai, force le rechargement des données
final bool forceRefresh;
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
final bool useValueListenable;
const ActivityChart({
super.key,
@@ -62,16 +58,14 @@ class ActivityChart extends StatefulWidget {
this.daysToShow = 15,
this.userId,
this.excludePassageTypes = const [2],
this.loadFromHive = false,
this.onPeriodChanged,
this.title = 'Dernière activité enregistrée sur 15 jours',
this.showDataLabels = true,
this.columnWidth = 0.8,
this.columnSpacing = 0.2,
this.showAllPassages = false,
this.forceRefresh = false,
}) : assert(loadFromHive || passageData != null,
'Soit loadFromHive doit être true, soit passageData doit être fourni');
this.useValueListenable = true,
});
@override
State<ActivityChart> createState() => _ActivityChartState();
@@ -96,16 +90,6 @@ class _ActivityChartState extends State<ActivityChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
// Données pour les graphiques
List<Map<String, dynamic>> _passageData = [];
List<ActivityData> _chartData = [];
bool _isLoading = true;
bool _hasData = false;
bool _dataLoaded = false;
// Période sélectionnée en jours
int _selectedDays = 15;
// Contrôleur de zoom pour le graphique
late ZoomPanBehavior _zoomPanBehavior;
@@ -117,9 +101,6 @@ class _ActivityChartState extends State<ActivityChart>
duration: const Duration(milliseconds: 1500),
);
// Initialiser la période sélectionnée avec la valeur par défaut du widget
_selectedDays = widget.daysToShow;
// Initialiser le contrôleur de zoom
_zoomPanBehavior = ZoomPanBehavior(
enablePinching: true,
@@ -128,83 +109,29 @@ class _ActivityChartState extends State<ActivityChart>
zoomMode: ZoomMode.x,
);
_loadData();
_animationController.forward();
}
/// Trouve la date du passage le plus récent
DateTime _getMostRecentDate() {
final allDates = [
..._passageData.map((data) => DateTime.parse(data['date'] as String)),
];
if (allDates.isEmpty) {
return DateTime.now();
}
return allDates.reduce((a, b) => a.isAfter(b) ? a : b);
}
@override
void didUpdateWidget(ActivityChart oldWidget) {
super.didUpdateWidget(oldWidget);
void _loadData() {
// Marquer comme chargé immédiatement pour éviter les appels multiples pendant le chargement
// Mais permettre un rechargement ultérieur si nécessaire
if (_dataLoaded && _hasData) return;
// Vérifier si les propriétés importantes ont changé
final bool periodChanged = oldWidget.periodType != widget.periodType ||
oldWidget.daysToShow != widget.daysToShow;
final bool dataSourceChanged = widget.useValueListenable
? false
: oldWidget.passageData != widget.passageData;
final bool filteringChanged = oldWidget.userId != widget.userId ||
!listEquals(
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
oldWidget.showAllPassages != widget.showAllPassages ||
oldWidget.useValueListenable != widget.useValueListenable;
_dataLoaded = true;
setState(() {
_isLoading = true;
});
if (widget.loadFromHive) {
// Charger les données depuis Hive
WidgetsBinding.instance.addPostFrameCallback((_) {
// Éviter de recharger si le widget a été démonté entre-temps
if (!mounted) return;
try {
// Utiliser les instances globales définies dans app.dart
// Créer une instance du service de données
final passageDataService = PassageDataService(
passageRepository: passageRepository,
userRepository: userRepository,
);
// Utiliser le service pour charger les données
_passageData = passageDataService.loadPassageData(
daysToShow: _selectedDays,
excludePassageTypes: widget.excludePassageTypes,
userId: widget.userId,
showAllPassages: widget.showAllPassages,
);
_prepareChartData();
// Mettre à jour l'état une seule fois après avoir préparé les données
if (mounted) {
setState(() {
_isLoading = false;
_hasData = _chartData.isNotEmpty;
});
}
} catch (e) {
// En cas d'erreur, réinitialiser l'état pour permettre une future tentative
if (mounted) {
setState(() {
_isLoading = false;
_hasData = false;
});
}
}
});
} else {
// Utiliser les données fournies directement
_passageData = widget.passageData ?? [];
_prepareChartData();
setState(() {
_isLoading = false;
_hasData = _chartData.isNotEmpty;
});
// Si des paramètres importants ont changé, relancer l'animation
if (periodChanged || dataSourceChanged || filteringChanged) {
_animationController.reset();
_animationController.forward();
}
}
@@ -215,47 +142,141 @@ class _ActivityChartState extends State<ActivityChart>
}
@override
void didUpdateWidget(ActivityChart oldWidget) {
super.didUpdateWidget(oldWidget);
// Vérifier si les propriétés importantes ont changé
final bool periodChanged = oldWidget.periodType != widget.periodType ||
oldWidget.daysToShow != widget.daysToShow;
final bool dataSourceChanged = widget.loadFromHive
? false
: oldWidget.passageData != widget.passageData;
final bool filteringChanged = oldWidget.userId != widget.userId ||
!listEquals(
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
oldWidget.showAllPassages != widget.showAllPassages;
final bool refreshForced = widget.forceRefresh && !oldWidget.forceRefresh;
// Si des paramètres importants ont changé ou si forceRefresh est passé à true, recharger les données
if (periodChanged ||
dataSourceChanged ||
filteringChanged ||
refreshForced) {
_selectedDays = widget.daysToShow;
_dataLoaded = false; // Réinitialiser l'état pour forcer le rechargement
_loadData();
Widget build(BuildContext context) {
if (widget.useValueListenable) {
return _buildWithValueListenable();
} else {
return _buildWithStaticData();
}
}
// La méthode _loadPassageDataFromHive a été intégrée directement dans _loadData
// pour éviter les appels multiples et les problèmes de cycle de vie
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
Widget _buildWithValueListenable() {
return ValueListenableBuilder(
valueListenable:
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final chartData = _calculateActivityData(passagesBox);
return _buildChart(chartData);
},
);
}
/// Prépare les données pour le graphique
void _prepareChartData() {
/// Construction du widget avec des données statiques
Widget _buildWithStaticData() {
if (widget.passageData == null) {
return SizedBox(
height: widget.height,
child: const Center(
child: Text('Aucune donnée fournie'),
),
);
}
final chartData = _prepareChartDataFromPassageData(widget.passageData!);
return _buildChart(chartData);
}
/// Calcule les données d'activité depuis la Hive box
List<ActivityData> _calculateActivityData(Box<PassageModel> passagesBox) {
try {
// Vérifier que les données sont disponibles
if (_passageData.isEmpty) {
_chartData = [];
return;
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
// Déterminer l'utilisateur cible selon les filtres
final int? targetUserId =
widget.showAllPassages ? null : (widget.userId ?? currentUser?.id);
// Calculer la date de début (nombre de jours en arrière)
final endDate = DateTime.now();
final startDate = endDate.subtract(Duration(days: widget.daysToShow - 1));
// Préparer les données par date
final Map<String, Map<int, int>> dataByDate = {};
// Initialiser toutes les dates de la période
for (int i = 0; i < widget.daysToShow; i++) {
final date = startDate.add(Duration(days: i));
final dateStr = DateFormat('yyyy-MM-dd').format(date);
dataByDate[dateStr] = {};
// Initialiser tous les types de passage possibles
for (final typeId in AppKeys.typesPassages.keys) {
if (!widget.excludePassageTypes.contains(typeId)) {
dataByDate[dateStr]![typeId] = 0;
}
}
}
// Parcourir les passages et les compter par date et type
for (final passage in passages) {
// Appliquer les filtres
bool shouldInclude = true;
// Filtrer par utilisateur si nécessaire
if (targetUserId != null && passage.fkUser != targetUserId) {
shouldInclude = false;
}
// Exclure certains types
if (widget.excludePassageTypes.contains(passage.fkType)) {
shouldInclude = false;
}
// Vérifier si le passage est dans la période
final passageDate = passage.passedAt;
if (passageDate.isBefore(startDate) || passageDate.isAfter(endDate)) {
shouldInclude = false;
}
if (shouldInclude) {
final dateStr = DateFormat('yyyy-MM-dd').format(passageDate);
if (dataByDate.containsKey(dateStr)) {
dataByDate[dateStr]![passage.fkType] =
(dataByDate[dateStr]![passage.fkType] ?? 0) + 1;
}
}
}
// Convertir en liste d'ActivityData
final List<ActivityData> chartData = [];
dataByDate.forEach((dateStr, passagesByType) {
final dateParts = dateStr.split('-');
if (dateParts.length == 3) {
try {
final date = DateTime(
int.parse(dateParts[0]),
int.parse(dateParts[1]),
int.parse(dateParts[2]),
);
chartData.add(ActivityData(
date: date,
dateStr: dateStr,
passagesByType: passagesByType,
));
} catch (e) {
debugPrint('Erreur de conversion de date: $dateStr');
}
}
});
// Trier par date
chartData.sort((a, b) => a.date.compareTo(b.date));
return chartData;
} catch (e) {
debugPrint('Erreur lors du calcul des données d\'activité: $e');
return [];
}
}
/// Prépare les données pour le graphique à partir des données de passage brutes (ancien système)
List<ActivityData> _prepareChartDataFromPassageData(
List<Map<String, dynamic>> passageData) {
try {
// Obtenir toutes les dates uniques
final Set<String> uniqueDatesSet = {};
for (final data in _passageData) {
for (final data in passageData) {
if (data.containsKey('date') && data['date'] != null) {
uniqueDatesSet.add(data['date'] as String);
}
@@ -266,7 +287,7 @@ class _ActivityChartState extends State<ActivityChart>
uniqueDates.sort();
// Créer les données pour chaque date
_chartData = [];
final List<ActivityData> chartData = [];
for (final dateStr in uniqueDates) {
final passagesByType = <int, int>{};
@@ -278,7 +299,7 @@ class _ActivityChartState extends State<ActivityChart>
}
// Remplir les données de passage
for (final data in _passageData) {
for (final data in passageData) {
if (data.containsKey('date') &&
data['date'] == dateStr &&
data.containsKey('type_passage') &&
@@ -301,37 +322,29 @@ class _ActivityChartState extends State<ActivityChart>
final date = DateTime(year, month, day);
// Ajouter les données à la liste
_chartData.add(ActivityData(
chartData.add(ActivityData(
date: date,
dateStr: dateStr,
passagesByType: passagesByType,
));
}
} catch (e) {
// Silencieux lors des erreurs de conversion de date pour éviter les logs excessifs
debugPrint('Erreur de conversion de date: $dateStr');
}
}
// Trier les données par date
_chartData.sort((a, b) => a.date.compareTo(b.date));
chartData.sort((a, b) => a.date.compareTo(b.date));
return chartData;
} catch (e) {
// Erreur silencieuse pour éviter les logs excessifs
_chartData = [];
debugPrint('Erreur lors de la préparation des données: $e');
return [];
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return SizedBox(
height: widget.height,
child: const Center(
child: CircularProgressIndicator(),
),
);
}
if (!_hasData || _chartData.isEmpty) {
/// Construit le graphique avec les données fournies
Widget _buildChart(List<ActivityData> chartData) {
if (chartData.isEmpty) {
return SizedBox(
height: widget.height,
child: const Center(
@@ -340,17 +353,12 @@ class _ActivityChartState extends State<ActivityChart>
);
}
// Préparer les données si nécessaire
if (_chartData.isEmpty) {
_prepareChartData();
}
return SizedBox(
height: widget.height,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre (conservé)
// Titre
if (widget.title.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
@@ -362,13 +370,13 @@ class _ActivityChartState extends State<ActivityChart>
),
),
),
// Graphique (occupe maintenant plus d'espace)
// Graphique
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
child: SfCartesianChart(
plotAreaBorderWidth: 0,
legend: Legend(
legend: const Legend(
isVisible: true,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
@@ -378,30 +386,25 @@ class _ActivityChartState extends State<ActivityChart>
intervalType: DateTimeIntervalType.days,
majorGridLines: const MajorGridLines(width: 0),
labelStyle: const TextStyle(fontSize: 10),
// Définir explicitement la plage de dates à afficher
minimum: _chartData.isNotEmpty ? _chartData.first.date : null,
maximum: _chartData.isNotEmpty ? _chartData.last.date : null,
// Assurer que tous les jours sont affichés
minimum: chartData.isNotEmpty ? chartData.first.date : null,
maximum: chartData.isNotEmpty ? chartData.last.date : null,
interval: 1,
axisLabelFormatter: (AxisLabelRenderDetails details) {
return ChartAxisLabel(details.text, details.textStyle);
},
),
primaryYAxis: NumericAxis(
labelStyle: const TextStyle(fontSize: 10),
axisLine: const AxisLine(width: 0),
majorTickLines: const MajorTickLines(size: 0),
majorGridLines: const MajorGridLines(
primaryYAxis: const NumericAxis(
labelStyle: TextStyle(fontSize: 10),
axisLine: AxisLine(width: 0),
majorTickLines: MajorTickLines(size: 0),
majorGridLines: MajorGridLines(
width: 0.5,
color: Colors.grey,
dashArray: <double>[5, 5], // Motif de pointillés
dashArray: <double>[5, 5],
),
title: const AxisTitle(
title: AxisTitle(
text: 'Passages',
textStyle: TextStyle(fontSize: 10, color: Colors.grey),
),
),
series: _buildSeries(),
series: _buildSeries(chartData),
tooltipBehavior: TooltipBehavior(enable: true),
zoomPanBehavior: _zoomPanBehavior,
),
@@ -413,11 +416,12 @@ class _ActivityChartState extends State<ActivityChart>
}
/// Construit les séries de données pour le graphique
List<CartesianSeries<ActivityData, DateTime>> _buildSeries() {
List<CartesianSeries<ActivityData, DateTime>> _buildSeries(
List<ActivityData> chartData) {
final List<CartesianSeries<ActivityData, DateTime>> series = [];
// Vérifier que les données sont disponibles
if (_chartData.isEmpty) {
if (chartData.isEmpty) {
return series;
}
@@ -444,25 +448,19 @@ class _ActivityChartState extends State<ActivityChart>
// Calculer le total pour ce type pour déterminer s'il faut l'afficher
int totalForType = 0;
for (final data in _chartData) {
for (final data in chartData) {
totalForType += data.passagesByType[typeId] ?? 0;
}
// On peut décider de ne pas afficher les types sans données
final addZeroValueTypes = false;
// Ajouter la série pour ce type
if (totalForType > 0 || addZeroValueTypes) {
// Ajouter la série pour ce type si elle a des données
if (totalForType > 0) {
series.add(
StackedColumnSeries<ActivityData, DateTime>(
name: typeName,
dataSource: _chartData,
dataSource: chartData,
xValueMapper: (ActivityData data, _) => data.date,
yValueMapper: (ActivityData data, _) {
final value = data.passagesByType.containsKey(typeId)
? data.passagesByType[typeId]!
: 0;
return value;
return data.passagesByType[typeId] ?? 0;
},
color: typeColor,
width: widget.columnWidth,

View File

@@ -3,9 +3,10 @@ library geosector_charts;
export 'payment_data.dart';
export 'payment_pie_chart.dart';
export 'payment_utils.dart';
export 'payment_summary_card.dart';
export 'passage_data.dart';
export 'passage_utils.dart';
export 'passage_pie_chart.dart';
export 'passage_summary_card.dart';
export 'activity_chart.dart';
export 'combined_chart.dart';

View File

@@ -1,13 +1,10 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/foundation.dart' show listEquals, mapEquals;
import 'package:intl/intl.dart';
import 'package:flutter/foundation.dart' show listEquals;
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/services/passage_data_service.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
/// Modèle de données pour le graphique en camembert des passages
class PassageChartData {
@@ -38,7 +35,7 @@ class PassageChartData {
/// Widget de graphique en camembert pour représenter la répartition des passages par type
class PassagePieChart extends StatefulWidget {
/// Liste des données de passages par type sous forme de Map avec typeId et count
/// Si loadFromHive est true, ce paramètre est ignoré
/// Si useValueListenable est true, ce paramètre est ignoré
final Map<int, int> passagesByType;
/// Taille du graphique
@@ -62,18 +59,21 @@ class PassagePieChart extends StatefulWidget {
/// Rayon central pour le format donut (en pourcentage)
final String innerRadius;
/// Charger les données depuis Hive
/// Charger les données depuis Hive (obsolète, utiliser useValueListenable)
final bool loadFromHive;
/// ID de l'utilisateur pour filtrer les passages (utilisé seulement si loadFromHive est true)
/// ID de l'utilisateur pour filtrer les passages
final int? userId;
/// Types de passages à exclure (utilisé seulement si loadFromHive est true)
/// Types de passages à exclure
final List<int> excludePassageTypes;
/// Afficher tous les passages sans filtrer par utilisateur (utilisé seulement si loadFromHive est true)
/// Afficher tous les passages sans filtrer par utilisateur
final bool showAllPassages;
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
final bool useValueListenable;
const PassagePieChart({
super.key,
this.passagesByType = const {},
@@ -88,6 +88,7 @@ class PassagePieChart extends StatefulWidget {
this.userId,
this.excludePassageTypes = const [2],
this.showAllPassages = false,
this.useValueListenable = true,
});
@override
@@ -98,20 +99,9 @@ class _PassagePieChartState extends State<PassagePieChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
/// Données de passages par type
late Map<int, int> _passagesByType;
/// Variables pour la mise en cache et l'optimisation
bool _dataLoaded = false;
bool _isLoading = false;
List<PassageChartData>? _cachedChartData;
List<CircularChartAnnotation>? _cachedAnnotations;
@override
void initState() {
super.initState();
_passagesByType = widget.passagesByType;
// Initialiser le contrôleur d'animation
_animationController = AnimationController(
vsync: this,
@@ -119,235 +109,21 @@ class _PassagePieChartState extends State<PassagePieChart>
);
_animationController.forward();
// Si nous n'utilisons pas Hive, préparer les données immédiatement
if (!widget.loadFromHive) {
_prepareChartData();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (widget.loadFromHive && !_dataLoaded && !_isLoading) {
_isLoading = true; // Prévenir les chargements multiples
_loadPassageDataFromHive(context);
}
}
@override
void didUpdateWidget(PassagePieChart oldWidget) {
super.didUpdateWidget(oldWidget);
// Vérifier si les propriétés importantes ont changé
final bool dataSourceChanged = widget.loadFromHive
? false
: !mapEquals(oldWidget.passagesByType, widget.passagesByType);
final bool filteringChanged = oldWidget.userId != widget.userId ||
!listEquals(
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
oldWidget.showAllPassages != widget.showAllPassages;
final bool visualChanged = oldWidget.size != widget.size ||
oldWidget.labelSize != widget.labelSize ||
oldWidget.showPercentage != widget.showPercentage ||
oldWidget.showIcons != widget.showIcons ||
oldWidget.showLegend != widget.showLegend ||
oldWidget.isDonut != widget.isDonut ||
oldWidget.innerRadius != widget.innerRadius;
// Relancer l'animation si les paramètres importants ont changé
final bool shouldResetAnimation = oldWidget.userId != widget.userId ||
!listEquals(oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
oldWidget.showAllPassages != widget.showAllPassages ||
oldWidget.useValueListenable != widget.useValueListenable;
// Si les paramètres de filtrage ou de source de données ont changé, recharger les données
if (dataSourceChanged || filteringChanged) {
_cachedChartData = null;
_cachedAnnotations = null;
// Relancer l'animation si les données ont changé
if (shouldResetAnimation) {
_animationController.reset();
_animationController.forward();
if (!widget.loadFromHive) {
_passagesByType = widget.passagesByType;
_prepareChartData();
} else if (!_isLoading) {
_dataLoaded = false;
_isLoading = true;
_loadPassageDataFromHive(context);
}
}
// Si seuls les paramètres visuels ont changé, recalculer les annotations sans recharger les données
else if (visualChanged) {
_cachedAnnotations = null;
}
}
/// Charge les données de passage depuis Hive en utilisant le service PassageDataService
void _loadPassageDataFromHive(BuildContext context) {
// Éviter les appels multiples pendant le chargement
if (_isLoading) {
debugPrint('PassagePieChart: Déjà en cours de chargement, ignoré');
return;
}
// Si les données sont déjà chargées et non vides, ne pas recharger
if (_dataLoaded && _passagesByType.isNotEmpty) {
debugPrint('PassagePieChart: Données déjà chargées, ignoré');
return;
}
debugPrint('PassagePieChart: Début du chargement des données');
setState(() {
_isLoading = true;
});
// Charger les données dans un addPostFrameCallback pour éviter les problèmes de cycle de vie
WidgetsBinding.instance.addPostFrameCallback((_) {
// Vérifier si le widget est toujours monté
if (!mounted) {
debugPrint('PassagePieChart: Widget démonté, chargement annulé');
return;
}
try {
debugPrint('PassagePieChart: Création du service de données');
// Utiliser les instances globales définies dans app.dart
// Vérifier que les repositories sont disponibles
if (passageRepository == null) {
debugPrint('PassagePieChart: ERREUR - passageRepository est null');
if (mounted) {
setState(() {
_isLoading = false;
});
}
return;
}
if (userRepository == null) {
debugPrint('PassagePieChart: ERREUR - userRepository est null');
if (mounted) {
setState(() {
_isLoading = false;
});
}
return;
}
// Créer une instance du service de données
final passageDataService = PassageDataService(
passageRepository: passageRepository,
userRepository: userRepository,
);
debugPrint(
'PassagePieChart: Chargement des données avec excludePassageTypes=${widget.excludePassageTypes}, userId=${widget.userId}, showAllPassages=${widget.showAllPassages}');
// Utiliser le service pour charger les données
final data = passageDataService.loadPassageDataForPieChart(
excludePassageTypes: widget.excludePassageTypes,
userId: widget.userId,
showAllPassages: widget.showAllPassages,
);
debugPrint('PassagePieChart: Données chargées: $data');
// Mettre à jour les données et les états
if (mounted) {
setState(() {
_passagesByType = data;
_dataLoaded = true;
_isLoading = false;
_cachedChartData =
null; // Forcer la régénération des données du graphique
_cachedAnnotations = null;
});
// Préparer les données du graphique
_prepareChartData();
debugPrint('PassagePieChart: Données préparées pour le graphique');
}
} catch (e) {
// Gérer les erreurs et réinitialiser l'état pour permettre une future tentative
debugPrint(
'PassagePieChart: ERREUR lors du chargement des données: $e');
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
});
}
/// Prépare les données pour le graphique en camembert avec mise en cache
List<PassageChartData> _prepareChartData() {
// Utiliser les données en cache si disponibles
if (_cachedChartData != null) {
debugPrint('PassagePieChart: Utilisation des données en cache');
return _cachedChartData!;
}
debugPrint('PassagePieChart: Préparation des données pour le graphique');
debugPrint('PassagePieChart: Données brutes: $_passagesByType');
// Vérifier si les données sont vides
if (_passagesByType.isEmpty) {
debugPrint('PassagePieChart: Aucune donnée disponible');
return [];
}
// Vérifier si les données contiennent uniquement des passages de type 2
bool onlyType2 = true;
_passagesByType.forEach((typeId, count) {
if (typeId != 2 && count > 0) {
onlyType2 = false;
}
});
if (onlyType2) {
debugPrint(
'PassagePieChart: Les données contiennent uniquement des passages de type 2');
}
final List<PassageChartData> chartData = [];
// Créer les données du graphique
_passagesByType.forEach((typeId, count) {
// Vérifier que le type existe et que le compteur est positif
if (count > 0 && AppKeys.typesPassages.containsKey(typeId)) {
// Vérifier si le type est exclu
bool isExcluded = widget.excludePassageTypes.contains(typeId);
if (isExcluded) {
debugPrint('PassagePieChart: Type $typeId exclu');
} else {
final typeInfo = AppKeys.typesPassages[typeId]!;
final typeName = typeInfo['titre'] as String;
debugPrint(
'PassagePieChart: Ajout du type $typeId ($typeName) avec $count passages');
chartData.add(PassageChartData(
typeId: typeId,
count: count,
title: typeName,
color: Color(typeInfo['couleur2'] as int),
icon: typeInfo['icon_data'] as IconData,
));
}
} else {
if (count <= 0) {
debugPrint('PassagePieChart: Type $typeId ignoré car count=$count');
} else if (!AppKeys.typesPassages.containsKey(typeId)) {
debugPrint(
'PassagePieChart: Type $typeId ignoré car non défini dans AppKeys.typesPassages');
}
}
});
debugPrint(
'PassagePieChart: ${chartData.length} types de passages ajoutés au graphique');
// Mettre en cache les données générées
_cachedChartData = chartData;
return chartData;
}
@override
@@ -358,19 +134,100 @@ class _PassagePieChartState extends State<PassagePieChart>
@override
Widget build(BuildContext context) {
// Si les données doivent être chargées depuis Hive mais ne sont pas encore prêtes
if (widget.loadFromHive && !_dataLoaded) {
return SizedBox(
width: widget.size,
height: widget.size,
child: const Center(
child: CircularProgressIndicator(),
),
);
if (widget.useValueListenable) {
return _buildWithValueListenable();
} else {
return _buildWithStaticData();
}
}
final chartData = _prepareChartData();
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
Widget _buildWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final chartData = _calculatePassageData(passagesBox);
return _buildChart(chartData);
},
);
}
/// Construction du widget avec des données statiques (ancien système)
Widget _buildWithStaticData() {
final chartData = _prepareChartDataFromMap(widget.passagesByType);
return _buildChart(chartData);
}
/// Calcule les données de passage depuis la Hive box
List<PassageChartData> _calculatePassageData(Box<PassageModel> passagesBox) {
try {
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
// Calculer les données selon les filtres
final Map<int, int> passagesByType = {};
// Initialiser tous les types de passage possibles
for (final typeId in AppKeys.typesPassages.keys) {
if (!widget.excludePassageTypes.contains(typeId)) {
passagesByType[typeId] = 0;
}
}
for (final passage in passages) {
// Appliquer les filtres
bool shouldInclude = true;
// Filtrer par utilisateur si nécessaire
if (!widget.showAllPassages && widget.userId != null) {
shouldInclude = passage.fkUser == widget.userId;
} else if (!widget.showAllPassages && currentUser != null) {
shouldInclude = passage.fkUser == currentUser.id;
}
// Exclure certains types
if (widget.excludePassageTypes.contains(passage.fkType)) {
shouldInclude = false;
}
if (shouldInclude) {
passagesByType[passage.fkType] =
(passagesByType[passage.fkType] ?? 0) + 1;
}
}
return _prepareChartDataFromMap(passagesByType);
} catch (e) {
debugPrint('Erreur lors du calcul des données de passage: $e');
return [];
}
}
/// Prépare les données pour le graphique en camembert à partir d'une Map
List<PassageChartData> _prepareChartDataFromMap(Map<int, int> passagesByType) {
final List<PassageChartData> chartData = [];
// Créer les données du graphique
passagesByType.forEach((typeId, count) {
// Vérifier que le type existe et que le compteur est positif
if (count > 0 && AppKeys.typesPassages.containsKey(typeId)) {
final typeInfo = AppKeys.typesPassages[typeId]!;
chartData.add(PassageChartData(
typeId: typeId,
count: count,
title: typeInfo['titre'] as String,
color: Color(typeInfo['couleur2'] as int),
icon: typeInfo['icon_data'] as IconData,
));
}
});
return chartData;
}
/// Construit le graphique avec les données fournies
Widget _buildChart(List<PassageChartData> chartData) {
// Si aucune donnée, afficher un message
if (chartData.isEmpty) {
return SizedBox(
@@ -448,8 +305,7 @@ class _PassagePieChartState extends State<PassagePieChart>
explodeOffset:
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
opacity: opacityAnimation.value,
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
animationDuration: 0,
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
)
@@ -485,8 +341,7 @@ class _PassagePieChartState extends State<PassagePieChart>
explodeOffset:
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
opacity: opacityAnimation.value,
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
animationDuration: 0,
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
),
@@ -499,14 +354,9 @@ class _PassagePieChartState extends State<PassagePieChart>
);
}
/// Crée les annotations d'icônes pour le graphique avec mise en cache
/// Crée les annotations d'icônes pour le graphique
List<CircularChartAnnotation> _buildIconAnnotations(
List<PassageChartData> chartData) {
// Utiliser les annotations en cache si disponibles
if (_cachedAnnotations != null) {
return _cachedAnnotations!;
}
final List<CircularChartAnnotation> annotations = [];
// Calculer le total pour les pourcentages
@@ -541,9 +391,6 @@ class _PassagePieChartState extends State<PassagePieChart>
currentAngle += segmentAngle;
}
// Mettre en cache les annotations générées
_cachedAnnotations = annotations;
return annotations;
}
}

View File

@@ -0,0 +1,353 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/app.dart';
/// Widget commun pour afficher une carte de synthèse des passages
/// avec liste des types à gauche et graphique en camembert à droite
class PassageSummaryCard extends StatelessWidget {
/// Titre de la carte
final String title;
/// Couleur de l'icône et du titre
final Color titleColor;
/// Icône à afficher dans le titre
final IconData? titleIcon;
/// Hauteur totale de la carte
final double? height;
/// Utiliser ValueListenableBuilder pour mise à jour automatique
final bool useValueListenable;
/// ID de l'utilisateur pour filtrer les passages (si null, tous les utilisateurs)
final int? userId;
/// Afficher tous les passages (admin) ou seulement ceux de l'utilisateur
final bool showAllPassages;
/// Types de passages à exclure du graphique
final List<int> excludePassageTypes;
/// Données statiques de passages par type (utilisé si useValueListenable = false)
final Map<int, int>? passagesByType;
/// Fonction de callback pour afficher la valeur totale personnalisée
final String Function(int totalPassages)? customTotalDisplay;
/// Afficher le graphique en mode desktop ou mobile
final bool isDesktop;
/// Icône d'arrière-plan (optionnelle)
final IconData? backgroundIcon;
/// Couleur de l'icône d'arrière-plan
final Color? backgroundIconColor;
/// Opacité de l'icône d'arrière-plan
final double backgroundIconOpacity;
/// Taille de l'icône d'arrière-plan
final double backgroundIconSize;
const PassageSummaryCard({
super.key,
required this.title,
this.titleColor = AppTheme.primaryColor,
this.titleIcon = Icons.route,
this.height,
this.useValueListenable = true,
this.userId,
this.showAllPassages = false,
this.excludePassageTypes = const [2], // Exclure "À finaliser" par défaut
this.passagesByType,
this.customTotalDisplay,
this.isDesktop = true,
this.backgroundIcon = Icons.route,
this.backgroundIconColor,
this.backgroundIconOpacity = 0.07,
this.backgroundIconSize = 180,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Stack(
children: [
// Icône d'arrière-plan (optionnelle)
if (backgroundIcon != null)
Positioned.fill(
child: Center(
child: Icon(
backgroundIcon,
size: backgroundIconSize,
color: (backgroundIconColor ?? AppTheme.primaryColor).withOpacity(backgroundIconOpacity),
),
),
),
// Contenu principal
Container(
height: height,
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre avec comptage
useValueListenable
? _buildTitleWithValueListenable()
: _buildTitleWithStaticData(),
const Divider(height: 24),
// Contenu principal
Expanded(
child: SizedBox(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Liste des passages à gauche
Expanded(
flex: isDesktop ? 1 : 2,
child: useValueListenable
? _buildPassagesListWithValueListenable()
: _buildPassagesListWithStaticData(),
),
// Séparateur vertical
if (isDesktop) const VerticalDivider(width: 24),
// Graphique en camembert à droite
Expanded(
flex: isDesktop ? 1 : 2,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: PassagePieChart(
useValueListenable: useValueListenable,
passagesByType: passagesByType ?? {},
excludePassageTypes: excludePassageTypes,
userId: showAllPassages ? null : userId,
size: double.infinity,
labelSize: 12,
showPercentage: true,
showIcons: false,
showLegend: false,
isDonut: true,
innerRadius: '50%',
),
),
),
],
),
),
),
],
),
),
],
),
);
}
/// Construction du titre avec ValueListenableBuilder
Widget _buildTitleWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final totalUserPassages = _calculateUserPassagesCount(passagesBox);
return Row(
children: [
if (titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(totalUserPassages) ?? totalUserPassages.toString(),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
},
);
}
/// Construction du titre avec données statiques
Widget _buildTitleWithStaticData() {
final totalPassages = passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
return Row(
children: [
if (titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(totalPassages) ?? totalPassages.toString(),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
}
/// Construction de la liste des passages avec ValueListenableBuilder
Widget _buildPassagesListWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final passagesCounts = _calculatePassagesCounts(passagesBox);
return _buildPassagesList(passagesCounts);
},
);
}
/// Construction de la liste des passages avec données statiques
Widget _buildPassagesListWithStaticData() {
return _buildPassagesList(passagesByType ?? {});
}
/// Construction de la liste des passages
Widget _buildPassagesList(Map<int, int> passagesCounts) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...AppKeys.typesPassages.entries.map((entry) {
final int typeId = entry.key;
final Map<String, dynamic> typeData = entry.value;
final int count = passagesCounts[typeId] ?? 0;
final Color color = Color(typeData['couleur2'] as int);
final IconData iconData = typeData['icon_data'] as IconData;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(
iconData,
color: Colors.white,
size: 16,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
typeData['titres'] as String,
style: const TextStyle(fontSize: 14),
),
),
Text(
count.toString(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}).toList(),
],
);
}
/// Calcule le nombre total de passages pour l'utilisateur
int _calculateUserPassagesCount(Box<PassageModel> passagesBox) {
if (showAllPassages) {
// Pour les administrateurs : tous les passages sauf ceux exclus
return passagesBox.values
.where((passage) => !excludePassageTypes.contains(passage.fkType))
.length;
} else {
// Pour les utilisateurs : seulement leurs passages
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId == null) return 0;
return passagesBox.values
.where((passage) =>
passage.fkUser == targetUserId &&
!excludePassageTypes.contains(passage.fkType))
.length;
}
}
/// Calcule les compteurs de passages par type
Map<int, int> _calculatePassagesCounts(Box<PassageModel> passagesBox) {
final Map<int, int> counts = {};
// Initialiser tous les types
for (final typeId in AppKeys.typesPassages.keys) {
counts[typeId] = 0;
}
if (showAllPassages) {
// Pour les administrateurs : compter tous les passages
for (final passage in passagesBox.values) {
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
}
} else {
// Pour les utilisateurs : compter seulement leurs passages
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId != null) {
for (final passage in passagesBox.values) {
if (passage.fkUser == targetUserId) {
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
}
}
}
}
return counts;
}
}

View File

@@ -1,11 +1,16 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/app.dart';
import 'dart:math' as math;
/// Widget de graphique en camembert pour représenter la répartition des règlements
class PaymentPieChart extends StatefulWidget {
/// Liste des données de règlement à afficher dans le graphique
/// Si useValueListenable est true, ce paramètre est ignoré
final List<PaymentData> payments;
/// Taille du graphique
@@ -41,9 +46,15 @@ class PaymentPieChart extends StatefulWidget {
/// Utiliser un dégradé pour simuler l'effet 3D
final bool useGradient;
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
final bool useValueListenable;
/// ID de l'utilisateur pour filtrer les passages
final int? userId;
const PaymentPieChart({
super.key,
required this.payments,
this.payments = const [],
this.size = 300,
this.labelSize = 12,
this.showPercentage = true,
@@ -55,6 +66,8 @@ class PaymentPieChart extends StatefulWidget {
this.effect3DIntensity = 1.0,
this.enableEnhancedExplode = false,
this.useGradient = false,
this.useValueListenable = true,
this.userId,
});
@override
@@ -80,20 +93,24 @@ class _PaymentPieChartState extends State<PaymentPieChart>
void didUpdateWidget(PaymentPieChart oldWidget) {
super.didUpdateWidget(oldWidget);
// Relancer l'animation si les données ont changé
// Utiliser une comparaison plus stricte pour éviter des animations inutiles
// Relancer l'animation si les paramètres importants ont changé
bool shouldResetAnimation = false;
if (oldWidget.payments.length != widget.payments.length) {
if (widget.useValueListenable != oldWidget.useValueListenable ||
widget.userId != oldWidget.userId) {
shouldResetAnimation = true;
} else {
// Comparer les éléments importants uniquement
for (int i = 0; i < oldWidget.payments.length; i++) {
if (i >= widget.payments.length) break;
if (oldWidget.payments[i].amount != widget.payments[i].amount ||
oldWidget.payments[i].title != widget.payments[i].title) {
} else if (!widget.useValueListenable) {
// Pour les données statiques, comparer les éléments
if (oldWidget.payments.length != widget.payments.length) {
shouldResetAnimation = true;
break;
} else {
for (int i = 0; i < oldWidget.payments.length; i++) {
if (i >= widget.payments.length) break;
if (oldWidget.payments[i].amount != widget.payments[i].amount ||
oldWidget.payments[i].title != widget.payments[i].title) {
shouldResetAnimation = true;
break;
}
}
}
}
@@ -110,15 +127,115 @@ class _PaymentPieChartState extends State<PaymentPieChart>
super.dispose();
}
/// Prépare les données pour le graphique en camembert
List<PaymentData> _prepareChartData() {
// Filtrer les règlements avec un montant > 0
return widget.payments.where((payment) => payment.amount > 0).toList();
}
@override
Widget build(BuildContext context) {
final chartData = _prepareChartData();
if (widget.useValueListenable) {
return _buildWithValueListenable();
} else {
return _buildWithStaticData();
}
}
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
Widget _buildWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final paymentData = _calculatePaymentData(passagesBox);
return _buildChart(paymentData);
},
);
}
/// Construction du widget avec des données statiques
Widget _buildWithStaticData() {
return _buildChart(widget.payments);
}
/// Calcule les données de règlement depuis la Hive box
List<PaymentData> _calculatePaymentData(Box<PassageModel> passagesBox) {
try {
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
final int? currentUserId = widget.userId ?? currentUser?.id;
// Initialiser les montants par type de règlement
final Map<int, double> paymentAmounts = {
0: 0.0, // Pas de règlement
1: 0.0, // Espèces
2: 0.0, // Chèques
3: 0.0, // CB
};
// Parcourir les passages et calculer les montants par type de règlement
for (final passage in passages) {
// Vérifier si le passage appartient à l'utilisateur actuel
if (currentUserId != null && passage.fkUser == currentUserId) {
final int typeReglement = passage.fkTypeReglement;
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
// Gérer les formats possibles (virgule ou point)
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
debugPrint('Erreur de conversion du montant: ${passage.montant}');
}
// Ne compter que les passages avec un montant > 0
if (montant > 0) {
// Ajouter au montant total par type de règlement
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] =
(paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
}
}
}
}
// Convertir le Map en List<PaymentData>
final List<PaymentData> paymentDataList = [];
paymentAmounts.forEach((typeReglement, montant) {
if (montant > 0) { // Ne retourner que les types avec un montant > 0
// Récupérer les informations depuis AppKeys.typesReglements
final reglementInfo = AppKeys.typesReglements[typeReglement];
if (reglementInfo != null) {
paymentDataList.add(PaymentData(
typeId: typeReglement,
title: reglementInfo['titre'] as String,
amount: montant,
color: Color(reglementInfo['couleur'] as int),
icon: reglementInfo['icon_data'] as IconData,
));
} else {
// Fallback pour les types non définis
paymentDataList.add(PaymentData(
typeId: typeReglement,
title: 'Type inconnu',
amount: montant,
color: Colors.grey,
icon: Icons.help_outline,
));
}
}
});
return paymentDataList;
} catch (e) {
debugPrint('Erreur lors du calcul des données de règlement: $e');
return [];
}
}
/// Construit le graphique avec les données fournies
Widget _buildChart(List<PaymentData> paymentData) {
final chartData = _prepareChartData(paymentData);
// Si aucune donnée, afficher un message
if (chartData.isEmpty) {
@@ -170,7 +287,6 @@ class _PaymentPieChartState extends State<PaymentPieChart>
yValueMapper: (PaymentData data, _) => data.amount,
pointColorMapper: (PaymentData data, _) {
if (widget.enable3DEffect) {
// Utiliser un angle différent pour chaque segment pour simuler un effet 3D
final index = chartData.indexOf(data);
final angle =
(index / chartData.length) * 2 * math.pi;
@@ -181,11 +297,9 @@ class _PaymentPieChartState extends State<PaymentPieChart>
}
return data.color;
},
// Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion
enableTooltip: true,
dataLabelMapper: (PaymentData data, _) {
if (widget.showPercentage) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(
0.0, (sum, item) => sum + item.amount);
final percentage = (data.amount / total * 100);
@@ -196,18 +310,14 @@ class _PaymentPieChartState extends State<PaymentPieChart>
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition
.inside, // Afficher les étiquettes à l'intérieur du donut
labelPosition: ChartDataLabelPosition.inside,
textStyle: TextStyle(
fontSize: widget.labelSize,
color: Colors
.white, // Texte blanc pour meilleure lisibilité
fontWeight: FontWeight
.bold, // Texte en gras pour meilleure lisibilité
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
innerRadius: widget.innerRadius,
// Effet d'explosion plus prononcé pour donner du relief avec animation
explode: true,
explodeAll: widget.enableEnhancedExplode,
explodeIndex: widget.enableEnhancedExplode ? null : 0,
@@ -216,13 +326,10 @@ class _PaymentPieChartState extends State<PaymentPieChart>
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
// Effet 3D via l'opacité et les couleurs avec animation
opacity: widget.enable3DEffect
? 0.95 * opacityAnimation.value
: opacityAnimation.value,
// Animation progressive du graphique
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
animationDuration: 0,
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
)
@@ -232,7 +339,6 @@ class _PaymentPieChartState extends State<PaymentPieChart>
yValueMapper: (PaymentData data, _) => data.amount,
pointColorMapper: (PaymentData data, _) {
if (widget.enable3DEffect) {
// Utiliser un angle différent pour chaque segment pour simuler un effet 3D
final index = chartData.indexOf(data);
final angle =
(index / chartData.length) * 2 * math.pi;
@@ -243,11 +349,9 @@ class _PaymentPieChartState extends State<PaymentPieChart>
}
return data.color;
},
// Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion
enableTooltip: true,
dataLabelMapper: (PaymentData data, _) {
if (widget.showPercentage) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(
0.0, (sum, item) => sum + item.amount);
final percentage = (data.amount / total * 100);
@@ -265,7 +369,6 @@ class _PaymentPieChartState extends State<PaymentPieChart>
length: '15%',
),
),
// Effet d'explosion plus prononcé pour donner du relief avec animation
explode: true,
explodeAll: widget.enableEnhancedExplode,
explodeIndex: widget.enableEnhancedExplode ? null : 0,
@@ -274,40 +377,35 @@ class _PaymentPieChartState extends State<PaymentPieChart>
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
// Effet 3D via l'opacité et les couleurs avec animation
opacity: widget.enable3DEffect
? 0.95 * opacityAnimation.value
: opacityAnimation.value,
// Animation progressive du graphique
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
animationDuration: 0,
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
),
],
annotations:
widget.showIcons ? _buildIconAnnotations(chartData) : null,
// Paramètres pour améliorer l'effet 3D
palette: widget.enable3DEffect ? _create3DPalette(chartData) : null,
// Ajouter un effet de bordure pour renforcer l'effet 3D
borderWidth: widget.enable3DEffect ? 0.5 : 0,
// Note: La rotation n'est pas directement prise en charge dans cette version de Syncfusion
),
);
},
);
}
/// Ce une couleur avec effet 3D en ajoutant des nuances
Color _create3DColor(Color baseColor, double intensity) {
// Ajuster la luminosité et la saturation pour créer un effet 3D plus prononcé
final hslColor = HSLColor.fromColor(baseColor);
/// Ppare les données pour le graphique en camembert
List<PaymentData> _prepareChartData(List<PaymentData> payments) {
// Filtrer les règlements avec un montant > 0
return payments.where((payment) => payment.amount > 0).toList();
}
// Augmenter la luminosité pour simuler un éclairage
/// Crée une couleur avec effet 3D en ajustant les nuances
Color _create3DColor(Color baseColor, double intensity) {
final hslColor = HSLColor.fromColor(baseColor);
final adjustedLightness =
(hslColor.lightness + 0.15 * intensity).clamp(0.0, 1.0);
// Augmenter légèrement la saturation pour des couleurs plus vives
final adjustedSaturation =
(hslColor.saturation + 0.05 * intensity).clamp(0.0, 1.0);
@@ -321,24 +419,17 @@ class _PaymentPieChartState extends State<PaymentPieChart>
List<Color> _create3DPalette(List<PaymentData> chartData) {
List<Color> palette = [];
// Créer des variations de couleurs pour chaque segment
for (var i = 0; i < chartData.length; i++) {
var data = chartData[i];
// Calculer un angle pour chaque segment pour simuler un éclairage directionnel
final angle = (i / chartData.length) * 2 * math.pi;
// Créer un effet d'ombre et de lumière en fonction de l'angle
final hslColor = HSLColor.fromColor(data.color);
// Ajuster la luminosité en fonction de l'angle
final lightAdjustment = 0.15 * widget.effect3DIntensity * math.sin(angle);
final adjustedLightness = (hslColor.lightness -
0.1 * widget.effect3DIntensity +
lightAdjustment)
.clamp(0.0, 1.0);
// Ajuster la saturation pour plus de profondeur
final adjustedSaturation =
(hslColor.saturation + 0.1 * widget.effect3DIntensity)
.clamp(0.0, 1.0);
@@ -356,10 +447,7 @@ class _PaymentPieChartState extends State<PaymentPieChart>
/// Crée une couleur avec effet 3D plus avancé
Color _createEnhanced3DColor(Color baseColor, double angle) {
// Simuler un effet de lumière directionnel
final hslColor = HSLColor.fromColor(baseColor);
// Ajuster la luminosité en fonction de l'angle pour simuler un éclairage
final adjustedLightness = hslColor.lightness +
(0.2 * widget.effect3DIntensity * math.sin(angle)).clamp(-0.3, 0.3);
@@ -371,21 +459,15 @@ class _PaymentPieChartState extends State<PaymentPieChart>
List<PaymentData> chartData) {
final List<CircularChartAnnotation> annotations = [];
// Calculer le total pour les pourcentages
double total = chartData.fold(0.0, (sum, item) => sum + item.amount);
// Position angulaire actuelle (en radians)
double currentAngle = 0;
for (int i = 0; i < chartData.length; i++) {
final data = chartData[i];
final percentage = data.amount / total;
// Calculer l'angle central de ce segment
final segmentAngle = percentage * 2 * 3.14159;
final midAngle = currentAngle + (segmentAngle / 2);
// Ajouter une annotation pour l'icône
annotations.add(
CircularChartAnnotation(
widget: Icon(
@@ -394,11 +476,10 @@ class _PaymentPieChartState extends State<PaymentPieChart>
size: 16,
),
radius: '50%',
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
angle: (midAngle * (180 / 3.14159)).toInt(),
),
);
// Mettre à jour l'angle actuel
currentAngle += segmentAngle;
}

View File

@@ -0,0 +1,463 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/app.dart';
/// Widget commun pour afficher une carte de synthèse des règlements
/// avec liste des types à gauche et graphique en camembert à droite
class PaymentSummaryCard extends StatelessWidget {
/// Titre de la carte
final String title;
/// Couleur de l'icône et du titre
final Color titleColor;
/// Icône à afficher dans le titre
final IconData? titleIcon;
/// Hauteur totale de la carte
final double? height;
/// Utiliser ValueListenableBuilder pour mise à jour automatique
final bool useValueListenable;
/// ID de l'utilisateur pour filtrer les passages (si null, tous les utilisateurs)
final int? userId;
/// Afficher tous les règlements (admin) ou seulement ceux de l'utilisateur
final bool showAllPayments;
/// Données statiques de règlements par type (utilisé si useValueListenable = false)
final Map<int, double>? paymentsByType;
/// Fonction de callback pour afficher la valeur totale personnalisée
final String Function(double totalAmount)? customTotalDisplay;
/// Afficher le graphique en mode desktop ou mobile
final bool isDesktop;
/// Icône d'arrière-plan (optionnelle)
final IconData? backgroundIcon;
/// Couleur de l'icône d'arrière-plan
final Color? backgroundIconColor;
/// Opacité de l'icône d'arrière-plan
final double backgroundIconOpacity;
/// Taille de l'icône d'arrière-plan
final double backgroundIconSize;
const PaymentSummaryCard({
super.key,
required this.title,
this.titleColor = AppTheme.accentColor,
this.titleIcon = Icons.payments,
this.height,
this.useValueListenable = true,
this.userId,
this.showAllPayments = false,
this.paymentsByType,
this.customTotalDisplay,
this.isDesktop = true,
this.backgroundIcon = Icons.euro_symbol,
this.backgroundIconColor,
this.backgroundIconOpacity = 0.07,
this.backgroundIconSize = 180,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Stack(
children: [
// Icône d'arrière-plan (optionnelle)
if (backgroundIcon != null)
Positioned.fill(
child: Center(
child: Icon(
backgroundIcon,
size: backgroundIconSize,
color: (backgroundIconColor ?? Colors.blue).withOpacity(backgroundIconOpacity),
),
),
),
// Contenu principal
Container(
height: height,
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre avec comptage
useValueListenable
? _buildTitleWithValueListenable()
: _buildTitleWithStaticData(),
const Divider(height: 24),
// Contenu principal
Expanded(
child: SizedBox(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Liste des règlements à gauche
Expanded(
flex: isDesktop ? 1 : 2,
child: useValueListenable
? _buildPaymentsListWithValueListenable()
: _buildPaymentsListWithStaticData(),
),
// Séparateur vertical
if (isDesktop) const VerticalDivider(width: 24),
// Graphique en camembert à droite
Expanded(
flex: isDesktop ? 1 : 2,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: PaymentPieChart(
useValueListenable: useValueListenable,
payments: useValueListenable ? [] : _convertMapToPaymentData(paymentsByType ?? {}),
userId: showAllPayments ? null : userId,
size: double.infinity,
labelSize: 12,
showPercentage: true,
showIcons: false,
showLegend: false,
isDonut: true,
innerRadius: '50%',
enable3DEffect: false,
effect3DIntensity: 0.0,
enableEnhancedExplode: false,
useGradient: false,
),
),
),
],
),
),
),
],
),
),
],
),
);
}
/// Construction du titre avec ValueListenableBuilder
Widget _buildTitleWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final paymentStats = _calculatePaymentStats(passagesBox);
return Row(
children: [
if (titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(paymentStats['totalAmount']) ??
'${paymentStats['totalAmount'].toStringAsFixed(2)}',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
},
);
}
/// Construction du titre avec données statiques
Widget _buildTitleWithStaticData() {
final totalAmount = paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
return Row(
children: [
if (titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(totalAmount) ?? '${totalAmount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
}
/// Construction de la liste des règlements avec ValueListenableBuilder
Widget _buildPaymentsListWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final paymentAmounts = _calculatePaymentAmounts(passagesBox);
return _buildPaymentsList(paymentAmounts);
},
);
}
/// Construction de la liste des règlements avec données statiques
Widget _buildPaymentsListWithStaticData() {
return _buildPaymentsList(paymentsByType ?? {});
}
/// Construction de la liste des règlements
Widget _buildPaymentsList(Map<int, double> paymentAmounts) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...AppKeys.typesReglements.entries.map((entry) {
final int typeId = entry.key;
final Map<String, dynamic> typeData = entry.value;
final double amount = paymentAmounts[typeId] ?? 0.0;
final Color color = Color(typeData['couleur'] as int);
final IconData iconData = typeData['icon_data'] as IconData;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(
iconData,
color: Colors.white,
size: 16,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
typeData['titre'] as String,
style: const TextStyle(fontSize: 14),
),
),
Text(
'${amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}).toList(),
],
);
}
/// Calcule les statistiques de règlement
Map<String, dynamic> _calculatePaymentStats(Box<PassageModel> passagesBox) {
if (showAllPayments) {
// Pour les administrateurs : tous les règlements
int passagesWithPaymentCount = 0;
double totalAmount = 0.0;
for (final passage in passagesBox.values) {
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
passagesWithPaymentCount++;
totalAmount += montant;
}
}
return {
'passagesCount': passagesWithPaymentCount,
'totalAmount': totalAmount,
};
} else {
// Pour les utilisateurs : seulement leurs règlements
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId == null) {
return {'passagesCount': 0, 'totalAmount': 0.0};
}
int passagesWithPaymentCount = 0;
double totalAmount = 0.0;
for (final passage in passagesBox.values) {
if (passage.fkUser == targetUserId) {
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
passagesWithPaymentCount++;
totalAmount += montant;
}
}
}
return {
'passagesCount': passagesWithPaymentCount,
'totalAmount': totalAmount,
};
}
}
/// Calcule les montants par type de règlement
Map<int, double> _calculatePaymentAmounts(Box<PassageModel> passagesBox) {
final Map<int, double> paymentAmounts = {};
// Initialiser tous les types
for (final typeId in AppKeys.typesReglements.keys) {
paymentAmounts[typeId] = 0.0;
}
if (showAllPayments) {
// Pour les administrateurs : compter tous les règlements
for (final passage in passagesBox.values) {
final int typeReglement = passage.fkTypeReglement;
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] = (paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
}
}
}
} else {
// Pour les utilisateurs : compter seulement leurs règlements
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId != null) {
for (final passage in passagesBox.values) {
if (passage.fkUser == targetUserId) {
final int typeReglement = passage.fkTypeReglement;
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] = (paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
}
}
}
}
}
}
return paymentAmounts;
}
/// Convertit une Map<int, double> en List<PaymentData> pour les données statiques
List<PaymentData> _convertMapToPaymentData(Map<int, double> paymentsMap) {
final List<PaymentData> paymentDataList = [];
paymentsMap.forEach((typeReglement, montant) {
if (montant > 0) { // Ne retourner que les types avec un montant > 0
// Récupérer les informations depuis AppKeys.typesReglements
final reglementInfo = AppKeys.typesReglements[typeReglement];
if (reglementInfo != null) {
paymentDataList.add(PaymentData(
typeId: typeReglement,
title: reglementInfo['titre'] as String,
amount: montant,
color: Color(reglementInfo['couleur'] as int),
icon: reglementInfo['icon_data'] as IconData,
));
} else {
// Fallback pour les types non définis
paymentDataList.add(PaymentData(
typeId: typeReglement,
title: 'Type inconnu',
amount: montant,
color: Colors.grey,
icon: Icons.help_outline,
));
}
}
});
return paymentDataList;
}
}

View File

@@ -1,33 +0,0 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
/// Utilitaires pour les paiements et règlements
class PaymentUtils {
/// Convertit les données de règlement depuis les constantes AppKeys
///
/// [paymentAmounts] est une Map associant l'ID du type de règlement à son montant
static List<PaymentData> getPaymentDataFromAmounts(
Map<int, double> paymentAmounts) {
final List<PaymentData> paymentDataList = [];
// Parcourir tous les types de règlements définis dans AppKeys
AppKeys.typesReglements.forEach((typeId, typeData) {
// Vérifier si nous avons un montant pour ce type de règlement
final double amount = paymentAmounts[typeId] ?? 0.0;
// Créer un objet PaymentData pour ce type de règlement
final PaymentData paymentData = PaymentData(
typeId: typeId,
amount: amount,
color: Color(typeData['couleur'] as int),
icon: typeData['icon_data'] as IconData,
title: typeData['titre'] as String,
);
paymentDataList.add(paymentData);
});
return paymentDataList;
}
}

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/shared/app_theme.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
/// Widget pour la zone de saisie des messages
class ChatInput extends StatefulWidget {

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/shared/app_theme.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
/// Widget pour afficher les messages d'une conversation
class ChatMessages extends StatelessWidget {

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/shared/app_theme.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
/// Widget pour afficher la barre latérale des contacts
class ChatSidebar extends StatelessWidget {

View File

@@ -22,6 +22,7 @@ class CustomTextField extends StatelessWidget {
final Color? fillColor;
final String? helperText;
final Function(String)? onFieldSubmitted;
final bool isRequired;
const CustomTextField({
super.key,
@@ -45,6 +46,7 @@ class CustomTextField extends StatelessWidget {
this.fillColor,
this.helperText,
this.onFieldSubmitted,
this.isRequired = false,
});
@override
@@ -65,93 +67,113 @@ class CustomTextField extends StatelessWidget {
const SizedBox(height: 8),
],
// Ajouter un Container avec une ombre pour créer un effet d'élévation
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: TextFormField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
validator: validator,
inputFormatters: inputFormatters,
maxLines: maxLines,
minLines: minLines,
readOnly: readOnly,
onTap: onTap,
onChanged: onChanged,
onFieldSubmitted: onFieldSubmitted,
autofocus: autofocus,
focusNode: focusNode,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onBackground,
),
decoration: InputDecoration(
hintText: hintText,
hintStyle: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onBackground.withOpacity(0.5),
),
errorText: errorText,
helperText: helperText,
helperStyle: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onBackground.withOpacity(0.6),
),
prefixIcon: prefixIcon != null
? Icon(prefixIcon, color: theme.colorScheme.primary)
: null,
suffixIcon: suffixIcon,
// Couleur de fond différente selon l'état (lecture seule ou éditable)
fillColor: fillColor ??
(readOnly
? const Color(0xFFF8F9FA) // Gris plus clair pour readOnly
: const Color(
0xFFECEFF1)), // Gris plus foncé pour éditable
filled: true,
// Ajouter une élévation avec une petite ombre
border: OutlineInputBorder(
Stack(
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
// Ajouter une ombre pour créer un effet d'élévation
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
gapPadding: 0,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
child: TextFormField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
validator: validator,
inputFormatters: inputFormatters,
maxLines: maxLines,
minLines: minLines,
readOnly: readOnly,
onTap: onTap,
onChanged: onChanged,
onFieldSubmitted: onFieldSubmitted,
autofocus: autofocus,
focusNode: focusNode,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onBackground,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.error,
width: 2,
decoration: InputDecoration(
hintText: hintText,
hintStyle: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onBackground.withOpacity(0.5),
),
errorText: errorText,
helperText: helperText,
helperStyle: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onBackground.withOpacity(0.6),
),
prefixIcon: prefixIcon != null
? Icon(prefixIcon, color: theme.colorScheme.primary)
: null,
suffixIcon: suffixIcon,
// Couleur de fond différente selon l'état (lecture seule ou éditable)
fillColor: fillColor ??
(readOnly
? const Color(
0xFFF8F9FA) // Gris plus clair pour readOnly
: const Color(
0xFFECEFF1)), // Gris plus foncé pour éditable
filled: true,
// Ajouter une élévation avec une petite ombre
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
// Ajouter une ombre pour créer un effet d'élévation
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
gapPadding: 0,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.error,
width: 2,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.error,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.error,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
),
// Point rouge en haut à droite pour indiquer que le champ est obligatoire
if (isRequired)
Positioned(
top: 0,
right: 0,
child: Container(
width: 10,
height: 10,
margin: const EdgeInsets.only(top: 8, right: 8),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
],
),
],
);

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/services/app_info_service.dart';
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
import 'package:geosector_app/presentation/widgets/profile_dialog.dart';
import 'package:go_router/go_router.dart';
@@ -12,9 +13,6 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
/// Le titre de la page actuelle (optionnel)
final String? pageTitle;
/// Actions supplémentaires à afficher dans l'AppBar
final List<Widget>? additionalActions;
/// Indique si le bouton "Nouveau passage" doit être affiché
final bool showNewPassageButton;
@@ -31,7 +29,6 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
Key? key,
required this.title,
this.pageTitle,
this.additionalActions,
this.showNewPassageButton = true,
this.onNewPassagePressed,
this.isAdmin = false,
@@ -56,10 +53,15 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
Widget _buildLogo() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Image.asset(
'assets/images/logo-geosector-1024.png',
width: 40,
height: 40,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/images/logo-geosector-1024.png',
width: 40,
height: 40,
),
],
),
);
}
@@ -71,33 +73,38 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
// Ajouter l'indicateur de connectivité
actions.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
child: const ConnectivityIndicator(
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
child: ConnectivityIndicator(
showErrorMessage: false,
showConnectionType: true,
),
),
);
// Ajouter les actions supplémentaires si elles existent
if (additionalActions != null && additionalActions!.isNotEmpty) {
actions.addAll(additionalActions!);
} else if (showNewPassageButton) {
// Ajouter le bouton "Nouveau passage" en haut à droite
actions.add(
TextButton.icon(
icon: const Icon(Icons.add_location_alt, color: Colors.white),
label: const Text('Nouveau passage',
style: TextStyle(color: Colors.white)),
onPressed: onNewPassagePressed,
style: TextButton.styleFrom(
backgroundColor: theme.colorScheme.secondary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
// Ajouter la version de l'application
actions.add(
Text(
AppInfoService.fullVersion,
style: const TextStyle(
fontSize: 12,
color: Colors.white70,
),
);
}
),
);
actions.add(
TextButton.icon(
icon: const Icon(Icons.add_location_alt, color: Colors.white),
label: const Text('Nouveau passage',
style: TextStyle(color: Colors.white)),
onPressed: onNewPassagePressed,
style: TextButton.styleFrom(
backgroundColor: theme.colorScheme.secondary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
);
// Ajouter le bouton "Mon compte"
actions.add(
@@ -128,25 +135,34 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
tooltip: 'Déconnexion',
onPressed: onLogoutPressed ??
() {
// Si aucun callback n'est fourni, utiliser le userRepository global
showDialog(
context: context,
builder: (context) => AlertDialog(
builder: (dialogContext) => AlertDialog(
title: const Text('Déconnexion'),
content:
const Text('Voulez-vous vraiment vous déconnecter ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
// Utiliser directement userRepository pour la déconnexion
// qui gère à la fois le nettoyage des données et la redirection
await userRepository.logoutWithUI(context);
// La redirection est gérée dans logoutWithUI
// Fermer la dialog d'abord
Navigator.of(dialogContext).pop();
// Utiliser le context original de l'AppBar pour la navigation
final success = await userRepository.logout(context);
// Vérification supplémentaire et navigation forcée si nécessaire
if (success && context.mounted) {
// Attendre un court instant pour que les changements d'état se propagent
await Future.delayed(
const Duration(milliseconds: 100));
// Navigation forcée vers la page d'accueil
context.go('/');
}
},
child: const Text('Déconnexion'),
),

View File

@@ -85,7 +85,6 @@ class DashboardLayout extends StatelessWidget {
appBar: DashboardAppBar(
title: title,
pageTitle: destinations[selectedIndex].label,
additionalActions: additionalActions,
showNewPassageButton: showNewPassageButton,
onNewPassagePressed: onNewPassagePressed,
isAdmin: isAdmin,

View File

@@ -1,141 +0,0 @@
# Documentation des Widgets Amicale
Cette documentation explique comment utiliser les widgets `AmicaleRowWidget` et `AmicaleTableWidget` pour afficher et gérer les données des amicales dans l'application.
## AmicaleRowWidget
Le widget `AmicaleRowWidget` représente une ligne dans un tableau d'amicales. Il affiche les informations d'une amicale avec les colonnes suivantes :
- ID
- Nom
- Code Postal
- Région
- Actions (boutons selon les droits de l'utilisateur)
### Propriétés
| Propriété | Type | Description |
| ------------- | --------------- | ---------------------------------------------------------------------------------- |
| `amicale` | `AmicaleModel` | **Obligatoire**. L'objet amicale à afficher. |
| `onEdit` | `VoidCallback?` | Fonction appelée lorsque l'utilisateur clique sur le bouton d'édition. |
| `onDelete` | `VoidCallback?` | Fonction appelée lorsque l'utilisateur clique sur le bouton de suppression. |
| `isAlternate` | `bool` | Indique si la ligne doit avoir une couleur de fond alternée. Par défaut à `false`. |
### Gestion des droits d'accès
Le widget gère automatiquement l'affichage des boutons d'action en fonction du rôle de l'utilisateur :
- Le bouton d'édition (crayon) est visible pour tous les utilisateurs avec un rôle > 1
- Le bouton de suppression (corbeille) est visible uniquement pour les utilisateurs avec un rôle > 2
### Exemple d'utilisation
```dart
AmicaleRowWidget(
amicale: amicale,
isAlternate: index % 2 == 1, // Alterner les couleurs
onEdit: () {
// Code pour gérer l'édition
},
onDelete: () {
// Code pour gérer la suppression
},
)
```
## AmicaleTableWidget
Le widget `AmicaleTableWidget` affiche un tableau complet d'amicales avec un en-tête et des lignes. Il utilise le widget `AmicaleRowWidget` pour afficher chaque ligne.
### Propriétés
| Propriété | Type | Description |
| -------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `amicales` | `List<AmicaleModel>` | **Obligatoire**. La liste des amicales à afficher. |
| `onEdit` | `Function(AmicaleModel)?` | Fonction appelée lorsque l'utilisateur clique sur le bouton d'édition d'une amicale. |
| `onDelete` | `Function(AmicaleModel)?` | Fonction appelée lorsque l'utilisateur clique sur le bouton de suppression d'une amicale. |
| `isLoading` | `bool` | Indique si les données sont en cours de chargement. Affiche un indicateur de chargement si `true`. Par défaut à `false`. |
| `emptyMessage` | `String?` | Message à afficher lorsque la liste des amicales est vide. |
### États du tableau
Le widget gère automatiquement différents états :
1. **Chargement** : Affiche un indicateur de chargement circulaire lorsque `isLoading` est `true`.
2. **Liste vide** : Affiche un message lorsque la liste des amicales est vide.
3. **Affichage normal** : Affiche la liste des amicales avec des lignes alternées.
### Exemple d'utilisation
```dart
AmicaleTableWidget(
amicales: _amicales,
isLoading: _isLoading,
onEdit: (amicale) {
// Code pour gérer l'édition de l'amicale
},
onDelete: (amicale) {
// Code pour gérer la suppression de l'amicale
},
emptyMessage: 'Aucune amicale trouvée. Veuillez en créer une nouvelle.',
)
```
## Intégration avec AmicaleRepository
Pour utiliser ces widgets avec le repository des amicales, vous devez :
1. Récupérer les amicales depuis le repository :
```dart
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
final amicales = amicaleRepository.getAllAmicales();
```
2. Gérer les actions d'édition et de suppression :
```dart
void _handleEdit(AmicaleModel amicale) {
// Naviguer vers la page d'édition ou afficher une boîte de dialogue
}
Future<void> _handleDelete(AmicaleModel amicale) async {
// Afficher une confirmation puis supprimer
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
await amicaleRepository.deleteAmicale(amicale.id);
// Recharger la liste
setState(() {
_amicales = amicaleRepository.getAllAmicales();
});
}
```
## Exemple complet
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/amicale_table_example.dart`.
## Personnalisation
### Styles
Les widgets utilisent les styles du thème de l'application pour la cohérence visuelle. Vous pouvez personnaliser l'apparence en modifiant le thème ou en surchargeant les styles dans votre implémentation.
### Colonnes et flexibilité
Les colonnes du tableau ont des valeurs de flex prédéfinies pour une mise en page optimale :
- ID : flex 1
- Nom : flex 4
- Code Postal : flex 2
- Région : flex 3
- Actions : flex 2
Vous pouvez ajuster ces valeurs en modifiant le code source si nécessaire.
## Bonnes pratiques
1. **Gestion des erreurs** : Ajoutez toujours une gestion des erreurs lors de l'interaction avec le repository.
2. **Confirmation des actions** : Demandez toujours une confirmation avant de supprimer une amicale.
3. **Actualisation des données** : Prévoyez un moyen de rafraîchir les données (bouton ou pull-to-refresh).
4. **Pagination** : Pour les grandes listes, envisagez d'implémenter une pagination.

View File

@@ -1,204 +0,0 @@
# Documentation du Widget EntiteForm
Cette documentation décrit le widget `EntiteForm` créé pour la création et la modification des entités (amicales) dans l'application GeoSector.
## Description
Le widget `EntiteForm` est un formulaire complet permettant de créer ou modifier une entité (amicale). Il gère l'affichage de tous les champs nécessaires, la validation des données et les restrictions d'accès basées sur le rôle de l'utilisateur.
## Propriétés
- `amicale` (AmicaleModel?, optionnel) : Le modèle d'amicale à modifier. Si null, le formulaire sera en mode création.
- `onSubmit` (Function(AmicaleModel)?, optionnel) : Callback appelé lorsque le formulaire est soumis avec succès.
- `readOnly` (bool, défaut: false) : Si true, tous les champs du formulaire seront en lecture seule.
## Champs du formulaire
Le formulaire inclut les champs suivants :
### Informations générales
- **Nom** : Nom de l'amicale (obligatoire)
### Adresse
- **Adresse ligne 1** : Première ligne d'adresse
- **Adresse ligne 2** : Seconde ligne d'adresse (optionnelle)
- **Code Postal** : Code postal (validation pour 5 chiffres)
- **Ville** : Nom de la ville
- **Région** : Sélection de la région via un dropdown
### Contact
- **Téléphone fixe** : Numéro de téléphone fixe (validation pour 10 chiffres)
- **Téléphone mobile** : Numéro de téléphone mobile (validation pour 10 chiffres)
- **Email** : Adresse email (obligatoire, avec validation de format)
### Informations avancées (visibles uniquement pour les administrateurs ou si déjà remplies)
- **GPS Latitude** : Coordonnée GPS latitude
- **GPS Longitude** : Coordonnée GPS longitude
- **Stripe ID** : Identifiant Stripe pour les paiements
### Options
- **Mode démo** : Indique si l'amicale est en mode démo
- **Copie des mails reçus** : Indique si l'amicale reçoit une copie des emails
- **Accepte les SMS** : Indique si l'amicale accepte les SMS
- **Actif** : Indique si l'amicale est active
## Restrictions d'accès
Certains champs sont soumis à des restrictions d'accès basées sur le rôle de l'utilisateur :
- Les champs suivants sont en lecture seule pour les utilisateurs avec un rôle ≤ 2 :
- fkRegion/libRegion
- gpsLat
- gpsLng
- stripeId
- chkDemo
- chkActive
## Exemple d'utilisation
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/entite_form_example.dart`.
### Utilisation simple
```dart
// Création d'une nouvelle amicale
EntiteForm(
onSubmit: (amicale) {
// Gérer la soumission
print('Nouvelle amicale: ${amicale.name}');
},
)
// Modification d'une amicale existante
EntiteForm(
amicale: amicaleExistante,
onSubmit: (amicale) {
// Gérer la soumission
print('Amicale modifiée: ${amicale.name}');
},
)
// Affichage en lecture seule
EntiteForm(
amicale: amicaleExistante,
readOnly: true,
)
```
### Utilisation avec gestion d'état
```dart
class _MyWidgetState extends State<MyWidget> {
AmicaleModel? _amicale;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadAmicale();
}
Future<void> _loadAmicale() async {
setState(() {
_isLoading = true;
});
try {
if (widget.amicaleId != null) {
// Récupérer l'amicale depuis le repository
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
final amicale = amicaleRepository.getAmicaleById(widget.amicaleId!);
setState(() {
_amicale = amicale;
_isLoading = false;
});
} else {
// Création d'une nouvelle amicale
setState(() {
_amicale = null;
_isLoading = false;
});
}
} catch (e) {
debugPrint('Erreur lors du chargement de l\'amicale: $e');
setState(() {
_isLoading = false;
});
}
}
void _handleSubmit(AmicaleModel amicale) async {
try {
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
// Sauvegarder l'amicale
final savedAmicale = await amicaleRepository.saveAmicale(amicale);
// Afficher un message de confirmation
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Amicale ${savedAmicale.name} sauvegardée avec succès'),
backgroundColor: Colors.green,
),
);
} catch (e) {
debugPrint('Erreur lors de la sauvegarde de l\'amicale: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la sauvegarde: $e'),
backgroundColor: Colors.red,
),
);
}
}
@override
Widget build(BuildContext context) {
return _isLoading
? const Center(child: CircularProgressIndicator())
: EntiteForm(
amicale: _amicale,
onSubmit: _handleSubmit,
);
}
}
```
## Intégration avec le système de rôles
Le widget utilise le `UserRepository` pour déterminer le rôle de l'utilisateur actuel et appliquer les restrictions d'accès en conséquence. Assurez-vous que le `UserRepository` est disponible dans l'arbre des widgets via un `Provider`.
```dart
// Dans le widget parent
return MultiProvider(
providers: [
Provider<UserRepository>(
create: (context) => userRepository,
),
Provider<AmicaleRepository>(
create: (context) => amicaleRepository,
),
],
child: MyWidget(),
);
```
## Personnalisation
Le widget utilise le thème de l'application pour le style. Vous pouvez personnaliser l'apparence en modifiant le thème ou en étendant le widget pour créer votre propre version personnalisée.
## Validation des données
Le formulaire inclut une validation pour les champs suivants :
- **Nom** : Ne peut pas être vide
- **Code Postal** : Doit contenir 5 chiffres s'il est rempli
- **Téléphone fixe** : Doit contenir 10 chiffres s'il est rempli
- **Téléphone mobile** : Doit contenir 10 chiffres s'il est rempli
- **Email** : Ne peut pas être vide et doit contenir un '@' et un '.'

View File

@@ -1,160 +0,0 @@
# Documentation du Widget EntiteForm avec RegionRepository
Cette documentation explique comment utiliser le widget `EntiteForm` avec le `RegionRepository` pour afficher et gérer les régions dans le formulaire d'entité.
## Intégration du RegionRepository
Le widget `EntiteForm` est conçu pour fonctionner avec le `RegionRepository` afin de récupérer la liste des régions disponibles pour le champ de sélection de région. Voici comment l'intégrer :
### 1. Initialisation du RegionRepository
Le `RegionRepository` doit être initialisé et fourni au widget `EntiteForm` via un `Provider`. Voici un exemple d'initialisation :
```dart
final regionRepository = RegionRepository();
await regionRepository.init();
```
### 2. Fournir le RegionRepository via Provider
Pour que le widget `EntiteForm` puisse accéder au `RegionRepository`, vous devez le fournir via un `Provider` :
```dart
MultiProvider(
providers: [
ChangeNotifierProvider<RegionRepository>.value(value: regionRepository),
// Autres providers si nécessaire
],
child: EntiteForm(
amicale: amicale,
onSubmit: handleSubmit,
readOnly: false,
),
)
```
### 3. Mise à jour des régions depuis l'API
Lorsque l'API renvoie les données des régions dans la réponse de login, vous devez les mettre à jour dans le `RegionRepository` :
```dart
// Dans le service qui gère la connexion
void handleLoginResponse(Map<String, dynamic> response) {
// Autres traitements...
// Mise à jour des régions si présentes dans la réponse
if (response.containsKey('regions') && response['regions'] is List) {
final regionRepository = Provider.of<RegionRepository>(context, listen: false);
regionRepository.updateRegionsFromApi(response['regions']);
}
}
```
## Fonctionnement avec les restrictions d'accès
Le widget `EntiteForm` gère automatiquement les restrictions d'accès basées sur le rôle de l'utilisateur :
- Pour les utilisateurs avec un rôle ≤ 2, le champ de sélection de région est en lecture seule
- Pour les utilisateurs avec un rôle > 2, le champ de sélection de région est modifiable
## Filtrage des régions selon le code postal
Le `RegionRepository` offre une méthode `getRegionByPostalCode` qui permet de filtrer les régions en fonction du code postal :
```dart
// Récupérer la région correspondant au code postal
final codePostal = '75001';
final region = regionRepository.getRegionByPostalCode(codePostal);
if (region != null) {
// Utiliser la région trouvée
print('Région trouvée : ${region.libelle}');
}
```
Cette fonctionnalité est particulièrement utile pour pré-remplir le champ de région lorsque l'utilisateur entre un code postal.
## Exemple complet d'utilisation
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/entite_form_with_regions_example.dart`.
### Exemple simplifié
```dart
class MyWidget extends StatefulWidget {
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
late RegionRepository _regionRepository;
AmicaleModel? _amicale;
bool _isLoading = true;
@override
void initState() {
super.initState();
_regionRepository = RegionRepository();
_initData();
}
Future<void> _initData() async {
setState(() {
_isLoading = true;
});
try {
// Initialiser le repository des régions
await _regionRepository.init();
// Charger l'amicale si nécessaire
// ...
setState(() {
_isLoading = false;
});
} catch (e) {
debugPrint('Erreur lors de l\'initialisation: $e');
setState(() {
_isLoading = false;
});
}
}
void _handleSubmit(AmicaleModel amicale) {
// Traiter la soumission du formulaire
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<RegionRepository>.value(value: _regionRepository),
],
child: Scaffold(
appBar: AppBar(title: Text('Formulaire d\'entité')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: EntiteForm(
amicale: _amicale,
onSubmit: _handleSubmit,
readOnly: false,
),
),
),
);
}
}
```
## Mise à jour du code postal et de la région
Pour mettre à jour automatiquement la région lorsque l'utilisateur change le code postal, vous pouvez étendre le widget `EntiteForm` ou créer un wrapper qui écoute les changements du champ de code postal et met à jour la région en conséquence.
## Remarques importantes
1. Assurez-vous que le `RegionRepository` est initialisé avant d'afficher le formulaire.
2. Le widget `EntiteForm` s'adapte automatiquement au rôle de l'utilisateur pour les restrictions d'accès.
3. Les régions sont filtrées en fonction du code postal de l'amicale pour les utilisateurs avec un rôle ≤ 2.
4. Pour les utilisateurs avec un rôle > 2, toutes les régions sont disponibles dans le dropdown.

View File

@@ -1,207 +0,0 @@
# Documentation des Widgets Membre
Cette documentation décrit les widgets créés pour afficher et gérer les données des membres dans l'application GeoSector.
## Widgets disponibles
### 1. MembreRowWidget
Widget qui représente une ligne individuelle dans un tableau de membres. Il affiche les informations d'un membre et des boutons d'action pour l'édition et la suppression.
#### Propriétés
- `membre` (MembreModel, requis) : Le modèle de membre à afficher
- `onEdit` (Function()?, optionnel) : Callback appelé lorsque le bouton d'édition est pressé
- `onDelete` (Function()?, optionnel) : Callback appelé lorsque le bouton de suppression est pressé
#### Colonnes affichées
- ID : Identifiant unique du membre
- Prénom (firstName) : Prénom du membre
- Nom (name) : Nom de famille du membre
- Secteur (sectName) : Nom du secteur auquel le membre est associé
- Rôle (fkRole) : Rôle du membre (affiché sous forme de texte : User, Admin, Super)
- Actions : Boutons d'édition et de suppression
### 2. MembreTableWidget
Widget qui affiche un tableau complet de membres avec en-tête et lignes. Il utilise le widget `MembreRowWidget` pour afficher chaque ligne.
#### Propriétés
- `membres` (List<MembreModel>, requis) : La liste des membres à afficher
- `onEdit` (Function(MembreModel)?, optionnel) : Callback appelé lorsque le bouton d'édition est pressé pour un membre
- `onDelete` (Function(MembreModel)?, optionnel) : Callback appelé lorsque le bouton de suppression est pressé pour un membre
- `showHeader` (bool, défaut: true) : Indique si l'en-tête du tableau doit être affiché
- `height` (double?, optionnel) : Hauteur du tableau (null pour prendre toute la hauteur disponible)
- `padding` (EdgeInsetsGeometry?, optionnel) : Padding du tableau
## Exemple d'utilisation
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/membre_table_example.dart`.
### Utilisation simple
```dart
// S'assurer que la boîte Hive est ouverte
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
}
// Récupérer les membres depuis la boîte Hive
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
final membres = membresBox.values.toList();
// Afficher le tableau
return MembreTableWidget(
membres: membres,
onEdit: (membre) {
// Gérer l'édition
},
onDelete: (membre) {
// Gérer la suppression
},
);
```
### Utilisation avec gestion d'état
```dart
class _MyWidgetState extends State<MyWidget> {
List<MembreModel> _membres = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadMembres();
}
Future<void> _loadMembres() async {
setState(() {
_isLoading = true;
});
try {
// S'assurer que la boîte Hive est ouverte
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
}
// Récupérer les membres depuis la boîte Hive
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
final membres = membresBox.values.toList();
setState(() {
_membres = membres;
_isLoading = false;
});
} catch (e) {
debugPrint('Erreur lors du chargement des membres: $e');
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return _isLoading
? const Center(child: CircularProgressIndicator())
: MembreTableWidget(
membres: _membres,
onEdit: _handleEdit,
onDelete: _handleDelete,
);
}
}
```
## Gestion des événements
### Édition d'un membre
```dart
void _handleEdit(MembreModel membre) {
// Exemple de gestion de l'événement d'édition
debugPrint('Édition du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
// Ici, vous pourriez ouvrir une boîte de dialogue ou naviguer vers une page d'édition
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Édition de membre'),
content: Text('Vous avez demandé à éditer le membre ${membre.firstName} ${membre.name}'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
),
);
}
```
### Suppression d'un membre
```dart
void _handleDelete(MembreModel membre) {
// Exemple de gestion de l'événement de suppression
debugPrint('Suppression du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
// Demander confirmation avant de supprimer
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmation de suppression'),
content: Text('Êtes-vous sûr de vouloir supprimer le membre ${membre.firstName} ${membre.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
// Fermer la boîte de dialogue
Navigator.of(context).pop();
try {
// Supprimer le membre de la boîte Hive
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
await membresBox.delete(membre.id);
// Mettre à jour l'état
setState(() {
_membres = _membres.where((m) => m.id != membre.id).toList();
});
// Afficher un message de confirmation
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Membre ${membre.firstName} ${membre.name} supprimé'),
backgroundColor: Colors.green,
),
);
} catch (e) {
debugPrint('Erreur lors de la suppression du membre: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la suppression: $e'),
backgroundColor: Colors.red,
),
);
}
},
child: const Text('Supprimer'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
);
}
```
## Personnalisation
Les widgets utilisent le thème de l'application pour le style. Vous pouvez personnaliser l'apparence en modifiant le thème ou en étendant les widgets pour créer vos propres versions personnalisées.

View File

@@ -1,234 +0,0 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/presentation/widgets/amicale_table_widget.dart';
import 'package:provider/provider.dart';
/// Exemple d'utilisation du widget AmicaleTableWidget
///
/// Ce widget montre comment intégrer le tableau d'amicales dans une page
/// et comment gérer les actions d'édition et de suppression.
class AmicaleTableExample extends StatefulWidget {
const AmicaleTableExample({Key? key}) : super(key: key);
@override
State<AmicaleTableExample> createState() => _AmicaleTableExampleState();
}
class _AmicaleTableExampleState extends State<AmicaleTableExample> {
bool _isLoading = true;
List<AmicaleModel> _amicales = [];
String? _errorMessage;
@override
void initState() {
super.initState();
_loadAmicales();
}
Future<void> _loadAmicales() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// Récupérer les amicales depuis le repository
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
final amicales = amicaleRepository.getAllAmicales();
setState(() {
_amicales = amicales;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Erreur lors du chargement des amicales: $e';
_isLoading = false;
});
}
}
void _handleEdit(AmicaleModel amicale) {
// Afficher une boîte de dialogue de confirmation
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Modifier l\'amicale'),
content: Text('Voulez-vous modifier l\'amicale ${amicale.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
// Naviguer vers la page de modification
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => EditAmicalePage(amicale: amicale),
// ),
// );
},
child: const Text('Modifier'),
),
],
),
);
}
void _handleDelete(AmicaleModel amicale) {
// Afficher une boîte de dialogue de confirmation
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer l\'amicale'),
content: Text(
'Êtes-vous sûr de vouloir supprimer l\'amicale ${amicale.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
onPressed: () {
Navigator.of(context).pop();
_deleteAmicale(amicale);
},
child: const Text('Supprimer'),
),
],
),
);
}
Future<void> _deleteAmicale(AmicaleModel amicale) async {
try {
setState(() {
_isLoading = true;
});
// Supprimer l'amicale via le repository
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
await amicaleRepository.deleteAmicale(amicale.id);
// Recharger la liste
await _loadAmicales();
// Afficher un message de succès
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Amicale ${amicale.name} supprimée avec succès'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'Erreur lors de la suppression: $e';
});
// Afficher un message d'erreur
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $_errorMessage'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Liste des amicales'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadAmicales,
tooltip: 'Actualiser',
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre et description
Text(
'Gestion des amicales',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Consultez, modifiez ou supprimez les amicales selon vos droits d\'accès.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
// Message d'erreur si présent
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 12),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
),
),
],
),
),
// Tableau des amicales
Expanded(
child: AmicaleTableWidget(
amicales: _amicales,
isLoading: _isLoading,
onDelete: _handleDelete,
emptyMessage:
'Aucune amicale trouvée. Veuillez en créer une nouvelle.',
readOnly: false, // Permettre la modification dans la modale
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// Naviguer vers la page de création
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => CreateAmicalePage(),
// ),
// );
},
tooltip: 'Ajouter une amicale',
child: const Icon(Icons.add),
),
);
}
}

View File

@@ -1,195 +0,0 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/entite_form.dart';
import 'package:provider/provider.dart';
/// Exemple d'utilisation du widget EntiteForm
///
/// Ce widget montre comment intégrer le formulaire d'entité dans une page
/// et comment gérer les événements de soumission.
class EntiteFormExample extends StatefulWidget {
final int? amicaleId;
final bool readOnly;
const EntiteFormExample({
Key? key,
this.amicaleId,
this.readOnly = false,
}) : super(key: key);
@override
State<EntiteFormExample> createState() => _EntiteFormExampleState();
}
class _EntiteFormExampleState extends State<EntiteFormExample> {
AmicaleModel? _amicale;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadAmicale();
}
Future<void> _loadAmicale() async {
setState(() {
_isLoading = true;
});
try {
if (widget.amicaleId != null) {
// Récupérer l'amicale depuis le repository
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
final amicale = amicaleRepository.getAmicaleById(widget.amicaleId!);
setState(() {
_amicale = amicale;
_isLoading = false;
});
} else {
// Création d'une nouvelle amicale
setState(() {
_amicale = null;
_isLoading = false;
});
}
} catch (e) {
debugPrint('Erreur lors du chargement de l\'amicale: $e');
setState(() {
_isLoading = false;
});
}
}
void _handleSubmit(AmicaleModel amicale) async {
try {
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
// Sauvegarder l'amicale
final savedAmicale = await amicaleRepository.saveAmicale(amicale);
// Afficher un message de confirmation
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Amicale ${savedAmicale.name} sauvegardée avec succès'),
backgroundColor: Colors.green,
),
);
// Retourner à la page précédente
Navigator.of(context).pop();
}
} catch (e) {
debugPrint('Erreur lors de la sauvegarde de l\'amicale: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la sauvegarde: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final userRepository = Provider.of<UserRepository>(context, listen: false);
final userRole = userRepository.getUserRole();
final bool canCreate = userRole >
1; // Seuls les utilisateurs avec rôle > 1 peuvent créer/modifier
return Scaffold(
appBar: AppBar(
title: Text(widget.amicaleId != null
? (widget.readOnly
? 'Détails de l\'amicale'
: 'Modifier l\'amicale')
: 'Nouvelle amicale'),
actions: [
if (!widget.readOnly && _amicale != null)
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _showDeleteConfirmation(context),
tooltip: 'Supprimer',
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: !canCreate && _amicale == null
? const Center(
child: Text(
'Vous n\'avez pas les droits pour créer une amicale'),
)
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: EntiteForm(
amicale: _amicale,
onSubmit: widget.readOnly ? null : _handleSubmit,
readOnly: widget.readOnly,
),
),
);
}
void _showDeleteConfirmation(BuildContext context) {
if (_amicale == null) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmation de suppression'),
content: Text(
'Êtes-vous sûr de vouloir supprimer l\'amicale ${_amicale!.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
try {
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
await amicaleRepository.deleteAmicale(_amicale!.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Amicale ${_amicale!.name} supprimée'),
backgroundColor: Colors.green,
),
);
// Retourner à la page précédente
Navigator.of(context).pop();
}
} catch (e) {
debugPrint('Erreur lors de la suppression de l\'amicale: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la suppression: $e'),
backgroundColor: Colors.red,
),
);
}
}
},
child: const Text('Supprimer'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
);
}
}

View File

@@ -1,136 +0,0 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/core/repositories/region_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/entite_form.dart';
import 'package:provider/provider.dart';
/// Exemple d'utilisation du widget EntiteForm avec le RegionRepository
///
/// Ce widget montre comment intégrer le formulaire d'entité dans une page
/// et comment utiliser le RegionRepository pour charger les régions.
class EntiteFormWithRegionsExample extends StatefulWidget {
final int? amicaleId;
final bool readOnly;
const EntiteFormWithRegionsExample({
Key? key,
this.amicaleId,
this.readOnly = false,
}) : super(key: key);
@override
State<EntiteFormWithRegionsExample> createState() =>
_EntiteFormWithRegionsExampleState();
}
class _EntiteFormWithRegionsExampleState
extends State<EntiteFormWithRegionsExample> {
AmicaleModel? _amicale;
bool _isLoading = true;
late RegionRepository _regionRepository;
@override
void initState() {
super.initState();
_regionRepository = RegionRepository();
_initRepositories();
}
Future<void> _initRepositories() async {
setState(() {
_isLoading = true;
});
try {
// Initialiser le repository des régions
await _regionRepository.init();
// Charger l'amicale si un ID est fourni
if (widget.amicaleId != null) {
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
final amicale = amicaleRepository.getAmicaleById(widget.amicaleId!);
setState(() {
_amicale = amicale;
_isLoading = false;
});
} else {
setState(() {
_isLoading = false;
});
}
} catch (e) {
debugPrint('Erreur lors de l\'initialisation: $e');
setState(() {
_isLoading = false;
});
}
}
void _handleSubmit(AmicaleModel amicale) async {
try {
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
// Sauvegarder l'amicale
final savedAmicale = await amicaleRepository.saveAmicale(amicale);
// Afficher un message de confirmation
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Amicale ${savedAmicale.name} sauvegardée avec succès'),
backgroundColor: Colors.green,
),
);
// Retourner à la page précédente
Navigator.of(context).pop();
}
} catch (e) {
debugPrint('Erreur lors de la sauvegarde de l\'amicale: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la sauvegarde: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
// Fournir le RegionRepository pour qu'il soit accessible par le widget EntiteForm
ChangeNotifierProvider<RegionRepository>.value(
value: _regionRepository),
],
child: Scaffold(
appBar: AppBar(
title: Text(widget.amicaleId != null
? (widget.readOnly
? 'Détails de l\'amicale'
: 'Modifier l\'amicale')
: 'Nouvelle amicale'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: EntiteForm(
amicale: _amicale,
onSubmit: widget.readOnly ? null : _handleSubmit,
readOnly: widget.readOnly,
),
),
),
);
}
}

View File

@@ -1,167 +0,0 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/presentation/widgets/membre_table_widget.dart';
import 'package:hive_flutter/hive_flutter.dart';
/// Exemple d'utilisation du widget MembreTableWidget
///
/// Ce widget montre comment intégrer le tableau de membres dans une page
/// et comment gérer les événements d'édition et de suppression.
class MembreTableExample extends StatefulWidget {
const MembreTableExample({Key? key}) : super(key: key);
@override
State<MembreTableExample> createState() => _MembreTableExampleState();
}
class _MembreTableExampleState extends State<MembreTableExample> {
List<MembreModel> _membres = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadMembres();
}
Future<void> _loadMembres() async {
setState(() {
_isLoading = true;
});
try {
// S'assurer que la boîte Hive est ouverte
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
}
// Récupérer les membres depuis la boîte Hive
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
final membres = membresBox.values.toList();
setState(() {
_membres = membres;
_isLoading = false;
});
} catch (e) {
debugPrint('Erreur lors du chargement des membres: $e');
setState(() {
_isLoading = false;
});
}
}
void _handleEdit(MembreModel membre) {
// Exemple de gestion de l'événement d'édition
debugPrint(
'Édition du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
// Ici, vous pourriez ouvrir une boîte de dialogue ou naviguer vers une page d'édition
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Édition de membre'),
content: Text(
'Vous avez demandé à éditer le membre ${membre.firstName} ${membre.name}'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
),
);
}
void _handleDelete(MembreModel membre) {
// Exemple de gestion de l'événement de suppression
debugPrint(
'Suppression du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
// Demander confirmation avant de supprimer
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmation de suppression'),
content: Text(
'Êtes-vous sûr de vouloir supprimer le membre ${membre.firstName} ${membre.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
// Fermer la boîte de dialogue
Navigator.of(context).pop();
try {
// Supprimer le membre de la boîte Hive
final membresBox =
Hive.box<MembreModel>(AppKeys.membresBoxName);
await membresBox.delete(membre.id);
// Mettre à jour l'état
setState(() {
_membres = _membres.where((m) => m.id != membre.id).toList();
});
// Afficher un message de confirmation
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Membre ${membre.firstName} ${membre.name} supprimé'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
debugPrint('Erreur lors de la suppression du membre: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la suppression: $e'),
backgroundColor: Colors.red,
),
);
}
}
},
child: const Text('Supprimer'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Tableau des Membres'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadMembres,
tooltip: 'Rafraîchir',
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: _isLoading
? const Center(child: CircularProgressIndicator())
: MembreTableWidget(
membres: _membres,
onEdit: _handleEdit,
onDelete: _handleDelete,
height:
null, // Utiliser null pour que le widget prenne toute la hauteur disponible
),
),
);
}
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'custom_text_field.dart';
import '../custom_text_field.dart';
class PassageForm extends StatefulWidget {
final Function(Map<String, dynamic>)? onSubmit;

View File

@@ -1,12 +1,9 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/help_dialog.dart';
import 'package:geosector_app/presentation/widgets/profile_dialog.dart';
/// Widget qui fournit une navigation responsive pour l'application.
/// Affiche une sidebar en mode desktop et une bottomBar en mode mobile.
@@ -125,7 +122,6 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
appBar: widget.showAppBar
? AppBar(
title: Text(widget.title),
actions: _buildAppBarActions(context),
)
: null,
body: isDesktop ? _buildDesktopLayout() : _buildMobileLayout(),
@@ -157,38 +153,6 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
);
}
/// Construction des actions de l'AppBar
List<Widget> _buildAppBarActions(BuildContext context) {
List<Widget> actions = [];
// Ajouter les actions supplémentaires si elles existent
if (widget.additionalActions != null &&
widget.additionalActions!.isNotEmpty) {
actions.addAll(widget.additionalActions!);
} else if (widget.showNewPassageButton && widget.selectedIndex == 0) {
// Ajouter le bouton "Nouveau passage" en haut à droite pour la page d'accueil
actions.add(
TextButton.icon(
icon: const Icon(Icons.add_location_alt, color: Colors.white),
label: const Text('Nouveau passage',
style: TextStyle(color: Colors.white)),
onPressed: widget.onNewPassagePressed ??
() {
// Fonction par défaut si onNewPassagePressed n'est pas fourni
_showPassageForm(context);
},
style: TextButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.secondary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
);
actions.add(const SizedBox(width: 16)); // Espacement à droite
}
return actions;
}
/// Construction de la barre de navigation inférieure pour mobile
Widget _buildBottomNavigationBar() {
final theme = Theme.of(context);
@@ -350,103 +314,15 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
const Spacer(),
const Divider(),
// Éléments du bas de la sidebar
// Éléments du bas de la sidebar (widgets personnalisés)
if (widget.sidebarBottomItems != null && !_isSidebarMinimized)
...widget.sidebarBottomItems!,
// Éléments par défaut du bas de la sidebar
if (!_isSidebarMinimized)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Paramètres',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
_SettingsItem(
icon: Icons.person,
title: 'Mon compte',
subtitle: null,
isSidebarMinimized: _isSidebarMinimized,
onTap: () {
// Afficher la boîte de dialogue de profil avec l'utilisateur actuel
// Utiliser l'instance globale définie dans app.dart
final user = userRepository.currentUser;
if (user != null) {
// Passer l'objet utilisateur complet
ProfileDialog.show(context, user);
} else {
// Afficher un message d'erreur si l'utilisateur n'est pas trouvé
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Erreur: Utilisateur non trouvé'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
},
),
// Option "Amicale & membres" - uniquement pour les administrateurs avec le rôle 2 et en version web
if (widget.isAdmin &&
userRepository.currentUser?.role == 2 &&
MediaQuery.of(context).size.width > 900)
_SettingsItem(
icon: Icons.people,
title: 'Amicale & membres',
isSidebarMinimized: _isSidebarMinimized,
onTap: () {
// Navigation vers le tableau de bord admin avec sélection de l'onglet "Amicale et membres"
context.go('/admin');
// Sélectionner l'onglet "Amicale et membres" (index 5)
// Nous devons sauvegarder cet index dans les paramètres pour que le tableau de bord
// puisse le récupérer et sélectionner le bon onglet
final settingsBox = Hive.box(AppKeys.settingsBoxName);
settingsBox.put('adminSelectedPageIndex', 5);
// Notifier l'utilisateur que la page est en cours de chargement
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Chargement de la page Amicale & membres...'),
duration: Duration(seconds: 2),
),
);
// Attendre un court instant pour permettre à la navigation de se terminer
Future.delayed(const Duration(milliseconds: 300), () {
// Forcer la sélection de l'onglet Amicale & membres
if (widget.isAdmin && widget.selectedIndex != 5) {
widget.onDestinationSelected(5);
}
});
},
),
// Option "Opérations" - uniquement pour les administrateurs et en version web
// Options administrateur - uniquement pour les administrateurs et en version web
if (widget.isAdmin && MediaQuery.of(context).size.width > 900)
_SettingsItem(
icon: Icons.calendar_today,
title: 'Opérations',
isSidebarMinimized: _isSidebarMinimized,
onTap: () {
// Navigation vers le tableau de bord admin
context.go('/admin');
...[],
// Note: Pas de page spécifique pour le moment, juste un placeholder
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fonctionnalité à venir'),
duration: Duration(seconds: 2),
),
);
},
),
const SizedBox(height: 16),
// Option "Aide" - toujours visible en bas
_SettingsItem(
icon: Icons.help_outline,
title: 'Aide',
@@ -456,6 +332,8 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
HelpDialog.show(context, widget.title);
},
),
const SizedBox(height: 16),
],
),
),
@@ -538,119 +416,6 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
);
}
}
/// Affiche le formulaire de passage
void _showPassageForm(BuildContext context) {
final theme = Theme.of(context);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(
'Nouveau passage',
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: InputDecoration(
labelText: 'Adresse',
prefixIcon: const Icon(Icons.location_on),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
decoration: InputDecoration(
labelText: 'Type de passage',
prefixIcon: const Icon(Icons.category),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
items: const [
DropdownMenuItem(
value: 1,
child: Text('Effectué'),
),
DropdownMenuItem(
value: 2,
child: Text('À finaliser'),
),
DropdownMenuItem(
value: 3,
child: Text('Refusé'),
),
DropdownMenuItem(
value: 4,
child: Text('Don'),
),
DropdownMenuItem(
value: 5,
child: Text('Lot'),
),
DropdownMenuItem(
value: 6,
child: Text('Maison vide'),
),
],
onChanged: (value) {},
),
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: 'Commentaire',
prefixIcon: const Icon(Icons.comment),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
maxLines: 3,
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(
'Annuler',
style: TextStyle(
color: theme.colorScheme.error,
),
),
),
ElevatedButton(
onPressed: () {
// Enregistrer le passage
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Passage enregistré avec succès'),
backgroundColor: theme.colorScheme.primary,
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
),
child: const Text('Enregistrer'),
),
],
),
);
}
}
/// Widget pour les éléments de paramètres

View File

@@ -3,113 +3,25 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/shared/app_theme.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
class SectorDistributionCard extends StatefulWidget {
class SectorDistributionCard extends StatelessWidget {
final String title;
final double? height;
final EdgeInsetsGeometry? padding;
final bool forceRefresh;
const SectorDistributionCard({
Key? key,
this.title = 'Répartition par secteur',
this.height,
this.padding,
this.forceRefresh = false,
}) : super(key: key);
@override
State<SectorDistributionCard> createState() => _SectorDistributionCardState();
}
class _SectorDistributionCardState extends State<SectorDistributionCard> {
List<Map<String, dynamic>> sectorStats = [];
bool isLoading = true;
@override
void initState() {
super.initState();
_loadSectorData();
}
@override
void didUpdateWidget(SectorDistributionCard oldWidget) {
super.didUpdateWidget(oldWidget);
// Recharger les données si forceRefresh est passé à true
if (widget.forceRefresh && !oldWidget.forceRefresh) {
_loadSectorData();
}
}
Future<void> _loadSectorData() async {
setState(() {
isLoading = true;
});
try {
// S'assurer que les boîtes Hive sont ouvertes
if (!Hive.isBoxOpen(AppKeys.sectorsBoxName)) {
await Hive.openBox<SectorModel>(AppKeys.sectorsBoxName);
}
if (!Hive.isBoxOpen(AppKeys.passagesBoxName)) {
await Hive.openBox<PassageModel>(AppKeys.passagesBoxName);
}
// Récupérer tous les secteurs
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
final List<SectorModel> sectors = sectorsBox.values.toList();
// Récupérer tous les passages
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final List<PassageModel> passages = passagesBox.values.toList();
// Compter les passages par secteur (en excluant ceux où fkType==2)
final Map<int, int> sectorCounts = {};
for (final passage in passages) {
// Exclure les passages où fkType==2
if (passage.fkSector != null && passage.fkType != 2) {
sectorCounts[passage.fkSector!] =
(sectorCounts[passage.fkSector!] ?? 0) + 1;
}
}
// Préparer les données pour l'affichage
List<Map<String, dynamic>> stats = [];
for (final sector in sectors) {
final count = sectorCounts[sector.id] ?? 0;
if (count > 0) {
stats.add({
'name': sector.libelle,
'count': count,
'color': sector.color.isEmpty
? 0xFF4B77BE
: int.tryParse(sector.color.replaceAll('#', '0xFF')) ??
0xFF4B77BE,
});
}
}
setState(() {
sectorStats = stats;
isLoading = false;
});
} catch (e) {
debugPrint('Erreur lors du chargement des données de secteur: $e');
setState(() {
isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Container(
height: widget.height,
padding: widget.padding ?? const EdgeInsets.all(AppTheme.spacingM),
height: height,
padding: padding ?? const EdgeInsets.all(AppTheme.spacingM),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
@@ -118,65 +30,117 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
if (isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: _loadSectorData,
tooltip: 'Rafraîchir',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: AppTheme.spacingM),
Expanded(
child: isLoading
? const Center(child: CircularProgressIndicator())
: sectorStats.isEmpty
? const Center(
child: Text('Aucune donnée de secteur disponible'))
: ListView.builder(
itemCount: sectorStats.length,
itemBuilder: (context, index) {
final sector = sectorStats[index];
return _buildSectorItem(
context,
sector['name'],
sector['count'],
Color(sector['color']),
);
},
),
child: _buildAutoRefreshContent(),
),
],
),
);
}
Widget _buildAutoRefreshContent() {
// Écouter les changements des deux boîtes
return ValueListenableBuilder(
valueListenable: Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
builder: (context, Box<SectorModel> sectorsBox, child) {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
return _buildContent(sectorsBox, passagesBox);
},
);
},
);
}
Widget _buildContent(Box<SectorModel> sectorsBox, Box<PassageModel> passagesBox) {
try {
// Calculer les statistiques
final sectorStats = _calculateSectorStats(sectorsBox, passagesBox);
if (sectorStats.isEmpty) {
return const Center(
child: Text('Aucune donnée de secteur disponible'),
);
}
return ListView.builder(
itemCount: sectorStats.length,
itemBuilder: (context, index) {
final sector = sectorStats[index];
return _buildSectorItem(
sector['name'],
sector['count'],
Color(sector['color']),
sectorStats,
);
},
);
} catch (e) {
debugPrint('Erreur lors du calcul des statistiques: $e');
return Center(
child: Text('Erreur: ${e.toString()}'),
);
}
}
List<Map<String, dynamic>> _calculateSectorStats(
Box<SectorModel> sectorsBox,
Box<PassageModel> passagesBox,
) {
// Récupérer tous les secteurs et passages
final List<SectorModel> sectors = sectorsBox.values.toList();
final List<PassageModel> passages = passagesBox.values.toList();
// Compter les passages par secteur (en excluant ceux où fkType==2)
final Map<int, int> sectorCounts = {};
for (final passage in passages) {
// Exclure les passages où fkType==2
if (passage.fkSector != null && passage.fkType != 2) {
sectorCounts[passage.fkSector!] =
(sectorCounts[passage.fkSector!] ?? 0) + 1;
}
}
// Préparer les données pour l'affichage
List<Map<String, dynamic>> stats = [];
for (final sector in sectors) {
final count = sectorCounts[sector.id] ?? 0;
if (count > 0) {
stats.add({
'name': sector.libelle,
'count': count,
'color': sector.color.isEmpty
? 0xFF4B77BE
: int.tryParse(sector.color.replaceAll('#', '0xFF')) ??
0xFF4B77BE,
});
}
}
// Trier par nombre de passages (décroissant)
stats.sort((a, b) => (b['count'] as int).compareTo(a['count'] as int));
return stats;
}
Widget _buildSectorItem(
BuildContext context,
String name,
int count,
Color color,
List<Map<String, dynamic>> allStats,
) {
final totalCount =
sectorStats.fold(0, (sum, item) => sum + (item['count'] as int));
allStats.fold(0, (sum, item) => sum + (item['count'] as int));
final percentage = totalCount > 0 ? (count / totalCount) * 100 : 0;
return Padding(
@@ -215,4 +179,4 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
),
);
}
}
}

View File

@@ -1,167 +0,0 @@
import 'package:flutter/material.dart';
class AppTheme {
// Couleurs principales
static const Color primaryColor = Color(0xFF4B77BE);
static const Color secondaryColor = Color(0xFF2C3E50);
static const Color accentColor = Color(0xFF3498DB);
static const Color backgroundColor = Color(0xFFF5F7FA);
static const Color cardColor = Colors.white;
// Couleurs de texte
static const Color textPrimaryColor = Color(0xFF2C3E50);
static const Color textSecondaryColor = Color(0xFF7F8C8D);
static const Color textLightColor = Color(0xFFBDC3C7);
// Couleurs des boutons
static const Color buttonPrimaryColor = Color(0xFF4B77BE);
static const Color buttonSecondaryColor = Color(0xFF95A5A6);
static const Color buttonSuccessColor = Color(0xFF2ECC71);
static const Color buttonDangerColor = Color(0xFFE74C3C);
static const Color buttonWarningColor = Color(0xFFF1C40F);
// Couleurs des charts
static const List<Color> chartColors = [
Color(0xFF4B77BE),
Color(0xFF2ECC71),
Color(0xFFE74C3C),
Color(0xFFF1C40F),
Color(0xFF9B59B6),
Color(0xFF1ABC9C),
Color(0xFFE67E22),
];
// Ombres
static List<BoxShadow> cardShadow = [
BoxShadow(
color: Colors.black.withOpacity(0.05),
spreadRadius: 1,
blurRadius: 10,
offset: const Offset(0, 3),
),
];
static List<BoxShadow> buttonShadow = [
BoxShadow(
color: Colors.black.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 5,
offset: const Offset(0, 2),
),
];
// Rayons des bordures
static const double borderRadiusSmall = 4.0;
static const double borderRadiusMedium = 8.0;
static const double borderRadiusLarge = 12.0;
// Espacement
static const double spacingXS = 4.0;
static const double spacingS = 8.0;
static const double spacingM = 16.0;
static const double spacingL = 24.0;
static const double spacingXL = 32.0;
static const double spacingXXL = 48.0;
// Thème light
static ThemeData lightTheme = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.light(
primary: primaryColor,
secondary: secondaryColor,
background: backgroundColor,
surface: cardColor,
),
scaffoldBackgroundColor: backgroundColor,
appBarTheme: const AppBarTheme(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
elevation: 0,
),
cardTheme: CardTheme(
color: cardColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: buttonPrimaryColor,
foregroundColor: Colors.white,
elevation: 2,
padding: const EdgeInsets.symmetric(
horizontal: spacingL,
vertical: spacingM,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: primaryColor,
side: const BorderSide(color: primaryColor),
padding: const EdgeInsets.symmetric(
horizontal: spacingL,
vertical: spacingM,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: primaryColor,
padding: const EdgeInsets.symmetric(
horizontal: spacingM,
vertical: spacingS,
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: BorderSide(color: textLightColor),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: BorderSide(color: textLightColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: BorderSide(color: primaryColor),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: spacingM,
vertical: spacingM,
),
),
textTheme: const TextTheme(
displayLarge: TextStyle(color: textPrimaryColor),
displayMedium: TextStyle(color: textPrimaryColor),
displaySmall: TextStyle(color: textPrimaryColor),
headlineLarge: TextStyle(color: textPrimaryColor),
headlineMedium: TextStyle(color: textPrimaryColor),
headlineSmall: TextStyle(color: textPrimaryColor),
titleLarge: TextStyle(color: textPrimaryColor),
titleMedium: TextStyle(color: textPrimaryColor),
titleSmall: TextStyle(color: textPrimaryColor),
bodyLarge: TextStyle(color: textPrimaryColor),
bodyMedium: TextStyle(color: textPrimaryColor),
bodySmall: TextStyle(color: textSecondaryColor),
labelLarge: TextStyle(color: textPrimaryColor),
labelMedium: TextStyle(color: textSecondaryColor),
labelSmall: TextStyle(color: textSecondaryColor),
),
dividerTheme: const DividerThemeData(
color: Color(0xFFECF0F1),
thickness: 1,
space: spacingM,
),
);
}