- 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
259 lines
8.7 KiB
Dart
259 lines
8.7 KiB
Dart
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'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|