Files
unionflow-server-api/unionflow-mobile-apps/lib/core/feedback/user_feedback.dart
DahoudG f89f6167cc 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
2025-09-15 01:44:16 +00:00

460 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../shared/theme/app_theme.dart';
import '../animations/loading_animations.dart';
/// Service de feedback utilisateur avec différents types de notifications
class UserFeedback {
/// Affiche un message de succès
static void showSuccess(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 3),
VoidCallback? onAction,
String? actionLabel,
}) {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(
Icons.check_circle,
color: Colors.white,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
backgroundColor: AppTheme.successColor,
duration: duration,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
action: onAction != null && actionLabel != null
? SnackBarAction(
label: actionLabel,
textColor: Colors.white,
onPressed: onAction,
)
: null,
),
);
}
/// Affiche un message d'information
static void showInfo(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 3),
VoidCallback? onAction,
String? actionLabel,
}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(
Icons.info,
color: Colors.white,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
),
],
),
backgroundColor: AppTheme.infoColor,
duration: duration,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
action: onAction != null && actionLabel != null
? SnackBarAction(
label: actionLabel,
textColor: Colors.white,
onPressed: onAction,
)
: null,
),
);
}
/// Affiche un message d'avertissement
static void showWarning(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 4),
VoidCallback? onAction,
String? actionLabel,
}) {
HapticFeedback.mediumImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(
Icons.warning,
color: Colors.white,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
backgroundColor: AppTheme.warningColor,
duration: duration,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
action: onAction != null && actionLabel != null
? SnackBarAction(
label: actionLabel,
textColor: Colors.white,
onPressed: onAction,
)
: null,
),
);
}
/// Affiche une boîte de dialogue de confirmation
static Future<bool> showConfirmation(
BuildContext context, {
required String title,
required String message,
String confirmText = 'Confirmer',
String cancelText = 'Annuler',
Color? confirmColor,
IconData? icon,
bool isDangerous = false,
}) async {
HapticFeedback.mediumImpact();
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
if (icon != null) ...[
Icon(
icon,
color: isDangerous ? AppTheme.errorColor : AppTheme.primaryColor,
size: 24,
),
const SizedBox(width: 12),
],
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
],
),
content: Text(
message,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(
cancelText,
style: const TextStyle(
color: AppTheme.textSecondary,
),
),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: confirmColor ??
(isDangerous ? AppTheme.errorColor : AppTheme.primaryColor),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(confirmText),
),
],
);
},
);
return result ?? false;
}
/// Affiche une boîte de dialogue de saisie
static Future<String?> showInputDialog(
BuildContext context, {
required String title,
required String label,
String? initialValue,
String? hintText,
String confirmText = 'OK',
String cancelText = 'Annuler',
TextInputType? keyboardType,
String? Function(String?)? validator,
int maxLines = 1,
}) async {
final controller = TextEditingController(text: initialValue);
final formKey = GlobalKey<FormState>();
final result = await showDialog<String>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: Form(
key: formKey,
child: TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: label,
hintText: hintText,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
keyboardType: keyboardType,
maxLines: maxLines,
validator: validator,
autofocus: true,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
cancelText,
style: const TextStyle(
color: AppTheme.textSecondary,
),
),
),
ElevatedButton(
onPressed: () {
if (formKey.currentState?.validate() ?? false) {
Navigator.of(context).pop(controller.text);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(confirmText),
),
],
);
},
);
controller.dispose();
return result;
}
/// Affiche un indicateur de chargement avec message et animation personnalisée
static void showLoading(
BuildContext context, {
String message = 'Chargement...',
bool barrierDismissible = false,
Widget? customLoader,
}) {
showDialog(
context: context,
barrierDismissible: barrierDismissible,
builder: (BuildContext context) {
return PopScope(
canPop: barrierDismissible,
child: AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
customLoader ?? LoadingAnimations.waves(
color: AppTheme.primaryColor,
size: 50,
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
),
],
),
),
);
},
);
}
/// Affiche un indicateur de chargement avec animation de points
static void showLoadingDots(
BuildContext context, {
String message = 'Chargement...',
bool barrierDismissible = false,
}) {
showLoading(
context,
message: message,
barrierDismissible: barrierDismissible,
customLoader: LoadingAnimations.dots(
color: AppTheme.primaryColor,
size: 12,
),
);
}
/// Affiche un indicateur de chargement avec animation de spinner
static void showLoadingSpinner(
BuildContext context, {
String message = 'Chargement...',
bool barrierDismissible = false,
}) {
showLoading(
context,
message: message,
barrierDismissible: barrierDismissible,
customLoader: LoadingAnimations.spinner(
color: AppTheme.primaryColor,
size: 50,
),
);
}
/// Ferme l'indicateur de chargement
static void hideLoading(BuildContext context) {
Navigator.of(context).pop();
}
/// Affiche un toast personnalisé
static void showToast(
BuildContext context,
String message, {
Duration duration = const Duration(seconds: 2),
Color? backgroundColor,
Color? textColor,
IconData? icon,
}) {
final overlay = Overlay.of(context);
late OverlayEntry overlayEntry;
overlayEntry = OverlayEntry(
builder: (context) => Positioned(
bottom: 100,
left: 20,
right: 20,
child: Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: backgroundColor ?? AppTheme.textPrimary.withOpacity(0.9),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
color: textColor ?? Colors.white,
size: 20,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
message,
style: TextStyle(
color: textColor ?? Colors.white,
fontSize: 14,
),
textAlign: TextAlign.center,
),
),
],
),
),
),
),
);
overlay.insert(overlayEntry);
Future.delayed(duration, () {
overlayEntry.remove();
});
}
}