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:
DahoudG
2025-09-15 01:44:16 +00:00
parent 73459b3092
commit f89f6167cc
290 changed files with 34563 additions and 3528 deletions

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