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,391 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'evenement_model.g.dart';
/// Modèle de données pour un événement UnionFlow
/// Aligné avec l'entité Evenement du serveur API
@JsonSerializable()
class EvenementModel extends Equatable {
/// ID unique de l'événement
final String? id;
/// Titre de l'événement
final String titre;
/// Description détaillée
final String? description;
/// Date et heure de début
@JsonKey(name: 'dateDebut')
final DateTime dateDebut;
/// Date et heure de fin
@JsonKey(name: 'dateFin')
final DateTime? dateFin;
/// Lieu de l'événement
final String? lieu;
/// Adresse complète
final String? adresse;
/// Type d'événement
@JsonKey(name: 'typeEvenement')
final TypeEvenement typeEvenement;
/// Statut de l'événement
final StatutEvenement statut;
/// Capacité maximale
@JsonKey(name: 'capaciteMax')
final int? capaciteMax;
/// Prix de participation
final double? prix;
/// Inscription requise
@JsonKey(name: 'inscriptionRequise')
final bool inscriptionRequise;
/// Date limite d'inscription
@JsonKey(name: 'dateLimiteInscription')
final DateTime? dateLimiteInscription;
/// Instructions particulières
@JsonKey(name: 'instructionsParticulieres')
final String? instructionsParticulieres;
/// Contact organisateur
@JsonKey(name: 'contactOrganisateur')
final String? contactOrganisateur;
/// Matériel requis
@JsonKey(name: 'materielRequis')
final String? materielRequis;
/// Visible au public
@JsonKey(name: 'visiblePublic')
final bool visiblePublic;
/// Événement actif
final bool actif;
/// Créé par
@JsonKey(name: 'creePar')
final String? creePar;
/// Date de création
@JsonKey(name: 'dateCreation')
final DateTime? dateCreation;
/// Modifié par
@JsonKey(name: 'modifiePar')
final String? modifiePar;
/// Date de modification
@JsonKey(name: 'dateModification')
final DateTime? dateModification;
/// Organisation associée (ID)
@JsonKey(name: 'organisationId')
final String? organisationId;
/// Organisateur (ID)
@JsonKey(name: 'organisateurId')
final String? organisateurId;
const EvenementModel({
this.id,
required this.titre,
this.description,
required this.dateDebut,
this.dateFin,
this.lieu,
this.adresse,
required this.typeEvenement,
required this.statut,
this.capaciteMax,
this.prix,
required this.inscriptionRequise,
this.dateLimiteInscription,
this.instructionsParticulieres,
this.contactOrganisateur,
this.materielRequis,
required this.visiblePublic,
required this.actif,
this.creePar,
this.dateCreation,
this.modifiePar,
this.dateModification,
this.organisationId,
this.organisateurId,
});
/// Factory pour créer depuis JSON
factory EvenementModel.fromJson(Map<String, dynamic> json) =>
_$EvenementModelFromJson(json);
/// Convertir vers JSON
Map<String, dynamic> toJson() => _$EvenementModelToJson(this);
/// Copie avec modifications
EvenementModel copyWith({
String? id,
String? titre,
String? description,
DateTime? dateDebut,
DateTime? dateFin,
String? lieu,
String? adresse,
TypeEvenement? typeEvenement,
StatutEvenement? statut,
int? capaciteMax,
double? prix,
bool? inscriptionRequise,
DateTime? dateLimiteInscription,
String? instructionsParticulieres,
String? contactOrganisateur,
String? materielRequis,
bool? visiblePublic,
bool? actif,
String? creePar,
DateTime? dateCreation,
String? modifiePar,
DateTime? dateModification,
String? organisationId,
String? organisateurId,
}) {
return EvenementModel(
id: id ?? this.id,
titre: titre ?? this.titre,
description: description ?? this.description,
dateDebut: dateDebut ?? this.dateDebut,
dateFin: dateFin ?? this.dateFin,
lieu: lieu ?? this.lieu,
adresse: adresse ?? this.adresse,
typeEvenement: typeEvenement ?? this.typeEvenement,
statut: statut ?? this.statut,
capaciteMax: capaciteMax ?? this.capaciteMax,
prix: prix ?? this.prix,
inscriptionRequise: inscriptionRequise ?? this.inscriptionRequise,
dateLimiteInscription: dateLimiteInscription ?? this.dateLimiteInscription,
instructionsParticulieres: instructionsParticulieres ?? this.instructionsParticulieres,
contactOrganisateur: contactOrganisateur ?? this.contactOrganisateur,
materielRequis: materielRequis ?? this.materielRequis,
visiblePublic: visiblePublic ?? this.visiblePublic,
actif: actif ?? this.actif,
creePar: creePar ?? this.creePar,
dateCreation: dateCreation ?? this.dateCreation,
modifiePar: modifiePar ?? this.modifiePar,
dateModification: dateModification ?? this.dateModification,
organisationId: organisationId ?? this.organisationId,
organisateurId: organisateurId ?? this.organisateurId,
);
}
/// Méthodes utilitaires
/// Vérifie si l'événement est à venir
bool get estAVenir => dateDebut.isAfter(DateTime.now());
/// Vérifie si l'événement est en cours
bool get estEnCours {
final maintenant = DateTime.now();
return dateDebut.isBefore(maintenant) &&
(dateFin?.isAfter(maintenant) ?? false);
}
/// Vérifie si l'événement est terminé
bool get estTermine {
final maintenant = DateTime.now();
return dateFin?.isBefore(maintenant) ?? dateDebut.isBefore(maintenant);
}
/// Vérifie si les inscriptions sont ouvertes
bool get inscriptionsOuvertes {
if (!inscriptionRequise) return false;
if (dateLimiteInscription == null) return estAVenir;
return dateLimiteInscription!.isAfter(DateTime.now()) && estAVenir;
}
/// Durée de l'événement
Duration? get duree {
if (dateFin == null) return null;
return dateFin!.difference(dateDebut);
}
/// Formatage de la durée
String get dureeFormatee {
final d = duree;
if (d == null) return 'Non spécifiée';
if (d.inDays > 0) {
return '${d.inDays} jour${d.inDays > 1 ? 's' : ''}';
} else if (d.inHours > 0) {
return '${d.inHours}h${d.inMinutes.remainder(60) > 0 ? '${d.inMinutes.remainder(60)}' : ''}';
} else {
return '${d.inMinutes} min';
}
}
@override
List<Object?> get props => [
id,
titre,
description,
dateDebut,
dateFin,
lieu,
adresse,
typeEvenement,
statut,
capaciteMax,
prix,
inscriptionRequise,
dateLimiteInscription,
instructionsParticulieres,
contactOrganisateur,
materielRequis,
visiblePublic,
actif,
creePar,
dateCreation,
modifiePar,
dateModification,
organisationId,
organisateurId,
];
}
/// Types d'événements disponibles
@JsonEnum()
enum TypeEvenement {
@JsonValue('ASSEMBLEE_GENERALE')
assembleeGenerale,
@JsonValue('REUNION')
reunion,
@JsonValue('FORMATION')
formation,
@JsonValue('CONFERENCE')
conference,
@JsonValue('ATELIER')
atelier,
@JsonValue('SEMINAIRE')
seminaire,
@JsonValue('EVENEMENT_SOCIAL')
evenementSocial,
@JsonValue('MANIFESTATION')
manifestation,
@JsonValue('CELEBRATION')
celebration,
@JsonValue('AUTRE')
autre,
}
/// Extension pour les libellés des types
extension TypeEvenementExtension on TypeEvenement {
String get libelle {
switch (this) {
case TypeEvenement.assembleeGenerale:
return 'Assemblée Générale';
case TypeEvenement.reunion:
return 'Réunion';
case TypeEvenement.formation:
return 'Formation';
case TypeEvenement.conference:
return 'Conférence';
case TypeEvenement.atelier:
return 'Atelier';
case TypeEvenement.seminaire:
return 'Séminaire';
case TypeEvenement.evenementSocial:
return 'Événement Social';
case TypeEvenement.manifestation:
return 'Manifestation';
case TypeEvenement.celebration:
return 'Célébration';
case TypeEvenement.autre:
return 'Autre';
}
}
String get icone {
switch (this) {
case TypeEvenement.assembleeGenerale:
return '🏛️';
case TypeEvenement.reunion:
return '👥';
case TypeEvenement.formation:
return '📚';
case TypeEvenement.conference:
return '🎤';
case TypeEvenement.atelier:
return '🔧';
case TypeEvenement.seminaire:
return '🎓';
case TypeEvenement.evenementSocial:
return '🎉';
case TypeEvenement.manifestation:
return '📢';
case TypeEvenement.celebration:
return '🎊';
case TypeEvenement.autre:
return '📅';
}
}
}
/// Statuts d'événements disponibles
@JsonEnum()
enum StatutEvenement {
@JsonValue('PLANIFIE')
planifie,
@JsonValue('CONFIRME')
confirme,
@JsonValue('EN_COURS')
enCours,
@JsonValue('TERMINE')
termine,
@JsonValue('ANNULE')
annule,
@JsonValue('REPORTE')
reporte,
}
/// Extension pour les libellés des statuts
extension StatutEvenementExtension on StatutEvenement {
String get libelle {
switch (this) {
case StatutEvenement.planifie:
return 'Planifié';
case StatutEvenement.confirme:
return 'Confirmé';
case StatutEvenement.enCours:
return 'En cours';
case StatutEvenement.termine:
return 'Terminé';
case StatutEvenement.annule:
return 'Annulé';
case StatutEvenement.reporte:
return 'Reporté';
}
}
String get couleur {
switch (this) {
case StatutEvenement.planifie:
return '#FFA500'; // Orange
case StatutEvenement.confirme:
return '#4CAF50'; // Vert
case StatutEvenement.enCours:
return '#2196F3'; // Bleu
case StatutEvenement.termine:
return '#9E9E9E'; // Gris
case StatutEvenement.annule:
return '#F44336'; // Rouge
case StatutEvenement.reporte:
return '#FF9800'; // Orange foncé
}
}
}

