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:
dahoud
2026-01-10 10:43:17 +00:00
parent 06031b01f2
commit 92612abbd7
321 changed files with 43137 additions and 4285 deletions

View 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]';
}
}
}

View File

@@ -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';
}

View File

@@ -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);
}
}
}

View File

@@ -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';
}

View 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,
);
}
}

View File

@@ -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;
}
}