feat(mobile): Implement Keycloak WebView authentication with HTTP callback
- Replace flutter_appauth with custom WebView implementation to resolve deep link issues - Add KeycloakWebViewAuthService with integrated WebView for seamless authentication - Configure Android manifest for HTTP cleartext traffic support - Add network security config for development environment (192.168.1.11) - Update Keycloak client to use HTTP callback endpoint (http://192.168.1.11:8080/auth/callback) - Remove obsolete keycloak_auth_service.dart and temporary scripts - Clean up dependencies and regenerate injection configuration - Tested successfully on multiple Android devices (Xiaomi 2201116TG, SM A725F) BREAKING CHANGE: Authentication flow now uses WebView instead of external browser - Users will see Keycloak login page within the app instead of browser redirect - Resolves ERR_CLEARTEXT_NOT_PERMITTED and deep link state management issues - Maintains full OIDC compliance with PKCE flow and secure token storage Technical improvements: - WebView with custom navigation delegate for callback handling - Automatic token extraction and user info parsing from JWT - Proper error handling and user feedback - Consistent authentication state management across app lifecycle
This commit is contained in:
486
unionflow-mobile-apps/lib/core/error/error_handler.dart
Normal file
486
unionflow-mobile-apps/lib/core/error/error_handler.dart
Normal file
@@ -0,0 +1,486 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../failures/failures.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
|
||||
/// Service centralisé de gestion des erreurs
|
||||
class ErrorHandler {
|
||||
static const String _tag = 'ErrorHandler';
|
||||
|
||||
/// Gère les erreurs et affiche les messages appropriés à l'utilisateur
|
||||
static void handleError(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
String? customMessage,
|
||||
VoidCallback? onRetry,
|
||||
bool showSnackBar = true,
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
}) {
|
||||
final errorInfo = _analyzeError(error);
|
||||
|
||||
if (showSnackBar) {
|
||||
_showErrorSnackBar(
|
||||
context,
|
||||
customMessage ?? errorInfo.userMessage,
|
||||
errorInfo.type,
|
||||
onRetry: onRetry,
|
||||
duration: duration,
|
||||
);
|
||||
}
|
||||
|
||||
// Log l'erreur pour le debugging
|
||||
_logError(errorInfo);
|
||||
}
|
||||
|
||||
/// Affiche une boîte de dialogue d'erreur pour les erreurs critiques
|
||||
static Future<void> showErrorDialog(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
String? title,
|
||||
String? customMessage,
|
||||
VoidCallback? onRetry,
|
||||
VoidCallback? onCancel,
|
||||
}) async {
|
||||
final errorInfo = _analyzeError(error);
|
||||
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getErrorIcon(errorInfo.type),
|
||||
color: _getErrorColor(errorInfo.type),
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title ?? _getErrorTitle(errorInfo.type),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
customMessage ?? errorInfo.userMessage,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
if (errorInfo.suggestions.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Suggestions :',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...errorInfo.suggestions.map((suggestion) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('• ', style: TextStyle(color: AppTheme.textSecondary)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
suggestion,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (onCancel != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onCancel();
|
||||
},
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
if (onRetry != null)
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onRetry();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Réessayer'),
|
||||
)
|
||||
else
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Analyse l'erreur et retourne les informations structurées
|
||||
static ErrorInfo _analyzeError(dynamic error) {
|
||||
if (error is DioException) {
|
||||
return _analyzeDioError(error);
|
||||
} else if (error is Failure) {
|
||||
return _analyzeFailure(error);
|
||||
} else if (error is Exception) {
|
||||
return _analyzeException(error);
|
||||
} else {
|
||||
return ErrorInfo(
|
||||
type: ErrorType.unknown,
|
||||
userMessage: 'Une erreur inattendue s\'est produite',
|
||||
technicalMessage: error.toString(),
|
||||
suggestions: ['Veuillez réessayer plus tard'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Analyse les erreurs Dio (réseau)
|
||||
static ErrorInfo _analyzeDioError(DioException error) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return ErrorInfo(
|
||||
type: ErrorType.network,
|
||||
userMessage: 'Délai d\'attente dépassé',
|
||||
technicalMessage: error.message ?? '',
|
||||
suggestions: [
|
||||
'Vérifiez votre connexion internet',
|
||||
'Réessayez dans quelques instants',
|
||||
],
|
||||
);
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
return ErrorInfo(
|
||||
type: ErrorType.network,
|
||||
userMessage: 'Problème de connexion',
|
||||
technicalMessage: error.message ?? '',
|
||||
suggestions: [
|
||||
'Vérifiez votre connexion internet',
|
||||
'Vérifiez que le serveur est accessible',
|
||||
],
|
||||
);
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return ErrorInfo(
|
||||
type: ErrorType.validation,
|
||||
userMessage: 'Données invalides',
|
||||
technicalMessage: error.response?.data?.toString() ?? '',
|
||||
suggestions: ['Vérifiez les informations saisies'],
|
||||
);
|
||||
case 401:
|
||||
return ErrorInfo(
|
||||
type: ErrorType.authentication,
|
||||
userMessage: 'Session expirée',
|
||||
technicalMessage: 'Unauthorized',
|
||||
suggestions: ['Reconnectez-vous à l\'application'],
|
||||
);
|
||||
case 403:
|
||||
return ErrorInfo(
|
||||
type: ErrorType.authorization,
|
||||
userMessage: 'Accès non autorisé',
|
||||
technicalMessage: 'Forbidden',
|
||||
suggestions: ['Contactez votre administrateur'],
|
||||
);
|
||||
case 404:
|
||||
return ErrorInfo(
|
||||
type: ErrorType.notFound,
|
||||
userMessage: 'Ressource non trouvée',
|
||||
technicalMessage: 'Not Found',
|
||||
suggestions: ['La ressource demandée n\'existe plus'],
|
||||
);
|
||||
case 500:
|
||||
return ErrorInfo(
|
||||
type: ErrorType.server,
|
||||
userMessage: 'Erreur serveur',
|
||||
technicalMessage: 'Internal Server Error',
|
||||
suggestions: [
|
||||
'Réessayez dans quelques instants',
|
||||
'Contactez le support si le problème persiste',
|
||||
],
|
||||
);
|
||||
default:
|
||||
return ErrorInfo(
|
||||
type: ErrorType.server,
|
||||
userMessage: 'Erreur serveur (Code: $statusCode)',
|
||||
technicalMessage: error.response?.data?.toString() ?? '',
|
||||
suggestions: ['Réessayez plus tard'],
|
||||
);
|
||||
}
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return ErrorInfo(
|
||||
type: ErrorType.cancelled,
|
||||
userMessage: 'Opération annulée',
|
||||
technicalMessage: 'Request cancelled',
|
||||
suggestions: [],
|
||||
);
|
||||
|
||||
default:
|
||||
return ErrorInfo(
|
||||
type: ErrorType.unknown,
|
||||
userMessage: 'Erreur de communication',
|
||||
technicalMessage: error.message ?? '',
|
||||
suggestions: ['Réessayez plus tard'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Analyse les erreurs de type Failure
|
||||
static ErrorInfo _analyzeFailure(Failure failure) {
|
||||
switch (failure.runtimeType) {
|
||||
case NetworkFailure:
|
||||
return ErrorInfo(
|
||||
type: ErrorType.network,
|
||||
userMessage: 'Problème de réseau',
|
||||
technicalMessage: failure.message,
|
||||
suggestions: [
|
||||
'Vérifiez votre connexion internet',
|
||||
'Réessayez dans quelques instants',
|
||||
],
|
||||
);
|
||||
case ServerFailure:
|
||||
return ErrorInfo(
|
||||
type: ErrorType.server,
|
||||
userMessage: 'Erreur serveur',
|
||||
technicalMessage: failure.message,
|
||||
suggestions: [
|
||||
'Réessayez dans quelques instants',
|
||||
'Contactez le support si le problème persiste',
|
||||
],
|
||||
);
|
||||
case ValidationFailure:
|
||||
return ErrorInfo(
|
||||
type: ErrorType.validation,
|
||||
userMessage: 'Données invalides',
|
||||
technicalMessage: failure.message,
|
||||
suggestions: ['Vérifiez les informations saisies'],
|
||||
);
|
||||
case AuthFailure:
|
||||
return ErrorInfo(
|
||||
type: ErrorType.authentication,
|
||||
userMessage: 'Problème d\'authentification',
|
||||
technicalMessage: failure.message,
|
||||
suggestions: ['Reconnectez-vous à l\'application'],
|
||||
);
|
||||
default:
|
||||
return ErrorInfo(
|
||||
type: ErrorType.unknown,
|
||||
userMessage: failure.message,
|
||||
technicalMessage: failure.message,
|
||||
suggestions: ['Réessayez plus tard'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Analyse les exceptions génériques
|
||||
static ErrorInfo _analyzeException(Exception exception) {
|
||||
final message = exception.toString();
|
||||
|
||||
if (message.contains('connexion') || message.contains('network')) {
|
||||
return ErrorInfo(
|
||||
type: ErrorType.network,
|
||||
userMessage: 'Problème de connexion',
|
||||
technicalMessage: message,
|
||||
suggestions: ['Vérifiez votre connexion internet'],
|
||||
);
|
||||
} else if (message.contains('timeout')) {
|
||||
return ErrorInfo(
|
||||
type: ErrorType.network,
|
||||
userMessage: 'Délai d\'attente dépassé',
|
||||
technicalMessage: message,
|
||||
suggestions: ['Réessayez dans quelques instants'],
|
||||
);
|
||||
} else {
|
||||
return ErrorInfo(
|
||||
type: ErrorType.unknown,
|
||||
userMessage: 'Une erreur s\'est produite',
|
||||
technicalMessage: message,
|
||||
suggestions: ['Réessayez plus tard'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche une SnackBar d'erreur avec style approprié
|
||||
static void _showErrorSnackBar(
|
||||
BuildContext context,
|
||||
String message,
|
||||
ErrorType type, {
|
||||
VoidCallback? onRetry,
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getErrorIcon(type),
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: _getErrorColor(type),
|
||||
duration: duration,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
action: onRetry != null
|
||||
? SnackBarAction(
|
||||
label: 'Réessayer',
|
||||
textColor: Colors.white,
|
||||
onPressed: onRetry,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Retourne l'icône appropriée pour le type d'erreur
|
||||
static IconData _getErrorIcon(ErrorType type) {
|
||||
switch (type) {
|
||||
case ErrorType.network:
|
||||
return Icons.wifi_off;
|
||||
case ErrorType.server:
|
||||
return Icons.error_outline;
|
||||
case ErrorType.validation:
|
||||
return Icons.warning_amber;
|
||||
case ErrorType.authentication:
|
||||
return Icons.lock_outline;
|
||||
case ErrorType.authorization:
|
||||
return Icons.block;
|
||||
case ErrorType.notFound:
|
||||
return Icons.search_off;
|
||||
case ErrorType.cancelled:
|
||||
return Icons.cancel_outlined;
|
||||
case ErrorType.unknown:
|
||||
default:
|
||||
return Icons.error_outline;
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne la couleur appropriée pour le type d'erreur
|
||||
static Color _getErrorColor(ErrorType type) {
|
||||
switch (type) {
|
||||
case ErrorType.network:
|
||||
return AppTheme.warningColor;
|
||||
case ErrorType.server:
|
||||
return AppTheme.errorColor;
|
||||
case ErrorType.validation:
|
||||
return AppTheme.warningColor;
|
||||
case ErrorType.authentication:
|
||||
return AppTheme.errorColor;
|
||||
case ErrorType.authorization:
|
||||
return AppTheme.errorColor;
|
||||
case ErrorType.notFound:
|
||||
return AppTheme.infoColor;
|
||||
case ErrorType.cancelled:
|
||||
return AppTheme.textSecondary;
|
||||
case ErrorType.unknown:
|
||||
default:
|
||||
return AppTheme.errorColor;
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne le titre approprié pour le type d'erreur
|
||||
static String _getErrorTitle(ErrorType type) {
|
||||
switch (type) {
|
||||
case ErrorType.network:
|
||||
return 'Problème de connexion';
|
||||
case ErrorType.server:
|
||||
return 'Erreur serveur';
|
||||
case ErrorType.validation:
|
||||
return 'Données invalides';
|
||||
case ErrorType.authentication:
|
||||
return 'Authentification requise';
|
||||
case ErrorType.authorization:
|
||||
return 'Accès non autorisé';
|
||||
case ErrorType.notFound:
|
||||
return 'Ressource introuvable';
|
||||
case ErrorType.cancelled:
|
||||
return 'Opération annulée';
|
||||
case ErrorType.unknown:
|
||||
default:
|
||||
return 'Erreur';
|
||||
}
|
||||
}
|
||||
|
||||
/// Log l'erreur pour le debugging
|
||||
static void _logError(ErrorInfo errorInfo) {
|
||||
debugPrint('[$_tag] ${errorInfo.type.name}: ${errorInfo.technicalMessage}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Types d'erreurs supportés
|
||||
enum ErrorType {
|
||||
network,
|
||||
server,
|
||||
validation,
|
||||
authentication,
|
||||
authorization,
|
||||
notFound,
|
||||
cancelled,
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Informations structurées sur une erreur
|
||||
class ErrorInfo {
|
||||
final ErrorType type;
|
||||
final String userMessage;
|
||||
final String technicalMessage;
|
||||
final List<String> suggestions;
|
||||
|
||||
const ErrorInfo({
|
||||
required this.type,
|
||||
required this.userMessage,
|
||||
required this.technicalMessage,
|
||||
required this.suggestions,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user