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,258 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:permission_handler/permission_handler.dart';
import '../models/membre_model.dart';
import '../../shared/theme/app_theme.dart';
/// Service de gestion des communications (appels, SMS, emails)
/// Gère les permissions et l'intégration avec les applications natives
class CommunicationService {
static final CommunicationService _instance = CommunicationService._internal();
factory CommunicationService() => _instance;
CommunicationService._internal();
/// Effectue un appel téléphonique vers un membre
Future<bool> callMember(BuildContext context, MembreModel membre) async {
try {
// Vérifier si le numéro de téléphone est valide
if (membre.telephone.isEmpty) {
_showErrorSnackBar(context, 'Numéro de téléphone non disponible pour ${membre.nomComplet}');
return false;
}
// Nettoyer le numéro de téléphone
final cleanPhone = _cleanPhoneNumber(membre.telephone);
if (cleanPhone.isEmpty) {
_showErrorSnackBar(context, 'Numéro de téléphone invalide pour ${membre.nomComplet}');
return false;
}
// Vérifier les permissions sur Android
if (Platform.isAndroid) {
final phonePermission = await Permission.phone.status;
if (phonePermission.isDenied) {
final result = await Permission.phone.request();
if (result.isDenied) {
_showPermissionDeniedDialog(context, 'Téléphone', 'effectuer des appels');
return false;
}
}
}
// Construire l'URL d'appel
final phoneUrl = Uri.parse('tel:$cleanPhone');
// Vérifier si l'application peut gérer les appels
if (await canLaunchUrl(phoneUrl)) {
// Feedback haptique
HapticFeedback.mediumImpact();
// Lancer l'appel
final success = await launchUrl(phoneUrl);
if (success) {
_showSuccessSnackBar(context, 'Appel lancé vers ${membre.nomComplet}');
// Log de l'action pour audit
debugPrint('📞 Appel effectué vers ${membre.nomComplet} (${membre.telephone})');
return true;
} else {
_showErrorSnackBar(context, 'Impossible de lancer l\'appel vers ${membre.nomComplet}');
return false;
}
} else {
_showErrorSnackBar(context, 'Application d\'appel non disponible sur cet appareil');
return false;
}
} catch (e) {
debugPrint('❌ Erreur lors de l\'appel vers ${membre.nomComplet}: $e');
_showErrorSnackBar(context, 'Erreur lors de l\'appel vers ${membre.nomComplet}');
return false;
}
}
/// Envoie un SMS à un membre
Future<bool> sendSMS(BuildContext context, MembreModel membre, {String? message}) async {
try {
// Vérifier si le numéro de téléphone est valide
if (membre.telephone.isEmpty) {
_showErrorSnackBar(context, 'Numéro de téléphone non disponible pour ${membre.nomComplet}');
return false;
}
// Nettoyer le numéro de téléphone
final cleanPhone = _cleanPhoneNumber(membre.telephone);
if (cleanPhone.isEmpty) {
_showErrorSnackBar(context, 'Numéro de téléphone invalide pour ${membre.nomComplet}');
return false;
}
// Construire l'URL SMS
String smsUrl = 'sms:$cleanPhone';
if (message != null && message.isNotEmpty) {
final encodedMessage = Uri.encodeComponent(message);
smsUrl += '?body=$encodedMessage';
}
final smsUri = Uri.parse(smsUrl);
// Vérifier si l'application peut gérer les SMS
if (await canLaunchUrl(smsUri)) {
// Feedback haptique
HapticFeedback.lightImpact();
// Lancer l'application SMS
final success = await launchUrl(smsUri);
if (success) {
_showSuccessSnackBar(context, 'SMS ouvert pour ${membre.nomComplet}');
// Log de l'action pour audit
debugPrint('💬 SMS ouvert pour ${membre.nomComplet} (${membre.telephone})');
return true;
} else {
_showErrorSnackBar(context, 'Impossible d\'ouvrir l\'application SMS');
return false;
}
} else {
_showErrorSnackBar(context, 'Application SMS non disponible sur cet appareil');
return false;
}
} catch (e) {
debugPrint('❌ Erreur lors de l\'envoi SMS vers ${membre.nomComplet}: $e');
_showErrorSnackBar(context, 'Erreur lors de l\'envoi SMS vers ${membre.nomComplet}');
return false;
}
}
/// Envoie un email à un membre
Future<bool> sendEmail(BuildContext context, MembreModel membre, {String? subject, String? body}) async {
try {
// Vérifier si l'email est valide
if (membre.email.isEmpty) {
_showErrorSnackBar(context, 'Adresse email non disponible pour ${membre.nomComplet}');
return false;
}
// Construire l'URL email
String emailUrl = 'mailto:${membre.email}';
final params = <String>[];
if (subject != null && subject.isNotEmpty) {
params.add('subject=${Uri.encodeComponent(subject)}');
}
if (body != null && body.isNotEmpty) {
params.add('body=${Uri.encodeComponent(body)}');
}
if (params.isNotEmpty) {
emailUrl += '?${params.join('&')}';
}
final emailUri = Uri.parse(emailUrl);
// Vérifier si l'application peut gérer les emails
if (await canLaunchUrl(emailUri)) {
// Feedback haptique
HapticFeedback.lightImpact();
// Lancer l'application email
final success = await launchUrl(emailUri);
if (success) {
_showSuccessSnackBar(context, 'Email ouvert pour ${membre.nomComplet}');
// Log de l'action pour audit
debugPrint('📧 Email ouvert pour ${membre.nomComplet} (${membre.email})');
return true;
} else {
_showErrorSnackBar(context, 'Impossible d\'ouvrir l\'application email');
return false;
}
} else {
_showErrorSnackBar(context, 'Application email non disponible sur cet appareil');
return false;
}
} catch (e) {
debugPrint('❌ Erreur lors de l\'envoi email vers ${membre.nomComplet}: $e');
_showErrorSnackBar(context, 'Erreur lors de l\'envoi email vers ${membre.nomComplet}');
return false;
}
}
/// Nettoie un numéro de téléphone en supprimant les caractères non numériques
String _cleanPhoneNumber(String phone) {
// Garder seulement les chiffres et le signe +
final cleaned = phone.replaceAll(RegExp(r'[^\d+]'), '');
// Vérifier que le numéro n'est pas vide après nettoyage
if (cleaned.isEmpty) return '';
// Si le numéro commence par +, le garder tel quel
if (cleaned.startsWith('+')) return cleaned;
// Si le numéro commence par 00, le remplacer par +
if (cleaned.startsWith('00')) {
return '+${cleaned.substring(2)}';
}
return cleaned;
}
/// Affiche un SnackBar de succès
void _showSuccessSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: AppTheme.successColor,
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
/// Affiche un SnackBar d'erreur
void _showErrorSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
),
);
}
/// Affiche une dialog pour les permissions refusées
void _showPermissionDeniedDialog(BuildContext context, String permission, String action) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Permission $permission requise'),
content: Text(
'L\'application a besoin de la permission $permission pour $action. '
'Veuillez autoriser cette permission dans les paramètres de l\'application.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
openAppSettings();
},
child: const Text('Paramètres'),
),
],
),
);
}
}