View File

@@ -0,0 +1,94 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'evenement_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
EvenementModel _$EvenementModelFromJson(Map<String, dynamic> json) =>
EvenementModel(
id: json['id'] as String?,
titre: json['titre'] as String,
description: json['description'] as String?,
dateDebut: DateTime.parse(json['dateDebut'] as String),
dateFin: json['dateFin'] == null
? null
: DateTime.parse(json['dateFin'] as String),
lieu: json['lieu'] as String?,
adresse: json['adresse'] as String?,
typeEvenement: $enumDecode(_$TypeEvenementEnumMap, json['typeEvenement']),
statut: $enumDecode(_$StatutEvenementEnumMap, json['statut']),
capaciteMax: (json['capaciteMax'] as num?)?.toInt(),
prix: (json['prix'] as num?)?.toDouble(),
inscriptionRequise: json['inscriptionRequise'] as bool,
dateLimiteInscription: json['dateLimiteInscription'] == null
? null
: DateTime.parse(json['dateLimiteInscription'] as String),
instructionsParticulieres: json['instructionsParticulieres'] as String?,
contactOrganisateur: json['contactOrganisateur'] as String?,
materielRequis: json['materielRequis'] as String?,
visiblePublic: json['visiblePublic'] as bool,
actif: json['actif'] as bool,
creePar: json['creePar'] as String?,
dateCreation: json['dateCreation'] == null
? null
: DateTime.parse(json['dateCreation'] as String),
modifiePar: json['modifiePar'] as String?,
dateModification: json['dateModification'] == null
? null
: DateTime.parse(json['dateModification'] as String),
organisationId: json['organisationId'] as String?,
organisateurId: json['organisateurId'] as String?,
);
Map<String, dynamic> _$EvenementModelToJson(EvenementModel instance) =>
<String, dynamic>{
'id': instance.id,
'titre': instance.titre,
'description': instance.description,
'dateDebut': instance.dateDebut.toIso8601String(),
'dateFin': instance.dateFin?.toIso8601String(),
'lieu': instance.lieu,
'adresse': instance.adresse,
'typeEvenement': _$TypeEvenementEnumMap[instance.typeEvenement]!,
'statut': _$StatutEvenementEnumMap[instance.statut]!,
'capaciteMax': instance.capaciteMax,
'prix': instance.prix,
'inscriptionRequise': instance.inscriptionRequise,
'dateLimiteInscription':
instance.dateLimiteInscription?.toIso8601String(),
'instructionsParticulieres': instance.instructionsParticulieres,
'contactOrganisateur': instance.contactOrganisateur,
'materielRequis': instance.materielRequis,
'visiblePublic': instance.visiblePublic,
'actif': instance.actif,
'creePar': instance.creePar,
'dateCreation': instance.dateCreation?.toIso8601String(),
'modifiePar': instance.modifiePar,
'dateModification': instance.dateModification?.toIso8601String(),
'organisationId': instance.organisationId,
'organisateurId': instance.organisateurId,
};
const _$TypeEvenementEnumMap = {
TypeEvenement.assembleeGenerale: 'ASSEMBLEE_GENERALE',
TypeEvenement.reunion: 'REUNION',
TypeEvenement.formation: 'FORMATION',
TypeEvenement.conference: 'CONFERENCE',
TypeEvenement.atelier: 'ATELIER',
TypeEvenement.seminaire: 'SEMINAIRE',
TypeEvenement.evenementSocial: 'EVENEMENT_SOCIAL',
TypeEvenement.manifestation: 'MANIFESTATION',
TypeEvenement.celebration: 'CELEBRATION',
TypeEvenement.autre: 'AUTRE',
};
const _$StatutEvenementEnumMap = {
StatutEvenement.planifie: 'PLANIFIE',
StatutEvenement.confirme: 'CONFIRME',
StatutEvenement.enCours: 'EN_COURS',
StatutEvenement.termine: 'TERMINE',
StatutEvenement.annule: 'ANNULE',
StatutEvenement.reporte: 'REPORTE',
};

View File

@@ -115,6 +115,32 @@ class MembreModel extends Equatable {
return parts.join(', ');
}
/// Libellé du statut formaté
String get statutLibelle {
switch (statut.toUpperCase()) {
case 'ACTIF':
return 'Actif';
case 'INACTIF':
return 'Inactif';
case 'SUSPENDU':
return 'Suspendu';
default:
return statut;
}
}
/// Âge calculé à partir de la date de naissance
int get age {
if (dateNaissance == null) return 0;
final now = DateTime.now();
int age = now.year - dateNaissance!.year;
if (now.month < dateNaissance!.month ||
(now.month == dateNaissance!.month && now.day < dateNaissance!.day)) {
age--;
}
return age;
}
/// Copie avec modifications
MembreModel copyWith({
String? id,