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,446 @@
import 'package:flutter/material.dart';
import '../../shared/theme/app_theme.dart';
/// Animations de chargement personnalisées
class LoadingAnimations {
/// Indicateur de chargement avec points animés
static Widget dots({
Color color = AppTheme.primaryColor,
double size = 8.0,
Duration duration = const Duration(milliseconds: 1200),
}) {
return _DotsLoadingAnimation(
color: color,
size: size,
duration: duration,
);
}
/// Indicateur de chargement avec vagues
static Widget waves({
Color color = AppTheme.primaryColor,
double size = 40.0,
Duration duration = const Duration(milliseconds: 1000),
}) {
return _WavesLoadingAnimation(
color: color,
size: size,
duration: duration,
);
}
/// Indicateur de chargement avec rotation
static Widget spinner({
Color color = AppTheme.primaryColor,
double size = 40.0,
double strokeWidth = 4.0,
Duration duration = const Duration(milliseconds: 1000),
}) {
return _SpinnerLoadingAnimation(
color: color,
size: size,
strokeWidth: strokeWidth,
duration: duration,
);
}
/// Indicateur de chargement avec pulsation
static Widget pulse({
Color color = AppTheme.primaryColor,
double size = 40.0,
Duration duration = const Duration(milliseconds: 1000),
}) {
return _PulseLoadingAnimation(
color: color,
size: size,
duration: duration,
);
}
/// Skeleton loader pour les cartes
static Widget skeleton({
double height = 100.0,
double width = double.infinity,
BorderRadius? borderRadius,
Duration duration = const Duration(milliseconds: 1500),
}) {
return _SkeletonLoadingAnimation(
height: height,
width: width,
borderRadius: borderRadius ?? BorderRadius.circular(8),
duration: duration,
);
}
}
/// Animation de points qui rebondissent
class _DotsLoadingAnimation extends StatefulWidget {
final Color color;
final double size;
final Duration duration;
const _DotsLoadingAnimation({
required this.color,
required this.size,
required this.duration,
});
@override
State<_DotsLoadingAnimation> createState() => _DotsLoadingAnimationState();
}
class _DotsLoadingAnimationState extends State<_DotsLoadingAnimation>
with TickerProviderStateMixin {
late List<AnimationController> _controllers;
late List<Animation<double>> _animations;
@override
void initState() {
super.initState();
_controllers = List.generate(3, (index) {
return AnimationController(
duration: widget.duration,
vsync: this,
);
});
_animations = _controllers.map((controller) {
return Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: controller, curve: Curves.easeInOut),
);
}).toList();
_startAnimations();
}
void _startAnimations() {
for (int i = 0; i < _controllers.length; i++) {
Future.delayed(Duration(milliseconds: i * 200), () {
if (mounted) {
_controllers[i].repeat(reverse: true);
}
});
}
}
@override
void dispose() {
for (final controller in _controllers) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (index) {
return AnimatedBuilder(
animation: _animations[index],
builder: (context, child) {
return Container(
margin: EdgeInsets.symmetric(horizontal: widget.size * 0.2),
child: Transform.translate(
offset: Offset(0, -widget.size * _animations[index].value),
child: Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: widget.color,
shape: BoxShape.circle,
),
),
),
);
},
);
}),
);
}
}
/// Animation de vagues
class _WavesLoadingAnimation extends StatefulWidget {
final Color color;
final double size;
final Duration duration;
const _WavesLoadingAnimation({
required this.color,
required this.size,
required this.duration,
});
@override
State<_WavesLoadingAnimation> createState() => _WavesLoadingAnimationState();
}
class _WavesLoadingAnimationState extends State<_WavesLoadingAnimation>
with TickerProviderStateMixin {
late List<AnimationController> _controllers;
late List<Animation<double>> _animations;
@override
void initState() {
super.initState();
_controllers = List.generate(4, (index) {
return AnimationController(
duration: widget.duration,
vsync: this,
);
});
_animations = _controllers.map((controller) {
return Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: controller, curve: Curves.easeInOut),
);
}).toList();
_startAnimations();
}
void _startAnimations() {
for (int i = 0; i < _controllers.length; i++) {
Future.delayed(Duration(milliseconds: i * 150), () {
if (mounted) {
_controllers[i].repeat();
}
});
}
}
@override
void dispose() {
for (final controller in _controllers) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.size,
height: widget.size,
child: Stack(
alignment: Alignment.center,
children: List.generate(4, (index) {
return AnimatedBuilder(
animation: _animations[index],
builder: (context, child) {
return Container(
width: widget.size * _animations[index].value,
height: widget.size * _animations[index].value,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: widget.color.withOpacity(1 - _animations[index].value),
width: 2,
),
),
);
},
);
}),
),
);
}
}
/// Animation de spinner personnalisé
class _SpinnerLoadingAnimation extends StatefulWidget {
final Color color;
final double size;
final double strokeWidth;
final Duration duration;
const _SpinnerLoadingAnimation({
required this.color,
required this.size,
required this.strokeWidth,
required this.duration,
});
@override
State<_SpinnerLoadingAnimation> createState() => _SpinnerLoadingAnimationState();
}
class _SpinnerLoadingAnimationState extends State<_SpinnerLoadingAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * 3.14159,
child: SizedBox(
width: widget.size,
height: widget.size,
child: CircularProgressIndicator(
strokeWidth: widget.strokeWidth,
valueColor: AlwaysStoppedAnimation<Color>(widget.color),
backgroundColor: widget.color.withOpacity(0.2),
),
),
);
},
);
}
}
/// Animation de pulsation
class _PulseLoadingAnimation extends StatefulWidget {
final Color color;
final double size;
final Duration duration;
const _PulseLoadingAnimation({
required this.color,
required this.size,
required this.duration,
});
@override
State<_PulseLoadingAnimation> createState() => _PulseLoadingAnimationState();
}
class _PulseLoadingAnimationState extends State<_PulseLoadingAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
);
_animation = Tween<double>(begin: 0.8, end: 1.2).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_controller.repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.scale(
scale: _animation.value,
child: Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: widget.color,
shape: BoxShape.circle,
),
),
);
},
);
}
}
/// Animation skeleton pour le chargement de contenu
class _SkeletonLoadingAnimation extends StatefulWidget {
final double height;
final double width;
final BorderRadius borderRadius;
final Duration duration;
const _SkeletonLoadingAnimation({
required this.height,
required this.width,
required this.borderRadius,
required this.duration,
});
@override
State<_SkeletonLoadingAnimation> createState() => _SkeletonLoadingAnimationState();
}
class _SkeletonLoadingAnimationState extends State<_SkeletonLoadingAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
);
_animation = Tween<double>(begin: -1.0, end: 2.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_controller.repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
borderRadius: widget.borderRadius,
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
stops: [
(_animation.value - 0.3).clamp(0.0, 1.0),
_animation.value.clamp(0.0, 1.0),
(_animation.value + 0.3).clamp(0.0, 1.0),
],
colors: const [
Color(0xFFE0E0E0),
Color(0xFFF5F5F5),
Color(0xFFE0E0E0),
],
),
),
);
},
);
}
}

