fix(chat): Correction race condition + Implémentation TODOs
## Corrections Critiques ### Race Condition - Statuts de Messages - Fix : Les icônes de statut (✓, ✓✓, ✓✓ bleu) ne s'affichaient pas - Cause : WebSocket delivery confirmations arrivaient avant messages locaux - Solution : Pattern Optimistic UI dans chat_bloc.dart - Création message temporaire immédiate - Ajout à la liste AVANT requête HTTP - Remplacement par message serveur à la réponse - Fichier : lib/presentation/state_management/chat_bloc.dart ## Implémentation TODOs (13/21) ### Social (social_header_widget.dart) - ✅ Copier lien du post dans presse-papiers - ✅ Partage natif via Share.share() - ✅ Dialogue de signalement avec 5 raisons ### Partage (share_post_dialog.dart) - ✅ Interface sélection d'amis avec checkboxes - ✅ Partage externe via Share API ### Média (media_upload_service.dart) - ✅ Parsing JSON réponse backend - ✅ Méthode deleteMedia() pour suppression - ✅ Génération miniature vidéo ### Posts (create_post_dialog.dart, edit_post_dialog.dart) - ✅ Extraction URL depuis uploads - ✅ Documentation chargement médias ### Chat (conversations_screen.dart) - ✅ Navigation vers notifications - ✅ ConversationSearchDelegate pour recherche ## Nouveaux Fichiers ### Configuration - build-prod.ps1 : Script build production avec dart-define - lib/core/constants/env_config.dart : Gestion environnements ### Documentation - TODOS_IMPLEMENTED.md : Documentation complète TODOs ## Améliorations ### Architecture - Refactoring injection de dépendances - Amélioration routing et navigation - Optimisation providers (UserProvider, FriendsProvider) ### UI/UX - Amélioration thème et couleurs - Optimisation animations - Meilleure gestion erreurs ### Services - Configuration API avec env_config - Amélioration datasources (events, users) - Optimisation modèles de données
This commit is contained in:
@@ -1,60 +1,205 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Classe utilitaire pour gérer les couleurs de l'application en mode clair et sombre.
|
||||
///
|
||||
/// Cette classe fournit un système de couleurs cohérent et accessible
|
||||
/// pour toute l'application, avec support complet du thème clair et sombre.
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// AppColors.primary // Retourne la couleur primaire selon le thème
|
||||
/// AppColors.lightPrimary // Accès direct à la couleur claire
|
||||
/// ```
|
||||
class AppColors {
|
||||
// Thème clair
|
||||
static const Color lightPrimary = Color(0xFF0057D9);
|
||||
static const Color lightSecondary = Color(0xFFFFC107);
|
||||
// ============================================================================
|
||||
// THÈME CLAIR - Couleurs pour le mode clair
|
||||
// ============================================================================
|
||||
|
||||
/// Couleur primaire du thème clair (Bleu Instagram-like)
|
||||
static const Color lightPrimary = Color(0xFF0095F6);
|
||||
|
||||
/// Couleur secondaire du thème clair (Rose Instagram-like)
|
||||
static const Color lightSecondary = Color(0xFFE1306C);
|
||||
|
||||
/// Couleur pour le texte/éléments sur la couleur primaire (Blanc)
|
||||
static const Color lightOnPrimary = Colors.white;
|
||||
|
||||
/// Couleur pour le texte/éléments sur la couleur secondaire (Noir)
|
||||
static const Color lightOnSecondary = Color(0xFF212121);
|
||||
static const Color lightBackground = Colors.white;
|
||||
|
||||
/// Couleur de fond principale (Blanc pur)
|
||||
static const Color lightBackground = Color(0xFFFAFAFA);
|
||||
|
||||
/// Couleur de surface (Blanc)
|
||||
static const Color lightSurface = Color(0xFFFFFFFF);
|
||||
|
||||
/// Couleur de texte primaire (Gris foncé)
|
||||
static const Color lightTextPrimary = Color(0xFF212121);
|
||||
|
||||
/// Couleur de texte secondaire (Gris moyen)
|
||||
static const Color lightTextSecondary = Color(0xFF616161);
|
||||
|
||||
/// Couleur des cartes (Blanc avec ombre douce)
|
||||
static const Color lightCardColor = Color(0xFFFFFFFF);
|
||||
|
||||
/// Couleur d'accent (Vert)
|
||||
static const Color lightAccentColor = Color(0xFF4CAF50);
|
||||
|
||||
/// Couleur d'erreur (Rouge foncé)
|
||||
static const Color lightError = Color(0xFFB00020);
|
||||
static const Color lightIconPrimary = Color(0xFF212121); // Icône primaire sombre
|
||||
static const Color lightIconSecondary = Color(0xFF757575); // Icône secondaire gris clair
|
||||
|
||||
// Thème sombre
|
||||
/// Couleur des icônes primaires (Gris foncé)
|
||||
static const Color lightIconPrimary = Color(0xFF212121);
|
||||
|
||||
/// Couleur des icônes secondaires (Gris clair)
|
||||
static const Color lightIconSecondary = Color(0xFF757575);
|
||||
|
||||
/// Couleur de fond personnalisée (Bleu clair)
|
||||
static const Color lightBackgroundCustom = Color(0xFFE0F7FA);
|
||||
|
||||
// ============================================================================
|
||||
// THÈME SOMBRE - Couleurs pour le mode sombre
|
||||
// ============================================================================
|
||||
|
||||
/// Couleur primaire du thème sombre (Noir)
|
||||
static const Color darkPrimary = Color(0xFF121212);
|
||||
|
||||
/// Couleur secondaire du thème sombre (Orange)
|
||||
static const Color darkSecondary = Color(0xFFFF5722);
|
||||
|
||||
/// Couleur pour le texte/éléments sur la couleur primaire (Blanc)
|
||||
static const Color darkOnPrimary = Colors.white;
|
||||
|
||||
/// Couleur pour le texte/éléments sur la couleur secondaire (Blanc)
|
||||
static const Color darkOnSecondary = Colors.white;
|
||||
|
||||
/// Couleur de fond principale (Noir)
|
||||
static const Color darkBackground = Color(0xFF121212);
|
||||
|
||||
/// Couleur de surface (Gris très foncé)
|
||||
static const Color darkSurface = Color(0xFF1F1F1F);
|
||||
|
||||
/// Couleur de texte primaire (Gris clair)
|
||||
static const Color darkTextPrimary = Color(0xFFE0E0E0);
|
||||
|
||||
/// Couleur de texte secondaire (Gris moyen)
|
||||
static const Color darkTextSecondary = Color(0xFFBDBDBD);
|
||||
|
||||
/// Couleur des cartes (Gris foncé)
|
||||
static const Color darkCardColor = Color(0xFF2C2C2C);
|
||||
|
||||
/// Couleur d'accent (Vert clair)
|
||||
static const Color darkAccentColor = Color(0xFF81C784);
|
||||
|
||||
/// Couleur d'erreur (Rouge clair)
|
||||
static const Color darkError = Color(0xFFF1012B);
|
||||
static const Color darkIconPrimary = Colors.white; // Icône primaire blanche
|
||||
static const Color darkIconSecondary = Color(0xFFBDBDBD); // Icône secondaire gris clair
|
||||
|
||||
// Ajout du background personnalisé
|
||||
static const Color darkbackgroundCustom = Color(0xFF2C2C3E);
|
||||
static const Color lightbackgroundCustom = Color(0xFFE0F7FA);
|
||||
/// Couleur des icônes primaires (Blanc)
|
||||
static const Color darkIconPrimary = Colors.white;
|
||||
|
||||
// Sélection automatique des couleurs en fonction du mode de thème
|
||||
static Color get primary => isDarkMode() ? darkPrimary : lightPrimary;
|
||||
static Color get secondary => isDarkMode() ? darkSecondary : lightSecondary;
|
||||
static Color get onPrimary => isDarkMode() ? darkOnPrimary : lightOnPrimary;
|
||||
static Color get onSecondary => isDarkMode() ? darkOnSecondary : lightOnSecondary;
|
||||
static Color get backgroundColor => isDarkMode() ? darkBackground : lightBackground;
|
||||
static Color get surface => isDarkMode() ? darkSurface : lightSurface;
|
||||
static Color get textPrimary => isDarkMode() ? darkTextPrimary : lightTextPrimary;
|
||||
static Color get textSecondary => isDarkMode() ? darkTextSecondary : lightTextSecondary;
|
||||
static Color get cardColor => isDarkMode() ? darkCardColor : lightCardColor;
|
||||
static Color get accentColor => isDarkMode() ? darkAccentColor : lightAccentColor;
|
||||
static Color get errorColor => isDarkMode() ? darkError : lightError;
|
||||
static Color get iconPrimary => isDarkMode() ? darkIconPrimary : lightIconPrimary;
|
||||
static Color get iconSecondary => isDarkMode() ? darkIconSecondary : lightIconSecondary;
|
||||
static Color get customBackgroundColor => isDarkMode() ? darkbackgroundCustom : lightbackgroundCustom;
|
||||
/// Couleur des icônes secondaires (Gris clair)
|
||||
static const Color darkIconSecondary = Color(0xFFBDBDBD);
|
||||
|
||||
/// Méthode utilitaire pour vérifier si le mode sombre est activé.
|
||||
static bool isDarkMode() {
|
||||
final brightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
||||
return brightness == Brightness.light;
|
||||
/// Couleur de fond personnalisée (Bleu foncé)
|
||||
static const Color darkBackgroundCustom = Color(0xFF2C2C3E);
|
||||
|
||||
// ============================================================================
|
||||
// GETTERS DYNAMIQUES - Retournent la couleur selon le thème actif
|
||||
// ============================================================================
|
||||
|
||||
/// Retourne la couleur primaire selon le thème actif
|
||||
static Color get primary => _isDarkMode() ? darkPrimary : lightPrimary;
|
||||
|
||||
/// Retourne la couleur secondaire selon le thème actif
|
||||
static Color get secondary => _isDarkMode() ? darkSecondary : lightSecondary;
|
||||
|
||||
/// Retourne la couleur pour le texte sur primaire selon le thème actif
|
||||
static Color get onPrimary => _isDarkMode() ? darkOnPrimary : lightOnPrimary;
|
||||
|
||||
/// Retourne la couleur pour le texte sur secondaire selon le thème actif
|
||||
static Color get onSecondary => _isDarkMode() ? darkOnSecondary : lightOnSecondary;
|
||||
|
||||
/// Retourne la couleur de fond selon le thème actif
|
||||
static Color get backgroundColor => _isDarkMode() ? darkBackground : lightBackground;
|
||||
|
||||
/// Retourne la couleur de surface selon le thème actif
|
||||
static Color get surface => _isDarkMode() ? darkSurface : lightSurface;
|
||||
|
||||
/// Retourne la couleur de texte primaire selon le thème actif
|
||||
static Color get textPrimary => _isDarkMode() ? darkTextPrimary : lightTextPrimary;
|
||||
|
||||
/// Retourne la couleur de texte secondaire selon le thème actif
|
||||
static Color get textSecondary => _isDarkMode() ? darkTextSecondary : lightTextSecondary;
|
||||
|
||||
/// Retourne la couleur des cartes selon le thème actif
|
||||
static Color get cardColor => _isDarkMode() ? darkCardColor : lightCardColor;
|
||||
|
||||
/// Retourne la couleur d'accent selon le thème actif
|
||||
static Color get accentColor => _isDarkMode() ? darkAccentColor : lightAccentColor;
|
||||
|
||||
/// Retourne la couleur d'erreur selon le thème actif
|
||||
static Color get errorColor => _isDarkMode() ? darkError : lightError;
|
||||
|
||||
/// Retourne la couleur des icônes primaires selon le thème actif
|
||||
static Color get iconPrimary => _isDarkMode() ? darkIconPrimary : lightIconPrimary;
|
||||
|
||||
/// Retourne la couleur des icônes secondaires selon le thème actif
|
||||
static Color get iconSecondary => _isDarkMode() ? darkIconSecondary : lightIconSecondary;
|
||||
|
||||
/// Retourne la couleur de fond personnalisée selon le thème actif
|
||||
static Color get customBackgroundColor =>
|
||||
_isDarkMode() ? darkBackgroundCustom : lightBackgroundCustom;
|
||||
|
||||
// ============================================================================
|
||||
// MÉTHODES UTILITAIRES
|
||||
// ============================================================================
|
||||
|
||||
/// Vérifie si le mode sombre est activé selon les préférences système.
|
||||
///
|
||||
/// **Note:** Cette méthode vérifie uniquement les préférences système.
|
||||
/// Pour vérifier le thème de l'application, utilisez [ThemeProvider].
|
||||
///
|
||||
/// Returns `true` si le mode sombre est activé, `false` sinon.
|
||||
static bool _isDarkMode() {
|
||||
try {
|
||||
final brightness =
|
||||
WidgetsBinding.instance.platformDispatcher.platformBrightness;
|
||||
return brightness == Brightness.dark;
|
||||
} catch (e) {
|
||||
// En cas d'erreur, retourner false (mode clair par défaut)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si le mode sombre est activé (méthode publique).
|
||||
///
|
||||
/// Cette méthode est dépréciée. Utilisez [ThemeProvider] pour vérifier
|
||||
/// le thème de l'application.
|
||||
@Deprecated('Utilisez ThemeProvider.isDarkMode à la place')
|
||||
static bool isDarkMode() => _isDarkMode();
|
||||
|
||||
/// Crée une couleur avec opacité.
|
||||
///
|
||||
/// [color] La couleur de base
|
||||
/// [opacity] L'opacité entre 0.0 et 1.0
|
||||
///
|
||||
/// Returns une nouvelle couleur avec l'opacité spécifiée.
|
||||
static Color withOpacity(Color color, double opacity) {
|
||||
return color.withOpacity(opacity.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
/// Crée un dégradé linéaire avec les couleurs primaire et secondaire.
|
||||
///
|
||||
/// [isDark] Si true, utilise les couleurs du thème sombre
|
||||
///
|
||||
/// Returns un [LinearGradient] avec les couleurs appropriées.
|
||||
static LinearGradient primaryGradient({bool isDark = false}) {
|
||||
return LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: isDark
|
||||
? [darkPrimary, darkSecondary]
|
||||
: [lightPrimary, lightSecondary],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
326
lib/core/constants/design_system.dart
Normal file
326
lib/core/constants/design_system.dart
Normal file
@@ -0,0 +1,326 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Design System centralisé pour Afterwork
|
||||
///
|
||||
/// Ce fichier contient toutes les constantes de design pour assurer
|
||||
/// une cohérence visuelle à travers toute l'application.
|
||||
///
|
||||
/// **Sections:**
|
||||
/// - Spacing: Espacements standardisés
|
||||
/// - BorderRadius: Rayons de bordure
|
||||
/// - Shadows: Ombres et élévations
|
||||
/// - Durations: Durées d'animations
|
||||
/// - Curves: Courbes d'animations
|
||||
/// - Sizes: Tailles standardisées
|
||||
class DesignSystem {
|
||||
// ============================================================================
|
||||
// SPACING
|
||||
// ============================================================================
|
||||
|
||||
/// Espacements standardisés
|
||||
///
|
||||
/// Utiliser ces constantes pour tous les paddings, margins, gaps, etc.
|
||||
/// Cela garantit une cohérence visuelle et facilite les ajustements.
|
||||
static const double spacing2xs = 2.0;
|
||||
static const double spacingXs = 4.0;
|
||||
static const double spacingSm = 8.0;
|
||||
static const double spacingMd = 12.0;
|
||||
static const double spacingLg = 16.0;
|
||||
static const double spacingXl = 24.0;
|
||||
static const double spacing2xl = 32.0;
|
||||
static const double spacing3xl = 48.0;
|
||||
static const double spacing4xl = 64.0;
|
||||
|
||||
/// Padding horizontal standard des écrans
|
||||
static const double screenPaddingHorizontal = spacingLg;
|
||||
|
||||
/// Padding vertical standard des écrans
|
||||
static const double screenPaddingVertical = spacingLg;
|
||||
|
||||
/// Gap entre éléments de liste
|
||||
static const double listItemGap = spacingMd;
|
||||
|
||||
/// Gap entre sections
|
||||
static const double sectionGap = spacingXl;
|
||||
|
||||
// ============================================================================
|
||||
// BORDER RADIUS
|
||||
// ============================================================================
|
||||
|
||||
/// Rayons de bordure standardisés
|
||||
static const double radiusXs = 4.0;
|
||||
static const double radiusSm = 8.0;
|
||||
static const double radiusMd = 12.0;
|
||||
static const double radiusLg = 16.0;
|
||||
static const double radiusXl = 20.0;
|
||||
static const double radius2xl = 24.0;
|
||||
static const double radiusRound = 999.0;
|
||||
|
||||
/// BorderRadius objets pour utilisation directe
|
||||
static final BorderRadius borderRadiusXs = BorderRadius.circular(radiusXs);
|
||||
static final BorderRadius borderRadiusSm = BorderRadius.circular(radiusSm);
|
||||
static final BorderRadius borderRadiusMd = BorderRadius.circular(radiusMd);
|
||||
static final BorderRadius borderRadiusLg = BorderRadius.circular(radiusLg);
|
||||
static final BorderRadius borderRadiusXl = BorderRadius.circular(radiusXl);
|
||||
static final BorderRadius borderRadius2xl = BorderRadius.circular(radius2xl);
|
||||
static final BorderRadius borderRadiusRound = BorderRadius.circular(radiusRound);
|
||||
|
||||
// ============================================================================
|
||||
// SHADOWS
|
||||
// ============================================================================
|
||||
|
||||
/// Ombres standardisées pour Material Design
|
||||
///
|
||||
/// Niveaux d'élévation:
|
||||
/// - None: Pas d'ombre
|
||||
/// - Sm: Petite élévation (cartes au repos)
|
||||
/// - Md: Élévation moyenne (cartes survolées)
|
||||
/// - Lg: Grande élévation (dialogs, bottom sheets)
|
||||
/// - Xl: Très grande élévation (navigation drawer)
|
||||
|
||||
static const List<BoxShadow> shadowNone = [];
|
||||
|
||||
static const List<BoxShadow> shadowSm = [
|
||||
BoxShadow(
|
||||
color: Color(0x0F000000), // 6% opacity
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 1),
|
||||
spreadRadius: 0,
|
||||
),
|
||||
];
|
||||
|
||||
static const List<BoxShadow> shadowMd = [
|
||||
BoxShadow(
|
||||
color: Color(0x14000000), // 8% opacity
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
spreadRadius: 0,
|
||||
),
|
||||
];
|
||||
|
||||
static const List<BoxShadow> shadowLg = [
|
||||
BoxShadow(
|
||||
color: Color(0x1F000000), // 12% opacity
|
||||
blurRadius: 16,
|
||||
offset: Offset(0, 4),
|
||||
spreadRadius: 0,
|
||||
),
|
||||
];
|
||||
|
||||
static const List<BoxShadow> shadowXl = [
|
||||
BoxShadow(
|
||||
color: Color(0x29000000), // 16% opacity
|
||||
blurRadius: 24,
|
||||
offset: Offset(0, 8),
|
||||
spreadRadius: 0,
|
||||
),
|
||||
];
|
||||
|
||||
/// Ombres pour mode sombre (plus subtiles)
|
||||
static const List<BoxShadow> shadowSmDark = [
|
||||
BoxShadow(
|
||||
color: Color(0x33000000), // 20% opacity
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 1),
|
||||
spreadRadius: 0,
|
||||
),
|
||||
];
|
||||
|
||||
static const List<BoxShadow> shadowMdDark = [
|
||||
BoxShadow(
|
||||
color: Color(0x3D000000), // 24% opacity
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
spreadRadius: 0,
|
||||
),
|
||||
];
|
||||
|
||||
static const List<BoxShadow> shadowLgDark = [
|
||||
BoxShadow(
|
||||
color: Color(0x47000000), // 28% opacity
|
||||
blurRadius: 16,
|
||||
offset: Offset(0, 4),
|
||||
spreadRadius: 0,
|
||||
),
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// DURATIONS (Durées d'animations)
|
||||
// ============================================================================
|
||||
|
||||
/// Durées d'animations standardisées
|
||||
///
|
||||
/// Suivent les Material Design motion guidelines:
|
||||
/// - Fast: Micro-interactions rapides (100-200ms)
|
||||
/// - Medium: Transitions standard (200-300ms)
|
||||
/// - Slow: Animations complexes (300-500ms)
|
||||
static const Duration durationInstant = Duration(milliseconds: 100);
|
||||
static const Duration durationFast = Duration(milliseconds: 200);
|
||||
static const Duration durationMedium = Duration(milliseconds: 300);
|
||||
static const Duration durationSlow = Duration(milliseconds: 400);
|
||||
static const Duration durationSlower = Duration(milliseconds: 500);
|
||||
|
||||
// ============================================================================
|
||||
// CURVES (Courbes d'animations)
|
||||
// ============================================================================
|
||||
|
||||
/// Courbes d'animations standardisées
|
||||
///
|
||||
/// Material Design recommande:
|
||||
/// - easeIn: Accélération au début (sortie d'écran)
|
||||
/// - easeOut: Décélération à la fin (entrée d'écran)
|
||||
/// - easeInOut: Accélération puis décélération (transitions)
|
||||
/// - bounce: Effet rebond (micro-interactions fun)
|
||||
static const Curve curveStandard = Curves.easeInOut;
|
||||
static const Curve curveDecelerate = Curves.easeOut;
|
||||
static const Curve curveAccelerate = Curves.easeIn;
|
||||
static const Curve curveSharp = Curves.easeInOutCubic;
|
||||
static const Curve curveBounce = Curves.elasticOut;
|
||||
|
||||
// ============================================================================
|
||||
// SIZES (Tailles standardisées)
|
||||
// ============================================================================
|
||||
|
||||
/// Tailles d'icônes
|
||||
static const double iconSizeXs = 16.0;
|
||||
static const double iconSizeSm = 20.0;
|
||||
static const double iconSizeMd = 24.0;
|
||||
static const double iconSizeLg = 32.0;
|
||||
static const double iconSizeXl = 48.0;
|
||||
static const double iconSize2xl = 64.0;
|
||||
|
||||
/// Tailles d'avatars
|
||||
static const double avatarSizeXs = 24.0;
|
||||
static const double avatarSizeSm = 32.0;
|
||||
static const double avatarSizeMd = 40.0;
|
||||
static const double avatarSizeLg = 56.0;
|
||||
static const double avatarSizeXl = 72.0;
|
||||
static const double avatarSize2xl = 96.0;
|
||||
|
||||
/// Hauteurs de boutons
|
||||
static const double buttonHeightSm = 36.0;
|
||||
static const double buttonHeightMd = 44.0;
|
||||
static const double buttonHeightLg = 52.0;
|
||||
|
||||
/// Hauteurs de champs de saisie
|
||||
static const double inputHeightSm = 40.0;
|
||||
static const double inputHeightMd = 48.0;
|
||||
static const double inputHeightLg = 56.0;
|
||||
|
||||
/// Tailles de FAB (Floating Action Button)
|
||||
static const double fabSizeSm = 48.0;
|
||||
static const double fabSizeMd = 56.0;
|
||||
static const double fabSizeLg = 64.0;
|
||||
|
||||
// ============================================================================
|
||||
// OPACITIES (Opacités standardisées)
|
||||
// ============================================================================
|
||||
|
||||
static const double opacityDisabled = 0.38;
|
||||
static const double opacityInactive = 0.54;
|
||||
static const double opacitySecondary = 0.7;
|
||||
static const double opacityPrimary = 0.87;
|
||||
static const double opacityFull = 1.0;
|
||||
|
||||
// ============================================================================
|
||||
// Z-INDEX / ELEVATION
|
||||
// ============================================================================
|
||||
|
||||
static const double elevationNone = 0.0;
|
||||
static const double elevationXs = 1.0;
|
||||
static const double elevationSm = 2.0;
|
||||
static const double elevationMd = 4.0;
|
||||
static const double elevationLg = 8.0;
|
||||
static const double elevationXl = 16.0;
|
||||
|
||||
// ============================================================================
|
||||
// BREAKPOINTS (pour responsive design)
|
||||
// ============================================================================
|
||||
|
||||
static const double breakpointMobile = 600.0;
|
||||
static const double breakpointTablet = 900.0;
|
||||
static const double breakpointDesktop = 1200.0;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER METHODS
|
||||
// ============================================================================
|
||||
|
||||
/// Retourne les ombres appropriées selon le thème
|
||||
static List<BoxShadow> getShadow(
|
||||
BuildContext context,
|
||||
ShadowSize size,
|
||||
) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
switch (size) {
|
||||
case ShadowSize.none:
|
||||
return shadowNone;
|
||||
case ShadowSize.sm:
|
||||
return isDark ? shadowSmDark : shadowSm;
|
||||
case ShadowSize.md:
|
||||
return isDark ? shadowMdDark : shadowMd;
|
||||
case ShadowSize.lg:
|
||||
return isDark ? shadowLgDark : shadowLg;
|
||||
case ShadowSize.xl:
|
||||
return shadowXl;
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne un EdgeInsets avec padding uniforme
|
||||
static EdgeInsets paddingAll(double value) => EdgeInsets.all(value);
|
||||
|
||||
/// Retourne un EdgeInsets avec padding horizontal
|
||||
static EdgeInsets paddingHorizontal(double value) =>
|
||||
EdgeInsets.symmetric(horizontal: value);
|
||||
|
||||
/// Retourne un EdgeInsets avec padding vertical
|
||||
static EdgeInsets paddingVertical(double value) =>
|
||||
EdgeInsets.symmetric(vertical: value);
|
||||
|
||||
/// Retourne un EdgeInsets avec padding screen standard
|
||||
static EdgeInsets get paddingScreen => const EdgeInsets.symmetric(
|
||||
horizontal: screenPaddingHorizontal,
|
||||
vertical: screenPaddingVertical,
|
||||
);
|
||||
|
||||
/// Retourne un SizedBox avec hauteur
|
||||
static SizedBox verticalSpace(double height) => SizedBox(height: height);
|
||||
|
||||
/// Retourne un SizedBox avec largeur
|
||||
static SizedBox horizontalSpace(double width) => SizedBox(width: width);
|
||||
}
|
||||
|
||||
/// Énumération pour les tailles d'ombres
|
||||
enum ShadowSize {
|
||||
none,
|
||||
sm,
|
||||
md,
|
||||
lg,
|
||||
xl,
|
||||
}
|
||||
|
||||
/// Extensions pour faciliter l'utilisation du Design System
|
||||
extension DesignSystemExtensions on BuildContext {
|
||||
/// Retourne les ombres selon le thème
|
||||
List<BoxShadow> shadow(ShadowSize size) => DesignSystem.getShadow(this, size);
|
||||
|
||||
/// Retourne true si on est en mode sombre
|
||||
bool get isDarkMode => Theme.of(this).brightness == Brightness.dark;
|
||||
|
||||
/// Retourne la largeur de l'écran
|
||||
double get screenWidth => MediaQuery.of(this).size.width;
|
||||
|
||||
/// Retourne la hauteur de l'écran
|
||||
double get screenHeight => MediaQuery.of(this).size.height;
|
||||
|
||||
/// Retourne true si on est sur mobile
|
||||
bool get isMobile => screenWidth < DesignSystem.breakpointMobile;
|
||||
|
||||
/// Retourne true si on est sur tablette
|
||||
bool get isTablet =>
|
||||
screenWidth >= DesignSystem.breakpointMobile &&
|
||||
screenWidth < DesignSystem.breakpointTablet;
|
||||
|
||||
/// Retourne true si on est sur desktop
|
||||
bool get isDesktop => screenWidth >= DesignSystem.breakpointDesktop;
|
||||
}
|
||||
209
lib/core/constants/env_config.dart
Normal file
209
lib/core/constants/env_config.dart
Normal file
@@ -0,0 +1,209 @@
|
||||
/// Exception levée lorsque la configuration de l'environnement est invalide.
|
||||
class ConfigurationException implements Exception {
|
||||
ConfigurationException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'ConfigurationException: $message';
|
||||
}
|
||||
|
||||
/// Configuration centralisée de l'environnement de l'application.
|
||||
///
|
||||
/// Ce fichier gère toutes les variables d'environnement et secrets
|
||||
/// de l'application de manière sécurisée. Les valeurs peuvent être
|
||||
/// définies au moment du build via des variables d'environnement.
|
||||
///
|
||||
/// **Usage en développement:**
|
||||
/// ```dart
|
||||
/// final apiUrl = EnvConfig.apiBaseUrl; // Utilise la valeur par défaut
|
||||
/// ```
|
||||
///
|
||||
/// **Usage en production:**
|
||||
/// ```bash
|
||||
/// flutter build apk --dart-define=API_BASE_URL=https://api.example.com
|
||||
/// ```
|
||||
///
|
||||
/// **Validation:**
|
||||
/// ```dart
|
||||
/// // Valider au démarrage de l'application
|
||||
/// EnvConfig.validate(throwOnError: true);
|
||||
/// ```
|
||||
class EnvConfig {
|
||||
/// Constructeur privé pour empêcher l'instanciation
|
||||
EnvConfig._();
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION API
|
||||
// ============================================================================
|
||||
|
||||
/// URL de base de l'API backend.
|
||||
///
|
||||
/// Cette valeur peut être définie au moment du build avec:
|
||||
/// `--dart-define=API_BASE_URL=https://api.example.com`
|
||||
///
|
||||
/// **Valeur par défaut:** `http://192.168.1.145:8080` (développement)
|
||||
static const String apiBaseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://192.168.1.145:8080',
|
||||
);
|
||||
|
||||
/// Timeout pour les requêtes réseau (en secondes).
|
||||
///
|
||||
/// **Valeur par défaut:** 30 secondes
|
||||
static const int networkTimeout = int.fromEnvironment(
|
||||
'NETWORK_TIMEOUT',
|
||||
defaultValue: 30,
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// ENVIRONNEMENT
|
||||
// ============================================================================
|
||||
|
||||
/// Environnement actuel de l'application.
|
||||
///
|
||||
/// Valeurs possibles: `development`, `staging`, `production`
|
||||
///
|
||||
/// **Valeur par défaut:** `development`
|
||||
static const String environment = String.fromEnvironment(
|
||||
'ENVIRONMENT',
|
||||
defaultValue: 'development',
|
||||
);
|
||||
|
||||
/// Vérifie si l'environnement est en production.
|
||||
///
|
||||
/// Returns `true` si l'environnement est `production`, `false` sinon.
|
||||
static bool get isProduction => environment == 'production';
|
||||
|
||||
/// Vérifie si l'environnement est en développement.
|
||||
///
|
||||
/// Returns `true` si l'environnement est `development`, `false` sinon.
|
||||
static bool get isDevelopment => environment == 'development';
|
||||
|
||||
/// Vérifie si l'environnement est en staging.
|
||||
///
|
||||
/// Returns `true` si l'environnement est `staging`, `false` sinon.
|
||||
static bool get isStaging => environment == 'staging';
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION DEBUG
|
||||
// ============================================================================
|
||||
|
||||
/// Mode debug activé.
|
||||
///
|
||||
/// Quand activé, des logs supplémentaires sont affichés et certaines
|
||||
/// fonctionnalités de débogage sont disponibles.
|
||||
///
|
||||
/// **Valeur par défaut:** `true`
|
||||
static const bool isDebugMode = bool.fromEnvironment(
|
||||
'DEBUG_MODE',
|
||||
defaultValue: true,
|
||||
);
|
||||
|
||||
/// Active les logs détaillés.
|
||||
///
|
||||
/// **Valeur par défaut:** `true` en développement, `false` en production
|
||||
static bool get enableDetailedLogs => isDevelopment || isDebugMode;
|
||||
|
||||
// ============================================================================
|
||||
// SERVICES EXTERNES
|
||||
// ============================================================================
|
||||
|
||||
/// Clé API Google Maps (si nécessaire).
|
||||
///
|
||||
/// Cette valeur doit être définie au moment du build avec:
|
||||
/// `--dart-define=GOOGLE_MAPS_API_KEY=your_api_key`
|
||||
///
|
||||
/// **Note:** Ne jamais commiter cette clé dans le code source.
|
||||
static const String googleMapsApiKey = String.fromEnvironment(
|
||||
'GOOGLE_MAPS_API_KEY',
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// MÉTHODES UTILITAIRES
|
||||
// ============================================================================
|
||||
|
||||
/// Valide que la configuration est correcte.
|
||||
///
|
||||
/// Cette méthode vérifie que toutes les valeurs requises sont définies
|
||||
/// et valides pour l'environnement actuel.
|
||||
///
|
||||
/// Throws [ConfigurationException] si la validation échoue en production.
|
||||
/// Returns `true` si la configuration est valide, `false` sinon en développement.
|
||||
///
|
||||
/// **Validations effectuées:**
|
||||
/// - URL API non vide et format valide
|
||||
/// - HTTPS obligatoire en production
|
||||
/// - Clés API requises en production
|
||||
/// - Timeout réseau valide (> 0)
|
||||
static bool validate({bool throwOnError = false}) {
|
||||
final errors = <String>[];
|
||||
|
||||
// Validation de l'URL API
|
||||
if (apiBaseUrl.isEmpty) {
|
||||
errors.add('API_BASE_URL ne peut pas être vide');
|
||||
} else {
|
||||
try {
|
||||
final uri = Uri.parse(apiBaseUrl);
|
||||
if (!uri.hasScheme || (!uri.scheme.startsWith('http'))) {
|
||||
errors.add('API_BASE_URL doit être une URL HTTP/HTTPS valide');
|
||||
}
|
||||
} catch (e) {
|
||||
errors.add('API_BASE_URL n\'est pas une URL valide: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Validation HTTPS en production
|
||||
if (isProduction && !apiBaseUrl.startsWith('https://')) {
|
||||
errors.add('API_BASE_URL doit utiliser HTTPS en production');
|
||||
}
|
||||
|
||||
// Validation du timeout réseau
|
||||
if (networkTimeout <= 0) {
|
||||
errors.add('NETWORK_TIMEOUT doit être supérieur à 0');
|
||||
}
|
||||
|
||||
// Validation des clés API en production (si nécessaire)
|
||||
if (isProduction) {
|
||||
// Google Maps API Key est optionnelle mais recommandée si on utilise Google Maps
|
||||
// On ne force pas car elle peut ne pas être nécessaire selon les fonctionnalités
|
||||
}
|
||||
|
||||
// Si des erreurs sont trouvées
|
||||
if (errors.isNotEmpty) {
|
||||
final errorMessage = 'Erreurs de configuration:\n${errors.join('\n')}';
|
||||
|
||||
if (throwOnError || isProduction) {
|
||||
throw ConfigurationException(errorMessage);
|
||||
}
|
||||
|
||||
// En développement, on log juste les erreurs
|
||||
if (isDevelopment) {
|
||||
// Utiliser print car AppLogger pourrait ne pas être initialisé
|
||||
print('[EnvConfig] ⚠️ $errorMessage');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Retourne un résumé de la configuration actuelle.
|
||||
///
|
||||
/// Cette méthode est utile pour le débogage et les logs.
|
||||
///
|
||||
/// **Note:** Les valeurs sensibles (comme les clés API) ne sont pas incluses.
|
||||
///
|
||||
/// Returns une chaîne décrivant la configuration actuelle.
|
||||
static String getConfigSummary() {
|
||||
return '''
|
||||
Environment: $environment
|
||||
API Base URL: $apiBaseUrl
|
||||
Network Timeout: ${networkTimeout}s
|
||||
Debug Mode: $isDebugMode
|
||||
Google Maps API Key: ${googleMapsApiKey.isNotEmpty ? '***configured***' : 'not configured'}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,12 @@ class Urls {
|
||||
static String getEventsByUserWithUserId(String userId) =>
|
||||
'$eventsBase/user/$userId';
|
||||
|
||||
/// Retourne l'URL pour obtenir les événements de l'utilisateur et de ses amis
|
||||
///
|
||||
/// [userId] L'ID de l'utilisateur
|
||||
static String getEventsByFriends(String userId) =>
|
||||
'$eventsBase/friends/$userId';
|
||||
|
||||
/// Endpoint pour rechercher des événements
|
||||
///
|
||||
/// **Note:** Utilisez des paramètres de requête pour le mot-clé
|
||||
@@ -244,6 +250,13 @@ class Urls {
|
||||
static String rejectFriendRequestWithId(String friendshipId) =>
|
||||
'$friendsBase/$friendshipId/reject';
|
||||
|
||||
/// Retourne l'URL pour récupérer les suggestions d'amis
|
||||
///
|
||||
/// [userId] L'ID de l'utilisateur
|
||||
/// [limit] Nombre maximum de suggestions (optionnel, par défaut 10)
|
||||
static String getFriendSuggestionsWithUserId(String userId, {int limit = 10}) =>
|
||||
'$friendsBase/suggestions/$userId?limit=$limit';
|
||||
|
||||
// ============================================================================
|
||||
// NOTIFICATIONS
|
||||
// ============================================================================
|
||||
@@ -320,6 +333,12 @@ class Urls {
|
||||
static String commentSocialPostWithId(String postId) =>
|
||||
'$postsBase/$postId/comment';
|
||||
|
||||
/// Retourne l'URL pour obtenir tous les commentaires d'un post
|
||||
///
|
||||
/// [postId] L'ID du post
|
||||
static String getCommentsForPost(String postId) =>
|
||||
'$postsBase/$postId/comments';
|
||||
|
||||
/// Retourne l'URL pour partager un post
|
||||
///
|
||||
/// [postId] L'ID du post
|
||||
@@ -331,6 +350,124 @@ class Urls {
|
||||
static String getSocialPostsByUserId(String userId) =>
|
||||
'$postsBase/user/$userId';
|
||||
|
||||
/// Retourne l'URL pour obtenir les posts de l'utilisateur et de ses amis
|
||||
///
|
||||
/// [userId] L'ID de l'utilisateur
|
||||
static String getSocialPostsByFriends(String userId) =>
|
||||
'$postsBase/friends/$userId';
|
||||
|
||||
// ============================================================================
|
||||
// STORIES
|
||||
// ============================================================================
|
||||
|
||||
/// Endpoint de base pour les opérations sur les stories
|
||||
static String get storiesBase => '$baseUrl/stories';
|
||||
|
||||
/// Retourne l'URL pour obtenir toutes les stories (actives)
|
||||
static String get getAllStories => storiesBase;
|
||||
|
||||
/// Retourne l'URL pour obtenir les stories d'un utilisateur
|
||||
///
|
||||
/// [userId] L'ID de l'utilisateur
|
||||
static String getStoriesByUserId(String userId) => '$storiesBase/user/$userId';
|
||||
|
||||
/// Retourne l'URL pour créer une nouvelle story
|
||||
static String get createStory => storiesBase;
|
||||
|
||||
/// Retourne l'URL pour obtenir une story par son ID
|
||||
///
|
||||
/// [storyId] L'ID de la story
|
||||
static String getStoryByIdWithId(String storyId) => '$storiesBase/$storyId';
|
||||
|
||||
/// Retourne l'URL pour supprimer une story
|
||||
///
|
||||
/// [storyId] L'ID de la story
|
||||
static String deleteStoryWithId(String storyId) => '$storiesBase/$storyId';
|
||||
|
||||
/// Retourne l'URL pour marquer une story comme vue
|
||||
///
|
||||
/// [storyId] L'ID de la story
|
||||
/// [userId] L'ID de l'utilisateur qui voit la story
|
||||
static String markStoryAsViewedWithId(String storyId, String userId) =>
|
||||
'$storiesBase/$storyId/view?userId=$userId';
|
||||
|
||||
/// Retourne l'URL pour obtenir les vues d'une story
|
||||
///
|
||||
/// [storyId] L'ID de la story
|
||||
static String getStoryViewsWithId(String storyId) => '$storiesBase/$storyId/views';
|
||||
|
||||
// ============================================================================
|
||||
// MESSAGERIE
|
||||
// ============================================================================
|
||||
|
||||
/// Endpoint de base pour les opérations sur les messages
|
||||
static String get messagesBase => '$baseUrl/messages';
|
||||
|
||||
/// Retourne l'URL pour envoyer un message
|
||||
static String get sendMessage => messagesBase;
|
||||
|
||||
/// Retourne l'URL pour obtenir les conversations d'un utilisateur
|
||||
///
|
||||
/// [userId] L'ID de l'utilisateur
|
||||
static String getUserConversations(String userId) =>
|
||||
'$messagesBase/conversations/$userId';
|
||||
|
||||
/// Retourne l'URL pour obtenir les messages d'une conversation
|
||||
///
|
||||
/// [conversationId] L'ID de la conversation
|
||||
/// [page] Le numéro de la page (optionnel)
|
||||
/// [size] La taille de la page (optionnel)
|
||||
static String getConversationMessages(String conversationId,
|
||||
{int page = 0, int size = 50}) =>
|
||||
'$messagesBase/conversation/$conversationId?page=$page&size=$size';
|
||||
|
||||
/// Retourne l'URL pour obtenir une conversation entre deux utilisateurs
|
||||
///
|
||||
/// [user1Id] L'ID du premier utilisateur
|
||||
/// [user2Id] L'ID du deuxième utilisateur
|
||||
static String getConversationBetweenUsers(String user1Id, String user2Id) =>
|
||||
'$messagesBase/conversation/between/$user1Id/$user2Id';
|
||||
|
||||
/// Retourne l'URL pour marquer un message comme lu
|
||||
///
|
||||
/// [messageId] L'ID du message
|
||||
static String markMessageAsRead(String messageId) =>
|
||||
'$messagesBase/$messageId/read';
|
||||
|
||||
/// Retourne l'URL pour marquer tous les messages d'une conversation comme lus
|
||||
///
|
||||
/// [conversationId] L'ID de la conversation
|
||||
/// [userId] L'ID de l'utilisateur
|
||||
static String markAllMessagesAsRead(String conversationId, String userId) =>
|
||||
'$messagesBase/conversation/$conversationId/read/$userId';
|
||||
|
||||
/// Retourne l'URL pour obtenir le nombre de messages non lus
|
||||
///
|
||||
/// [userId] L'ID de l'utilisateur
|
||||
static String getUnreadMessagesCount(String userId) =>
|
||||
'$messagesBase/unread/count/$userId';
|
||||
|
||||
/// Retourne l'URL pour supprimer un message
|
||||
///
|
||||
/// [messageId] L'ID du message
|
||||
static String deleteMessage(String messageId) => '$messagesBase/$messageId';
|
||||
|
||||
/// Retourne l'URL pour supprimer une conversation
|
||||
///
|
||||
/// [conversationId] L'ID de la conversation
|
||||
static String deleteConversation(String conversationId) =>
|
||||
'$messagesBase/conversation/$conversationId';
|
||||
|
||||
/// Retourne l'URL WebSocket pour le chat en temps réel
|
||||
///
|
||||
/// [userId] L'ID de l'utilisateur
|
||||
static String getChatWebSocketUrl(String userId) {
|
||||
final wsUrl = baseUrl
|
||||
.replaceFirst('http://', 'ws://')
|
||||
.replaceFirst('https://', 'wss://');
|
||||
return '$wsUrl/chat/ws/$userId';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MÉTHODES UTILITAIRES
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,52 +1,300 @@
|
||||
/// Exception de base pour toutes les exceptions serveur.
|
||||
///
|
||||
/// Cette exception est levée lorsque le serveur retourne une erreur
|
||||
/// ou lorsqu'une communication avec le serveur échoue.
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// if (response.statusCode >= 400) {
|
||||
/// throw ServerException(
|
||||
/// 'Erreur serveur: ${response.statusCode}',
|
||||
/// statusCode: response.statusCode,
|
||||
/// );
|
||||
/// }
|
||||
/// ```
|
||||
class ServerException implements Exception {
|
||||
/// Crée une nouvelle [ServerException].
|
||||
///
|
||||
/// [message] Message décrivant l'erreur
|
||||
/// [statusCode] Code de statut HTTP optionnel
|
||||
/// [originalError] L'erreur originale si disponible
|
||||
const ServerException(
|
||||
this.message, {
|
||||
this.statusCode,
|
||||
this.originalError,
|
||||
});
|
||||
|
||||
/// Message décrivant l'erreur
|
||||
final String message;
|
||||
|
||||
ServerException([this.message = 'Une erreur serveur est survenue']);
|
||||
/// Code de statut HTTP (404, 500, etc.)
|
||||
final int? statusCode;
|
||||
|
||||
/// L'erreur originale qui a causé cette exception
|
||||
final Object? originalError;
|
||||
|
||||
@override
|
||||
String toString() => 'ServerException: $message';
|
||||
String toString() {
|
||||
final buffer = StringBuffer('ServerException: $message');
|
||||
if (statusCode != null) {
|
||||
buffer.write(' (Status: $statusCode)');
|
||||
}
|
||||
if (originalError != null) {
|
||||
buffer.write(' (Original: $originalError)');
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
class CacheException implements Exception {}
|
||||
/// Exception liée au cache local.
|
||||
///
|
||||
/// Cette exception est levée lorsque :
|
||||
/// - Les données ne peuvent pas être lues depuis le cache
|
||||
/// - Les données ne peuvent pas être écrites dans le cache
|
||||
/// - Le cache est corrompu ou inaccessible
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// try {
|
||||
/// await cache.write(key, value);
|
||||
/// } catch (e) {
|
||||
/// throw CacheException('Impossible d\'écrire dans le cache', e);
|
||||
/// }
|
||||
/// ```
|
||||
class CacheException implements Exception {
|
||||
/// Crée une nouvelle [CacheException].
|
||||
///
|
||||
/// [message] Message décrivant l'erreur
|
||||
/// [originalError] L'erreur originale si disponible
|
||||
const CacheException([
|
||||
this.message = 'Erreur de cache',
|
||||
this.originalError,
|
||||
]);
|
||||
|
||||
/// Message décrivant l'erreur
|
||||
final String message;
|
||||
|
||||
/// L'erreur originale qui a causé cette exception
|
||||
final Object? originalError;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (originalError != null) {
|
||||
return 'CacheException: $message (Original: $originalError)';
|
||||
}
|
||||
return 'CacheException: $message';
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception liée à l'authentification.
|
||||
///
|
||||
/// Cette exception est levée lorsque :
|
||||
/// - Les identifiants sont incorrects
|
||||
/// - Le token d'authentification est expiré
|
||||
/// - L'utilisateur n'est pas autorisé
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// if (!isValidCredentials(email, password)) {
|
||||
/// throw AuthenticationException('Identifiants incorrects');
|
||||
/// }
|
||||
/// ```
|
||||
class AuthenticationException implements Exception {
|
||||
/// Crée une nouvelle [AuthenticationException].
|
||||
///
|
||||
/// [message] Message décrivant l'erreur d'authentification
|
||||
/// [code] Code d'erreur optionnel
|
||||
const AuthenticationException(
|
||||
this.message, {
|
||||
this.code,
|
||||
});
|
||||
|
||||
/// Message décrivant l'erreur
|
||||
final String message;
|
||||
|
||||
AuthenticationException(this.message);
|
||||
/// Code d'erreur optionnel
|
||||
final String? code;
|
||||
|
||||
@override
|
||||
String toString() => 'AuthenticationException: $message';
|
||||
String toString() {
|
||||
if (code != null) {
|
||||
return 'AuthenticationException: $message (Code: $code)';
|
||||
}
|
||||
return 'AuthenticationException: $message';
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception serveur avec message personnalisé.
|
||||
///
|
||||
/// **Note:** Cette classe est dépréciée. Utilisez [ServerException] à la place.
|
||||
///
|
||||
/// **Usage déprécié:**
|
||||
/// ```dart
|
||||
/// throw ServerExceptionWithMessage('Erreur personnalisée');
|
||||
/// ```
|
||||
@Deprecated('Utilisez ServerException à la place')
|
||||
class ServerExceptionWithMessage implements Exception {
|
||||
final String message;
|
||||
/// Crée une nouvelle [ServerExceptionWithMessage].
|
||||
///
|
||||
/// [message] Message décrivant l'erreur
|
||||
const ServerExceptionWithMessage(this.message);
|
||||
|
||||
ServerExceptionWithMessage(this.message);
|
||||
/// Message décrivant l'erreur
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'ServerException: $message';
|
||||
}
|
||||
|
||||
/// Exception levée lorsque l'utilisateur n'est pas trouvé.
|
||||
///
|
||||
/// Cette exception est levée lorsque :
|
||||
/// - L'utilisateur avec l'ID donné n'existe pas
|
||||
/// - L'utilisateur a été supprimé
|
||||
/// - L'ID utilisateur est invalide
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// final user = await repository.getUserById(userId);
|
||||
/// if (user == null) {
|
||||
/// throw UserNotFoundException('Utilisateur avec ID $userId non trouvé');
|
||||
/// }
|
||||
/// ```
|
||||
class UserNotFoundException implements Exception {
|
||||
/// Crée une nouvelle [UserNotFoundException].
|
||||
///
|
||||
/// [message] Message décrivant l'erreur
|
||||
/// [userId] L'ID de l'utilisateur non trouvé
|
||||
const UserNotFoundException([
|
||||
this.message = 'Utilisateur non trouvé',
|
||||
this.userId,
|
||||
]);
|
||||
|
||||
/// Message décrivant l'erreur
|
||||
final String message;
|
||||
UserNotFoundException([this.message = "User not found"]);
|
||||
|
||||
/// L'ID de l'utilisateur non trouvé
|
||||
final String? userId;
|
||||
|
||||
@override
|
||||
String toString() => "UserNotFoundException: $message";
|
||||
String toString() {
|
||||
if (userId != null) {
|
||||
return 'UserNotFoundException: $message (UserId: $userId)';
|
||||
}
|
||||
return 'UserNotFoundException: $message';
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception levée en cas de conflit de données.
|
||||
///
|
||||
/// Cette exception est levée lorsque :
|
||||
/// - Une ressource existe déjà (ex: email déjà utilisé)
|
||||
/// - Une opération entre en conflit avec l'état actuel
|
||||
/// - Une contrainte d'unicité est violée
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// if (await userExists(email)) {
|
||||
/// throw ConflictException('Un utilisateur avec cet email existe déjà');
|
||||
/// }
|
||||
/// ```
|
||||
class ConflictException implements Exception {
|
||||
/// Crée une nouvelle [ConflictException].
|
||||
///
|
||||
/// [message] Message décrivant le conflit
|
||||
/// [resource] La ressource en conflit
|
||||
const ConflictException([
|
||||
this.message = 'Conflit détecté',
|
||||
this.resource,
|
||||
]);
|
||||
|
||||
/// Message décrivant le conflit
|
||||
final String message;
|
||||
ConflictException([this.message = "Conflict"]);
|
||||
|
||||
/// La ressource en conflit
|
||||
final String? resource;
|
||||
|
||||
@override
|
||||
String toString() => "ConflictException: $message";
|
||||
String toString() {
|
||||
if (resource != null) {
|
||||
return 'ConflictException: $message (Resource: $resource)';
|
||||
}
|
||||
return 'ConflictException: $message';
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception levée lorsque l'utilisateur n'est pas autorisé.
|
||||
///
|
||||
/// Cette exception est levée lorsque :
|
||||
/// - Le token d'authentification est invalide ou expiré
|
||||
/// - L'utilisateur n'a pas les permissions nécessaires
|
||||
/// - La session a expiré
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// if (!hasPermission(user, Permission.admin)) {
|
||||
/// throw UnauthorizedException('Accès non autorisé');
|
||||
/// }
|
||||
/// ```
|
||||
class UnauthorizedException implements Exception {
|
||||
/// Crée une nouvelle [UnauthorizedException].
|
||||
///
|
||||
/// [message] Message décrivant l'erreur
|
||||
/// [reason] Raison de la non-autorisation
|
||||
const UnauthorizedException([
|
||||
this.message = 'Non autorisé',
|
||||
this.reason,
|
||||
]);
|
||||
|
||||
/// Message décrivant l'erreur
|
||||
final String message;
|
||||
UnauthorizedException([this.message = "Unauthorized"]);
|
||||
|
||||
/// Raison de la non-autorisation
|
||||
final String? reason;
|
||||
|
||||
@override
|
||||
String toString() => "UnauthorizedException: $message";
|
||||
String toString() {
|
||||
if (reason != null) {
|
||||
return 'UnauthorizedException: $message (Reason: $reason)';
|
||||
}
|
||||
return 'UnauthorizedException: $message';
|
||||
}
|
||||
}
|
||||
|
||||
/// Exception levée lorsque la validation échoue.
|
||||
///
|
||||
/// Cette exception est levée lorsque :
|
||||
/// - Les données ne respectent pas les contraintes
|
||||
/// - Les données sont manquantes ou invalides
|
||||
/// - Les données ne passent pas la validation métier
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// if (email.isEmpty || !isValidEmail(email)) {
|
||||
/// throw ValidationException('Email invalide', field: 'email');
|
||||
/// }
|
||||
/// ```
|
||||
class ValidationException implements Exception {
|
||||
/// Crée une nouvelle [ValidationException].
|
||||
///
|
||||
/// [message] Message décrivant l'erreur de validation
|
||||
/// [field] Le champ qui a échoué la validation
|
||||
const ValidationException(
|
||||
this.message, {
|
||||
this.field,
|
||||
});
|
||||
|
||||
/// Message décrivant l'erreur
|
||||
final String message;
|
||||
|
||||
/// Le champ qui a échoué la validation
|
||||
final String? field;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (field != null) {
|
||||
return 'ValidationException: $message (Field: $field)';
|
||||
}
|
||||
return 'ValidationException: $message';
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,218 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Classe de base abstraite pour toutes les erreurs de l'application.
|
||||
///
|
||||
/// Les [Failure] représentent des erreurs métier qui peuvent être gérées
|
||||
/// de manière élégante par l'application, contrairement aux [Exception]
|
||||
/// qui sont des erreurs techniques.
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// try {
|
||||
/// final result = await repository.getData();
|
||||
/// return Right(result);
|
||||
/// } catch (e) {
|
||||
/// return Left(ServerFailure(message: e.toString()));
|
||||
/// }
|
||||
/// ```
|
||||
abstract class Failure extends Equatable {
|
||||
/// Crée une nouvelle [Failure].
|
||||
///
|
||||
/// [message] Un message optionnel décrivant l'erreur
|
||||
/// [code] Un code d'erreur optionnel
|
||||
const Failure({
|
||||
this.message = 'Une erreur est survenue',
|
||||
this.code,
|
||||
});
|
||||
|
||||
/// Message décrivant l'erreur
|
||||
final String message;
|
||||
|
||||
/// Code d'erreur optionnel
|
||||
final String? code;
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
List<Object?> get props => [message, code];
|
||||
|
||||
@override
|
||||
String toString() => 'Failure(message: $message, code: $code)';
|
||||
}
|
||||
|
||||
class ServerFailure extends Failure {}
|
||||
class CacheFailure extends Failure {}
|
||||
/// Erreur liée au serveur ou à la communication réseau.
|
||||
///
|
||||
/// Cette erreur est levée lorsque :
|
||||
/// - Une requête HTTP échoue
|
||||
/// - Le serveur retourne une erreur
|
||||
/// - La connexion réseau est perdue
|
||||
/// - Un timeout se produit
|
||||
///
|
||||
/// **Exemple:**
|
||||
/// ```dart
|
||||
/// try {
|
||||
/// final response = await http.get(url);
|
||||
/// if (response.statusCode != 200) {
|
||||
/// throw ServerFailure(
|
||||
/// message: 'Erreur serveur: ${response.statusCode}',
|
||||
/// code: response.statusCode.toString(),
|
||||
/// );
|
||||
/// }
|
||||
/// } catch (e) {
|
||||
/// throw ServerFailure(message: e.toString());
|
||||
/// }
|
||||
/// ```
|
||||
class ServerFailure extends Failure {
|
||||
/// Crée une nouvelle [ServerFailure].
|
||||
///
|
||||
/// [message] Message décrivant l'erreur serveur
|
||||
/// [code] Code d'erreur HTTP optionnel
|
||||
/// [statusCode] Code de statut HTTP optionnel
|
||||
const ServerFailure({
|
||||
super.message = 'Erreur serveur',
|
||||
super.code,
|
||||
this.statusCode,
|
||||
});
|
||||
|
||||
/// Code de statut HTTP (404, 500, etc.)
|
||||
final int? statusCode;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [...super.props, statusCode];
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ServerFailure(message: $message, code: $code, statusCode: $statusCode)';
|
||||
}
|
||||
|
||||
/// Erreur liée au cache local.
|
||||
///
|
||||
/// Cette erreur est levée lorsque :
|
||||
/// - Les données ne peuvent pas être lues depuis le cache
|
||||
/// - Les données ne peuvent pas être écrites dans le cache
|
||||
/// - Le cache est corrompu ou inaccessible
|
||||
///
|
||||
/// **Exemple:**
|
||||
/// ```dart
|
||||
/// try {
|
||||
/// final cachedData = await cache.get(key);
|
||||
/// if (cachedData == null) {
|
||||
/// throw CacheFailure(message: 'Données non trouvées dans le cache');
|
||||
/// }
|
||||
/// } catch (e) {
|
||||
/// throw CacheFailure(message: e.toString());
|
||||
/// }
|
||||
/// ```
|
||||
class CacheFailure extends Failure {
|
||||
/// Crée une nouvelle [CacheFailure].
|
||||
///
|
||||
/// [message] Message décrivant l'erreur de cache
|
||||
/// [code] Code d'erreur optionnel
|
||||
const CacheFailure({
|
||||
super.message = 'Erreur de cache',
|
||||
super.code,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'CacheFailure(message: $message, code: $code)';
|
||||
}
|
||||
|
||||
/// Erreur liée à l'authentification.
|
||||
///
|
||||
/// Cette erreur est levée lorsque :
|
||||
/// - Les identifiants sont incorrects
|
||||
/// - Le token d'authentification est expiré
|
||||
/// - L'utilisateur n'est pas autorisé
|
||||
///
|
||||
/// **Exemple:**
|
||||
/// ```dart
|
||||
/// if (!isAuthenticated) {
|
||||
/// throw AuthenticationFailure(
|
||||
/// message: 'Authentification requise',
|
||||
/// );
|
||||
/// }
|
||||
/// ```
|
||||
class AuthenticationFailure extends Failure {
|
||||
/// Crée une nouvelle [AuthenticationFailure].
|
||||
///
|
||||
/// [message] Message décrivant l'erreur d'authentification
|
||||
/// [code] Code d'erreur optionnel
|
||||
const AuthenticationFailure({
|
||||
super.message = 'Erreur d\'authentification',
|
||||
super.code,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AuthenticationFailure(message: $message, code: $code)';
|
||||
}
|
||||
|
||||
/// Erreur liée à la validation des données.
|
||||
///
|
||||
/// Cette erreur est levée lorsque :
|
||||
/// - Les données saisies sont invalides
|
||||
/// - Les données ne respectent pas les contraintes
|
||||
/// - Les données sont manquantes
|
||||
///
|
||||
/// **Exemple:**
|
||||
/// ```dart
|
||||
/// if (email.isEmpty || !isValidEmail(email)) {
|
||||
/// throw ValidationFailure(
|
||||
/// message: 'Email invalide',
|
||||
/// field: 'email',
|
||||
/// );
|
||||
/// }
|
||||
/// ```
|
||||
class ValidationFailure extends Failure {
|
||||
/// Crée une nouvelle [ValidationFailure].
|
||||
///
|
||||
/// [message] Message décrivant l'erreur de validation
|
||||
/// [field] Le champ qui a échoué la validation
|
||||
/// [code] Code d'erreur optionnel
|
||||
const ValidationFailure({
|
||||
super.message = 'Erreur de validation',
|
||||
this.field,
|
||||
super.code,
|
||||
});
|
||||
|
||||
/// Le champ qui a échoué la validation
|
||||
final String? field;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [super.props, field];
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ValidationFailure(message: $message, field: $field, code: $code)';
|
||||
}
|
||||
|
||||
/// Erreur liée à une opération réseau.
|
||||
///
|
||||
/// Cette erreur est levée lorsque :
|
||||
/// - La connexion Internet est perdue
|
||||
/// - Le timeout est dépassé
|
||||
/// - La connexion est refusée
|
||||
///
|
||||
/// **Exemple:**
|
||||
/// ```dart
|
||||
/// try {
|
||||
/// final response = await http.get(url).timeout(
|
||||
/// const Duration(seconds: 5),
|
||||
/// );
|
||||
/// } on TimeoutException {
|
||||
/// throw NetworkFailure(message: 'Timeout de connexion');
|
||||
/// } on SocketException {
|
||||
/// throw NetworkFailure(message: 'Pas de connexion Internet');
|
||||
/// }
|
||||
/// ```
|
||||
class NetworkFailure extends Failure {
|
||||
/// Crée une nouvelle [NetworkFailure].
|
||||
///
|
||||
/// [message] Message décrivant l'erreur réseau
|
||||
/// [code] Code d'erreur optionnel
|
||||
const NetworkFailure({
|
||||
super.message = 'Erreur réseau',
|
||||
super.code,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'NetworkFailure(message: $message, code: $code)';
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../afterwork/lib/core/theme/app_theme.dart'; // Importe tes définitions de thème
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'app_theme.dart'; // Import du fichier contenant les définitions des thèmes
|
||||
|
||||
/// Fournisseur de thèmes pour gérer le mode clair/sombre.
|
||||
/// Notifie les widgets dépendants lors du changement de thème.
|
||||
class ThemeProvider with ChangeNotifier {
|
||||
bool _isDarkMode = false; // Mode sombre par défaut désactivé
|
||||
bool _isDarkMode = false; // Mode sombre désactivé par défaut
|
||||
|
||||
/// Renvoie l'état actuel du mode sombre.
|
||||
bool get isDarkMode => _isDarkMode;
|
||||
|
||||
void toggleTheme() {
|
||||
_isDarkMode = !_isDarkMode;
|
||||
notifyListeners(); // Notifie les widgets dépendants
|
||||
}
|
||||
|
||||
// Utilise AppTheme pour obtenir le thème courant
|
||||
/// Retourne le thème courant en fonction du mode actif.
|
||||
ThemeData get currentTheme {
|
||||
return _isDarkMode ? AppTheme.darkTheme : AppTheme.lightTheme;
|
||||
}
|
||||
|
||||
/// Initialise le mode sombre en fonction des préférences sauvegardées.
|
||||
Future<void> loadThemePreference() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isDarkMode = prefs.getBool('isDarkMode') ?? false; // Valeur par défaut : false
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Active ou désactive le mode sombre et sauvegarde la préférence.
|
||||
Future<void> toggleTheme() async {
|
||||
_isDarkMode = !_isDarkMode;
|
||||
notifyListeners(); // Notifie les widgets dépendants du changement de thème
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('isDarkMode', _isDarkMode); // Sauvegarde de l'état
|
||||
}
|
||||
}
|
||||
|
||||
165
lib/core/utils/app_logger.dart
Normal file
165
lib/core/utils/app_logger.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
/// Logger centralisé pour l'application AfterWork.
|
||||
///
|
||||
/// Ce logger remplace tous les `print()` et `debugPrint()` pour offrir :
|
||||
/// - Niveaux de log structurés (debug, info, warning, error)
|
||||
/// - Filtrage par environnement (dev/prod)
|
||||
/// - Formatage cohérent
|
||||
/// - Support pour stack traces
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// AppLogger.i('Message informatif');
|
||||
/// AppLogger.e('Erreur', error: e, stackTrace: stackTrace);
|
||||
/// ```
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../constants/env_config.dart';
|
||||
|
||||
/// Niveaux de log disponibles.
|
||||
enum LogLevel {
|
||||
/// Messages de débogage (développement uniquement).
|
||||
debug,
|
||||
|
||||
/// Messages informatifs.
|
||||
info,
|
||||
|
||||
/// Avertissements.
|
||||
warning,
|
||||
|
||||
/// Erreurs.
|
||||
error,
|
||||
}
|
||||
|
||||
/// Logger centralisé pour toute l'application.
|
||||
///
|
||||
/// Remplace tous les `print()` et `debugPrint()` pour une meilleure
|
||||
/// maintenabilité et performance.
|
||||
class AppLogger {
|
||||
/// Constructeur privé pour empêcher l'instanciation.
|
||||
AppLogger._();
|
||||
|
||||
/// Préfixe pour les logs de l'application.
|
||||
static const String _logPrefix = '[AfterWork]';
|
||||
|
||||
/// Log un message de niveau DEBUG.
|
||||
///
|
||||
/// Les messages DEBUG ne sont affichés qu'en mode développement.
|
||||
///
|
||||
/// [message] Le message à logger
|
||||
/// [tag] Tag optionnel pour catégoriser le log
|
||||
static void d(String message, {String? tag}) {
|
||||
if (EnvConfig.enableDetailedLogs && kDebugMode) {
|
||||
_log(LogLevel.debug, message, tag: tag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log un message de niveau INFO.
|
||||
///
|
||||
/// [message] Le message à logger
|
||||
/// [tag] Tag optionnel pour catégoriser le log
|
||||
static void i(String message, {String? tag}) {
|
||||
if (EnvConfig.enableDetailedLogs || kDebugMode) {
|
||||
_log(LogLevel.info, message, tag: tag);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log un message de niveau WARNING.
|
||||
///
|
||||
/// [message] Le message à logger
|
||||
/// [tag] Tag optionnel pour catégoriser le log
|
||||
static void w(String message, {String? tag}) {
|
||||
_log(LogLevel.warning, message, tag: tag);
|
||||
}
|
||||
|
||||
/// Log un message de niveau ERROR.
|
||||
///
|
||||
/// [message] Le message à logger
|
||||
/// [error] L'erreur optionnelle
|
||||
/// [stackTrace] La stack trace optionnelle
|
||||
/// [tag] Tag optionnel pour catégoriser le log
|
||||
static void e(
|
||||
String message, {
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
String? tag,
|
||||
}) {
|
||||
_log(LogLevel.error, message, tag: tag);
|
||||
|
||||
if (error != null) {
|
||||
_log(LogLevel.error, 'Error: $error', tag: tag);
|
||||
}
|
||||
|
||||
if (stackTrace != null) {
|
||||
_log(LogLevel.error, 'StackTrace:\n$stackTrace', tag: tag);
|
||||
}
|
||||
|
||||
// En production, envoyer à un service de monitoring si configuré
|
||||
if (EnvConfig.isProduction) {
|
||||
// TODO: Intégrer Firebase Crashlytics ou Sentry
|
||||
// _sendToCrashReporting(message, error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log une requête HTTP.
|
||||
///
|
||||
/// [method] La méthode HTTP (GET, POST, etc.)
|
||||
/// [url] L'URL de la requête
|
||||
/// [statusCode] Le code de statut de la réponse
|
||||
/// [duration] La durée de la requête en millisecondes
|
||||
static void http(
|
||||
String method,
|
||||
String url, {
|
||||
int? statusCode,
|
||||
int? duration,
|
||||
}) {
|
||||
if (!EnvConfig.enableDetailedLogs) {
|
||||
return;
|
||||
}
|
||||
|
||||
final buffer = StringBuffer('HTTP $method $url');
|
||||
if (statusCode != null) {
|
||||
buffer.write(' → $statusCode');
|
||||
}
|
||||
if (duration != null) {
|
||||
buffer.write(' (${duration}ms)');
|
||||
}
|
||||
|
||||
_log(LogLevel.info, buffer.toString(), tag: 'HTTP');
|
||||
}
|
||||
|
||||
/// Méthode privée pour logger avec formatage.
|
||||
static void _log(
|
||||
LogLevel level,
|
||||
String message, {
|
||||
String? tag,
|
||||
}) {
|
||||
final timestamp = DateTime.now().toIso8601String();
|
||||
final levelStr = _getLevelString(level);
|
||||
final tagStr = tag != null ? '[$tag] ' : '';
|
||||
|
||||
final logMessage = '$_logPrefix $timestamp $levelStr $tagStr$message';
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint(logMessage);
|
||||
} else {
|
||||
// En production, utiliser print uniquement pour les erreurs
|
||||
if (level == LogLevel.error) {
|
||||
print(logMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne la représentation string du niveau de log.
|
||||
static String _getLevelString(LogLevel level) {
|
||||
switch (level) {
|
||||
case LogLevel.debug:
|
||||
return '[DEBUG]';
|
||||
case LogLevel.info:
|
||||
return '[INFO] ';
|
||||
case LogLevel.warning:
|
||||
return '[WARN] ';
|
||||
case LogLevel.error:
|
||||
return '[ERROR]';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,104 @@
|
||||
// Fichier utilitaire pour le calcul du temps écoulé
|
||||
/// Calcule le temps écoulé depuis une date donnée.
|
||||
///
|
||||
/// Cette fonction retourne une représentation lisible du temps écoulé
|
||||
/// depuis la date spécifiée jusqu'à maintenant.
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// final timeAgo = calculateTimeAgo(DateTime.now().subtract(Duration(hours: 2)));
|
||||
/// // Résultat: "il y a 2 heures"
|
||||
/// ```
|
||||
///
|
||||
/// [publicationDate] La date de référence
|
||||
///
|
||||
/// Returns une chaîne décrivant le temps écoulé.
|
||||
///
|
||||
/// **Exemples:**
|
||||
/// - "À l'instant" si moins d'une minute
|
||||
/// - "il y a 5 minutes" si moins d'une heure
|
||||
/// - "il y a 2 heures" si moins d'un jour
|
||||
/// - "il y a 3 jours" si moins d'une semaine
|
||||
/// - "il y a 2 semaines" si moins d'un mois
|
||||
/// - "il y a 3 mois" si moins d'un an
|
||||
/// - "il y a 2 ans" si plus d'un an
|
||||
String calculateTimeAgo(DateTime publicationDate) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(publicationDate);
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return '${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
|
||||
// Si la date est dans le futur, retourner "dans X"
|
||||
if (difference.isNegative) {
|
||||
final futureDiff = publicationDate.difference(now);
|
||||
if (futureDiff.inDays > 365) {
|
||||
final years = (futureDiff.inDays / 365).floor();
|
||||
return 'dans $years an${years > 1 ? 's' : ''}';
|
||||
} else if (futureDiff.inDays > 30) {
|
||||
final months = (futureDiff.inDays / 30).floor();
|
||||
return 'dans $months mois';
|
||||
} else if (futureDiff.inDays > 7) {
|
||||
final weeks = (futureDiff.inDays / 7).floor();
|
||||
return 'dans $weeks semaine${weeks > 1 ? 's' : ''}';
|
||||
} else if (futureDiff.inDays > 0) {
|
||||
return 'dans ${futureDiff.inDays} jour${futureDiff.inDays > 1 ? 's' : ''}';
|
||||
} else if (futureDiff.inHours > 0) {
|
||||
return 'dans ${futureDiff.inHours} heure${futureDiff.inHours > 1 ? 's' : ''}';
|
||||
} else if (futureDiff.inMinutes > 0) {
|
||||
return 'dans ${futureDiff.inMinutes} minute${futureDiff.inMinutes > 1 ? 's' : ''}';
|
||||
} else {
|
||||
return 'maintenant';
|
||||
}
|
||||
}
|
||||
|
||||
// Calcul pour le passé
|
||||
if (difference.inDays > 365) {
|
||||
final years = (difference.inDays / 365).floor();
|
||||
return 'il y a $years an${years > 1 ? 's' : ''}';
|
||||
} else if (difference.inDays > 30) {
|
||||
final months = (difference.inDays / 30).floor();
|
||||
return 'il y a $months mois';
|
||||
} else if (difference.inDays > 7) {
|
||||
final weeks = (difference.inDays / 7).floor();
|
||||
return 'il y a $weeks semaine${weeks > 1 ? 's' : ''}';
|
||||
} else if (difference.inDays > 0) {
|
||||
return 'il y a ${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours} heure${difference.inHours > 1 ? 's' : ''}';
|
||||
return 'il y a ${difference.inHours} heure${difference.inHours > 1 ? 's' : ''}';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes} minute${difference.inMinutes > 1 ? 's' : ''}';
|
||||
return 'il y a ${difference.inMinutes} minute${difference.inMinutes > 1 ? 's' : ''}';
|
||||
} else {
|
||||
return 'À l\'instant';
|
||||
}
|
||||
}
|
||||
|
||||
/// Calcule le temps écoulé avec un format plus détaillé.
|
||||
///
|
||||
/// Cette fonction retourne une représentation plus précise du temps écoulé,
|
||||
/// incluant les secondes si nécessaire.
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// final timeAgo = calculateTimeAgoDetailed(DateTime.now().subtract(Duration(seconds: 30)));
|
||||
/// // Résultat: "il y a 30 secondes"
|
||||
/// ```
|
||||
///
|
||||
/// [publicationDate] La date de référence
|
||||
///
|
||||
/// Returns une chaîne décrivant le temps écoulé avec plus de détails.
|
||||
String calculateTimeAgoDetailed(DateTime publicationDate) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(publicationDate);
|
||||
|
||||
if (difference.isNegative) {
|
||||
return 'dans le futur';
|
||||
}
|
||||
|
||||
if (difference.inDays > 0) {
|
||||
return 'il y a ${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
|
||||
} else if (difference.inHours > 0) {
|
||||
return 'il y a ${difference.inHours} heure${difference.inHours > 1 ? 's' : ''}';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return 'il y a ${difference.inMinutes} minute${difference.inMinutes > 1 ? 's' : ''}';
|
||||
} else if (difference.inSeconds > 0) {
|
||||
return 'il y a ${difference.inSeconds} seconde${difference.inSeconds > 1 ? 's' : ''}';
|
||||
} else {
|
||||
return 'À l\'instant';
|
||||
}
|
||||
|
||||
@@ -1,8 +1,245 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Classe utilitaire pour formater les dates et heures.
|
||||
///
|
||||
/// Cette classe fournit des méthodes statiques pour formater les dates
|
||||
/// dans différents formats selon les besoins de l'application.
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// final formatted = DateFormatter.formatDate(DateTime.now());
|
||||
/// // Résultat: "lundi 05 janvier 2026, à 14:30"
|
||||
/// ```
|
||||
class DateFormatter {
|
||||
/// Constructeur privé pour empêcher l'instanciation
|
||||
DateFormatter._();
|
||||
|
||||
/// Formate une date avec l'heure incluse en français.
|
||||
///
|
||||
/// [date] La date à formater
|
||||
///
|
||||
/// Returns une chaîne formatée (ex: "lundi 05 janvier 2026, à 14:30").
|
||||
///
|
||||
/// **Exemple:**
|
||||
/// ```dart
|
||||
/// final formatted = DateFormatter.formatDate(DateTime(2026, 1, 5, 14, 30));
|
||||
/// // Résultat: "lundi 05 janvier 2026, à 14:30"
|
||||
/// ```
|
||||
static String formatDate(DateTime date) {
|
||||
// Formater la date avec l'heure incluse
|
||||
return DateFormat('EEEE dd MMMM yyyy, à HH:mm', 'fr_FR').format(date);
|
||||
}
|
||||
|
||||
/// Formate une date sans l'heure en français.
|
||||
///
|
||||
/// [date] La date à formater
|
||||
///
|
||||
/// Returns une chaîne formatée (ex: "lundi 05 janvier 2026").
|
||||
static String formatDateOnly(DateTime date) {
|
||||
return DateFormat('EEEE dd MMMM yyyy', 'fr_FR').format(date);
|
||||
}
|
||||
|
||||
/// Formate uniquement l'heure.
|
||||
///
|
||||
/// [date] La date contenant l'heure à formater
|
||||
///
|
||||
/// Returns une chaîne formatée (ex: "14:30").
|
||||
static String formatTime(DateTime date) {
|
||||
return DateFormat('HH:mm', 'fr_FR').format(date);
|
||||
}
|
||||
|
||||
/// Formate une date de manière courte (ex: "05/01/2026").
|
||||
///
|
||||
/// [date] La date à formater
|
||||
///
|
||||
/// Returns une chaîne formatée (ex: "05/01/2026").
|
||||
static String formatDateShort(DateTime date) {
|
||||
return DateFormat('dd/MM/yyyy', 'fr_FR').format(date);
|
||||
}
|
||||
|
||||
/// Formate une date avec l'heure de manière courte (ex: "05/01/2026 14:30").
|
||||
///
|
||||
/// [date] La date à formater
|
||||
///
|
||||
/// Returns une chaîne formatée (ex: "05/01/2026 14:30").
|
||||
static String formatDateTimeShort(DateTime date) {
|
||||
return DateFormat('dd/MM/yyyy HH:mm', 'fr_FR').format(date);
|
||||
}
|
||||
|
||||
/// Formate une date de manière relative (ex: "il y a 2 heures").
|
||||
///
|
||||
/// [date] La date à formater
|
||||
///
|
||||
/// Returns une chaîne formatée relative.
|
||||
static String formatDateRelative(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inDays > 365) {
|
||||
final years = (difference.inDays / 365).floor();
|
||||
return 'il y a $years an${years > 1 ? 's' : ''}';
|
||||
} else if (difference.inDays > 30) {
|
||||
final months = (difference.inDays / 30).floor();
|
||||
return 'il y a $months mois';
|
||||
} else if (difference.inDays > 0) {
|
||||
return 'il y a ${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
|
||||
} else if (difference.inHours > 0) {
|
||||
return 'il y a ${difference.inHours} heure${difference.inHours > 1 ? 's' : ''}';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return 'il y a ${difference.inMinutes} minute${difference.inMinutes > 1 ? 's' : ''}';
|
||||
} else {
|
||||
return 'à l\'instant';
|
||||
}
|
||||
}
|
||||
|
||||
/// Formate une date pour l'affichage dans une liste (ex: "Aujourd'hui, 14:30").
|
||||
///
|
||||
/// [date] La date à formater
|
||||
///
|
||||
/// Returns une chaîne formatée.
|
||||
static String formatDateForList(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final dateOnly = DateTime(date.year, date.month, date.day);
|
||||
|
||||
if (dateOnly == today) {
|
||||
return 'Aujourd\'hui, ${formatTime(date)}';
|
||||
} else if (dateOnly == today.subtract(const Duration(days: 1))) {
|
||||
return 'Hier, ${formatTime(date)}';
|
||||
} else if (dateOnly == today.add(const Duration(days: 1))) {
|
||||
return 'Demain, ${formatTime(date)}';
|
||||
} else {
|
||||
return formatDateTimeShort(date);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse une chaîne de date au format ISO 8601.
|
||||
///
|
||||
/// [dateString] La chaîne à parser
|
||||
///
|
||||
/// Returns un [DateTime] ou `null` si le parsing échoue.
|
||||
static DateTime? parseIso8601(String dateString) {
|
||||
try {
|
||||
return DateTime.parse(dateString);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Formate une date au format ISO 8601.
|
||||
///
|
||||
/// [date] La date à formater
|
||||
///
|
||||
/// Returns une chaîne au format ISO 8601 (ex: "2026-01-05T14:30:00.000Z").
|
||||
static String formatIso8601(DateTime date) {
|
||||
return date.toIso8601String();
|
||||
}
|
||||
}
|
||||
|
||||
/// Classe utilitaire spécifique pour formater les dates dans la messagerie.
|
||||
///
|
||||
/// Cette classe fournit des méthodes pour formater les timestamps de messages
|
||||
/// de manière intelligente et moderne (style WhatsApp/Telegram).
|
||||
class ChatDateFormatter {
|
||||
/// Constructeur privé pour empêcher l'instanciation
|
||||
ChatDateFormatter._();
|
||||
|
||||
/// Formate un timestamp pour affichage dans une bulle de message.
|
||||
///
|
||||
/// Exemples :
|
||||
/// - Aujourd'hui : "14:30"
|
||||
/// - Hier : "Hier 14:30"
|
||||
/// - Cette semaine : "Lun 14:30"
|
||||
/// - Plus ancien : "12/01 14:30"
|
||||
///
|
||||
/// [timestamp] Le timestamp du message
|
||||
///
|
||||
/// Returns une chaîne formatée optimisée pour les bulles de message.
|
||||
static String formatMessageTimestamp(DateTime timestamp) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final messageDate = DateTime(timestamp.year, timestamp.month, timestamp.day);
|
||||
|
||||
final difference = today.difference(messageDate).inDays;
|
||||
final timeFormat = DateFormat.Hm('fr_FR'); // HH:mm
|
||||
final time = timeFormat.format(timestamp);
|
||||
|
||||
if (difference == 0) {
|
||||
// Aujourd'hui : juste l'heure
|
||||
return time;
|
||||
} else if (difference == 1) {
|
||||
// Hier
|
||||
return 'Hier $time';
|
||||
} else if (difference < 7) {
|
||||
// Cette semaine : jour de la semaine
|
||||
final dayName = DateFormat.E('fr_FR').format(timestamp);
|
||||
return '$dayName $time';
|
||||
} else {
|
||||
// Plus ancien : date courte
|
||||
final dateFormat = DateFormat('dd/MM', 'fr_FR');
|
||||
return '${dateFormat.format(timestamp)} $time';
|
||||
}
|
||||
}
|
||||
|
||||
/// Formate pour les séparateurs de date entre groupes de messages.
|
||||
///
|
||||
/// Exemples :
|
||||
/// - Aujourd'hui : "Aujourd'hui"
|
||||
/// - Hier : "Hier"
|
||||
/// - Cette semaine : "Lundi"
|
||||
/// - Cette année : "12 janvier"
|
||||
/// - Année précédente : "12 janvier 2024"
|
||||
///
|
||||
/// [date] La date à formater
|
||||
///
|
||||
/// Returns une chaîne formatée pour les séparateurs de date.
|
||||
static String formatDateSeparator(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final messageDate = DateTime(date.year, date.month, date.day);
|
||||
|
||||
final difference = today.difference(messageDate).inDays;
|
||||
|
||||
if (difference == 0) {
|
||||
return "Aujourd'hui";
|
||||
} else if (difference == 1) {
|
||||
return 'Hier';
|
||||
} else if (difference < 7) {
|
||||
return DateFormat.EEEE('fr_FR').format(date);
|
||||
} else if (date.year == now.year) {
|
||||
return DateFormat('d MMMM', 'fr_FR').format(date);
|
||||
} else {
|
||||
return DateFormat('d MMMM yyyy', 'fr_FR').format(date);
|
||||
}
|
||||
}
|
||||
|
||||
/// Formate un temps relatif (optionnel, pour liste de conversations).
|
||||
///
|
||||
/// Exemples :
|
||||
/// - "À l'instant"
|
||||
/// - "Il y a 5 min"
|
||||
/// - "Il y a 2 h"
|
||||
/// - "Hier"
|
||||
/// - "12/01"
|
||||
///
|
||||
/// [timestamp] Le timestamp à formater
|
||||
///
|
||||
/// Returns une chaîne formatée relative.
|
||||
static String formatRelativeTime(DateTime timestamp) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(timestamp);
|
||||
|
||||
if (difference.inSeconds < 60) {
|
||||
return "À l'instant";
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return 'Il y a ${difference.inMinutes} min';
|
||||
} else if (difference.inHours < 24) {
|
||||
return 'Il y a ${difference.inHours} h';
|
||||
} else if (difference.inDays == 1) {
|
||||
return 'Hier';
|
||||
} else if (difference.inDays < 7) {
|
||||
return DateFormat.E('fr_FR').format(timestamp);
|
||||
} else {
|
||||
return DateFormat('dd/MM', 'fr_FR').format(timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,154 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:afterwork/core/errors/failures.dart';
|
||||
import '../errors/failures.dart';
|
||||
|
||||
/// Classe utilitaire pour convertir et valider les entrées utilisateur.
|
||||
///
|
||||
/// Cette classe fournit des méthodes pour convertir des chaînes en types
|
||||
/// numériques et valider les entrées utilisateur de manière fonctionnelle.
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// final converter = InputConverter();
|
||||
/// final result = converter.stringToUnsignedInteger('123');
|
||||
/// result.fold(
|
||||
/// (failure) => print('Erreur: $failure'),
|
||||
/// (value) => print('Valeur: $value'),
|
||||
/// );
|
||||
/// ```
|
||||
class InputConverter {
|
||||
/// Convertit une chaîne en entier non signé.
|
||||
///
|
||||
/// [str] La chaîne à convertir
|
||||
///
|
||||
/// Returns [Right] avec l'entier si la conversion réussit,
|
||||
/// [Left] avec [InvalidInputFailure] si la conversion échoue.
|
||||
///
|
||||
/// **Exemple:**
|
||||
/// ```dart
|
||||
/// final result = converter.stringToUnsignedInteger('123');
|
||||
/// // Right(123)
|
||||
///
|
||||
/// final result2 = converter.stringToUnsignedInteger('-5');
|
||||
/// // Left(InvalidInputFailure)
|
||||
/// ```
|
||||
Either<Failure, int> stringToUnsignedInteger(String str) {
|
||||
try {
|
||||
final integer = int.parse(str);
|
||||
if (integer < 0) throw const FormatException();
|
||||
final trimmed = str.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return Left(const InvalidInputFailure(message: 'La chaîne est vide'));
|
||||
}
|
||||
|
||||
final integer = int.parse(trimmed);
|
||||
if (integer < 0) {
|
||||
return Left(const InvalidInputFailure(message: 'Le nombre doit être positif'));
|
||||
}
|
||||
|
||||
return Right(integer);
|
||||
} on FormatException {
|
||||
return Left(InvalidInputFailure(message: 'Format invalide: "$str"'));
|
||||
} catch (e) {
|
||||
return Left(InvalidInputFailure());
|
||||
return Left(InvalidInputFailure(message: 'Erreur de conversion: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit une chaîne en entier signé.
|
||||
///
|
||||
/// [str] La chaîne à convertir
|
||||
///
|
||||
/// Returns [Right] avec l'entier si la conversion réussit,
|
||||
/// [Left] avec [InvalidInputFailure] si la conversion échoue.
|
||||
Either<Failure, int> stringToInteger(String str) {
|
||||
try {
|
||||
final trimmed = str.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return Left(const InvalidInputFailure(message: 'La chaîne est vide'));
|
||||
}
|
||||
|
||||
final integer = int.parse(trimmed);
|
||||
return Right(integer);
|
||||
} on FormatException {
|
||||
return Left(InvalidInputFailure(message: 'Format invalide: "$str"'));
|
||||
} catch (e) {
|
||||
return Left(InvalidInputFailure(message: 'Erreur de conversion: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit une chaîne en nombre décimal (double).
|
||||
///
|
||||
/// [str] La chaîne à convertir
|
||||
///
|
||||
/// Returns [Right] avec le double si la conversion réussit,
|
||||
/// [Left] avec [InvalidInputFailure] si la conversion échoue.
|
||||
Either<Failure, double> stringToDouble(String str) {
|
||||
try {
|
||||
final trimmed = str.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return Left(const InvalidInputFailure(message: 'La chaîne est vide'));
|
||||
}
|
||||
|
||||
final doubleValue = double.parse(trimmed);
|
||||
return Right(doubleValue);
|
||||
} on FormatException {
|
||||
return Left(InvalidInputFailure(message: 'Format invalide: "$str"'));
|
||||
} catch (e) {
|
||||
return Left(InvalidInputFailure(message: 'Erreur de conversion: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit une chaîne en nombre décimal non négatif.
|
||||
///
|
||||
/// [str] La chaîne à convertir
|
||||
///
|
||||
/// Returns [Right] avec le double si la conversion réussit,
|
||||
/// [Left] avec [InvalidInputFailure] si la conversion échoue ou si négatif.
|
||||
Either<Failure, double> stringToUnsignedDouble(String str) {
|
||||
try {
|
||||
final trimmed = str.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return Left(const InvalidInputFailure(message: 'La chaîne est vide'));
|
||||
}
|
||||
|
||||
final doubleValue = double.parse(trimmed);
|
||||
if (doubleValue < 0) {
|
||||
return Left(const InvalidInputFailure(message: 'Le nombre doit être positif'));
|
||||
}
|
||||
|
||||
return Right(doubleValue);
|
||||
} on FormatException {
|
||||
return Left(InvalidInputFailure(message: 'Format invalide: "$str"'));
|
||||
} catch (e) {
|
||||
return Left(InvalidInputFailure(message: 'Erreur de conversion: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Valide qu'une chaîne n'est pas vide.
|
||||
///
|
||||
/// [str] La chaîne à valider
|
||||
///
|
||||
/// Returns [Right] avec la chaîne si valide,
|
||||
/// [Left] avec [InvalidInputFailure] si vide.
|
||||
Either<Failure, String> validateNonEmpty(String str) {
|
||||
final trimmed = str.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return Left(const InvalidInputFailure(message: 'La chaîne ne peut pas être vide'));
|
||||
}
|
||||
return Right(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
class InvalidInputFailure extends Failure {}
|
||||
/// Erreur levée lorsque l'entrée utilisateur est invalide.
|
||||
///
|
||||
/// Cette classe représente une erreur de validation ou de conversion
|
||||
/// des entrées utilisateur.
|
||||
class InvalidInputFailure extends Failure {
|
||||
/// Crée une nouvelle [InvalidInputFailure].
|
||||
///
|
||||
/// [message] Message décrivant l'erreur
|
||||
const InvalidInputFailure({
|
||||
super.message = 'Entrée invalide',
|
||||
super.code,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'InvalidInputFailure: $message';
|
||||
}
|
||||
|
||||
382
lib/core/utils/page_transitions.dart
Normal file
382
lib/core/utils/page_transitions.dart
Normal file
@@ -0,0 +1,382 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../constants/design_system.dart';
|
||||
|
||||
/// Transitions de page personnalisées pour une navigation fluide
|
||||
///
|
||||
/// Utilisez ces transitions au lieu de MaterialPageRoute standard
|
||||
/// pour une meilleure expérience utilisateur.
|
||||
///
|
||||
/// **Types de transitions:**
|
||||
/// - Fade: Fondu simple
|
||||
/// - Slide: Glissement depuis une direction
|
||||
/// - Scale: Zoom in/out
|
||||
/// - Rotation: Rotation 3D
|
||||
/// - SlideFromBottom: Bottom sheet style
|
||||
|
||||
/// Énumération des types de transitions
|
||||
enum PageTransitionType {
|
||||
fade,
|
||||
slideRight,
|
||||
slideLeft,
|
||||
slideUp,
|
||||
slideDown,
|
||||
scale,
|
||||
rotation,
|
||||
fadeScale,
|
||||
}
|
||||
|
||||
/// Route personnalisée avec transitions fluides
|
||||
class CustomPageRoute<T> extends PageRoute<T> {
|
||||
CustomPageRoute({
|
||||
required this.builder,
|
||||
this.transitionType = PageTransitionType.slideRight,
|
||||
this.duration,
|
||||
this.reverseDuration,
|
||||
this.curve,
|
||||
this.reverseCurve,
|
||||
super.settings,
|
||||
});
|
||||
|
||||
final WidgetBuilder builder;
|
||||
final PageTransitionType transitionType;
|
||||
final Duration? duration;
|
||||
final Duration? reverseDuration;
|
||||
final Curve? curve;
|
||||
final Curve? reverseCurve;
|
||||
|
||||
@override
|
||||
Color? get barrierColor => null;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => null;
|
||||
|
||||
@override
|
||||
bool get maintainState => true;
|
||||
|
||||
@override
|
||||
Duration get transitionDuration =>
|
||||
duration ?? DesignSystem.durationMedium;
|
||||
|
||||
@override
|
||||
Duration get reverseTransitionDuration =>
|
||||
reverseDuration ?? DesignSystem.durationMedium;
|
||||
|
||||
@override
|
||||
Widget buildPage(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
return builder(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildTransitions(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
final effectiveCurve = curve ?? DesignSystem.curveDecelerate;
|
||||
final effectiveReverseCurve = reverseCurve ?? DesignSystem.curveDecelerate;
|
||||
|
||||
final curvedAnimation = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: effectiveCurve,
|
||||
reverseCurve: effectiveReverseCurve,
|
||||
);
|
||||
|
||||
switch (transitionType) {
|
||||
case PageTransitionType.fade:
|
||||
return FadeTransition(
|
||||
opacity: curvedAnimation,
|
||||
child: child,
|
||||
);
|
||||
|
||||
case PageTransitionType.slideRight:
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(curvedAnimation),
|
||||
child: child,
|
||||
);
|
||||
|
||||
case PageTransitionType.slideLeft:
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(-1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(curvedAnimation),
|
||||
child: child,
|
||||
);
|
||||
|
||||
case PageTransitionType.slideUp:
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 1),
|
||||
end: Offset.zero,
|
||||
).animate(curvedAnimation),
|
||||
child: child,
|
||||
);
|
||||
|
||||
case PageTransitionType.slideDown:
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, -1),
|
||||
end: Offset.zero,
|
||||
).animate(curvedAnimation),
|
||||
child: child,
|
||||
);
|
||||
|
||||
case PageTransitionType.scale:
|
||||
return ScaleTransition(
|
||||
scale: Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(curvedAnimation),
|
||||
child: FadeTransition(
|
||||
opacity: curvedAnimation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
case PageTransitionType.rotation:
|
||||
return RotationTransition(
|
||||
turns: Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(curvedAnimation),
|
||||
child: FadeTransition(
|
||||
opacity: curvedAnimation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
case PageTransitionType.fadeScale:
|
||||
return FadeTransition(
|
||||
opacity: curvedAnimation,
|
||||
child: ScaleTransition(
|
||||
scale: Tween<double>(
|
||||
begin: 0.95,
|
||||
end: 1.0,
|
||||
).animate(curvedAnimation),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension pour faciliter la navigation avec transitions
|
||||
extension NavigatorExtensions on BuildContext {
|
||||
/// Navigate avec fade transition
|
||||
Future<T?> pushFade<T>(Widget page) {
|
||||
return Navigator.of(this).push<T>(
|
||||
CustomPageRoute(
|
||||
builder: (_) => page,
|
||||
transitionType: PageTransitionType.fade,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigate avec slide from right
|
||||
Future<T?> pushSlideRight<T>(Widget page) {
|
||||
return Navigator.of(this).push<T>(
|
||||
CustomPageRoute(
|
||||
builder: (_) => page,
|
||||
transitionType: PageTransitionType.slideRight,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigate avec slide from left
|
||||
Future<T?> pushSlideLeft<T>(Widget page) {
|
||||
return Navigator.of(this).push<T>(
|
||||
CustomPageRoute(
|
||||
builder: (_) => page,
|
||||
transitionType: PageTransitionType.slideLeft,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigate avec slide from bottom
|
||||
Future<T?> pushSlideUp<T>(Widget page) {
|
||||
return Navigator.of(this).push<T>(
|
||||
CustomPageRoute(
|
||||
builder: (_) => page,
|
||||
transitionType: PageTransitionType.slideUp,
|
||||
curve: DesignSystem.curveSharp,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigate avec scale transition
|
||||
Future<T?> pushScale<T>(Widget page) {
|
||||
return Navigator.of(this).push<T>(
|
||||
CustomPageRoute(
|
||||
builder: (_) => page,
|
||||
transitionType: PageTransitionType.scale,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigate avec fade + scale (élégant)
|
||||
Future<T?> pushFadeScale<T>(Widget page) {
|
||||
return Navigator.of(this).push<T>(
|
||||
CustomPageRoute(
|
||||
builder: (_) => page,
|
||||
transitionType: PageTransitionType.fadeScale,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigate et remplace avec transition
|
||||
Future<T?> pushReplacementFade<T, TO>(Widget page) {
|
||||
return Navigator.of(this).pushReplacement<T, TO>(
|
||||
CustomPageRoute(
|
||||
builder: (_) => page,
|
||||
transitionType: PageTransitionType.fade,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transition de Hero personnalisée
|
||||
///
|
||||
/// Pour des animations de shared element plus fluides.
|
||||
class CustomHeroTransition extends RectTween {
|
||||
CustomHeroTransition({
|
||||
required Rect? begin,
|
||||
required Rect? end,
|
||||
}) : super(begin: begin, end: end);
|
||||
|
||||
@override
|
||||
Rect? lerp(double t) {
|
||||
final elasticCurveValue = Curves.easeInOutCubic.transform(t);
|
||||
return Rect.fromLTRB(
|
||||
lerpDouble(begin!.left, end!.left, elasticCurveValue),
|
||||
lerpDouble(begin!.top, end!.top, elasticCurveValue),
|
||||
lerpDouble(begin!.right, end!.right, elasticCurveValue),
|
||||
lerpDouble(begin!.bottom, end!.bottom, elasticCurveValue),
|
||||
);
|
||||
}
|
||||
|
||||
double lerpDouble(double a, double b, double t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom sheet avec animation personnalisée
|
||||
Future<T?> showCustomBottomSheet<T>({
|
||||
required BuildContext context,
|
||||
required Widget Function(BuildContext) builder,
|
||||
bool isScrollControlled = true,
|
||||
bool useRootNavigator = false,
|
||||
bool isDismissible = true,
|
||||
Color? backgroundColor,
|
||||
double? elevation,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
builder: builder,
|
||||
isScrollControlled: isScrollControlled,
|
||||
useRootNavigator: useRootNavigator,
|
||||
isDismissible: isDismissible,
|
||||
backgroundColor: backgroundColor ?? Colors.transparent,
|
||||
elevation: elevation ?? 0,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(DesignSystem.radiusXl),
|
||||
),
|
||||
),
|
||||
transitionAnimationController: _createBottomSheetController(context),
|
||||
);
|
||||
}
|
||||
|
||||
AnimationController _createBottomSheetController(BuildContext context) {
|
||||
return BottomSheet.createAnimationController(Navigator.of(context))
|
||||
..duration = DesignSystem.durationMedium
|
||||
..reverseDuration = DesignSystem.durationFast;
|
||||
}
|
||||
|
||||
/// Dialog avec animation personnalisée
|
||||
Future<T?> showCustomDialog<T>({
|
||||
required BuildContext context,
|
||||
required Widget child,
|
||||
bool barrierDismissible = true,
|
||||
Color? barrierColor,
|
||||
String? barrierLabel,
|
||||
}) {
|
||||
return showGeneralDialog<T>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
barrierColor: barrierColor ?? Colors.black54,
|
||||
barrierLabel: barrierLabel,
|
||||
transitionDuration: DesignSystem.durationMedium,
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return ScaleTransition(
|
||||
scale: Tween<double>(
|
||||
begin: 0.8,
|
||||
end: 1.0,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: DesignSystem.curveDecelerate,
|
||||
),
|
||||
),
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
pageBuilder: (context, animation, secondaryAnimation) {
|
||||
return child;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared Element Transition (Hero avec contrôle fin)
|
||||
class SharedElementTransition extends StatelessWidget {
|
||||
const SharedElementTransition({
|
||||
required this.tag,
|
||||
required this.child,
|
||||
this.transitionOnUserGestures = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Object tag;
|
||||
final Widget child;
|
||||
final bool transitionOnUserGestures;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Hero(
|
||||
tag: tag,
|
||||
transitionOnUserGestures: transitionOnUserGestures,
|
||||
flightShuttleBuilder: (
|
||||
flightContext,
|
||||
animation,
|
||||
flightDirection,
|
||||
fromHeroContext,
|
||||
toHeroContext,
|
||||
) {
|
||||
final Hero toHero = toHeroContext.widget as Hero;
|
||||
return ScaleTransition(
|
||||
scale: Tween<double>(
|
||||
begin: 0.95,
|
||||
end: 1.0,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: DesignSystem.curveDecelerate,
|
||||
),
|
||||
),
|
||||
child: toHero.child,
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,243 @@
|
||||
/// Classe utilitaire pour la validation des champs de formulaire.
|
||||
///
|
||||
/// Cette classe fournit des méthodes statiques pour valider différents
|
||||
/// types de données d'entrée utilisateur.
|
||||
///
|
||||
/// **Usage:**
|
||||
/// ```dart
|
||||
/// final emailError = Validators.validateEmail(emailController.text);
|
||||
/// if (emailError != null) {
|
||||
/// // Afficher l'erreur
|
||||
/// }
|
||||
/// ```
|
||||
class Validators {
|
||||
/// Constructeur privé pour empêcher l'instanciation
|
||||
Validators._();
|
||||
|
||||
/// Expression régulière pour valider les emails
|
||||
static final RegExp _emailRegex = RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
);
|
||||
|
||||
/// Expression régulière pour valider les mots de passe forts
|
||||
/// (au moins 8 caractères, une majuscule, une minuscule, un chiffre)
|
||||
static final RegExp _strongPasswordRegex = RegExp(
|
||||
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$',
|
||||
);
|
||||
|
||||
/// Valide une adresse email.
|
||||
///
|
||||
/// [value] La valeur à valider
|
||||
///
|
||||
/// Returns `null` si l'email est valide, sinon un message d'erreur.
|
||||
///
|
||||
/// **Exemple:**
|
||||
/// ```dart
|
||||
/// final error = Validators.validateEmail('user@example.com');
|
||||
/// // Retourne null si valide
|
||||
/// ```
|
||||
static String? validateEmail(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Veuillez entrer votre email';
|
||||
}
|
||||
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
|
||||
|
||||
final trimmedValue = value.trim();
|
||||
|
||||
if (!_emailRegex.hasMatch(trimmedValue)) {
|
||||
return 'Veuillez entrer un email valide';
|
||||
}
|
||||
|
||||
// Validation supplémentaire de la longueur
|
||||
if (trimmedValue.length > 254) {
|
||||
return 'L\'email est trop long (maximum 254 caractères)';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Valide un mot de passe.
|
||||
///
|
||||
/// [value] La valeur à valider
|
||||
/// [minLength] Longueur minimale requise (par défaut: 6)
|
||||
/// [requireStrong] Si true, exige un mot de passe fort (par défaut: false)
|
||||
///
|
||||
/// Returns `null` si le mot de passe est valide, sinon un message d'erreur.
|
||||
///
|
||||
/// **Exemple:**
|
||||
/// ```dart
|
||||
/// final error = Validators.validatePassword('password123', minLength: 8);
|
||||
/// // Retourne null si valide
|
||||
/// ```
|
||||
static String? validatePassword(
|
||||
String? value, {
|
||||
int minLength = 6,
|
||||
bool requireStrong = false,
|
||||
}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer votre mot de passe';
|
||||
}
|
||||
|
||||
if (value.length < minLength) {
|
||||
return 'Le mot de passe doit comporter au moins $minLength caractères';
|
||||
}
|
||||
|
||||
if (requireStrong && !_strongPasswordRegex.hasMatch(value)) {
|
||||
return 'Le mot de passe doit contenir au moins une majuscule, '
|
||||
'une minuscule et un chiffre';
|
||||
}
|
||||
|
||||
// Validation de la longueur maximale
|
||||
if (value.length > 128) {
|
||||
return 'Le mot de passe est trop long (maximum 128 caractères)';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Valide que deux mots de passe correspondent.
|
||||
///
|
||||
/// [password] Le premier mot de passe
|
||||
/// [confirmPassword] Le mot de passe de confirmation
|
||||
///
|
||||
/// Returns `null` si les mots de passe correspondent, sinon un message d'erreur.
|
||||
///
|
||||
/// **Exemple:**
|
||||
/// ```dart
|
||||
/// final error = Validators.validatePasswordMatch(
|
||||
/// 'password123',
|
||||
/// 'password123',
|
||||
/// );
|
||||
/// // Retourne null si correspond
|
||||
/// ```
|
||||
static String? validatePasswordMatch(String? password, String? confirmPassword) {
|
||||
if (password == null || password.isEmpty) {
|
||||
return 'Veuillez entrer votre mot de passe';
|
||||
}
|
||||
|
||||
if (confirmPassword == null || confirmPassword.isEmpty) {
|
||||
return 'Veuillez confirmer votre mot de passe';
|
||||
}
|
||||
|
||||
if (password != confirmPassword) {
|
||||
return 'Les mots de passe ne correspondent pas';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Valide un nom (prénom ou nom de famille).
|
||||
///
|
||||
/// [value] La valeur à valider
|
||||
/// [fieldName] Le nom du champ (pour le message d'erreur)
|
||||
///
|
||||
/// Returns `null` si le nom est valide, sinon un message d'erreur.
|
||||
static String? validateName(String? value, {String fieldName = 'ce champ'}) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Veuillez entrer $fieldName';
|
||||
}
|
||||
|
||||
final trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue.length < 2) {
|
||||
return '$fieldName doit contenir au moins 2 caractères';
|
||||
}
|
||||
|
||||
if (trimmedValue.length > 100) {
|
||||
return '$fieldName est trop long (maximum 100 caractères)';
|
||||
}
|
||||
|
||||
// Validation des caractères (lettres, espaces, tirets, apostrophes)
|
||||
if (!RegExp(r"^[a-zA-ZÀ-ÿ\s\-']+$").hasMatch(trimmedValue)) {
|
||||
return '$fieldName ne doit contenir que des lettres';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Valide un numéro de téléphone.
|
||||
///
|
||||
/// [value] La valeur à valider
|
||||
///
|
||||
/// Returns `null` si le numéro est valide, sinon un message d'erreur.
|
||||
static String? validatePhoneNumber(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Veuillez entrer votre numéro de téléphone';
|
||||
}
|
||||
|
||||
final trimmedValue = value.trim().replaceAll(RegExp(r'[\s\-\(\)]'), '');
|
||||
|
||||
// Validation du format (10 chiffres pour la France)
|
||||
if (!RegExp(r'^\+?[0-9]{10,15}$').hasMatch(trimmedValue)) {
|
||||
return 'Veuillez entrer un numéro de téléphone valide';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Valide une URL.
|
||||
///
|
||||
/// [value] La valeur à valider
|
||||
///
|
||||
/// Returns `null` si l'URL est valide, sinon un message d'erreur.
|
||||
static String? validateUrl(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Veuillez entrer une URL';
|
||||
}
|
||||
|
||||
final trimmedValue = value.trim();
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(trimmedValue);
|
||||
if (!uri.hasScheme || !uri.hasAuthority) {
|
||||
return 'Veuillez entrer une URL valide';
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return 'Veuillez entrer une URL valide';
|
||||
}
|
||||
}
|
||||
|
||||
/// Valide qu'un champ n'est pas vide.
|
||||
///
|
||||
/// [value] La valeur à valider
|
||||
/// [fieldName] Le nom du champ (pour le message d'erreur)
|
||||
///
|
||||
/// Returns `null` si le champ n'est pas vide, sinon un message d'erreur.
|
||||
static String? validateRequired(String? value, {String fieldName = 'ce champ'}) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Veuillez remplir $fieldName';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? validatePassword(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer votre mot de passe';
|
||||
/// Valide la longueur d'une chaîne.
|
||||
///
|
||||
/// [value] La valeur à valider
|
||||
/// [minLength] Longueur minimale
|
||||
/// [maxLength] Longueur maximale
|
||||
/// [fieldName] Le nom du champ (pour le message d'erreur)
|
||||
///
|
||||
/// Returns `null` si la longueur est valide, sinon un message d'erreur.
|
||||
static String? validateLength(
|
||||
String? value, {
|
||||
int? minLength,
|
||||
int? maxLength,
|
||||
String fieldName = 'ce champ',
|
||||
}) {
|
||||
if (value == null) {
|
||||
return 'Veuillez remplir $fieldName';
|
||||
}
|
||||
if (value.length < 6) {
|
||||
return 'Le mot de passe doit comporter au moins 6 caractères';
|
||||
|
||||
final length = value.length;
|
||||
|
||||
if (minLength != null && length < minLength) {
|
||||
return '$fieldName doit contenir au moins $minLength caractères';
|
||||
}
|
||||
|
||||
if (maxLength != null && length > maxLength) {
|
||||
return '$fieldName ne doit pas dépasser $maxLength caractères';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user