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:
353
unionflow-mobile-apps/lib/core/validation/form_validator.dart
Normal file
353
unionflow-mobile-apps/lib/core/validation/form_validator.dart
Normal 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;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user