View File

@@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
/// Transitions de pages personnalisées pour une meilleure UX
class PageTransitions {
/// Transition de glissement depuis la droite (par défaut iOS)
static PageRouteBuilder<T> slideFromRight<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const begin = Offset(1.0, 0.0);
const end = Offset.zero;
const curve = Curves.easeInOut;
var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
return SlideTransition(
position: animation.drive(tween),
child: child,
);
},
);
}
/// Transition de glissement depuis le bas
static PageRouteBuilder<T> slideFromBottom<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 350),
reverseTransitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const begin = Offset(0.0, 1.0);
const end = Offset.zero;
const curve = Curves.easeOutCubic;
var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
return SlideTransition(
position: animation.drive(tween),
child: child,
);
},
);
}
/// Transition de fondu
static PageRouteBuilder<T> fadeIn<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 400),
reverseTransitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
);
}
/// Transition d'échelle avec fondu
static PageRouteBuilder<T> scaleWithFade<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 400),
reverseTransitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.easeInOutCubic;
var scaleTween = Tween(begin: 0.8, end: 1.0).chain(
CurveTween(curve: curve),
);
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve),
);
return ScaleTransition(
scale: animation.drive(scaleTween),
child: FadeTransition(
opacity: animation.drive(fadeTween),
child: child,
),
);
},
);
}
/// Transition de rotation avec échelle
static PageRouteBuilder<T> rotateScale<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 500),
reverseTransitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.elasticOut;
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve),
);
var rotationTween = Tween(begin: 0.5, end: 1.0).chain(
CurveTween(curve: Curves.easeInOut),
);
return ScaleTransition(
scale: animation.drive(scaleTween),
child: RotationTransition(
turns: animation.drive(rotationTween),
child: child,
),
);
},
);
}
/// Transition personnalisée avec effet de rebond
static PageRouteBuilder<T> bounceIn<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 600),
reverseTransitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.bounceOut;
var scaleTween = Tween(begin: 0.3, end: 1.0).chain(
CurveTween(curve: curve),
);
return ScaleTransition(
scale: animation.drive(scaleTween),
child: child,
);
},
);
}
/// Transition de glissement avec parallaxe
static PageRouteBuilder<T> slideWithParallax<T>(Widget page) {
return PageRouteBuilder<T>(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 350),
reverseTransitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const primaryBegin = Offset(1.0, 0.0);
const primaryEnd = Offset.zero;
const secondaryBegin = Offset.zero;
const secondaryEnd = Offset(-0.3, 0.0);
const curve = Curves.easeInOut;
var primaryTween = Tween(begin: primaryBegin, end: primaryEnd).chain(
CurveTween(curve: curve),
);
var secondaryTween = Tween(begin: secondaryBegin, end: secondaryEnd).chain(
CurveTween(curve: curve),
);
return Stack(
children: [
SlideTransition(
position: secondaryAnimation.drive(secondaryTween),
child: Container(), // Page précédente
),
SlideTransition(
position: animation.drive(primaryTween),
child: child,
),
],
);
},
);
}
}
/// Extensions pour faciliter l'utilisation des transitions
extension NavigatorTransitions on NavigatorState {
/// Navigation avec transition de glissement depuis la droite
Future<T?> pushSlideFromRight<T>(Widget page) {
return push<T>(PageTransitions.slideFromRight<T>(page));
}
/// Navigation avec transition de glissement depuis le bas
Future<T?> pushSlideFromBottom<T>(Widget page) {
return push<T>(PageTransitions.slideFromBottom<T>(page));
}
/// Navigation avec transition de fondu
Future<T?> pushFadeIn<T>(Widget page) {
return push<T>(PageTransitions.fadeIn<T>(page));
}
/// Navigation avec transition d'échelle et fondu
Future<T?> pushScaleWithFade<T>(Widget page) {
return push<T>(PageTransitions.scaleWithFade<T>(page));
}
/// Navigation avec transition de rebond
Future<T?> pushBounceIn<T>(Widget page) {
return push<T>(PageTransitions.bounceIn<T>(page));
}
/// Navigation avec transition de parallaxe
Future<T?> pushSlideWithParallax<T>(Widget page) {
return push<T>(PageTransitions.slideWithParallax<T>(page));
}
}
/// Widget d'animation pour les éléments de liste
class AnimatedListItem extends StatefulWidget {
final Widget child;
final int index;
final Duration delay;
final Duration duration;
final Curve curve;
final Offset slideOffset;
const AnimatedListItem({
super.key,
required this.child,
required this.index,
this.delay = const Duration(milliseconds: 100),
this.duration = const Duration(milliseconds: 500),
this.curve = Curves.easeOutCubic,
this.slideOffset = const Offset(0, 50),
});
@override
State<AnimatedListItem> createState() => _AnimatedListItemState();
}
class _AnimatedListItemState extends State<AnimatedListItem>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: widget.curve,
));
_slideAnimation = Tween<Offset>(
begin: widget.slideOffset,
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: widget.curve,
));
// Démarrer l'animation avec un délai basé sur l'index
Future.delayed(
Duration(milliseconds: widget.delay.inMilliseconds * widget.index),
() {
if (mounted) {
_controller.forward();
}
},
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.translate(
offset: _slideAnimation.value,
child: Opacity(
opacity: _fadeAnimation.value,
child: widget.child,
),
);
},
);
}
}