274 lines
8.5 KiB
Dart
274 lines
8.5 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'dart:async';
|
|
|
|
/// Exception personnalisée pour les erreurs API avec méthodes d'affichage intégrées
|
|
class ApiException implements Exception {
|
|
final String message;
|
|
final int? statusCode;
|
|
final String? errorCode;
|
|
final Map<String, dynamic>? details;
|
|
final Object? originalError;
|
|
|
|
const ApiException(
|
|
this.message, {
|
|
this.statusCode,
|
|
this.errorCode,
|
|
this.details,
|
|
this.originalError,
|
|
});
|
|
|
|
/// Créer une ApiException depuis une DioException
|
|
factory ApiException.fromDioException(DioException dioException) {
|
|
final response = dioException.response;
|
|
final statusCode = response?.statusCode;
|
|
|
|
// Essayer d'extraire le message de la réponse API
|
|
String message = 'Erreur de communication avec le serveur';
|
|
String? errorCode;
|
|
Map<String, dynamic>? details;
|
|
|
|
if (response?.data != null) {
|
|
try {
|
|
final data = response!.data as Map<String, dynamic>;
|
|
|
|
// Message spécifique de l'API
|
|
if (data.containsKey('message')) {
|
|
message = data['message'] as String;
|
|
}
|
|
|
|
// Code d'erreur spécifique
|
|
if (data.containsKey('error_code')) {
|
|
errorCode = data['error_code'] as String;
|
|
}
|
|
|
|
// Détails supplémentaires
|
|
if (data.containsKey('errors')) {
|
|
details = data['errors'] as Map<String, dynamic>?;
|
|
}
|
|
} catch (e) {
|
|
// Si on ne peut pas parser la réponse, utiliser le message par défaut
|
|
}
|
|
}
|
|
|
|
// Messages par défaut selon le code de statut
|
|
if (response?.data == null || message == 'Erreur de communication avec le serveur') {
|
|
switch (statusCode) {
|
|
case 400:
|
|
message = 'Données invalides';
|
|
break;
|
|
case 401:
|
|
message = 'Non autorisé : veuillez vous reconnecter';
|
|
break;
|
|
case 403:
|
|
message = 'Accès interdit';
|
|
break;
|
|
case 404:
|
|
message = 'Ressource non trouvée';
|
|
break;
|
|
case 409:
|
|
message = 'Conflit : données déjà existantes';
|
|
break;
|
|
case 422:
|
|
message = 'Données de validation incorrectes';
|
|
break;
|
|
case 500:
|
|
message = 'Erreur serveur interne';
|
|
break;
|
|
case 502:
|
|
case 503:
|
|
case 504:
|
|
message = 'Service temporairement indisponible';
|
|
break;
|
|
default:
|
|
switch (dioException.type) {
|
|
case DioExceptionType.connectionTimeout:
|
|
case DioExceptionType.sendTimeout:
|
|
case DioExceptionType.receiveTimeout:
|
|
message = 'Délai d\'attente dépassé';
|
|
break;
|
|
case DioExceptionType.connectionError:
|
|
message = 'Problème de connexion réseau';
|
|
break;
|
|
case DioExceptionType.cancel:
|
|
message = 'Requête annulée';
|
|
break;
|
|
default:
|
|
message = 'Erreur de communication avec le serveur';
|
|
}
|
|
}
|
|
}
|
|
|
|
return ApiException(
|
|
message,
|
|
statusCode: statusCode,
|
|
errorCode: errorCode,
|
|
details: details,
|
|
originalError: dioException,
|
|
);
|
|
}
|
|
|
|
/// Créer une ApiException depuis n'importe quelle erreur
|
|
factory ApiException.fromError(Object error) {
|
|
if (error is ApiException) {
|
|
return error;
|
|
}
|
|
|
|
final errorString = error.toString();
|
|
|
|
if (errorString.contains('SocketException') || errorString.contains('NetworkException')) {
|
|
return const ApiException('Problème de connexion réseau');
|
|
}
|
|
if (errorString.contains('TimeoutException')) {
|
|
return const ApiException('Délai d\'attente dépassé');
|
|
}
|
|
|
|
return ApiException('Erreur inattendue', originalError: error);
|
|
}
|
|
|
|
@override
|
|
String toString() => message;
|
|
|
|
/// Obtenir un message d'erreur formaté pour l'affichage
|
|
String get displayMessage => message;
|
|
|
|
/// Vérifier si c'est une erreur de validation
|
|
bool get isValidationError => statusCode == 422 || statusCode == 400;
|
|
|
|
/// Vérifier si c'est une erreur d'authentification
|
|
bool get isAuthError => statusCode == 401 || statusCode == 403;
|
|
|
|
/// Vérifier si c'est une erreur de conflit (données déjà existantes)
|
|
bool get isConflictError => statusCode == 409;
|
|
|
|
/// Vérifier si c'est une erreur réseau
|
|
bool get isNetworkError =>
|
|
statusCode == null ||
|
|
statusCode == 502 ||
|
|
statusCode == 503 ||
|
|
statusCode == 504 ||
|
|
originalError is DioException && (originalError as DioException).type == DioExceptionType.connectionError;
|
|
|
|
// === MÉTHODES D'AFFICHAGE INTÉGRÉES ===
|
|
|
|
/// Afficher cette erreur (méthode d'instance)
|
|
void show(BuildContext context, {Duration? duration}) {
|
|
if (context.mounted) {
|
|
final isInDialog = _isInDialog(context);
|
|
|
|
if (isInDialog) {
|
|
_showOverlaySnackBar(context, displayMessage, Colors.red, duration);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(displayMessage),
|
|
backgroundColor: Colors.red,
|
|
duration: duration ?? const Duration(seconds: 5),
|
|
action: SnackBarAction(
|
|
label: 'Fermer',
|
|
textColor: Colors.white,
|
|
onPressed: () => ScaffoldMessenger.of(context).hideCurrentSnackBar(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Méthode statique pour afficher une erreur depuis n'importe quel objet
|
|
static void showError(BuildContext context, Object error, {Duration? duration}) {
|
|
final apiException = ApiException.fromError(error);
|
|
apiException.show(context, duration: duration);
|
|
}
|
|
|
|
/// Méthode statique pour afficher un message de succès (compatible avec les Dialogs)
|
|
static void showSuccess(BuildContext context, String message, {Duration? duration}) {
|
|
if (context.mounted) {
|
|
final isInDialog = _isInDialog(context);
|
|
|
|
if (isInDialog) {
|
|
_showOverlaySnackBar(context, message, Colors.green, duration);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(message),
|
|
backgroundColor: Colors.green,
|
|
duration: duration ?? const Duration(seconds: 3),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Vérifier si le contexte est dans un Dialog
|
|
static bool _isInDialog(BuildContext context) {
|
|
return context.findAncestorWidgetOfExactType<Dialog>() != null;
|
|
}
|
|
|
|
/// Afficher un SnackBar en overlay au-dessus de tout
|
|
static void _showOverlaySnackBar(BuildContext context, String message, Color backgroundColor, Duration? duration) {
|
|
final overlay = Overlay.of(context);
|
|
late OverlayEntry overlayEntry;
|
|
|
|
overlayEntry = OverlayEntry(
|
|
builder: (context) => Positioned(
|
|
top: MediaQuery.of(context).padding.top + 20, // En haut de l'écran
|
|
left: 16,
|
|
right: 16,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: backgroundColor,
|
|
borderRadius: BorderRadius.circular(8),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.3),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
backgroundColor == Colors.red ? Icons.error_outline : Icons.check_circle_outline,
|
|
color: Colors.white,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
message,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close, color: Colors.white, size: 18),
|
|
onPressed: () => overlayEntry.remove(),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(minWidth: 30, minHeight: 30),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
overlay.insert(overlayEntry);
|
|
|
|
// Auto-suppression après la durée spécifiée
|
|
Timer(duration ?? const Duration(seconds: 5), () {
|
|
if (overlayEntry.mounted) {
|
|
overlayEntry.remove();
|
|
}
|
|
});
|
|
}
|
|
}
|