Initial commit: unionflow-mobile-apps
Application Flutter complète (sans build artifacts). Signed-off-by: lions dev Team
This commit is contained in:
96
lib/core/config/environment.dart
Normal file
96
lib/core/config/environment.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
/// Environnements de déploiement de l'application
|
||||
enum Environment { dev, staging, prod }
|
||||
|
||||
/// Configuration centralisée par environnement.
|
||||
/// Les URLs sont injectées via --dart-define=ENV=dev|staging|prod
|
||||
class AppConfig {
|
||||
static late final Environment _environment;
|
||||
static late final String apiBaseUrl;
|
||||
static late final String keycloakBaseUrl;
|
||||
static late final String wsBaseUrl;
|
||||
static late final bool enableDebugMode;
|
||||
static late final bool enableLogging;
|
||||
static late final bool enableCrashReporting;
|
||||
static late final bool enableAnalytics;
|
||||
|
||||
/// Initialise la configuration à partir de l'environnement.
|
||||
/// Appeler dans main() avant runApp().
|
||||
static void initialize() {
|
||||
const envString = String.fromEnvironment('ENV', defaultValue: 'dev');
|
||||
_environment = Environment.values.firstWhere(
|
||||
(e) => e.name == envString,
|
||||
orElse: () => Environment.dev,
|
||||
);
|
||||
|
||||
switch (_environment) {
|
||||
case Environment.dev:
|
||||
apiBaseUrl = const String.fromEnvironment(
|
||||
'API_URL',
|
||||
defaultValue: 'http://localhost:8085',
|
||||
);
|
||||
keycloakBaseUrl = const String.fromEnvironment(
|
||||
'KEYCLOAK_URL',
|
||||
defaultValue: 'http://localhost:8180',
|
||||
);
|
||||
wsBaseUrl = const String.fromEnvironment(
|
||||
'WS_URL',
|
||||
defaultValue: 'ws://localhost:8085',
|
||||
);
|
||||
enableDebugMode = true;
|
||||
enableLogging = true;
|
||||
enableCrashReporting = false;
|
||||
enableAnalytics = false;
|
||||
|
||||
case Environment.staging:
|
||||
apiBaseUrl = const String.fromEnvironment(
|
||||
'API_URL',
|
||||
defaultValue: 'https://api-staging.lions.dev',
|
||||
);
|
||||
keycloakBaseUrl = const String.fromEnvironment(
|
||||
'KEYCLOAK_URL',
|
||||
defaultValue: 'https://security-staging.lions.dev',
|
||||
);
|
||||
wsBaseUrl = const String.fromEnvironment(
|
||||
'WS_URL',
|
||||
defaultValue: 'wss://api-staging.lions.dev',
|
||||
);
|
||||
enableDebugMode = false;
|
||||
enableLogging = true;
|
||||
enableCrashReporting = true;
|
||||
enableAnalytics = false;
|
||||
|
||||
case Environment.prod:
|
||||
apiBaseUrl = const String.fromEnvironment(
|
||||
'API_URL',
|
||||
defaultValue: 'https://api.lions.dev',
|
||||
);
|
||||
keycloakBaseUrl = const String.fromEnvironment(
|
||||
'KEYCLOAK_URL',
|
||||
defaultValue: 'https://security.lions.dev',
|
||||
);
|
||||
wsBaseUrl = const String.fromEnvironment(
|
||||
'WS_URL',
|
||||
defaultValue: 'wss://api.lions.dev',
|
||||
);
|
||||
enableDebugMode = false;
|
||||
enableLogging = false;
|
||||
enableCrashReporting = true;
|
||||
enableAnalytics = true;
|
||||
}
|
||||
}
|
||||
|
||||
static Environment get environment => _environment;
|
||||
static bool get isDev => _environment == Environment.dev;
|
||||
static bool get isStaging => _environment == Environment.staging;
|
||||
static bool get isProd => _environment == Environment.prod;
|
||||
|
||||
/// URL complète du realm Keycloak
|
||||
static String get keycloakRealmUrl => '$keycloakBaseUrl/realms/unionflow';
|
||||
|
||||
/// URL du endpoint token Keycloak
|
||||
static String get keycloakTokenUrl =>
|
||||
'$keycloakRealmUrl/protocol/openid-connect/token';
|
||||
|
||||
/// URL du endpoint WebSocket dashboard
|
||||
static String get wsDashboardUrl => '$wsBaseUrl/ws/dashboard';
|
||||
}
|
||||
299
lib/core/constants/app_constants.dart
Normal file
299
lib/core/constants/app_constants.dart
Normal file
@@ -0,0 +1,299 @@
|
||||
/// Constantes globales de l'application
|
||||
library app_constants;
|
||||
|
||||
/// Constantes de l'application UnionFlow
|
||||
class AppConstants {
|
||||
// Empêcher l'instanciation
|
||||
AppConstants._();
|
||||
|
||||
// ============================================================================
|
||||
// API & BACKEND
|
||||
// Les URLs sont gérées par AppConfig (lib/core/config/environment.dart).
|
||||
// Utiliser AppConfig.apiBaseUrl, AppConfig.keycloakBaseUrl, etc.
|
||||
// ============================================================================
|
||||
|
||||
/// Realm Keycloak
|
||||
static const String keycloakRealm = 'unionflow';
|
||||
|
||||
/// Client ID Keycloak
|
||||
static const String keycloakClientId = 'unionflow-mobile';
|
||||
|
||||
/// Redirect URI pour l'authentification
|
||||
static const String redirectUri = 'dev.lions.unionflow-mobile://auth/callback';
|
||||
|
||||
// ============================================================================
|
||||
// PAGINATION
|
||||
// ============================================================================
|
||||
|
||||
/// Taille de page par défaut pour les listes paginées
|
||||
static const int defaultPageSize = 20;
|
||||
|
||||
/// Taille de page maximale
|
||||
static const int maxPageSize = 100;
|
||||
|
||||
/// Taille de page minimale
|
||||
static const int minPageSize = 5;
|
||||
|
||||
/// Page initiale
|
||||
static const int initialPage = 0;
|
||||
|
||||
// ============================================================================
|
||||
// TIMEOUTS
|
||||
// ============================================================================
|
||||
|
||||
/// Timeout de connexion (en secondes)
|
||||
static const Duration connectTimeout = Duration(seconds: 30);
|
||||
|
||||
/// Timeout d'envoi (en secondes)
|
||||
static const Duration sendTimeout = Duration(seconds: 30);
|
||||
|
||||
/// Timeout de réception (en secondes)
|
||||
static const Duration receiveTimeout = Duration(seconds: 30);
|
||||
|
||||
// ============================================================================
|
||||
// CACHE
|
||||
// ============================================================================
|
||||
|
||||
/// Durée d'expiration du cache (en heures)
|
||||
static const Duration cacheExpiration = Duration(hours: 1);
|
||||
|
||||
/// Durée d'expiration du cache pour les données statiques (en jours)
|
||||
static const Duration staticCacheExpiration = Duration(days: 7);
|
||||
|
||||
/// Taille maximale du cache (en MB)
|
||||
static const int maxCacheSize = 100;
|
||||
|
||||
// ============================================================================
|
||||
// UI & DESIGN
|
||||
// ============================================================================
|
||||
|
||||
/// Padding par défaut
|
||||
static const double defaultPadding = 16.0;
|
||||
|
||||
/// Padding petit
|
||||
static const double smallPadding = 8.0;
|
||||
|
||||
/// Padding large
|
||||
static const double largePadding = 24.0;
|
||||
|
||||
/// Padding extra large
|
||||
static const double extraLargePadding = 32.0;
|
||||
|
||||
/// Rayon de bordure par défaut
|
||||
static const double defaultRadius = 8.0;
|
||||
|
||||
/// Rayon de bordure petit
|
||||
static const double smallRadius = 4.0;
|
||||
|
||||
/// Rayon de bordure large
|
||||
static const double largeRadius = 12.0;
|
||||
|
||||
/// Rayon de bordure extra large
|
||||
static const double extraLargeRadius = 16.0;
|
||||
|
||||
/// Hauteur de l'AppBar
|
||||
static const double appBarHeight = 56.0;
|
||||
|
||||
/// Hauteur du BottomNavigationBar
|
||||
static const double bottomNavBarHeight = 60.0;
|
||||
|
||||
/// Largeur maximale pour les écrans larges (tablettes, desktop)
|
||||
static const double maxContentWidth = 1200.0;
|
||||
|
||||
// ============================================================================
|
||||
// ANIMATIONS
|
||||
// ============================================================================
|
||||
|
||||
/// Durée d'animation par défaut
|
||||
static const Duration defaultAnimationDuration = Duration(milliseconds: 300);
|
||||
|
||||
/// Durée d'animation rapide
|
||||
static const Duration fastAnimationDuration = Duration(milliseconds: 150);
|
||||
|
||||
/// Durée d'animation lente
|
||||
static const Duration slowAnimationDuration = Duration(milliseconds: 500);
|
||||
|
||||
// ============================================================================
|
||||
// VALIDATION
|
||||
// ============================================================================
|
||||
|
||||
/// Longueur minimale du mot de passe
|
||||
static const int minPasswordLength = 8;
|
||||
|
||||
/// Longueur maximale du mot de passe
|
||||
static const int maxPasswordLength = 128;
|
||||
|
||||
/// Longueur minimale du nom
|
||||
static const int minNameLength = 2;
|
||||
|
||||
/// Longueur maximale du nom
|
||||
static const int maxNameLength = 100;
|
||||
|
||||
/// Longueur maximale de la description
|
||||
static const int maxDescriptionLength = 1000;
|
||||
|
||||
/// Longueur maximale du titre
|
||||
static const int maxTitleLength = 200;
|
||||
|
||||
// ============================================================================
|
||||
// FORMATS
|
||||
// ============================================================================
|
||||
|
||||
/// Format de date par défaut (dd/MM/yyyy)
|
||||
static const String defaultDateFormat = 'dd/MM/yyyy';
|
||||
|
||||
/// Format de date et heure (dd/MM/yyyy HH:mm)
|
||||
static const String defaultDateTimeFormat = 'dd/MM/yyyy HH:mm';
|
||||
|
||||
/// Format de date court (dd/MM/yy)
|
||||
static const String shortDateFormat = 'dd/MM/yy';
|
||||
|
||||
/// Format de date long (EEEE dd MMMM yyyy)
|
||||
static const String longDateFormat = 'EEEE dd MMMM yyyy';
|
||||
|
||||
/// Format d'heure (HH:mm)
|
||||
static const String timeFormat = 'HH:mm';
|
||||
|
||||
/// Format d'heure avec secondes (HH:mm:ss)
|
||||
static const String timeWithSecondsFormat = 'HH:mm:ss';
|
||||
|
||||
// ============================================================================
|
||||
// DEVISE
|
||||
// ============================================================================
|
||||
|
||||
/// Devise par défaut
|
||||
static const String defaultCurrency = 'EUR';
|
||||
|
||||
/// Symbole de la devise par défaut
|
||||
static const String defaultCurrencySymbol = '€';
|
||||
|
||||
// ============================================================================
|
||||
// IMAGES
|
||||
// ============================================================================
|
||||
|
||||
/// Taille maximale d'upload d'image (en MB)
|
||||
static const int maxImageUploadSize = 5;
|
||||
|
||||
/// Formats d'image acceptés
|
||||
static const List<String> acceptedImageFormats = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
|
||||
/// Qualité de compression d'image (0-100)
|
||||
static const int imageCompressionQuality = 85;
|
||||
|
||||
// ============================================================================
|
||||
// DOCUMENTS
|
||||
// ============================================================================
|
||||
|
||||
/// Taille maximale d'upload de document (en MB)
|
||||
static const int maxDocumentUploadSize = 10;
|
||||
|
||||
/// Formats de document acceptés
|
||||
static const List<String> acceptedDocumentFormats = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt'];
|
||||
|
||||
// ============================================================================
|
||||
// NOTIFICATIONS
|
||||
// ============================================================================
|
||||
|
||||
/// Durée d'affichage des snackbars (en secondes)
|
||||
static const Duration snackbarDuration = Duration(seconds: 3);
|
||||
|
||||
/// Durée d'affichage des snackbars d'erreur (en secondes)
|
||||
static const Duration errorSnackbarDuration = Duration(seconds: 5);
|
||||
|
||||
/// Durée d'affichage des snackbars de succès (en secondes)
|
||||
static const Duration successSnackbarDuration = Duration(seconds: 2);
|
||||
|
||||
// ============================================================================
|
||||
// RECHERCHE
|
||||
// ============================================================================
|
||||
|
||||
/// Délai de debounce pour la recherche (en millisecondes)
|
||||
static const Duration searchDebounce = Duration(milliseconds: 500);
|
||||
|
||||
/// Nombre minimum de caractères pour déclencher une recherche
|
||||
static const int minSearchLength = 2;
|
||||
|
||||
// ============================================================================
|
||||
// REFRESH
|
||||
// ============================================================================
|
||||
|
||||
/// Intervalle de rafraîchissement automatique (en minutes)
|
||||
static const Duration autoRefreshInterval = Duration(minutes: 5);
|
||||
|
||||
// ============================================================================
|
||||
// STORAGE KEYS
|
||||
// ============================================================================
|
||||
|
||||
/// Clé pour le token d'accès
|
||||
static const String accessTokenKey = 'access_token';
|
||||
|
||||
/// Clé pour le refresh token
|
||||
static const String refreshTokenKey = 'refresh_token';
|
||||
|
||||
/// Clé pour l'ID token
|
||||
static const String idTokenKey = 'id_token';
|
||||
|
||||
/// Clé pour les données utilisateur
|
||||
static const String userDataKey = 'user_data';
|
||||
|
||||
/// Clé pour les préférences de thème
|
||||
static const String themePreferenceKey = 'theme_preference';
|
||||
|
||||
/// Clé pour les préférences de langue
|
||||
static const String languagePreferenceKey = 'language_preference';
|
||||
|
||||
/// Clé pour le mode hors ligne
|
||||
static const String offlineModeKey = 'offline_mode';
|
||||
|
||||
// ============================================================================
|
||||
// REGEX PATTERNS
|
||||
// ============================================================================
|
||||
|
||||
/// Pattern pour valider un email
|
||||
static const String emailPattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$';
|
||||
|
||||
/// Pattern pour valider un numéro de téléphone français
|
||||
static const String phonePattern = r'^(?:(?:\+|00)33|0)\s*[1-9](?:[\s.-]*\d{2}){4}$';
|
||||
|
||||
/// Pattern pour valider un code postal français
|
||||
static const String postalCodePattern = r'^\d{5}$';
|
||||
|
||||
/// Pattern pour valider une URL
|
||||
static const String urlPattern = r'^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$';
|
||||
|
||||
// ============================================================================
|
||||
// FEATURES FLAGS
|
||||
// ============================================================================
|
||||
|
||||
// Feature flags pilotés par AppConfig (lib/core/config/environment.dart).
|
||||
// Utiliser AppConfig.enableDebugMode, AppConfig.enableLogging, etc.
|
||||
|
||||
/// Activer le mode offline
|
||||
static const bool enableOfflineMode = false;
|
||||
|
||||
// ============================================================================
|
||||
// APP INFO
|
||||
// ============================================================================
|
||||
|
||||
/// Nom de l'application
|
||||
static const String appName = 'UnionFlow';
|
||||
|
||||
/// Version de l'application
|
||||
static const String appVersion = '1.0.0';
|
||||
|
||||
/// Build number
|
||||
static const String buildNumber = '1';
|
||||
|
||||
/// Email de support
|
||||
static const String supportEmail = 'support@unionflow.com';
|
||||
|
||||
/// URL du site web
|
||||
static const String websiteUrl = 'https://unionflow.com';
|
||||
|
||||
/// URL des conditions d'utilisation
|
||||
static const String termsOfServiceUrl = 'https://unionflow.com/terms';
|
||||
|
||||
/// URL de la politique de confidentialité
|
||||
static const String privacyPolicyUrl = 'https://unionflow.com/privacy';
|
||||
}
|
||||
|
||||
3
lib/core/constants/lcb_ft_constants.dart
Normal file
3
lib/core/constants/lcb_ft_constants.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
/// Constantes LCB-FT (anti-blanchiment) pour l'UI.
|
||||
/// Au-dessus de ce montant, l'origine des fonds est obligatoire côté backend.
|
||||
const double kSeuilOrigineFondsObligatoireXOF = 500000.0;
|
||||
26
lib/core/data/models/seuil_lcb_ft_model.dart
Normal file
26
lib/core/data/models/seuil_lcb_ft_model.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
/// Modèle pour le seuil LCB-FT récupéré depuis l'API.
|
||||
/// Endpoint: GET /api/parametres-lcb-ft/seuil-justification
|
||||
class SeuilLcbFtModel {
|
||||
final double montantSeuil;
|
||||
final String codeDevise;
|
||||
|
||||
const SeuilLcbFtModel({
|
||||
required this.montantSeuil,
|
||||
required this.codeDevise,
|
||||
});
|
||||
|
||||
factory SeuilLcbFtModel.fromJson(Map<String, dynamic> json) {
|
||||
return SeuilLcbFtModel(
|
||||
montantSeuil: (json['montantSeuil'] as num).toDouble(),
|
||||
codeDevise: json['codeDevise'] as String? ?? 'XOF',
|
||||
);
|
||||
}
|
||||
|
||||
/// Seuil par défaut si l'API échoue (500k XOF selon spec LCB-FT BCEAO).
|
||||
factory SeuilLcbFtModel.defaultSeuil() {
|
||||
return const SeuilLcbFtModel(
|
||||
montantSeuil: 500000.0,
|
||||
codeDevise: 'XOF',
|
||||
);
|
||||
}
|
||||
}
|
||||
84
lib/core/data/repositories/parametres_lcb_ft_repository.dart
Normal file
84
lib/core/data/repositories/parametres_lcb_ft_repository.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
|
||||
import 'package:unionflow_mobile_apps/core/utils/logger.dart';
|
||||
import '../models/seuil_lcb_ft_model.dart';
|
||||
|
||||
/// Repository pour les paramètres LCB-FT (seuils anti-blanchiment).
|
||||
/// Endpoints: GET /api/parametres-lcb-ft, GET /api/parametres-lcb-ft/seuil-justification
|
||||
@lazySingleton
|
||||
class ParametresLcbFtRepository {
|
||||
final ApiClient _apiClient;
|
||||
static const String _base = '/api/parametres-lcb-ft';
|
||||
|
||||
ParametresLcbFtRepository(this._apiClient);
|
||||
|
||||
/// Récupère uniquement le seuil de justification (endpoint léger).
|
||||
/// Paramètres optionnels : organisationId, codeDevise (XOF par défaut).
|
||||
/// Retourne le seuil par défaut (500k XOF) en cas d'erreur.
|
||||
Future<SeuilLcbFtModel> getSeuilJustification({
|
||||
String? organisationId,
|
||||
String codeDevise = 'XOF',
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (organisationId != null && organisationId.isNotEmpty) {
|
||||
queryParams['organisationId'] = organisationId;
|
||||
}
|
||||
queryParams['codeDevise'] = codeDevise;
|
||||
|
||||
final response = await _apiClient.get(
|
||||
'$_base/seuil-justification',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
return SeuilLcbFtModel.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
AppLogger.warning(
|
||||
'ParametresLcbFtRepository: getSeuilJustification status ${response.statusCode}, fallback au seuil par défaut',
|
||||
);
|
||||
return SeuilLcbFtModel.defaultSeuil();
|
||||
} catch (e, st) {
|
||||
AppLogger.error(
|
||||
'ParametresLcbFtRepository: getSeuilJustification échoué, fallback au seuil par défaut',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
return SeuilLcbFtModel.defaultSeuil();
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les paramètres LCB-FT complets (tous les seuils + config).
|
||||
/// Pour usage admin ou affichage détaillé.
|
||||
Future<Map<String, dynamic>?> getParametres({
|
||||
String? organisationId,
|
||||
String codeDevise = 'XOF',
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (organisationId != null && organisationId.isNotEmpty) {
|
||||
queryParams['organisationId'] = organisationId;
|
||||
}
|
||||
queryParams['codeDevise'] = codeDevise;
|
||||
|
||||
final response = await _apiClient.get(_base, queryParameters: queryParams);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
AppLogger.warning(
|
||||
'ParametresLcbFtRepository: getParametres status ${response.statusCode}',
|
||||
);
|
||||
return null;
|
||||
} catch (e, st) {
|
||||
AppLogger.error(
|
||||
'ParametresLcbFtRepository: getParametres échoué',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
lib/core/di/injection.dart
Normal file
13
lib/core/di/injection.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import 'injection.config.dart';
|
||||
|
||||
final GetIt getIt = GetIt.instance;
|
||||
|
||||
@InjectableInit(
|
||||
initializerName: 'init', // default
|
||||
preferRelativeImports: true, // default
|
||||
asExtension: true, // default
|
||||
)
|
||||
void configureDependencies() => getIt.init();
|
||||
19
lib/core/di/injection_container.dart
Normal file
19
lib/core/di/injection_container.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
/// Export getIt for convenience
|
||||
export 'injection.dart' show getIt;
|
||||
|
||||
import 'injection.dart';
|
||||
|
||||
/// Service locator global
|
||||
final GetIt sl = getIt;
|
||||
|
||||
/// Initialise toutes les dépendances de l'application
|
||||
Future<void> initializeDependencies() async {
|
||||
configureDependencies();
|
||||
}
|
||||
|
||||
/// Nettoie toutes les dépendances (optionnel, pour les tests)
|
||||
Future<void> cleanupDependencies() async {
|
||||
await sl.reset();
|
||||
}
|
||||
22
lib/core/di/register_module.dart
Normal file
22
lib/core/di/register_module.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@module
|
||||
abstract class RegisterModule {
|
||||
@lazySingleton
|
||||
Connectivity get connectivity => Connectivity();
|
||||
|
||||
@lazySingleton
|
||||
FlutterSecureStorage get storage => const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
@lazySingleton
|
||||
http.Client get httpClient => http.Client();
|
||||
|
||||
@preResolve
|
||||
Future<SharedPreferences> get sharedPreferences => SharedPreferences.getInstance();
|
||||
}
|
||||
192
lib/core/error/error_handler.dart
Normal file
192
lib/core/error/error_handler.dart
Normal file
@@ -0,0 +1,192 @@
|
||||
/// Gestionnaire d'erreurs global pour l'application
|
||||
library error_handler;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// Classe utilitaire pour gérer les erreurs de manière centralisée
|
||||
class ErrorHandler {
|
||||
/// Convertit une erreur en message utilisateur lisible
|
||||
static String getErrorMessage(dynamic error) {
|
||||
if (error is DioException) {
|
||||
return _handleDioError(error);
|
||||
} else if (error is String) {
|
||||
return error;
|
||||
} else if (error is Exception) {
|
||||
return error.toString().replaceAll('Exception: ', '');
|
||||
}
|
||||
return 'Une erreur inattendue s\'est produite.';
|
||||
}
|
||||
|
||||
/// Gère les erreurs Dio spécifiques
|
||||
static String _handleDioError(DioException error) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
return 'Délai de connexion dépassé.\nVérifiez votre connexion internet.';
|
||||
|
||||
case DioExceptionType.sendTimeout:
|
||||
return 'Délai d\'envoi dépassé.\nVérifiez votre connexion internet.';
|
||||
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'Délai de réception dépassé.\nLe serveur met trop de temps à répondre.';
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
return _handleBadResponse(error.response);
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return 'Requête annulée.';
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
return 'Erreur de connexion.\nVérifiez votre connexion internet.';
|
||||
|
||||
case DioExceptionType.badCertificate:
|
||||
return 'Erreur de certificat SSL.\nLa connexion n\'est pas sécurisée.';
|
||||
|
||||
case DioExceptionType.unknown:
|
||||
default:
|
||||
if (error.message?.contains('SocketException') ?? false) {
|
||||
return 'Impossible de se connecter au serveur.\nVérifiez votre connexion internet.';
|
||||
}
|
||||
return 'Erreur de connexion.\nVeuillez réessayer.';
|
||||
}
|
||||
}
|
||||
|
||||
/// Gère les réponses HTTP avec erreur
|
||||
static String _handleBadResponse(Response? response) {
|
||||
if (response == null) {
|
||||
return 'Erreur serveur inconnue.';
|
||||
}
|
||||
|
||||
// Essayer d'extraire le message d'erreur du body
|
||||
String? errorMessage;
|
||||
if (response.data is Map) {
|
||||
errorMessage = response.data['message'] ??
|
||||
response.data['error'] ??
|
||||
response.data['details'];
|
||||
}
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 400:
|
||||
return errorMessage ?? 'Requête invalide.\nVérifiez les données saisies.';
|
||||
|
||||
case 401:
|
||||
return errorMessage ?? 'Non authentifié.\nVeuillez vous reconnecter.';
|
||||
|
||||
case 403:
|
||||
return errorMessage ?? 'Accès refusé.\nVous n\'avez pas les permissions nécessaires.';
|
||||
|
||||
case 404:
|
||||
return errorMessage ?? 'Ressource non trouvée.';
|
||||
|
||||
case 409:
|
||||
return errorMessage ?? 'Conflit.\nCette ressource existe déjà.';
|
||||
|
||||
case 422:
|
||||
return errorMessage ?? 'Données invalides.\nVérifiez les informations saisies.';
|
||||
|
||||
case 429:
|
||||
return 'Trop de requêtes.\nVeuillez patienter quelques instants.';
|
||||
|
||||
case 500:
|
||||
return errorMessage ?? 'Erreur serveur interne.\nVeuillez réessayer plus tard.';
|
||||
|
||||
case 502:
|
||||
return 'Passerelle incorrecte.\nLe serveur est temporairement indisponible.';
|
||||
|
||||
case 503:
|
||||
return 'Service temporairement indisponible.\nVeuillez réessayer plus tard.';
|
||||
|
||||
case 504:
|
||||
return 'Délai d\'attente de la passerelle dépassé.\nLe serveur met trop de temps à répondre.';
|
||||
|
||||
default:
|
||||
return errorMessage ?? 'Erreur serveur (${response.statusCode}).\nVeuillez réessayer.';
|
||||
}
|
||||
}
|
||||
|
||||
/// Détermine si l'erreur est une erreur réseau
|
||||
static bool isNetworkError(dynamic error) {
|
||||
if (error is DioException) {
|
||||
return error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.connectionError ||
|
||||
(error.message?.contains('SocketException') ?? false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Détermine si l'erreur est une erreur d'authentification
|
||||
static bool isAuthError(dynamic error) {
|
||||
if (error is DioException && error.response != null) {
|
||||
return error.response!.statusCode == 401;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Détermine si l'erreur est une erreur de permissions
|
||||
static bool isPermissionError(dynamic error) {
|
||||
if (error is DioException && error.response != null) {
|
||||
return error.response!.statusCode == 403;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Détermine si l'erreur est une erreur de validation
|
||||
static bool isValidationError(dynamic error) {
|
||||
if (error is DioException && error.response != null) {
|
||||
return error.response!.statusCode == 400 ||
|
||||
error.response!.statusCode == 422;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Détermine si l'erreur est une erreur serveur
|
||||
static bool isServerError(dynamic error) {
|
||||
if (error is DioException && error.response != null) {
|
||||
final statusCode = error.response!.statusCode ?? 0;
|
||||
return statusCode >= 500 && statusCode < 600;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Extrait les détails de validation d'une erreur
|
||||
static Map<String, dynamic>? getValidationErrors(dynamic error) {
|
||||
if (error is DioException &&
|
||||
error.response != null &&
|
||||
error.response!.data is Map) {
|
||||
final data = error.response!.data as Map;
|
||||
if (data.containsKey('errors')) {
|
||||
return data['errors'] as Map<String, dynamic>?;
|
||||
}
|
||||
if (data.containsKey('validationErrors')) {
|
||||
return data['validationErrors'] as Map<String, dynamic>?;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension pour faciliter l'utilisation de ErrorHandler
|
||||
extension ErrorHandlerExtension on Object {
|
||||
/// Convertit l'objet en message d'erreur lisible
|
||||
String toErrorMessage() => ErrorHandler.getErrorMessage(this);
|
||||
|
||||
/// Vérifie si c'est une erreur réseau
|
||||
bool get isNetworkError => ErrorHandler.isNetworkError(this);
|
||||
|
||||
/// Vérifie si c'est une erreur d'authentification
|
||||
bool get isAuthError => ErrorHandler.isAuthError(this);
|
||||
|
||||
/// Vérifie si c'est une erreur de permissions
|
||||
bool get isPermissionError => ErrorHandler.isPermissionError(this);
|
||||
|
||||
/// Vérifie si c'est une erreur de validation
|
||||
bool get isValidationError => ErrorHandler.isValidationError(this);
|
||||
|
||||
/// Vérifie si c'est une erreur serveur
|
||||
bool get isServerError => ErrorHandler.isServerError(this);
|
||||
|
||||
/// Récupère les erreurs de validation
|
||||
Map<String, dynamic>? get validationErrors => ErrorHandler.getValidationErrors(this);
|
||||
}
|
||||
|
||||
74
lib/core/error/exceptions.dart
Normal file
74
lib/core/error/exceptions.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
/// Exception de base pour l'application
|
||||
abstract class AppException implements Exception {
|
||||
final String message;
|
||||
final String? code;
|
||||
|
||||
const AppException(this.message, [this.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'AppException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception serveur
|
||||
class ServerException extends AppException {
|
||||
const ServerException(super.message, [super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'ServerException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception de cache
|
||||
class CacheException extends AppException {
|
||||
const CacheException(super.message, [super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'CacheException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception de réseau
|
||||
class NetworkException extends AppException {
|
||||
const NetworkException(super.message, [super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'NetworkException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception d'authentification
|
||||
class AuthException extends AppException {
|
||||
const AuthException(super.message, [super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'AuthException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception de validation
|
||||
class ValidationException extends AppException {
|
||||
const ValidationException(super.message, [super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'ValidationException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception non autorisé (401)
|
||||
class UnauthorizedException extends AppException {
|
||||
const UnauthorizedException([super.message = 'Non autorisé', super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'UnauthorizedException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception non trouvé (404)
|
||||
class NotFoundException extends AppException {
|
||||
const NotFoundException([super.message = 'Ressource non trouvée', super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'NotFoundException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Exception interdit (403)
|
||||
class ForbiddenException extends AppException {
|
||||
const ForbiddenException([super.message = 'Accès interdit', super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'ForbiddenException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
143
lib/core/error/failures.dart
Normal file
143
lib/core/error/failures.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Classe de base pour tous les échecs
|
||||
abstract class Failure extends Equatable {
|
||||
final String message;
|
||||
final String? code;
|
||||
final bool isRetryable;
|
||||
final String? userFriendlyMessage;
|
||||
|
||||
const Failure(
|
||||
this.message, [
|
||||
this.code,
|
||||
this.isRetryable = false,
|
||||
this.userFriendlyMessage,
|
||||
]);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, code, isRetryable, userFriendlyMessage];
|
||||
|
||||
/// Get user-friendly message for display in UI
|
||||
String getUserMessage() => userFriendlyMessage ?? message;
|
||||
|
||||
@override
|
||||
String toString() => 'Failure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Échec serveur
|
||||
class ServerFailure extends Failure {
|
||||
const ServerFailure(
|
||||
super.message, [
|
||||
super.code,
|
||||
super.isRetryable = true, // Server errors are retryable
|
||||
super.userFriendlyMessage = 'Le serveur rencontre un problème. Veuillez réessayer.',
|
||||
]);
|
||||
|
||||
@override
|
||||
String toString() => 'ServerFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Échec de cache
|
||||
class CacheFailure extends Failure {
|
||||
const CacheFailure(super.message, [super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'CacheFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Échec de réseau
|
||||
class NetworkFailure extends Failure {
|
||||
const NetworkFailure(
|
||||
super.message, [
|
||||
super.code,
|
||||
super.isRetryable = true, // Network errors are retryable
|
||||
super.userFriendlyMessage = 'Pas de connexion Internet. Vérifiez votre réseau.',
|
||||
]);
|
||||
|
||||
@override
|
||||
String toString() => 'NetworkFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Échec d'authentification
|
||||
class AuthFailure extends Failure {
|
||||
const AuthFailure(super.message, [super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'AuthFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Échec de validation
|
||||
class ValidationFailure extends Failure {
|
||||
const ValidationFailure(
|
||||
super.message, [
|
||||
super.code,
|
||||
super.isRetryable = false, // Validation errors are not retryable
|
||||
super.userFriendlyMessage,
|
||||
]);
|
||||
|
||||
@override
|
||||
String toString() => 'ValidationFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Échec de permission
|
||||
class PermissionFailure extends Failure {
|
||||
const PermissionFailure(super.message, [super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'PermissionFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Échec de données non trouvées
|
||||
class NotFoundFailure extends Failure {
|
||||
const NotFoundFailure(
|
||||
super.message, [
|
||||
super.code,
|
||||
super.isRetryable = false, // Not found errors are not retryable
|
||||
super.userFriendlyMessage,
|
||||
]);
|
||||
|
||||
@override
|
||||
String toString() => 'NotFoundFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Échec non autorisé (401)
|
||||
class UnauthorizedFailure extends Failure {
|
||||
const UnauthorizedFailure(
|
||||
super.message, [
|
||||
super.code,
|
||||
super.isRetryable = false, // Auth errors are not retryable
|
||||
super.userFriendlyMessage = 'Votre session a expiré. Veuillez vous reconnecter.',
|
||||
]);
|
||||
|
||||
@override
|
||||
String toString() => 'UnauthorizedFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Échec interdit (403)
|
||||
class ForbiddenFailure extends Failure {
|
||||
const ForbiddenFailure(
|
||||
super.message, [
|
||||
super.code,
|
||||
super.isRetryable = false, // Forbidden errors are not retryable
|
||||
super.userFriendlyMessage = 'Vous n\'avez pas les permissions nécessaires.',
|
||||
]);
|
||||
|
||||
@override
|
||||
String toString() => 'ForbiddenFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Échec inattendu
|
||||
class UnexpectedFailure extends Failure {
|
||||
const UnexpectedFailure(super.message, [super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'UnexpectedFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Fonctionnalité non implémentée
|
||||
class NotImplementedFailure extends Failure {
|
||||
const NotImplementedFailure(super.message, [super.code]);
|
||||
|
||||
@override
|
||||
String toString() => 'NotImplementedFailure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
102
lib/core/l10n/locale_provider.dart
Normal file
102
lib/core/l10n/locale_provider.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
/// Provider pour gérer la locale de l'application
|
||||
library locale_provider;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
|
||||
/// Provider pour la gestion de la locale
|
||||
class LocaleProvider extends ChangeNotifier {
|
||||
static const String _localeKey = 'app_locale';
|
||||
|
||||
Locale _locale = const Locale('fr');
|
||||
|
||||
/// Locale actuelle
|
||||
Locale get locale => _locale;
|
||||
|
||||
/// Locales supportées
|
||||
static const List<Locale> supportedLocales = [
|
||||
Locale('fr'),
|
||||
Locale('en'),
|
||||
];
|
||||
|
||||
/// Initialiser la locale depuis les préférences
|
||||
Future<void> initialize() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final localeCode = prefs.getString(_localeKey);
|
||||
|
||||
if (localeCode != null) {
|
||||
_locale = Locale(localeCode);
|
||||
AppLogger.info('Locale chargée: $localeCode');
|
||||
} else {
|
||||
AppLogger.info('Locale par défaut: fr');
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors du chargement de la locale',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Changer la locale
|
||||
Future<void> setLocale(Locale locale) async {
|
||||
if (!supportedLocales.contains(locale)) {
|
||||
AppLogger.warning('Locale non supportée: ${locale.languageCode}');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_locale == locale) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
_locale = locale;
|
||||
notifyListeners();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_localeKey, locale.languageCode);
|
||||
|
||||
AppLogger.info('Locale changée: ${locale.languageCode}');
|
||||
AppLogger.userAction('Change language', data: {'locale': locale.languageCode});
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.error(
|
||||
'Erreur lors du changement de locale',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Basculer entre FR et EN
|
||||
Future<void> toggleLocale() async {
|
||||
final newLocale = _locale.languageCode == 'fr'
|
||||
? const Locale('en')
|
||||
: const Locale('fr');
|
||||
await setLocale(newLocale);
|
||||
}
|
||||
|
||||
/// Obtenir le nom de la langue actuelle
|
||||
String get currentLanguageName {
|
||||
switch (_locale.languageCode) {
|
||||
case 'fr':
|
||||
return 'Français';
|
||||
case 'en':
|
||||
return 'English';
|
||||
default:
|
||||
return 'Français';
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifier si la locale est française
|
||||
bool get isFrench => _locale.languageCode == 'fr';
|
||||
|
||||
/// Vérifier si la locale est anglaise
|
||||
bool get isEnglish => _locale.languageCode == 'en';
|
||||
}
|
||||
|
||||
561
lib/core/navigation/adaptive_navigation.dart
Normal file
561
lib/core/navigation/adaptive_navigation.dart
Normal file
@@ -0,0 +1,561 @@
|
||||
/// Système de navigation adaptatif basé sur les rôles
|
||||
/// Navigation qui s'adapte selon les permissions et rôles utilisateurs
|
||||
library adaptive_navigation;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
|
||||
import '../../features/authentication/data/models/user_role.dart';
|
||||
import '../../features/authentication/data/models/permission_matrix.dart';
|
||||
import '../../shared/widgets/adaptive_widget.dart';
|
||||
|
||||
/// Élément de navigation adaptatif
|
||||
class AdaptiveNavigationItem {
|
||||
/// Icône de l'élément
|
||||
final IconData icon;
|
||||
|
||||
/// Icône sélectionnée (optionnelle)
|
||||
final IconData? selectedIcon;
|
||||
|
||||
/// Libellé de l'élément
|
||||
final String label;
|
||||
|
||||
/// Route de destination
|
||||
final String route;
|
||||
|
||||
/// Permissions requises pour afficher cet élément
|
||||
final List<String> requiredPermissions;
|
||||
|
||||
/// Rôles minimum requis
|
||||
final UserRole? minimumRole;
|
||||
|
||||
/// Badge de notification (optionnel)
|
||||
final String? badge;
|
||||
|
||||
/// Couleur personnalisée (optionnelle)
|
||||
final Color? color;
|
||||
|
||||
const AdaptiveNavigationItem({
|
||||
required this.icon,
|
||||
this.selectedIcon,
|
||||
required this.label,
|
||||
required this.route,
|
||||
this.requiredPermissions = const [],
|
||||
this.minimumRole,
|
||||
this.badge,
|
||||
this.color,
|
||||
});
|
||||
}
|
||||
|
||||
/// Drawer de navigation adaptatif
|
||||
class AdaptiveNavigationDrawer extends StatelessWidget {
|
||||
/// Callback de navigation
|
||||
final Function(String route) onNavigate;
|
||||
|
||||
/// Callback de déconnexion
|
||||
final VoidCallback onLogout;
|
||||
|
||||
/// Éléments de navigation personnalisés
|
||||
final List<AdaptiveNavigationItem>? customItems;
|
||||
|
||||
const AdaptiveNavigationDrawer({
|
||||
super.key,
|
||||
required this.onNavigate,
|
||||
required this.onLogout,
|
||||
this.customItems,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AdaptiveWidget(
|
||||
roleWidgets: {
|
||||
UserRole.superAdmin: () => _buildSuperAdminDrawer(context),
|
||||
UserRole.orgAdmin: () => _buildOrgAdminDrawer(context),
|
||||
UserRole.moderator: () => _buildModeratorDrawer(context),
|
||||
UserRole.activeMember: () => _buildActiveMemberDrawer(context),
|
||||
UserRole.simpleMember: () => _buildSimpleMemberDrawer(context),
|
||||
UserRole.visitor: () => _buildVisitorDrawer(context),
|
||||
},
|
||||
fallbackWidget: _buildBasicDrawer(context),
|
||||
loadingWidget: _buildLoadingDrawer(context),
|
||||
);
|
||||
}
|
||||
|
||||
/// Drawer pour Super Admin
|
||||
Widget _buildSuperAdminDrawer(BuildContext context) {
|
||||
final items = [
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.dashboard,
|
||||
label: 'Command Center',
|
||||
route: '/dashboard',
|
||||
requiredPermissions: [PermissionMatrix.SYSTEM_ADMIN],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.business,
|
||||
label: 'Organisations',
|
||||
route: '/organizations',
|
||||
requiredPermissions: [PermissionMatrix.ORG_CREATE],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.people,
|
||||
label: 'Utilisateurs Globaux',
|
||||
route: '/global-users',
|
||||
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_ALL],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.settings,
|
||||
label: 'Administration',
|
||||
route: '/system-admin',
|
||||
requiredPermissions: [PermissionMatrix.SYSTEM_CONFIG],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.analytics,
|
||||
label: 'Analytics',
|
||||
route: '/analytics',
|
||||
requiredPermissions: [PermissionMatrix.DASHBOARD_ANALYTICS],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.security,
|
||||
label: 'Sécurité',
|
||||
route: '/security',
|
||||
requiredPermissions: [PermissionMatrix.SYSTEM_SECURITY],
|
||||
),
|
||||
];
|
||||
|
||||
return _buildDrawer(
|
||||
context,
|
||||
'Super Administrateur',
|
||||
const Color(0xFF6C5CE7),
|
||||
Icons.admin_panel_settings,
|
||||
items,
|
||||
);
|
||||
}
|
||||
|
||||
/// Drawer pour Org Admin
|
||||
Widget _buildOrgAdminDrawer(BuildContext context) {
|
||||
final items = [
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.dashboard,
|
||||
label: 'Control Panel',
|
||||
route: '/dashboard',
|
||||
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.people,
|
||||
label: 'Membres',
|
||||
route: '/members',
|
||||
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_ALL],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.account_balance_wallet,
|
||||
label: 'Finances',
|
||||
route: '/finances',
|
||||
requiredPermissions: [PermissionMatrix.FINANCES_VIEW_ALL],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.event,
|
||||
label: 'Événements',
|
||||
route: '/events',
|
||||
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_ALL],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.volunteer_activism,
|
||||
label: 'Solidarité',
|
||||
route: '/solidarity',
|
||||
requiredPermissions: [PermissionMatrix.SOLIDARITY_VIEW_ALL],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.assessment,
|
||||
label: 'Rapports',
|
||||
route: '/reports',
|
||||
requiredPermissions: [PermissionMatrix.REPORTS_GENERATE],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.settings,
|
||||
label: 'Configuration',
|
||||
route: '/org-settings',
|
||||
requiredPermissions: [PermissionMatrix.ORG_CONFIG],
|
||||
),
|
||||
];
|
||||
|
||||
return _buildDrawer(
|
||||
context,
|
||||
'Administrateur',
|
||||
const Color(0xFF0984E3),
|
||||
Icons.business_center,
|
||||
items,
|
||||
);
|
||||
}
|
||||
|
||||
/// Drawer pour Modérateur
|
||||
Widget _buildModeratorDrawer(BuildContext context) {
|
||||
final items = [
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.dashboard,
|
||||
label: 'Management Hub',
|
||||
route: '/dashboard',
|
||||
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.gavel,
|
||||
label: 'Modération',
|
||||
route: '/moderation',
|
||||
requiredPermissions: [PermissionMatrix.MODERATION_CONTENT],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.people,
|
||||
label: 'Membres',
|
||||
route: '/members',
|
||||
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_ALL],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.event,
|
||||
label: 'Événements',
|
||||
route: '/events',
|
||||
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_ALL],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.message,
|
||||
label: 'Communication',
|
||||
route: '/communication',
|
||||
requiredPermissions: [PermissionMatrix.COMM_MODERATE],
|
||||
),
|
||||
];
|
||||
|
||||
return _buildDrawer(
|
||||
context,
|
||||
'Modérateur',
|
||||
const Color(0xFFE17055),
|
||||
Icons.manage_accounts,
|
||||
items,
|
||||
);
|
||||
}
|
||||
|
||||
/// Drawer pour Membre Actif
|
||||
Widget _buildActiveMemberDrawer(BuildContext context) {
|
||||
final items = [
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.dashboard,
|
||||
label: 'Activity Center',
|
||||
route: '/dashboard',
|
||||
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.person,
|
||||
label: 'Mon Profil',
|
||||
route: '/profile',
|
||||
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_OWN],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.event,
|
||||
label: 'Événements',
|
||||
route: '/events',
|
||||
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_ALL],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.volunteer_activism,
|
||||
label: 'Solidarité',
|
||||
route: '/solidarity',
|
||||
requiredPermissions: [PermissionMatrix.SOLIDARITY_VIEW_ALL],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.payment,
|
||||
label: 'Mes Cotisations',
|
||||
route: '/my-finances',
|
||||
requiredPermissions: [PermissionMatrix.FINANCES_VIEW_OWN],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.message,
|
||||
label: 'Messages',
|
||||
route: '/messages',
|
||||
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
|
||||
),
|
||||
];
|
||||
|
||||
return _buildDrawer(
|
||||
context,
|
||||
'Membre Actif',
|
||||
const Color(0xFF00B894),
|
||||
Icons.groups,
|
||||
items,
|
||||
);
|
||||
}
|
||||
|
||||
/// Drawer pour Membre Simple
|
||||
Widget _buildSimpleMemberDrawer(BuildContext context) {
|
||||
final items = [
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.dashboard,
|
||||
label: 'Mon Espace',
|
||||
route: '/dashboard',
|
||||
requiredPermissions: [PermissionMatrix.DASHBOARD_VIEW],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.person,
|
||||
label: 'Mon Profil',
|
||||
route: '/profile',
|
||||
requiredPermissions: [PermissionMatrix.MEMBERS_VIEW_OWN],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.event,
|
||||
label: 'Événements',
|
||||
route: '/events',
|
||||
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_PUBLIC],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.payment,
|
||||
label: 'Mes Cotisations',
|
||||
route: '/my-finances',
|
||||
requiredPermissions: [PermissionMatrix.FINANCES_VIEW_OWN],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.help,
|
||||
label: 'Aide',
|
||||
route: '/help',
|
||||
requiredPermissions: [],
|
||||
),
|
||||
];
|
||||
|
||||
return _buildDrawer(
|
||||
context,
|
||||
'Membre',
|
||||
const Color(0xFF00CEC9),
|
||||
Icons.person,
|
||||
items,
|
||||
);
|
||||
}
|
||||
|
||||
/// Drawer pour Visiteur
|
||||
Widget _buildVisitorDrawer(BuildContext context) {
|
||||
final items = [
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.home,
|
||||
label: 'Accueil',
|
||||
route: '/dashboard',
|
||||
requiredPermissions: [],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.info,
|
||||
label: 'À Propos',
|
||||
route: '/about',
|
||||
requiredPermissions: [],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.event,
|
||||
label: 'Événements Publics',
|
||||
route: '/public-events',
|
||||
requiredPermissions: [PermissionMatrix.EVENTS_VIEW_PUBLIC],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.contact_mail,
|
||||
label: 'Contact',
|
||||
route: '/contact',
|
||||
requiredPermissions: [],
|
||||
),
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.login,
|
||||
label: 'Se Connecter',
|
||||
route: '/login',
|
||||
requiredPermissions: [],
|
||||
),
|
||||
];
|
||||
|
||||
return _buildDrawer(
|
||||
context,
|
||||
'Visiteur',
|
||||
const Color(0xFF6C5CE7),
|
||||
Icons.waving_hand,
|
||||
items,
|
||||
);
|
||||
}
|
||||
|
||||
/// Drawer basique de fallback
|
||||
Widget _buildBasicDrawer(BuildContext context) {
|
||||
return _buildDrawer(
|
||||
context,
|
||||
'UnionFlow',
|
||||
Colors.grey,
|
||||
Icons.dashboard,
|
||||
[
|
||||
const AdaptiveNavigationItem(
|
||||
icon: Icons.home,
|
||||
label: 'Accueil',
|
||||
route: '/dashboard',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Drawer de chargement
|
||||
Widget _buildLoadingDrawer(BuildContext context) {
|
||||
return Drawer(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF6C5CE7), Color(0xFF5A4FCF)],
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un drawer avec les éléments spécifiés
|
||||
Widget _buildDrawer(
|
||||
BuildContext context,
|
||||
String title,
|
||||
Color color,
|
||||
IconData icon,
|
||||
List<AdaptiveNavigationItem> items,
|
||||
) {
|
||||
return Drawer(
|
||||
child: Column(
|
||||
children: [
|
||||
// En-tête du drawer
|
||||
_buildDrawerHeader(context, title, color, icon),
|
||||
|
||||
// Éléments de navigation
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
...items.map((item) => _buildNavigationItem(context, item)),
|
||||
const Divider(),
|
||||
_buildLogoutItem(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit l'en-tête du drawer
|
||||
Widget _buildDrawerHeader(
|
||||
BuildContext context,
|
||||
String title,
|
||||
Color color,
|
||||
IconData icon,
|
||||
) {
|
||||
return DrawerHeader(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [color, color.withOpacity(0.8)],
|
||||
),
|
||||
),
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
if (state is AuthAuthenticated) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: Colors.white, size: 32),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
state.user.fullName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
state.user.email,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, color: Colors.white, size: 32),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit un élément de navigation
|
||||
Widget _buildNavigationItem(
|
||||
BuildContext context,
|
||||
AdaptiveNavigationItem item,
|
||||
) {
|
||||
return SecureWidget(
|
||||
requiredPermissions: item.requiredPermissions,
|
||||
child: ListTile(
|
||||
leading: Icon(item.icon, color: item.color),
|
||||
title: Text(item.label),
|
||||
trailing: item.badge != null
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
item.badge!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
onNavigate(item.route);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit l'élément de déconnexion
|
||||
Widget _buildLogoutItem(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.logout, color: Colors.red),
|
||||
title: const Text(
|
||||
'Déconnexion',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
onLogout();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
188
lib/core/navigation/main_navigation_layout.dart
Normal file
188
lib/core/navigation/main_navigation_layout.dart
Normal file
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'more_page.dart';
|
||||
|
||||
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
|
||||
import '../../features/authentication/data/models/user_role.dart';
|
||||
import '../../shared/design_system/unionflow_design_system.dart';
|
||||
import '../../features/dashboard/presentation/pages/role_dashboards/role_dashboards.dart';
|
||||
import '../../features/dashboard/presentation/pages/role_dashboards/org_admin_dashboard_loader.dart';
|
||||
import '../../features/members/presentation/pages/members_page_wrapper.dart';
|
||||
import '../../features/events/presentation/pages/events_page_wrapper.dart';
|
||||
import '../../features/contributions/presentation/pages/contributions_page_wrapper.dart';
|
||||
import '../../features/adhesions/presentation/pages/adhesions_page_wrapper.dart';
|
||||
import '../../features/solidarity/presentation/pages/demandes_aide_page_wrapper.dart';
|
||||
import '../../features/admin/presentation/pages/user_management_page.dart';
|
||||
|
||||
import '../../features/about/presentation/pages/about_page.dart';
|
||||
import '../../features/help/presentation/pages/help_support_page.dart';
|
||||
import '../../features/notifications/presentation/pages/notifications_page_wrapper.dart';
|
||||
import '../../features/profile/presentation/pages/profile_page_wrapper.dart';
|
||||
import '../../features/settings/presentation/pages/system_settings_page.dart';
|
||||
import '../../features/backup/presentation/pages/backup_page.dart';
|
||||
import '../../features/logs/presentation/pages/logs_page.dart';
|
||||
import '../../features/reports/presentation/pages/reports_page_wrapper.dart';
|
||||
import '../../features/epargne/presentation/pages/epargne_page.dart';
|
||||
|
||||
import '../../features/dashboard/presentation/bloc/dashboard_bloc.dart';
|
||||
import '../di/injection.dart';
|
||||
|
||||
/// Layout principal avec navigation hybride
|
||||
/// Bottom Navigation pour les sections principales + Drawer pour fonctions avancées
|
||||
class MainNavigationLayout extends StatefulWidget {
|
||||
const MainNavigationLayout({super.key});
|
||||
|
||||
@override
|
||||
State<MainNavigationLayout> createState() => _MainNavigationLayoutState();
|
||||
}
|
||||
|
||||
class _MainNavigationLayoutState extends State<MainNavigationLayout> {
|
||||
int _selectedIndex = 0;
|
||||
List<Widget>? _cachedPages;
|
||||
UserRole? _lastRole;
|
||||
String? _lastUserId;
|
||||
|
||||
/// Obtient le dashboard approprié selon le rôle de l'utilisateur
|
||||
Widget _getDashboardForRole(UserRole role, String userId, String? orgId) {
|
||||
// Admin d'organisation sans orgId (organizationContexts vide) : charger /mes puis dashboard
|
||||
if (role == UserRole.orgAdmin && (orgId == null || orgId.isEmpty)) {
|
||||
return OrgAdminDashboardLoader(userId: userId);
|
||||
}
|
||||
return BlocProvider<DashboardBloc>(
|
||||
create: (context) => getIt<DashboardBloc>()
|
||||
..add(LoadDashboardData(
|
||||
organizationId: orgId ?? '',
|
||||
userId: userId,
|
||||
)),
|
||||
child: _buildDashboardView(role),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDashboardView(UserRole role) {
|
||||
switch (role) {
|
||||
case UserRole.superAdmin:
|
||||
return const SuperAdminDashboard();
|
||||
case UserRole.orgAdmin:
|
||||
return const OrgAdminDashboard();
|
||||
case UserRole.moderator:
|
||||
return const ModeratorDashboard();
|
||||
case UserRole.consultant:
|
||||
return const ConsultantDashboard();
|
||||
case UserRole.hrManager:
|
||||
return const HRManagerDashboard();
|
||||
case UserRole.activeMember:
|
||||
return const ActiveMemberDashboard();
|
||||
case UserRole.simpleMember:
|
||||
return const SimpleMemberDashboard();
|
||||
case UserRole.visitor:
|
||||
return const VisitorDashboard();
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient les pages et les met en cache pour éviter les rebuilds inutiles
|
||||
List<Widget> _getPages(UserRole role, String userId, String? orgId) {
|
||||
if (_cachedPages != null && _lastRole == role && _lastUserId == userId) {
|
||||
return _cachedPages!;
|
||||
}
|
||||
|
||||
debugPrint('🔄 [MainNavigationLayout] Initialisation des pages (Role: $role, User: $userId)');
|
||||
_lastRole = role;
|
||||
_lastUserId = userId;
|
||||
|
||||
final canManageMembers = role.hasLevelOrAbove(UserRole.hrManager);
|
||||
|
||||
_cachedPages = [
|
||||
_getDashboardForRole(role, userId, orgId),
|
||||
if (canManageMembers) const MembersPageWrapper(),
|
||||
const EventsPageWrapper(),
|
||||
const MorePage(),
|
||||
];
|
||||
return _cachedPages!;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
if (state is! AuthAuthenticated) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final orgId = state.user.organizationContexts.isNotEmpty
|
||||
? state.user.organizationContexts.first.organizationId
|
||||
: null;
|
||||
final pages = _getPages(state.effectiveRole, state.user.id, orgId);
|
||||
final safeIndex = _selectedIndex >= pages.length ? 0 : _selectedIndex;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: ColorTokens.background,
|
||||
body: SafeArea(
|
||||
top: true, // Respecte le StatusBar
|
||||
bottom: false, // Le BottomNavigationBar gère son propre SafeArea
|
||||
child: IndexedStack(
|
||||
index: safeIndex,
|
||||
children: pages,
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: ColorTokens.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ColorTokens.shadow,
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: BottomNavigationBar(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
currentIndex: safeIndex,
|
||||
onTap: (index) {
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
});
|
||||
},
|
||||
backgroundColor: ColorTokens.surface,
|
||||
selectedItemColor: ColorTokens.primary,
|
||||
unselectedItemColor: ColorTokens.onSurfaceVariant,
|
||||
selectedLabelStyle: TypographyTokens.labelSmall.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
unselectedLabelStyle: TypographyTokens.labelSmall,
|
||||
elevation: 0, // Géré par le Container
|
||||
items: [
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
activeIcon: Icon(Icons.dashboard),
|
||||
label: 'Dashboard',
|
||||
),
|
||||
if (state.effectiveRole.hasLevelOrAbove(UserRole.hrManager))
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.people_outline),
|
||||
activeIcon: Icon(Icons.people),
|
||||
label: 'Membres',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.event_outlined),
|
||||
activeIcon: Icon(Icons.event),
|
||||
label: 'Événements',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.more_horiz_outlined),
|
||||
activeIcon: Icon(Icons.more_horiz),
|
||||
label: 'Plus',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
332
lib/core/navigation/more_page.dart
Normal file
332
lib/core/navigation/more_page.dart
Normal file
@@ -0,0 +1,332 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
|
||||
import '../../features/authentication/data/models/user_role.dart';
|
||||
import '../../shared/design_system/unionflow_design_system.dart';
|
||||
import '../../shared/widgets/core_card.dart';
|
||||
import '../../shared/widgets/mini_avatar.dart';
|
||||
|
||||
import '../../features/admin/presentation/pages/user_management_page.dart';
|
||||
import '../../features/settings/presentation/pages/system_settings_page.dart';
|
||||
import '../../features/backup/presentation/pages/backup_page.dart';
|
||||
import '../../features/logs/presentation/pages/logs_page.dart';
|
||||
import '../../features/reports/presentation/pages/reports_page_wrapper.dart';
|
||||
import '../../features/epargne/presentation/pages/epargne_page.dart';
|
||||
import '../../features/contributions/presentation/pages/contributions_page_wrapper.dart';
|
||||
import '../../features/adhesions/presentation/pages/adhesions_page_wrapper.dart';
|
||||
import '../../features/solidarity/presentation/pages/demandes_aide_page_wrapper.dart';
|
||||
import '../../features/organizations/presentation/pages/organizations_page_wrapper.dart';
|
||||
|
||||
/// Page "Plus" avec les fonctions avancées selon le rôle (Menu Principal Extensif)
|
||||
class MorePage extends StatelessWidget {
|
||||
const MorePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
if (state is! AuthAuthenticated) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: ColorTokens.background,
|
||||
appBar: const UFAppBar(
|
||||
title: 'PLUS',
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Profil utilisateur
|
||||
_buildUserProfile(state),
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Options selon le rôle
|
||||
..._buildRoleBasedOptions(context, state),
|
||||
|
||||
const SizedBox(height: SpacingTokens.md),
|
||||
|
||||
// Options communes
|
||||
..._buildCommonOptions(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserProfile(AuthAuthenticated state) {
|
||||
return CoreCard(
|
||||
child: Row(
|
||||
children: [
|
||||
MiniAvatar(
|
||||
fallbackText: state.user.firstName.isNotEmpty ? state.user.firstName[0].toUpperCase() : 'U',
|
||||
size: 40,
|
||||
imageUrl: state.user.avatar,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${state.user.firstName} ${state.user.lastName}',
|
||||
style: AppTypography.actionText,
|
||||
),
|
||||
Text(
|
||||
state.effectiveRole.displayName.toUpperCase(),
|
||||
style: AppTypography.badgeText.copyWith(
|
||||
color: AppColors.primaryGreen,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildRoleBasedOptions(BuildContext context, AuthAuthenticated state) {
|
||||
final options = <Widget>[];
|
||||
|
||||
// Options Super Admin uniquement
|
||||
if (state.effectiveRole == UserRole.superAdmin) {
|
||||
options.addAll([
|
||||
_buildSectionTitle('Administration Système'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.people,
|
||||
title: 'Gestion des utilisateurs',
|
||||
subtitle: 'Utilisateurs Keycloak et rôles',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const UserManagementPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.settings,
|
||||
title: 'Paramètres Système',
|
||||
subtitle: 'Configuration globale',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const SystemSettingsPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.backup,
|
||||
title: 'Sauvegarde & Restauration',
|
||||
subtitle: 'Gestion des sauvegardes',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const BackupPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.article,
|
||||
title: 'Logs & Monitoring',
|
||||
subtitle: 'Surveillance et journaux',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const LogsPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// Options Admin+ (Admin Organisation et Super Admin)
|
||||
if (state.effectiveRole == UserRole.orgAdmin || state.effectiveRole == UserRole.superAdmin) {
|
||||
options.addAll([
|
||||
_buildSectionTitle('Administration'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.business,
|
||||
title: 'Gestion des Organisations',
|
||||
subtitle: 'Créer et gérer les organisations',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const OrganizationsPageWrapper()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildSectionTitle('Workflow Financier'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.pending_actions,
|
||||
title: 'Approbations en attente',
|
||||
subtitle: 'Valider les transactions financières',
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, '/approvals');
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.account_balance_wallet,
|
||||
title: 'Gestion des Budgets',
|
||||
subtitle: 'Créer et suivre les budgets',
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, '/budgets');
|
||||
},
|
||||
),
|
||||
_buildSectionTitle('Communication'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.message,
|
||||
title: 'Messages & Broadcast',
|
||||
subtitle: 'Communiquer avec les membres',
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, '/messages');
|
||||
},
|
||||
),
|
||||
_buildSectionTitle('Rapports & Analytics'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.assessment,
|
||||
title: 'Rapports & Analytics',
|
||||
subtitle: 'Statistiques détaillées',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const ReportsPageWrapper()),
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// Options Modérateur (Communication limitée)
|
||||
if (state.effectiveRole == UserRole.moderator) {
|
||||
options.addAll([
|
||||
_buildSectionTitle('Communication'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.message,
|
||||
title: 'Messages aux membres',
|
||||
subtitle: 'Communiquer avec les membres',
|
||||
onTap: () {
|
||||
Navigator.pushNamed(context, '/messages');
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
List<Widget> _buildCommonOptions(BuildContext context) {
|
||||
return [
|
||||
_buildSectionTitle('Général'),
|
||||
_buildOptionTile(
|
||||
icon: Icons.payment,
|
||||
title: 'Cotisations',
|
||||
subtitle: 'Gérer les cotisations',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const CotisationsPageWrapper()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.how_to_reg,
|
||||
title: 'Demandes d\'adhésion',
|
||||
subtitle: 'Demandes d\'adhésion à une organisation',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const AdhesionsPageWrapper()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.volunteer_activism,
|
||||
title: 'Demandes d\'aide',
|
||||
subtitle: 'Solidarité – demandes d\'aide',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const DemandesAidePageWrapper()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildOptionTile(
|
||||
icon: Icons.savings_outlined,
|
||||
title: 'Comptes épargne',
|
||||
subtitle: 'Mutuelle épargne – dépôts (LCB-FT)',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const EpargnePage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 24, bottom: 8, left: 4),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: AppTypography.subtitleSmall.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.1,
|
||||
color: AppColors.textSecondaryLight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOptionTile({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required VoidCallback onTap,
|
||||
Color? color,
|
||||
}) {
|
||||
final effectiveColor = color ?? AppColors.primaryGreen;
|
||||
|
||||
return CoreCard(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: effectiveColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: effectiveColor,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: AppTypography.actionText.copyWith(
|
||||
color: color ?? AppColors.textPrimaryLight,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: AppTypography.subtitleSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.textSecondaryLight,
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
133
lib/core/network/api_client.dart
Normal file
133
lib/core/network/api_client.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../config/environment.dart';
|
||||
import '../di/injection.dart';
|
||||
import '../error/error_handler.dart';
|
||||
import '../utils/logger.dart';
|
||||
import '../../features/authentication/presentation/bloc/auth_bloc.dart';
|
||||
import '../../features/authentication/data/datasources/keycloak_auth_service.dart';
|
||||
|
||||
/// Client réseau unifié basé sur Dio (Version DRY & Minimaliste).
|
||||
@lazySingleton
|
||||
class ApiClient {
|
||||
late final Dio _dio;
|
||||
|
||||
static const FlutterSecureStorage _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
|
||||
);
|
||||
|
||||
ApiClient() {
|
||||
_dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Intercepteur de Log (Uniquement en Dev)
|
||||
if (AppConfig.enableLogging) {
|
||||
_dio.interceptors.add(LogInterceptor(
|
||||
requestHeader: true,
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
logPrint: (obj) => print('🌐 [API] $obj'),
|
||||
));
|
||||
}
|
||||
|
||||
// Intercepteur de Token & Refresh automatique
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
// Utilise la clé 'kc_access' synchronisée avec KeycloakAuthService
|
||||
final token = await _storage.read(key: 'kc_access');
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
return handler.next(options);
|
||||
},
|
||||
onError: (DioException e, handler) async {
|
||||
// Évite une boucle infinie si le retry échoue aussi avec 401
|
||||
final isRetry = e.requestOptions.extra['custom_retry'] == true;
|
||||
|
||||
if (e.response?.statusCode == 401 && !isRetry) {
|
||||
final responseBody = e.response?.data;
|
||||
debugPrint('🔑 [API] 401 Detected. Body: $responseBody. Attempting token refresh...');
|
||||
final refreshed = await _refreshToken();
|
||||
|
||||
if (refreshed) {
|
||||
final token = await _storage.read(key: 'kc_access');
|
||||
if (token != null) {
|
||||
// Marque la requête comme étant un retry
|
||||
final options = e.requestOptions;
|
||||
options.extra['custom_retry'] = true;
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
|
||||
try {
|
||||
debugPrint('🔄 [API] Retrying request: ${options.path}');
|
||||
final response = await _dio.fetch(options);
|
||||
return handler.resolve(response);
|
||||
} on DioException catch (retryError) {
|
||||
final retryBody = retryError.response?.data;
|
||||
debugPrint('🚨 [API] Retry failed with status: ${retryError.response?.statusCode}. Body: $retryBody');
|
||||
if (retryError.response?.statusCode == 401) {
|
||||
debugPrint('🚪 [API] Persistent 401. Force Logout.');
|
||||
_forceLogout();
|
||||
}
|
||||
return handler.next(retryError);
|
||||
} catch (retryError) {
|
||||
debugPrint('🚨 [API] Retry critical error: $retryError');
|
||||
return handler.next(e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugPrint('🚪 [API] Refresh failed. Force Logout.');
|
||||
_forceLogout();
|
||||
}
|
||||
}
|
||||
return handler.next(e);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _forceLogout() {
|
||||
try {
|
||||
final authBloc = getIt<AuthBloc>();
|
||||
authBloc.add(const AuthLogoutRequested());
|
||||
} catch (e, st) {
|
||||
AppLogger.error(
|
||||
'ApiClient: force logout failed - ${ErrorHandler.getErrorMessage(e)}',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _refreshToken() async {
|
||||
try {
|
||||
final authService = getIt<KeycloakAuthService>();
|
||||
final newToken = await authService.refreshToken();
|
||||
return newToken != null;
|
||||
} catch (e, st) {
|
||||
AppLogger.error(
|
||||
'ApiClient: refresh token failed - ${ErrorHandler.getErrorMessage(e)}',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response<T>> get<T>(String path, {Map<String, dynamic>? queryParameters, Options? options}) => _dio.get<T>(path, queryParameters: queryParameters, options: options);
|
||||
Future<Response<T>> post<T>(String path, {dynamic data, Map<String, dynamic>? queryParameters, Options? options}) => _dio.post<T>(path, data: data, queryParameters: queryParameters, options: options);
|
||||
Future<Response<T>> put<T>(String path, {dynamic data, Map<String, dynamic>? queryParameters, Options? options}) => _dio.put<T>(path, data: data, queryParameters: queryParameters, options: options);
|
||||
Future<Response<T>> delete<T>(String path, {dynamic data, Map<String, dynamic>? queryParameters, Options? options}) => _dio.delete<T>(path, data: data, queryParameters: queryParameters, options: options);
|
||||
}
|
||||
21
lib/core/network/network_info.dart
Normal file
21
lib/core/network/network_info.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
||||
/// Interface pour vérifier la connectivité réseau
|
||||
abstract class NetworkInfo {
|
||||
Future<bool> get isConnected;
|
||||
}
|
||||
|
||||
/// Implémentation de NetworkInfo utilisant connectivity_plus
|
||||
@LazySingleton(as: NetworkInfo)
|
||||
class NetworkInfoImpl implements NetworkInfo {
|
||||
final Connectivity connectivity;
|
||||
|
||||
NetworkInfoImpl(this.connectivity);
|
||||
|
||||
@override
|
||||
Future<bool> get isConnected async {
|
||||
final result = await connectivity.checkConnectivity();
|
||||
return result.any((r) => r != ConnectivityResult.none);
|
||||
}
|
||||
}
|
||||
169
lib/core/network/offline_manager.dart
Normal file
169
lib/core/network/offline_manager.dart
Normal file
@@ -0,0 +1,169 @@
|
||||
/// Offline-first manager for connectivity monitoring and operation queueing
|
||||
library offline_manager;
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../storage/pending_operations_store.dart';
|
||||
import '../utils/logger.dart' show AppLogger;
|
||||
|
||||
/// Status of network connectivity
|
||||
enum ConnectivityStatus {
|
||||
online,
|
||||
offline,
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Offline manager that monitors connectivity and manages offline operations
|
||||
@singleton
|
||||
class OfflineManager {
|
||||
final Connectivity _connectivity;
|
||||
final PendingOperationsStore _operationsStore;
|
||||
|
||||
ConnectivityStatus _currentStatus = ConnectivityStatus.unknown;
|
||||
final _statusController = StreamController<ConnectivityStatus>.broadcast();
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
|
||||
|
||||
OfflineManager(
|
||||
this._connectivity,
|
||||
this._operationsStore,
|
||||
) {
|
||||
_initConnectivityMonitoring();
|
||||
}
|
||||
|
||||
/// Current connectivity status
|
||||
ConnectivityStatus get currentStatus => _currentStatus;
|
||||
|
||||
/// Stream of connectivity status changes
|
||||
Stream<ConnectivityStatus> get statusStream => _statusController.stream;
|
||||
|
||||
/// Check if device is currently online
|
||||
Future<bool> get isOnline async {
|
||||
final result = await _connectivity.checkConnectivity();
|
||||
return result.any((r) => r != ConnectivityResult.none);
|
||||
}
|
||||
|
||||
/// Initialize connectivity monitoring
|
||||
void _initConnectivityMonitoring() {
|
||||
// Check initial connectivity
|
||||
_connectivity.checkConnectivity().then((result) {
|
||||
_updateStatus(result);
|
||||
});
|
||||
|
||||
// Listen for connectivity changes
|
||||
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(
|
||||
_updateStatus,
|
||||
onError: (error) {
|
||||
AppLogger.error('Connectivity monitoring error', error: error);
|
||||
_updateStatus([ConnectivityResult.none]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Update connectivity status
|
||||
void _updateStatus(List<ConnectivityResult> results) {
|
||||
final isConnected = results.any((r) => r != ConnectivityResult.none);
|
||||
final newStatus = isConnected
|
||||
? ConnectivityStatus.online
|
||||
: ConnectivityStatus.offline;
|
||||
|
||||
if (newStatus != _currentStatus) {
|
||||
final previousStatus = _currentStatus;
|
||||
_currentStatus = newStatus;
|
||||
_statusController.add(newStatus);
|
||||
|
||||
AppLogger.info('Connectivity changed: $previousStatus → $newStatus');
|
||||
|
||||
// When back online, process pending operations
|
||||
if (newStatus == ConnectivityStatus.online &&
|
||||
previousStatus == ConnectivityStatus.offline) {
|
||||
_processPendingOperations();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue an operation for later retry when offline
|
||||
Future<void> queueOperation({
|
||||
required String operationType,
|
||||
required String endpoint,
|
||||
required Map<String, dynamic> data,
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
try {
|
||||
await _operationsStore.addPendingOperation(
|
||||
operationType: operationType,
|
||||
endpoint: endpoint,
|
||||
data: data,
|
||||
headers: headers,
|
||||
);
|
||||
AppLogger.info('Operation queued: $operationType on $endpoint');
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to queue operation', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Process all pending operations when back online
|
||||
Future<void> _processPendingOperations() async {
|
||||
AppLogger.info('Processing pending operations...');
|
||||
|
||||
try {
|
||||
final operations = await _operationsStore.getPendingOperations();
|
||||
|
||||
if (operations.isEmpty) {
|
||||
AppLogger.info('No pending operations to process');
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.info('Found ${operations.length} pending operations');
|
||||
|
||||
// Process operations one by one
|
||||
for (final operation in operations) {
|
||||
try {
|
||||
// Note: Actual retry logic is delegated to the calling code
|
||||
// This manager only provides the queuing mechanism
|
||||
AppLogger.info('Pending operation ready for retry: ${operation['operationType']}');
|
||||
} catch (e) {
|
||||
AppLogger.error('Error processing pending operation', error: e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to process pending operations', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually trigger processing of pending operations
|
||||
Future<void> retryPendingOperations() async {
|
||||
if (_currentStatus == ConnectivityStatus.online) {
|
||||
await _processPendingOperations();
|
||||
} else {
|
||||
AppLogger.warning('Cannot retry pending operations while offline');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all pending operations
|
||||
Future<void> clearPendingOperations() async {
|
||||
try {
|
||||
await _operationsStore.clearAll();
|
||||
AppLogger.info('Pending operations cleared');
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to clear pending operations', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get count of pending operations
|
||||
Future<int> getPendingOperationsCount() async {
|
||||
try {
|
||||
final operations = await _operationsStore.getPendingOperations();
|
||||
return operations.length;
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to get pending operations count', error: e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
_connectivitySubscription?.cancel();
|
||||
_statusController.close();
|
||||
}
|
||||
}
|
||||
160
lib/core/network/retry_policy.dart
Normal file
160
lib/core/network/retry_policy.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
/// Retry policy with exponential backoff for network requests
|
||||
library retry_policy;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
/// Configuration for retry behavior
|
||||
class RetryConfig {
|
||||
/// Maximum number of retry attempts
|
||||
final int maxAttempts;
|
||||
|
||||
/// Initial delay before first retry (milliseconds)
|
||||
final int initialDelayMs;
|
||||
|
||||
/// Maximum delay between retries (milliseconds)
|
||||
final int maxDelayMs;
|
||||
|
||||
/// Multiplier for exponential backoff
|
||||
final double backoffMultiplier;
|
||||
|
||||
/// Whether to add jitter to retry delays
|
||||
final bool useJitter;
|
||||
|
||||
const RetryConfig({
|
||||
this.maxAttempts = 3,
|
||||
this.initialDelayMs = 1000,
|
||||
this.maxDelayMs = 30000,
|
||||
this.backoffMultiplier = 2.0,
|
||||
this.useJitter = true,
|
||||
});
|
||||
|
||||
/// Default configuration for standard API calls
|
||||
static const standard = RetryConfig();
|
||||
|
||||
/// Configuration for critical operations
|
||||
static const critical = RetryConfig(
|
||||
maxAttempts: 5,
|
||||
initialDelayMs: 500,
|
||||
maxDelayMs: 60000,
|
||||
);
|
||||
|
||||
/// Configuration for background sync
|
||||
static const backgroundSync = RetryConfig(
|
||||
maxAttempts: 10,
|
||||
initialDelayMs: 2000,
|
||||
maxDelayMs: 120000,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retry policy implementation with exponential backoff
|
||||
class RetryPolicy {
|
||||
final RetryConfig config;
|
||||
final Random _random = Random();
|
||||
|
||||
RetryPolicy({RetryConfig? config}) : config = config ?? RetryConfig.standard;
|
||||
|
||||
/// Executes an operation with retry logic
|
||||
///
|
||||
/// [operation]: The async operation to execute
|
||||
/// [shouldRetry]: Optional function to determine if error is retryable
|
||||
/// [onRetry]: Optional callback when retry attempt is made
|
||||
///
|
||||
/// Returns the result of the operation
|
||||
/// Throws the last error if all retries fail
|
||||
Future<T> execute<T>({
|
||||
required Future<T> Function() operation,
|
||||
bool Function(dynamic error)? shouldRetry,
|
||||
void Function(int attempt, dynamic error, Duration delay)? onRetry,
|
||||
}) async {
|
||||
int attempt = 0;
|
||||
dynamic lastError;
|
||||
|
||||
while (attempt < config.maxAttempts) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
attempt++;
|
||||
|
||||
// Check if we should retry this error
|
||||
final retryable = shouldRetry?.call(error) ?? _isRetryableError(error);
|
||||
|
||||
if (!retryable || attempt >= config.maxAttempts) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Calculate delay with exponential backoff
|
||||
final delay = _calculateDelay(attempt);
|
||||
|
||||
// Notify about retry
|
||||
onRetry?.call(attempt, error, delay);
|
||||
|
||||
// Wait before next attempt
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// Should never reach here, but throw last error just in case
|
||||
throw lastError!;
|
||||
}
|
||||
|
||||
/// Calculates delay for given attempt number
|
||||
Duration _calculateDelay(int attempt) {
|
||||
// Exponential backoff: initialDelay * (multiplier ^ (attempt - 1))
|
||||
final exponentialDelay = config.initialDelayMs *
|
||||
pow(config.backoffMultiplier, attempt - 1).toInt();
|
||||
|
||||
// Cap at max delay
|
||||
var delayMs = min(exponentialDelay, config.maxDelayMs);
|
||||
|
||||
// Add jitter to prevent thundering herd
|
||||
if (config.useJitter) {
|
||||
final jitter = _random.nextDouble() * 0.3; // ±30% jitter
|
||||
delayMs = (delayMs * (1 + jitter - 0.15)).toInt();
|
||||
}
|
||||
|
||||
return Duration(milliseconds: delayMs);
|
||||
}
|
||||
|
||||
/// Determines if an error is retryable
|
||||
bool _isRetryableError(dynamic error) {
|
||||
// Network errors are retryable
|
||||
if (error is TimeoutException) return true;
|
||||
if (error is SocketException) return true;
|
||||
|
||||
// HTTP status codes that are retryable
|
||||
if (error.toString().contains('500')) return true; // Internal Server Error
|
||||
if (error.toString().contains('502')) return true; // Bad Gateway
|
||||
if (error.toString().contains('503')) return true; // Service Unavailable
|
||||
if (error.toString().contains('504')) return true; // Gateway Timeout
|
||||
if (error.toString().contains('429')) return true; // Too Many Requests
|
||||
|
||||
// Client errors (4xx) are generally not retryable
|
||||
if (error.toString().contains('400')) return false; // Bad Request
|
||||
if (error.toString().contains('401')) return false; // Unauthorized
|
||||
if (error.toString().contains('403')) return false; // Forbidden
|
||||
if (error.toString().contains('404')) return false; // Not Found
|
||||
|
||||
// Default: don't retry unknown errors
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension to add retry capability to Future operations
|
||||
extension RetryExtension<T> on Future<T> Function() {
|
||||
/// Executes this operation with retry logic
|
||||
Future<T> withRetry({
|
||||
RetryConfig? config,
|
||||
bool Function(dynamic error)? shouldRetry,
|
||||
void Function(int attempt, dynamic error, Duration delay)? onRetry,
|
||||
}) {
|
||||
final policy = RetryPolicy(config: config);
|
||||
return policy.execute(
|
||||
operation: this,
|
||||
shouldRetry: shouldRetry,
|
||||
onRetry: onRetry,
|
||||
);
|
||||
}
|
||||
}
|
||||
98
lib/core/storage/dashboard_cache_manager.dart
Normal file
98
lib/core/storage/dashboard_cache_manager.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../utils/logger.dart';
|
||||
import '../../features/authentication/data/models/user_role.dart';
|
||||
|
||||
/// UnionFlow Mobile - Gestionnaire de Cache Unique (DRY)
|
||||
/// Gère le cache mémoire (L1) et disque (L2) avec SharedPreferences.
|
||||
class DashboardCacheManager {
|
||||
static final Map<String, dynamic> _memoryCache = {};
|
||||
static final Map<String, DateTime> _cacheTimestamps = {};
|
||||
static SharedPreferences? _prefs;
|
||||
|
||||
static const Duration _defaultExpiry = Duration(minutes: 15);
|
||||
|
||||
static Future<void> initialize() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
debugPrint('📦 DashboardCacheManager Initialisé');
|
||||
}
|
||||
|
||||
static T? get<T>(String key) {
|
||||
// 1. Check mémoire
|
||||
if (_memoryCache.containsKey(key)) {
|
||||
final ts = _cacheTimestamps[key];
|
||||
if (ts != null && DateTime.now().difference(ts) < _defaultExpiry) {
|
||||
return _memoryCache[key] as T?;
|
||||
}
|
||||
}
|
||||
// 2. Check disque
|
||||
if (_prefs != null) {
|
||||
final jsonStr = _prefs!.getString('cache_$key');
|
||||
if (jsonStr != null) {
|
||||
try {
|
||||
final data = jsonDecode(jsonStr);
|
||||
_memoryCache[key] = data;
|
||||
_cacheTimestamps[key] = DateTime.now();
|
||||
return data as T?;
|
||||
} catch (e, st) {
|
||||
AppLogger.error('DashboardCacheManager.get: décodage JSON échoué pour key=$key', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<void> set<T>(String key, T value) async {
|
||||
_memoryCache[key] = value;
|
||||
_cacheTimestamps[key] = DateTime.now();
|
||||
if (_prefs != null) {
|
||||
try {
|
||||
await _prefs!.setString('cache_$key', jsonEncode(value));
|
||||
} catch (e, st) {
|
||||
AppLogger.error('DashboardCacheManager.set: écriture disque échouée pour key=$key', error: e, stackTrace: st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> invalidateForRole(UserRole role) async {
|
||||
_memoryCache.removeWhere((key, _) => key.contains(role.name));
|
||||
_cacheTimestamps.removeWhere((key, _) => key.contains(role.name));
|
||||
if (_prefs != null) {
|
||||
final keys = _prefs!.getKeys().where((k) => k.startsWith('cache_') && k.contains(role.name));
|
||||
for (final k in keys) {
|
||||
await _prefs!.remove(k);
|
||||
}
|
||||
}
|
||||
debugPrint('🗑️ Cache invalidé pour le rôle: ${role.displayName}');
|
||||
}
|
||||
|
||||
static Future<void> clear() async {
|
||||
_memoryCache.clear();
|
||||
_cacheTimestamps.clear();
|
||||
if (_prefs != null) {
|
||||
final keys = _prefs!.getKeys().where((k) => k.startsWith('cache_'));
|
||||
for (final k in keys) {
|
||||
await _prefs!.remove(k);
|
||||
}
|
||||
}
|
||||
debugPrint('🧹 Cache vidé globalement');
|
||||
}
|
||||
|
||||
/// Délégation instance pour l’injection de dépendances
|
||||
Future<void> setKey<T>(String key, T value) async => set<T>(key, value);
|
||||
|
||||
/// Délégation instance pour l’injection de dépendances
|
||||
T? getKey<T>(String key) => get<T>(key);
|
||||
|
||||
/// Statistiques du cache (entrées, etc.)
|
||||
Map<String, dynamic> getCacheStats() {
|
||||
final latest = _cacheTimestamps.isEmpty ? null : _cacheTimestamps.values.reduce((a, b) => a.isAfter(b) ? a : b);
|
||||
return {
|
||||
'entries': _memoryCache.length,
|
||||
'timestamp': latest?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
154
lib/core/storage/pending_operations_store.dart
Normal file
154
lib/core/storage/pending_operations_store.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
/// Storage for pending operations that failed due to network issues
|
||||
library pending_operations_store;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../utils/logger.dart' show AppLogger;
|
||||
|
||||
/// Store for persisting failed operations to retry later
|
||||
@singleton
|
||||
class PendingOperationsStore {
|
||||
static const String _keyPendingOperations = 'pending_operations';
|
||||
final SharedPreferences _preferences;
|
||||
|
||||
PendingOperationsStore(this._preferences);
|
||||
|
||||
/// Add a pending operation to the store
|
||||
Future<void> addPendingOperation({
|
||||
required String operationType,
|
||||
required String endpoint,
|
||||
required Map<String, dynamic> data,
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
try {
|
||||
final operations = await getPendingOperations();
|
||||
|
||||
final newOperation = {
|
||||
'id': DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
'operationType': operationType,
|
||||
'endpoint': endpoint,
|
||||
'data': data,
|
||||
'headers': headers ?? {},
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
'retryCount': 0,
|
||||
};
|
||||
|
||||
operations.add(newOperation);
|
||||
|
||||
await _saveOperations(operations);
|
||||
|
||||
AppLogger.info('Pending operation added: $operationType on $endpoint');
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to add pending operation', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all pending operations
|
||||
Future<List<Map<String, dynamic>>> getPendingOperations() async {
|
||||
try {
|
||||
final json = _preferences.getString(_keyPendingOperations);
|
||||
|
||||
if (json == null || json.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final List<dynamic> decoded = jsonDecode(json);
|
||||
return decoded.cast<Map<String, dynamic>>();
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to get pending operations', error: e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a pending operation by ID
|
||||
Future<void> removePendingOperation(String id) async {
|
||||
try {
|
||||
final operations = await getPendingOperations();
|
||||
operations.removeWhere((op) => op['id'] == id);
|
||||
await _saveOperations(operations);
|
||||
|
||||
AppLogger.info('Pending operation removed: $id');
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to remove pending operation', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update retry count for an operation
|
||||
Future<void> incrementRetryCount(String id) async {
|
||||
try {
|
||||
final operations = await getPendingOperations();
|
||||
|
||||
final index = operations.indexWhere((op) => op['id'] == id);
|
||||
if (index != -1) {
|
||||
operations[index]['retryCount'] = (operations[index]['retryCount'] ?? 0) + 1;
|
||||
operations[index]['lastRetryTimestamp'] = DateTime.now().toIso8601String();
|
||||
await _saveOperations(operations);
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to increment retry count', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all pending operations
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
await _preferences.remove(_keyPendingOperations);
|
||||
AppLogger.info('All pending operations cleared');
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to clear pending operations', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove operations older than a certain duration
|
||||
Future<void> removeOldOperations({Duration maxAge = const Duration(days: 7)}) async {
|
||||
try {
|
||||
final operations = await getPendingOperations();
|
||||
final now = DateTime.now();
|
||||
|
||||
final filtered = operations.where((op) {
|
||||
final timestamp = DateTime.parse(op['timestamp'] as String);
|
||||
return now.difference(timestamp) < maxAge;
|
||||
}).toList();
|
||||
|
||||
if (filtered.length != operations.length) {
|
||||
await _saveOperations(filtered);
|
||||
AppLogger.info('Removed ${operations.length - filtered.length} old operations');
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to remove old operations', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get operations by type
|
||||
Future<List<Map<String, dynamic>>> getOperationsByType(String operationType) async {
|
||||
try {
|
||||
final operations = await getPendingOperations();
|
||||
return operations.where((op) => op['operationType'] == operationType).toList();
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to get operations by type', error: e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get count of pending operations
|
||||
Future<int> getCount() async {
|
||||
final operations = await getPendingOperations();
|
||||
return operations.length;
|
||||
}
|
||||
|
||||
/// Save operations to storage
|
||||
Future<void> _saveOperations(List<Map<String, dynamic>> operations) async {
|
||||
try {
|
||||
final json = jsonEncode(operations);
|
||||
await _preferences.setString(_keyPendingOperations, json);
|
||||
} catch (e) {
|
||||
AppLogger.error('Failed to save operations', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
lib/core/usecases/usecase.dart
Normal file
17
lib/core/usecases/usecase.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import '../error/failures.dart';
|
||||
|
||||
/// Interface de base pour tous les cas d'usage
|
||||
abstract class UseCase<Type, Params> {
|
||||
Future<Either<Failure, Type>> call(Params params);
|
||||
}
|
||||
|
||||
/// Cas d'usage sans paramètres
|
||||
abstract class NoParamsUseCase<Type> {
|
||||
Future<Either<Failure, Type>> call();
|
||||
}
|
||||
|
||||
/// Classe pour représenter l'absence de paramètres
|
||||
class NoParams {
|
||||
const NoParams();
|
||||
}
|
||||
95
lib/core/utils/error_formatter.dart
Normal file
95
lib/core/utils/error_formatter.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
/// Utilitaire pour formater les messages d'erreur venant du backend.
|
||||
/// Gère notamment les erreurs LCB-FT (anti-blanchiment).
|
||||
class ErrorFormatter {
|
||||
/// Formate une erreur en message utilisateur convivial.
|
||||
///
|
||||
/// Détecte et formate spécialement les erreurs LCB-FT (origine des fonds manquante).
|
||||
/// Supprime les préfixes techniques comme "Exception: " ou "DioException: ".
|
||||
static String format(dynamic error) {
|
||||
if (error == null) return 'Une erreur inconnue est survenue';
|
||||
|
||||
final errorString = error.toString();
|
||||
|
||||
// Erreur LCB-FT : origine des fonds manquante
|
||||
if (errorString.contains('origine des fonds') ||
|
||||
errorString.contains('LCB-FT') ||
|
||||
errorString.contains('au-dessus du seuil')) {
|
||||
return '🛡️ L\'origine des fonds est obligatoire pour cette opération (conformité LCB-FT anti-blanchiment).\n\nVeuillez préciser d\'où proviennent les fonds.';
|
||||
}
|
||||
|
||||
// Erreur KYC
|
||||
if (errorString.contains('KYC') || errorString.contains('vérification identité')) {
|
||||
return '🛡️ Votre identité doit être vérifiée pour cette opération (conformité KYC).\n\nContactez votre administrateur.';
|
||||
}
|
||||
|
||||
// Erreur solde insuffisant
|
||||
if (errorString.contains('solde') && errorString.contains('insuffisant')) {
|
||||
return '💳 Solde insuffisant pour effectuer cette opération.';
|
||||
}
|
||||
|
||||
// Erreur réseau / timeout
|
||||
if (errorString.contains('SocketException') ||
|
||||
errorString.contains('timeout') ||
|
||||
errorString.contains('network')) {
|
||||
return '📡 Erreur de connexion. Vérifiez votre connexion internet et réessayez.';
|
||||
}
|
||||
|
||||
// Erreur 400 générique (validation backend)
|
||||
if (errorString.contains('400') || errorString.contains('Bad Request')) {
|
||||
// Essayer d'extraire le message du backend
|
||||
final match = RegExp(r'message["\s:]+([^"}\n]+)', caseSensitive: false)
|
||||
.firstMatch(errorString);
|
||||
if (match != null && match.group(1) != null) {
|
||||
return match.group(1)!.trim();
|
||||
}
|
||||
return 'Données invalides. Vérifiez les informations saisies.';
|
||||
}
|
||||
|
||||
// Erreur 401 / 403 (authentification / autorisation)
|
||||
if (errorString.contains('401') || errorString.contains('403')) {
|
||||
return '🔒 Vous n\'avez pas les autorisations nécessaires pour cette opération.';
|
||||
}
|
||||
|
||||
// Erreur 404 (ressource non trouvée)
|
||||
if (errorString.contains('404')) {
|
||||
return 'Ressource non trouvée. Elle a peut-être été supprimée.';
|
||||
}
|
||||
|
||||
// Erreur 500 (erreur serveur)
|
||||
if (errorString.contains('500') || errorString.contains('Internal Server')) {
|
||||
return '🔧 Erreur serveur. Nos équipes ont été notifiées. Réessayez plus tard.';
|
||||
}
|
||||
|
||||
// Nettoyer les préfixes techniques
|
||||
String cleaned = errorString
|
||||
.replaceFirst('Exception: ', '')
|
||||
.replaceFirst('DioException: ', '')
|
||||
.replaceFirst('DioError: ', '')
|
||||
.replaceFirst('Error: ', '')
|
||||
.trim();
|
||||
|
||||
// Si le message est trop long, le tronquer
|
||||
if (cleaned.length > 200) {
|
||||
cleaned = '${cleaned.substring(0, 197)}...';
|
||||
}
|
||||
|
||||
return cleaned.isNotEmpty ? cleaned : 'Une erreur est survenue';
|
||||
}
|
||||
|
||||
/// Détermine si une erreur est critique (nécessite intervention admin).
|
||||
static bool isCritical(dynamic error) {
|
||||
final errorString = error.toString().toLowerCase();
|
||||
return errorString.contains('kyc') ||
|
||||
errorString.contains('vérification identité') ||
|
||||
errorString.contains('401') ||
|
||||
errorString.contains('403');
|
||||
}
|
||||
|
||||
/// Détermine si une erreur est liée au LCB-FT.
|
||||
static bool isLcbFtError(dynamic error) {
|
||||
final errorString = error.toString().toLowerCase();
|
||||
return errorString.contains('origine des fonds') ||
|
||||
errorString.contains('lcb-ft') ||
|
||||
errorString.contains('anti-blanchiment');
|
||||
}
|
||||
}
|
||||
325
lib/core/utils/logger.dart
Normal file
325
lib/core/utils/logger.dart
Normal file
@@ -0,0 +1,325 @@
|
||||
/// Logger centralisé pour l'application
|
||||
library logger;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../config/environment.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
|
||||
/// Niveaux de log
|
||||
enum LogLevel {
|
||||
debug,
|
||||
info,
|
||||
warning,
|
||||
error,
|
||||
fatal,
|
||||
}
|
||||
|
||||
/// Logger centralisé pour toute l'application
|
||||
class AppLogger {
|
||||
// Empêcher l'instanciation
|
||||
AppLogger._();
|
||||
|
||||
/// Couleurs ANSI pour les logs en console
|
||||
static const String _reset = '\x1B[0m';
|
||||
static const String _red = '\x1B[31m';
|
||||
static const String _green = '\x1B[32m';
|
||||
static const String _yellow = '\x1B[33m';
|
||||
static const String _blue = '\x1B[34m';
|
||||
static const String _magenta = '\x1B[35m';
|
||||
static const String _cyan = '\x1B[36m';
|
||||
static const String _white = '\x1B[37m';
|
||||
|
||||
/// Log de niveau DEBUG (bleu)
|
||||
static void debug(String message, {String? tag}) {
|
||||
if (AppConfig.enableLogging && kDebugMode) {
|
||||
_log(LogLevel.debug, message, tag: tag, color: _blue);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log de niveau INFO (vert)
|
||||
static void info(String message, {String? tag}) {
|
||||
if (AppConfig.enableLogging && kDebugMode) {
|
||||
_log(LogLevel.info, message, tag: tag, color: _green);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log de niveau WARNING (jaune)
|
||||
static void warning(String message, {String? tag}) {
|
||||
if (AppConfig.enableLogging && kDebugMode) {
|
||||
_log(LogLevel.warning, message, tag: tag, color: _yellow);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log de niveau ERROR (rouge)
|
||||
static void error(
|
||||
String message, {
|
||||
String? tag,
|
||||
dynamic error,
|
||||
StackTrace? stackTrace,
|
||||
}) {
|
||||
if (AppConfig.enableLogging) {
|
||||
_log(LogLevel.error, message, tag: tag, color: _red);
|
||||
|
||||
if (error != null) {
|
||||
_log(LogLevel.error, 'Error: $error', tag: tag, color: _red);
|
||||
}
|
||||
|
||||
if (stackTrace != null) {
|
||||
_log(LogLevel.error, 'StackTrace:\n$stackTrace', tag: tag, color: _red);
|
||||
}
|
||||
|
||||
// Envoi au service de monitoring si configuré
|
||||
if (AppConfig.enableCrashReporting) {
|
||||
_sendToMonitoring(message, error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Log de niveau FATAL (magenta)
|
||||
static void fatal(
|
||||
String message, {
|
||||
String? tag,
|
||||
dynamic error,
|
||||
StackTrace? stackTrace,
|
||||
}) {
|
||||
if (AppConfig.enableLogging) {
|
||||
_log(LogLevel.fatal, message, tag: tag, color: _magenta);
|
||||
|
||||
if (error != null) {
|
||||
_log(LogLevel.fatal, 'Error: $error', tag: tag, color: _magenta);
|
||||
}
|
||||
|
||||
if (stackTrace != null) {
|
||||
_log(LogLevel.fatal, 'StackTrace:\n$stackTrace', tag: tag, color: _magenta);
|
||||
}
|
||||
|
||||
// Envoi au service de monitoring si configuré
|
||||
if (AppConfig.enableCrashReporting) {
|
||||
_sendToMonitoring(message, error, stackTrace, isFatal: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Log d'une requête HTTP
|
||||
static void httpRequest({
|
||||
required String method,
|
||||
required String url,
|
||||
Map<String, dynamic>? headers,
|
||||
dynamic body,
|
||||
}) {
|
||||
if (AppConfig.enableLogging && kDebugMode) {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('┌─────────────────────────────────────────────────');
|
||||
buffer.writeln('│ HTTP REQUEST');
|
||||
buffer.writeln('├─────────────────────────────────────────────────');
|
||||
buffer.writeln('│ Method: $method');
|
||||
buffer.writeln('│ URL: $url');
|
||||
|
||||
if (headers != null && headers.isNotEmpty) {
|
||||
buffer.writeln('│ Headers:');
|
||||
headers.forEach((key, value) {
|
||||
buffer.writeln('│ $key: $value');
|
||||
});
|
||||
}
|
||||
|
||||
if (body != null) {
|
||||
buffer.writeln('│ Body: $body');
|
||||
}
|
||||
|
||||
buffer.writeln('└─────────────────────────────────────────────────');
|
||||
|
||||
_log(LogLevel.debug, buffer.toString(), color: _cyan);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log d'une réponse HTTP
|
||||
static void httpResponse({
|
||||
required int statusCode,
|
||||
required String url,
|
||||
Map<String, dynamic>? headers,
|
||||
dynamic body,
|
||||
Duration? duration,
|
||||
}) {
|
||||
if (AppConfig.enableLogging && kDebugMode) {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('┌─────────────────────────────────────────────────');
|
||||
buffer.writeln('│ HTTP RESPONSE');
|
||||
buffer.writeln('├─────────────────────────────────────────────────');
|
||||
buffer.writeln('│ Status: $statusCode');
|
||||
buffer.writeln('│ URL: $url');
|
||||
|
||||
if (duration != null) {
|
||||
buffer.writeln('│ Duration: ${duration.inMilliseconds}ms');
|
||||
}
|
||||
|
||||
if (headers != null && headers.isNotEmpty) {
|
||||
buffer.writeln('│ Headers:');
|
||||
headers.forEach((key, value) {
|
||||
buffer.writeln('│ $key: $value');
|
||||
});
|
||||
}
|
||||
|
||||
if (body != null) {
|
||||
buffer.writeln('│ Body: $body');
|
||||
}
|
||||
|
||||
buffer.writeln('└─────────────────────────────────────────────────');
|
||||
|
||||
final color = statusCode >= 200 && statusCode < 300 ? _green : _red;
|
||||
_log(LogLevel.debug, buffer.toString(), color: color);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log d'un événement BLoC
|
||||
static void blocEvent(String blocName, String eventName, {dynamic data}) {
|
||||
if (AppConfig.enableLogging && kDebugMode) {
|
||||
final message = data != null
|
||||
? '[$blocName] Event: $eventName | Data: $data'
|
||||
: '[$blocName] Event: $eventName';
|
||||
_log(LogLevel.debug, message, color: _cyan);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log d'un changement d'état BLoC
|
||||
static void blocState(String blocName, String stateName, {dynamic data}) {
|
||||
if (AppConfig.enableLogging && kDebugMode) {
|
||||
final message = data != null
|
||||
? '[$blocName] State: $stateName | Data: $data'
|
||||
: '[$blocName] State: $stateName';
|
||||
_log(LogLevel.debug, message, color: _magenta);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log d'une navigation
|
||||
static void navigation(String from, String to) {
|
||||
if (AppConfig.enableLogging && kDebugMode) {
|
||||
_log(LogLevel.debug, 'Navigation: $from → $to', color: _yellow);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log d'une action utilisateur
|
||||
static void userAction(String action, {Map<String, dynamic>? data}) {
|
||||
if (AppConfig.enableLogging && kDebugMode) {
|
||||
final message = data != null
|
||||
? 'User Action: $action | Data: $data'
|
||||
: 'User Action: $action';
|
||||
_log(LogLevel.info, message, color: _green);
|
||||
}
|
||||
|
||||
// Envoi au service d'analytics si configuré
|
||||
if (AppConfig.enableAnalytics) {
|
||||
_sendToAnalytics(action, data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Méthode privée pour logger avec formatage
|
||||
static void _log(
|
||||
LogLevel level,
|
||||
String message, {
|
||||
String? tag,
|
||||
String color = _white,
|
||||
}) {
|
||||
final timestamp = DateTime.now().toIso8601String();
|
||||
final levelStr = level.name.toUpperCase().padRight(7);
|
||||
final tagStr = tag != null ? '[$tag] ' : '';
|
||||
|
||||
if (kDebugMode) {
|
||||
// En mode debug, utiliser les couleurs
|
||||
debugPrint('$color$timestamp | $levelStr | $tagStr$message$_reset');
|
||||
} else {
|
||||
// En mode release, pas de couleurs
|
||||
debugPrint('$timestamp | $levelStr | $tagStr$message');
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback optionnel pour envoyer les erreurs au monitoring (Sentry / Firebase Crashlytics).
|
||||
/// À enregistrer au démarrage de l'app quand le SDK est intégré.
|
||||
static void Function(String message, dynamic error, StackTrace? stackTrace, {bool isFatal})? onMonitoringReport;
|
||||
|
||||
/// Callback optionnel pour envoyer les événements analytics (Firebase Analytics / Mixpanel).
|
||||
/// À enregistrer au démarrage de l'app quand le SDK est intégré.
|
||||
static void Function(String action, Map<String, dynamic>? data)? onAnalyticsEvent;
|
||||
|
||||
/// Envoyer les erreurs à un service de monitoring
|
||||
static void _sendToMonitoring(
|
||||
String message,
|
||||
dynamic error,
|
||||
StackTrace? stackTrace, {
|
||||
bool isFatal = false,
|
||||
}) {
|
||||
if (onMonitoringReport != null) {
|
||||
try {
|
||||
onMonitoringReport!(message, error, stackTrace, isFatal: isFatal);
|
||||
} catch (e, st) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('AppLogger: échec envoi monitoring: $e');
|
||||
debugPrint('$st');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kDebugMode && (error != null || stackTrace != null)) {
|
||||
debugPrint('AppLogger: monitoring non configuré (enregistrer onMonitoringReport pour Sentry/Crashlytics)');
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoyer les événements à un service d'analytics
|
||||
static void _sendToAnalytics(String action, Map<String, dynamic>? data) {
|
||||
if (onAnalyticsEvent != null) {
|
||||
try {
|
||||
onAnalyticsEvent!(action, data);
|
||||
} catch (e, st) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('AppLogger: échec envoi analytics: $e');
|
||||
debugPrint('$st');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kDebugMode) {
|
||||
debugPrint('AppLogger: analytics non configuré (enregistrer onAnalyticsEvent pour Firebase/Mixpanel)');
|
||||
}
|
||||
}
|
||||
|
||||
/// Divider pour séparer visuellement les logs
|
||||
static void divider({String? title}) {
|
||||
if (AppConfig.enableLogging && kDebugMode) {
|
||||
if (title != null) {
|
||||
debugPrint('$_cyan═══════════════════════════════════════════════════$_reset');
|
||||
debugPrint('$_cyan $title$_reset');
|
||||
debugPrint('$_cyan═══════════════════════════════════════════════════$_reset');
|
||||
} else {
|
||||
debugPrint('$_cyan═══════════════════════════════════════════════════$_reset');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension pour faciliter le logging depuis n'importe où
|
||||
extension LoggerExtension on Object {
|
||||
/// Log debug
|
||||
void logDebug(String message) {
|
||||
AppLogger.debug(message, tag: runtimeType.toString());
|
||||
}
|
||||
|
||||
/// Log info
|
||||
void logInfo(String message) {
|
||||
AppLogger.info(message, tag: runtimeType.toString());
|
||||
}
|
||||
|
||||
/// Log warning
|
||||
void logWarning(String message) {
|
||||
AppLogger.warning(message, tag: runtimeType.toString());
|
||||
}
|
||||
|
||||
/// Log error
|
||||
void logError(String message, {dynamic error, StackTrace? stackTrace}) {
|
||||
AppLogger.error(
|
||||
message,
|
||||
tag: runtimeType.toString(),
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
355
lib/core/validation/validators.dart
Normal file
355
lib/core/validation/validators.dart
Normal file
@@ -0,0 +1,355 @@
|
||||
/// Core validation utilities for form fields
|
||||
library validators;
|
||||
|
||||
/// Validator function type
|
||||
typedef FieldValidator = String? Function(String?)?;
|
||||
|
||||
/// Compose multiple validators
|
||||
FieldValidator composeValidators(List<FieldValidator> validators) {
|
||||
return (String? value) {
|
||||
for (final validator in validators) {
|
||||
final result = validator?.call(value);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Common validators
|
||||
class Validators {
|
||||
Validators._(); // Prevent instantiation
|
||||
|
||||
/// Required field validator
|
||||
static FieldValidator required({String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return message ?? 'Ce champ est requis';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Minimum length validator
|
||||
static FieldValidator minLength(int length, {String? message}) {
|
||||
return (String? value) {
|
||||
if (value != null && value.trim().length < length) {
|
||||
return message ?? 'Minimum $length caractères requis';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Maximum length validator
|
||||
static FieldValidator maxLength(int length, {String? message}) {
|
||||
return (String? value) {
|
||||
if (value != null && value.length > length) {
|
||||
return message ?? 'Maximum $length caractères autorisés';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Email validator
|
||||
static FieldValidator email({String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null; // Use required() separately if needed
|
||||
}
|
||||
|
||||
final emailRegex = RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
);
|
||||
|
||||
if (!emailRegex.hasMatch(value.trim())) {
|
||||
return message ?? 'Adresse email invalide';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Numeric validator
|
||||
static FieldValidator numeric({String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null; // Use required() separately if needed
|
||||
}
|
||||
|
||||
if (double.tryParse(value.trim()) == null) {
|
||||
return message ?? 'Veuillez entrer un nombre valide';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Minimum value validator (for numeric fields)
|
||||
static FieldValidator minValue(double min, {String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final numValue = double.tryParse(value.trim());
|
||||
if (numValue == null) {
|
||||
return 'Nombre invalide';
|
||||
}
|
||||
|
||||
if (numValue < min) {
|
||||
return message ?? 'La valeur doit être au moins $min';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Maximum value validator (for numeric fields)
|
||||
static FieldValidator maxValue(double max, {String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final numValue = double.tryParse(value.trim());
|
||||
if (numValue == null) {
|
||||
return 'Nombre invalide';
|
||||
}
|
||||
|
||||
if (numValue > max) {
|
||||
return message ?? 'La valeur doit être au maximum $max';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Range validator (for numeric fields)
|
||||
static FieldValidator range(double min, double max, {String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final numValue = double.tryParse(value.trim());
|
||||
if (numValue == null) {
|
||||
return 'Nombre invalide';
|
||||
}
|
||||
|
||||
if (numValue < min || numValue > max) {
|
||||
return message ?? 'La valeur doit être entre $min et $max';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Phone number validator (simple version)
|
||||
static FieldValidator phone({String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Allow digits, spaces, +, -, ()
|
||||
final phoneRegex = RegExp(r'^[\d\s\+\-\(\)]+$');
|
||||
if (!phoneRegex.hasMatch(value.trim())) {
|
||||
return message ?? 'Numéro de téléphone invalide';
|
||||
}
|
||||
|
||||
// Check minimum length (at least 8 digits)
|
||||
final digitsOnly = value.replaceAll(RegExp(r'[^\d]'), '');
|
||||
if (digitsOnly.length < 8) {
|
||||
return message ?? 'Numéro de téléphone trop court';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// URL validator
|
||||
static FieldValidator url({String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(value.trim());
|
||||
if (!uri.hasScheme || !uri.hasAuthority) {
|
||||
return message ?? 'URL invalide';
|
||||
}
|
||||
} catch (e) {
|
||||
return message ?? 'URL invalide';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Pattern/Regex validator
|
||||
static FieldValidator pattern(RegExp regex, {String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!regex.hasMatch(value.trim())) {
|
||||
return message ?? 'Format invalide';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Match validator (confirm password, etc.)
|
||||
static FieldValidator match(String otherValue, {String? message}) {
|
||||
return (String? value) {
|
||||
if (value != otherValue) {
|
||||
return message ?? 'Les valeurs ne correspondent pas';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Custom validator
|
||||
static FieldValidator custom(bool Function(String?) test, {String? message}) {
|
||||
return (String? value) {
|
||||
if (!test(value)) {
|
||||
return message ?? 'Valeur invalide';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Alphanumeric validator
|
||||
static FieldValidator alphanumeric({String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final alphanumericRegex = RegExp(r'^[a-zA-Z0-9]+$');
|
||||
if (!alphanumericRegex.hasMatch(value.trim())) {
|
||||
return message ?? 'Seuls les caractères alphanumériques sont autorisés';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// No whitespace validator
|
||||
static FieldValidator noWhitespace({String? message}) {
|
||||
return (String? value) {
|
||||
if (value == null) return null;
|
||||
|
||||
if (value.contains(' ')) {
|
||||
return message ?? 'Les espaces ne sont pas autorisés';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Finance-specific validators
|
||||
class FinanceValidators {
|
||||
FinanceValidators._();
|
||||
|
||||
/// Amount validator (positive number with max 2 decimals)
|
||||
static FieldValidator amount({
|
||||
double? min,
|
||||
double? max,
|
||||
String? message,
|
||||
}) {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if numeric
|
||||
final numValue = double.tryParse(value.trim());
|
||||
if (numValue == null) {
|
||||
return 'Montant invalide';
|
||||
}
|
||||
|
||||
// Check if positive
|
||||
if (numValue <= 0) {
|
||||
return 'Le montant doit être positif';
|
||||
}
|
||||
|
||||
// Check min/max
|
||||
if (min != null && numValue < min) {
|
||||
return message ?? 'Le montant minimum est $min';
|
||||
}
|
||||
if (max != null && numValue > max) {
|
||||
return message ?? 'Le montant maximum est $max';
|
||||
}
|
||||
|
||||
// Check max 2 decimals
|
||||
final parts = value.trim().split('.');
|
||||
if (parts.length > 1 && parts[1].length > 2) {
|
||||
return 'Maximum 2 décimales autorisées';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// Budget line name validator
|
||||
static FieldValidator budgetLineName() {
|
||||
return composeValidators([
|
||||
Validators.required(message: 'Le nom de la ligne budgétaire est requis'),
|
||||
Validators.minLength(3, message: 'Minimum 3 caractères'),
|
||||
Validators.maxLength(100, message: 'Maximum 100 caractères'),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Budget description validator
|
||||
static FieldValidator budgetDescription({bool required = false}) {
|
||||
return composeValidators([
|
||||
if (required)
|
||||
Validators.required(message: 'La description est requise'),
|
||||
Validators.maxLength(500, message: 'Maximum 500 caractères'),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Rejection reason validator
|
||||
static FieldValidator rejectionReason() {
|
||||
return composeValidators([
|
||||
Validators.required(message: 'La raison du rejet est requise'),
|
||||
Validators.minLength(10, message: 'Veuillez fournir une raison plus détaillée (min 10 caractères)'),
|
||||
Validators.maxLength(500, message: 'Maximum 500 caractères'),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Approval comment validator (optional but with constraints if provided)
|
||||
static FieldValidator approvalComment() {
|
||||
return composeValidators([
|
||||
Validators.maxLength(500, message: 'Maximum 500 caractères'),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Budget name validator
|
||||
static FieldValidator budgetName() {
|
||||
return composeValidators([
|
||||
Validators.required(message: 'Le nom du budget est requis'),
|
||||
Validators.minLength(3, message: 'Minimum 3 caractères'),
|
||||
Validators.maxLength(200, message: 'Maximum 200 caractères'),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Fiscal year validator
|
||||
static FieldValidator fiscalYear() {
|
||||
return (String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'L\'année est requise';
|
||||
}
|
||||
|
||||
final year = int.tryParse(value.trim());
|
||||
if (year == null) {
|
||||
return 'Année invalide';
|
||||
}
|
||||
|
||||
final currentYear = DateTime.now().year;
|
||||
if (year < currentYear - 5 || year > currentYear + 10) {
|
||||
return 'L\'année doit être entre ${currentYear - 5} et ${currentYear + 10}';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
}
|
||||
4
lib/core/websocket/websocket.dart
Normal file
4
lib/core/websocket/websocket.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
/// WebSocket core exports
|
||||
library websocket;
|
||||
|
||||
export 'websocket_service.dart';
|
||||
349
lib/core/websocket/websocket_service.dart
Normal file
349
lib/core/websocket/websocket_service.dart
Normal file
@@ -0,0 +1,349 @@
|
||||
/// Service WebSocket pour temps réel (Kafka events)
|
||||
library websocket_service;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:web_socket_channel/status.dart' as status;
|
||||
|
||||
import '../config/environment.dart';
|
||||
import '../utils/logger.dart';
|
||||
|
||||
/// Events WebSocket typés
|
||||
abstract class WebSocketEvent {
|
||||
final String eventType;
|
||||
final DateTime timestamp;
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
WebSocketEvent({
|
||||
required this.eventType,
|
||||
required this.timestamp,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
factory WebSocketEvent.fromJson(Map<String, dynamic> json) {
|
||||
final eventType = json['eventType'] as String;
|
||||
final timestamp = DateTime.parse(json['timestamp'] as String);
|
||||
final data = json['data'] as Map<String, dynamic>;
|
||||
|
||||
switch (eventType) {
|
||||
case 'APPROVAL_PENDING':
|
||||
case 'APPROVAL_APPROVED':
|
||||
case 'APPROVAL_REJECTED':
|
||||
return FinanceApprovalEvent(
|
||||
eventType: eventType,
|
||||
timestamp: timestamp,
|
||||
data: data,
|
||||
organizationId: json['organizationId'] as String?,
|
||||
);
|
||||
|
||||
case 'DASHBOARD_STATS_UPDATED':
|
||||
case 'KPI_UPDATED':
|
||||
return DashboardStatsEvent(
|
||||
eventType: eventType,
|
||||
timestamp: timestamp,
|
||||
data: data,
|
||||
organizationId: json['organizationId'] as String?,
|
||||
);
|
||||
|
||||
case 'USER_NOTIFICATION':
|
||||
case 'BROADCAST_NOTIFICATION':
|
||||
return NotificationEvent(
|
||||
eventType: eventType,
|
||||
timestamp: timestamp,
|
||||
data: data,
|
||||
userId: json['userId'] as String?,
|
||||
organizationId: json['organizationId'] as String?,
|
||||
);
|
||||
|
||||
case 'MEMBER_CREATED':
|
||||
case 'MEMBER_UPDATED':
|
||||
return MemberEvent(
|
||||
eventType: eventType,
|
||||
timestamp: timestamp,
|
||||
data: data,
|
||||
organizationId: json['organizationId'] as String?,
|
||||
);
|
||||
|
||||
case 'CONTRIBUTION_PAID':
|
||||
return ContributionEvent(
|
||||
eventType: eventType,
|
||||
timestamp: timestamp,
|
||||
data: data,
|
||||
organizationId: json['organizationId'] as String?,
|
||||
);
|
||||
|
||||
default:
|
||||
return GenericEvent(
|
||||
eventType: eventType,
|
||||
timestamp: timestamp,
|
||||
data: data,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FinanceApprovalEvent extends WebSocketEvent {
|
||||
final String? organizationId;
|
||||
|
||||
FinanceApprovalEvent({
|
||||
required super.eventType,
|
||||
required super.timestamp,
|
||||
required super.data,
|
||||
this.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
class DashboardStatsEvent extends WebSocketEvent {
|
||||
final String? organizationId;
|
||||
|
||||
DashboardStatsEvent({
|
||||
required super.eventType,
|
||||
required super.timestamp,
|
||||
required super.data,
|
||||
this.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
class NotificationEvent extends WebSocketEvent {
|
||||
final String? userId;
|
||||
final String? organizationId;
|
||||
|
||||
NotificationEvent({
|
||||
required super.eventType,
|
||||
required super.timestamp,
|
||||
required super.data,
|
||||
this.userId,
|
||||
this.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
class MemberEvent extends WebSocketEvent {
|
||||
final String? organizationId;
|
||||
|
||||
MemberEvent({
|
||||
required super.eventType,
|
||||
required super.timestamp,
|
||||
required super.data,
|
||||
this.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
class ContributionEvent extends WebSocketEvent {
|
||||
final String? organizationId;
|
||||
|
||||
ContributionEvent({
|
||||
required super.eventType,
|
||||
required super.timestamp,
|
||||
required super.data,
|
||||
this.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
class GenericEvent extends WebSocketEvent {
|
||||
GenericEvent({
|
||||
required super.eventType,
|
||||
required super.timestamp,
|
||||
required super.data,
|
||||
});
|
||||
}
|
||||
|
||||
/// Service WebSocket pour recevoir les events temps réel du backend
|
||||
@singleton
|
||||
class WebSocketService {
|
||||
WebSocketChannel? _channel;
|
||||
Timer? _reconnectTimer;
|
||||
Timer? _heartbeatTimer;
|
||||
|
||||
final StreamController<WebSocketEvent> _eventController = StreamController.broadcast();
|
||||
final StreamController<bool> _connectionStatusController = StreamController.broadcast();
|
||||
|
||||
bool _isConnected = false;
|
||||
bool _shouldReconnect = true;
|
||||
int _reconnectAttempts = 0;
|
||||
|
||||
/// Stream des events WebSocket typés
|
||||
Stream<WebSocketEvent> get eventStream => _eventController.stream;
|
||||
|
||||
/// Stream du statut de connexion
|
||||
Stream<bool> get connectionStatusStream => _connectionStatusController.stream;
|
||||
|
||||
/// Statut de connexion actuel
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
/// Connexion au WebSocket
|
||||
void connect() {
|
||||
if (_isConnected || _channel != null) {
|
||||
AppLogger.info('WebSocket déjà connecté');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final wsUrl = _buildWebSocketUrl();
|
||||
AppLogger.info('Connexion WebSocket à $wsUrl...');
|
||||
|
||||
_channel = WebSocketChannel.connect(Uri.parse(wsUrl));
|
||||
|
||||
_channel!.stream.listen(
|
||||
_onMessage,
|
||||
onError: _onError,
|
||||
onDone: _onDone,
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
_isConnected = true;
|
||||
_reconnectAttempts = 0;
|
||||
_connectionStatusController.add(true);
|
||||
|
||||
// Heartbeat toutes les 30 secondes
|
||||
_startHeartbeat();
|
||||
|
||||
AppLogger.info('✅ WebSocket connecté avec succès');
|
||||
} catch (e) {
|
||||
AppLogger.error('Erreur connexion WebSocket', error: e);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Déconnexion du WebSocket
|
||||
void disconnect() {
|
||||
AppLogger.info('Déconnexion WebSocket...');
|
||||
_shouldReconnect = false;
|
||||
_stopHeartbeat();
|
||||
_stopReconnectTimer();
|
||||
|
||||
_channel?.sink.close(status.goingAway);
|
||||
_channel = null;
|
||||
|
||||
_isConnected = false;
|
||||
_connectionStatusController.add(false);
|
||||
}
|
||||
|
||||
/// Dispose des ressources
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_eventController.close();
|
||||
_connectionStatusController.close();
|
||||
}
|
||||
|
||||
/// Construit l'URL WebSocket depuis l'URL backend
|
||||
String _buildWebSocketUrl() {
|
||||
var baseUrl = AppConfig.apiBaseUrl;
|
||||
|
||||
// Remplacer http/https par ws/wss
|
||||
if (baseUrl.startsWith('https://')) {
|
||||
baseUrl = baseUrl.replaceFirst('https://', 'wss://');
|
||||
} else if (baseUrl.startsWith('http://')) {
|
||||
baseUrl = baseUrl.replaceFirst('http://', 'ws://');
|
||||
}
|
||||
|
||||
return '$baseUrl/ws/dashboard';
|
||||
}
|
||||
|
||||
/// Gestion des messages reçus
|
||||
void _onMessage(dynamic message) {
|
||||
try {
|
||||
if (AppConfig.enableLogging) {
|
||||
AppLogger.debug('WebSocket message reçu: $message');
|
||||
}
|
||||
|
||||
final json = jsonDecode(message as String) as Map<String, dynamic>;
|
||||
final type = json['type'] as String?;
|
||||
|
||||
// Gérer les messages système
|
||||
if (type == 'connected') {
|
||||
AppLogger.info('🔗 WebSocket: ${json['data']['message']}');
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'pong') {
|
||||
if (AppConfig.enableLogging) {
|
||||
AppLogger.debug('WebSocket heartbeat pong reçu');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'ack') {
|
||||
return; // Accusé de réception, ignoré
|
||||
}
|
||||
|
||||
// Event métier (Kafka)
|
||||
if (json.containsKey('eventType')) {
|
||||
final event = WebSocketEvent.fromJson(json);
|
||||
_eventController.add(event);
|
||||
AppLogger.info('📨 Event reçu: ${event.eventType}');
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('Erreur parsing message WebSocket', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gestion des erreurs
|
||||
void _onError(dynamic error) {
|
||||
AppLogger.error('WebSocket error', error: error);
|
||||
_isConnected = false;
|
||||
_connectionStatusController.add(false);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
/// Gestion de la fermeture de connexion
|
||||
void _onDone() {
|
||||
AppLogger.info('WebSocket connexion fermée');
|
||||
_isConnected = false;
|
||||
_connectionStatusController.add(false);
|
||||
_stopHeartbeat();
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
/// Planifier une reconnexion avec backoff exponentiel
|
||||
void _scheduleReconnect() {
|
||||
if (!_shouldReconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
_stopReconnectTimer();
|
||||
|
||||
// Backoff exponentiel : 2^attempts secondes (max 60s)
|
||||
final delaySeconds = (2 << _reconnectAttempts).clamp(1, 60);
|
||||
_reconnectAttempts++;
|
||||
|
||||
AppLogger.info('⏳ Reconnexion WebSocket dans ${delaySeconds}s (tentative $_reconnectAttempts)');
|
||||
|
||||
_reconnectTimer = Timer(Duration(seconds: delaySeconds), () {
|
||||
AppLogger.info('🔄 Tentative de reconnexion WebSocket...');
|
||||
connect();
|
||||
});
|
||||
}
|
||||
|
||||
/// Arrêter le timer de reconnexion
|
||||
void _stopReconnectTimer() {
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
}
|
||||
|
||||
/// Démarrer le heartbeat (ping toutes les 30s)
|
||||
void _startHeartbeat() {
|
||||
_stopHeartbeat();
|
||||
|
||||
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
||||
if (_isConnected && _channel != null) {
|
||||
try {
|
||||
_channel!.sink.add(jsonEncode({'type': 'ping'}));
|
||||
if (AppConfig.enableLogging) {
|
||||
AppLogger.debug('WebSocket heartbeat ping envoyé');
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('Erreur envoi heartbeat', error: e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Arrêter le heartbeat
|
||||
void _stopHeartbeat() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user