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,353 @@
import 'package:flutter/material.dart';
/// Service de validation des formulaires avec règles métier
class FormValidator {
/// Valide un champ requis
static String? required(String? value, {String? fieldName}) {
if (value == null || value.trim().isEmpty) {
return '${fieldName ?? 'Ce champ'} est requis';
}
return null;
}
/// Valide un email
static String? email(String? value, {bool required = true}) {
if (!required && (value == null || value.trim().isEmpty)) {
return null;
}
if (value == null || value.trim().isEmpty) {
return 'L\'email est requis';
}
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(value.trim())) {
return 'Format d\'email invalide';
}
return null;
}
/// Valide un numéro de téléphone
static String? phone(String? value, {bool required = true}) {
if (!required && (value == null || value.trim().isEmpty)) {
return null;
}
if (value == null || value.trim().isEmpty) {
return 'Le numéro de téléphone est requis';
}
// Supprimer tous les espaces et caractères spéciaux sauf + et chiffres
final cleanPhone = value.replaceAll(RegExp(r'[^\d+]'), '');
// Vérifier le format international (+225XXXXXXXX) ou local (XXXXXXXX)
final phoneRegex = RegExp(r'^(\+225)?[0-9]{8,10}$');
if (!phoneRegex.hasMatch(cleanPhone)) {
return 'Format de téléphone invalide (ex: +225XXXXXXXX)';
}
return null;
}
/// Valide la longueur minimale
static String? minLength(String? value, int minLength, {String? fieldName}) {
if (value == null || value.trim().isEmpty) {
return null; // Laisse la validation required s'en occuper
}
if (value.trim().length < minLength) {
return '${fieldName ?? 'Ce champ'} doit contenir au moins $minLength caractères';
}
return null;
}
/// Valide la longueur maximale
static String? maxLength(String? value, int maxLength, {String? fieldName}) {
if (value == null || value.trim().isEmpty) {
return null;
}
if (value.trim().length > maxLength) {
return '${fieldName ?? 'Ce champ'} ne peut pas dépasser $maxLength caractères';
}
return null;
}
/// Valide un nom (prénom ou nom de famille)
static String? name(String? value, {String? fieldName, bool required = true}) {
if (!required && (value == null || value.trim().isEmpty)) {
return null;
}
final requiredError = FormValidator.required(value, fieldName: fieldName);
if (requiredError != null) return requiredError;
final minLengthError = minLength(value, 2, fieldName: fieldName);
if (minLengthError != null) return minLengthError;
final maxLengthError = maxLength(value, 50, fieldName: fieldName);
if (maxLengthError != null) return maxLengthError;
// Vérifier que le nom ne contient que des lettres, espaces, tirets et apostrophes
final nameRegex = RegExp(r'^[a-zA-ZÀ-ÿ\s\-\u0027]+$');
if (!nameRegex.hasMatch(value!.trim())) {
return '${fieldName ?? 'Ce champ'} ne peut contenir que des lettres';
}
return null;
}
/// Valide une date de naissance
static String? birthDate(DateTime? value, {int minAge = 0, int maxAge = 120}) {
if (value == null) {
return 'La date de naissance est requise';
}
final now = DateTime.now();
final age = now.year - value.year;
if (value.isAfter(now)) {
return 'La date de naissance ne peut pas être dans le futur';
}
if (age < minAge) {
return 'L\'âge minimum requis est de $minAge ans';
}
if (age > maxAge) {
return 'L\'âge maximum autorisé est de $maxAge ans';
}
return null;
}
/// Valide un numéro de membre
static String? memberNumber(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Le numéro de membre est requis';
}
// Format: MBR suivi de 3 chiffres minimum
final memberRegex = RegExp(r'^MBR\d{3,}$');
if (!memberRegex.hasMatch(value.trim())) {
return 'Format invalide (ex: MBR001)';
}
return null;
}
/// Valide une adresse
static String? address(String? value, {bool required = false}) {
if (!required && (value == null || value.trim().isEmpty)) {
return null;
}
if (required) {
final requiredError = FormValidator.required(value, fieldName: 'L\'adresse');
if (requiredError != null) return requiredError;
}
final maxLengthError = maxLength(value, 200, fieldName: 'L\'adresse');
if (maxLengthError != null) return maxLengthError;
return null;
}
/// Valide une profession
static String? profession(String? value, {bool required = false}) {
if (!required && (value == null || value.trim().isEmpty)) {
return null;
}
if (required) {
final requiredError = FormValidator.required(value, fieldName: 'La profession');
if (requiredError != null) return requiredError;
}
final maxLengthError = maxLength(value, 100, fieldName: 'La profession');
if (maxLengthError != null) return maxLengthError;
return null;
}
/// Combine plusieurs validateurs
static String? Function(String?) combine(List<String? Function(String?)> validators) {
return (String? value) {
for (final validator in validators) {
final error = validator(value);
if (error != null) return error;
}
return null;
};
}
/// Valide un formulaire complet et retourne les erreurs
static Map<String, String> validateForm(Map<String, dynamic> data, Map<String, String? Function(dynamic)> rules) {
final errors = <String, String>{};
for (final entry in rules.entries) {
final field = entry.key;
final validator = entry.value;
final value = data[field];
final error = validator(value);
if (error != null) {
errors[field] = error;
}
}
return errors;
}
/// Valide les données d'un membre
static Map<String, String> validateMember(Map<String, dynamic> memberData) {
return validateForm(memberData, {
'prenom': (value) => name(value, fieldName: 'Le prénom'),
'nom': (value) => name(value, fieldName: 'Le nom'),
'email': (value) => email(value),
'telephone': (value) => phone(value),
'dateNaissance': (value) => value is DateTime ? birthDate(value, minAge: 16) : 'Date de naissance invalide',
'adresse': (value) => address(value),
'profession': (value) => profession(value),
});
}
}
/// Widget de champ de texte avec validation en temps réel
class ValidatedTextField extends StatefulWidget {
final TextEditingController controller;
final String label;
final String? hintText;
final IconData? prefixIcon;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final List<String? Function(String?)> validators;
final bool obscureText;
final int? maxLines;
final int? maxLength;
final bool enabled;
final VoidCallback? onTap;
final ValueChanged<String>? onChanged;
final bool validateOnChange;
const ValidatedTextField({
super.key,
required this.controller,
required this.label,
this.hintText,
this.prefixIcon,
this.keyboardType,
this.textInputAction,
this.validators = const [],
this.obscureText = false,
this.maxLines = 1,
this.maxLength,
this.enabled = true,
this.onTap,
this.onChanged,
this.validateOnChange = true,
});
@override
State<ValidatedTextField> createState() => _ValidatedTextFieldState();
}
class _ValidatedTextFieldState extends State<ValidatedTextField> {
String? _errorText;
bool _hasBeenTouched = false;
@override
void initState() {
super.initState();
if (widget.validateOnChange) {
widget.controller.addListener(_validateField);
}
}
@override
void dispose() {
if (widget.validateOnChange) {
widget.controller.removeListener(_validateField);
}
super.dispose();
}
void _validateField() {
if (!_hasBeenTouched) return;
final value = widget.controller.text;
String? error;
for (final validator in widget.validators) {
error = validator(value);
if (error != null) break;
}
if (mounted) {
setState(() {
_errorText = error;
});
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: widget.controller,
decoration: InputDecoration(
labelText: widget.label,
hintText: widget.hintText,
prefixIcon: widget.prefixIcon != null ? Icon(widget.prefixIcon) : null,
errorText: _errorText,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.grey),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.blue, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.red),
),
),
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
obscureText: widget.obscureText,
maxLines: widget.maxLines,
maxLength: widget.maxLength,
enabled: widget.enabled,
onTap: widget.onTap,
onChanged: (value) {
if (!_hasBeenTouched) {
setState(() {
_hasBeenTouched = true;
});
}
widget.onChanged?.call(value);
if (widget.validateOnChange) {
_validateField();
}
},
validator: (value) {
for (final validator in widget.validators) {
final error = validator(value);
if (error != null) return error;
}
return null;
},
),
],
);
}
}