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,
),
);
},
);
}
}

View File

@@ -78,6 +78,9 @@ class AuthState extends Equatable {
/// Vérifie si l'utilisateur est connecté
bool get isAuthenticated => status == AuthStatus.authenticated;
/// Vérifie si l'authentification est en cours de vérification
bool get isChecking => status == AuthStatus.checking;
/// Vérifie si la session est valide
bool get isSessionValid {
if (!isAuthenticated || expiresAt == null) return false;

View File

@@ -7,6 +7,7 @@ class UserInfo extends Equatable {
final String firstName;
final String lastName;
final String role;
final List<String>? roles;
final String? profilePicture;
final bool isActive;
@@ -16,6 +17,7 @@ class UserInfo extends Equatable {
required this.firstName,
required this.lastName,
required this.role,
this.roles,
this.profilePicture,
required this.isActive,
});
@@ -35,6 +37,7 @@ class UserInfo extends Equatable {
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
role: json['role'] ?? 'membre',
roles: json['roles'] != null ? List<String>.from(json['roles']) : null,
profilePicture: json['profilePicture'],
isActive: json['isActive'] ?? true,
);
@@ -47,6 +50,7 @@ class UserInfo extends Equatable {
'firstName': firstName,
'lastName': lastName,
'role': role,
'roles': roles,
'profilePicture': profilePicture,
'isActive': isActive,
};
@@ -58,6 +62,7 @@ class UserInfo extends Equatable {
String? firstName,
String? lastName,
String? role,
List<String>? roles,
String? profilePicture,
bool? isActive,
}) {
@@ -67,6 +72,7 @@ class UserInfo extends Equatable {
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
role: role ?? this.role,
roles: roles ?? this.roles,
profilePicture: profilePicture ?? this.profilePicture,
isActive: isActive ?? this.isActive,
);
@@ -79,6 +85,7 @@ class UserInfo extends Equatable {
firstName,
lastName,
role,
roles,
profilePicture,
isActive,
];

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../../../features/auth/presentation/pages/keycloak_login_page.dart';
import '../../../features/navigation/presentation/pages/main_navigation.dart';
import '../services/keycloak_webview_auth_service.dart';
import '../models/auth_state.dart';
import '../../di/injection.dart';
/// Wrapper qui gère l'authentification et le routage
class AuthWrapper extends StatefulWidget {
const AuthWrapper({super.key});
@override
State<AuthWrapper> createState() => _AuthWrapperState();
}
class _AuthWrapperState extends State<AuthWrapper> {
late KeycloakWebViewAuthService _authService;
@override
void initState() {
super.initState();
_authService = getIt<KeycloakWebViewAuthService>();
}
@override
Widget build(BuildContext context) {
return StreamBuilder<AuthState>(
stream: _authService.authStateStream,
initialData: _authService.currentState,
builder: (context, snapshot) {
final authState = snapshot.data ?? const AuthState.unknown();
// Affichage de l'écran de chargement pendant la vérification
if (authState.isChecking) {
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Vérification de l\'authentification...'),
],
),
),
);
}
// Si l'utilisateur est authentifié, afficher l'application principale
if (authState.isAuthenticated) {
return const MainNavigation();
}
// Sinon, afficher la page de connexion
return const KeycloakLoginPage();
},
);
}
}

View File

@@ -0,0 +1,373 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:injectable/injectable.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../models/auth_state.dart';
import '../models/user_info.dart';
import 'package:dio/dio.dart';
@singleton
class KeycloakWebViewAuthService {
static const String _keycloakBaseUrl = 'http://192.168.1.11:8180';
static const String _realm = 'unionflow';
static const String _clientId = 'unionflow-mobile';
static const String _redirectUrl = 'http://192.168.1.11:8080/auth/callback';
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
final Dio _dio = Dio();
// Stream pour l'état d'authentification
final _authStateController = StreamController<AuthState>.broadcast();
Stream<AuthState> get authStateStream => _authStateController.stream;
AuthState _currentState = const AuthState.unauthenticated();
AuthState get currentState => _currentState;
KeycloakWebViewAuthService() {
_initializeAuthState();
}
Future<void> _initializeAuthState() async {
print('🔄 Initialisation du service d\'authentification WebView...');
try {
final accessToken = await _secureStorage.read(key: 'access_token');
if (accessToken != null && !JwtDecoder.isExpired(accessToken)) {
final userInfo = await _getUserInfoFromToken(accessToken);
final refreshToken = await _secureStorage.read(key: 'refresh_token');
if (userInfo != null && refreshToken != null) {
final expiresAt = DateTime.fromMillisecondsSinceEpoch(
JwtDecoder.decode(accessToken)['exp'] * 1000
);
_updateAuthState(AuthState.authenticated(
user: userInfo,
accessToken: accessToken,
refreshToken: refreshToken,
expiresAt: expiresAt,
));
return;
}
}
// Tentative de refresh si le token d'accès est expiré
final refreshToken = await _secureStorage.read(key: 'refresh_token');
if (refreshToken != null && !JwtDecoder.isExpired(refreshToken)) {
final success = await _refreshTokens();
if (success) return;
}
// Aucun token valide trouvé
await _clearTokens();
_updateAuthState(const AuthState.unauthenticated());
} catch (e) {
print('❌ Erreur lors de l\'initialisation: $e');
await _clearTokens();
_updateAuthState(const AuthState.unauthenticated());
}
}
Future<void> loginWithWebView(BuildContext context) async {
print('🔐 Début de la connexion Keycloak WebView...');
try {
_updateAuthState(const AuthState.checking());
// Génération des paramètres PKCE
final codeVerifier = _generateCodeVerifier();
final codeChallenge = _generateCodeChallenge(codeVerifier);
final state = _generateRandomString(32);
// Construction de l'URL d'autorisation
final authUrl = _buildAuthorizationUrl(codeChallenge, state);
print('🌐 URL d\'autorisation: $authUrl');
// Ouverture de la WebView
final result = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (context) => KeycloakWebViewPage(
authUrl: authUrl,
redirectUrl: _redirectUrl,
),
),
);
if (result != null) {
// Traitement du code d'autorisation
await _handleAuthorizationCode(result, codeVerifier, state);
} else {
print('❌ Authentification annulée par l\'utilisateur');
_updateAuthState(const AuthState.unauthenticated());
}
} catch (e) {
print('❌ Erreur lors de la connexion: $e');
_updateAuthState(const AuthState.unauthenticated());
rethrow;
}
}
String _buildAuthorizationUrl(String codeChallenge, String state) {
final params = {
'client_id': _clientId,
'redirect_uri': _redirectUrl,
'response_type': 'code',
'scope': 'openid profile email',
'code_challenge': codeChallenge,
'code_challenge_method': 'S256',
'state': state,
};
final queryString = params.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
return '$_keycloakBaseUrl/realms/$_realm/protocol/openid-connect/auth?$queryString';
}
Future<void> _handleAuthorizationCode(String authCode, String codeVerifier, String expectedState) async {
print('🔄 Traitement du code d\'autorisation...');
try {
// Échange du code contre des tokens
final response = await _dio.post(
'$_keycloakBaseUrl/realms/$_realm/protocol/openid-connect/token',
data: {
'grant_type': 'authorization_code',
'client_id': _clientId,
'code': authCode,
'redirect_uri': _redirectUrl,
'code_verifier': codeVerifier,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
),
);
if (response.statusCode == 200) {
final tokens = response.data;
await _storeTokens(tokens);
final userInfo = await _getUserInfoFromToken(tokens['access_token']);
if (userInfo != null) {
final expiresAt = DateTime.fromMillisecondsSinceEpoch(
JwtDecoder.decode(tokens['access_token'])['exp'] * 1000
);
_updateAuthState(AuthState.authenticated(
user: userInfo,
accessToken: tokens['access_token'],
refreshToken: tokens['refresh_token'],
expiresAt: expiresAt,
));
print('✅ Authentification réussie pour: ${userInfo.email}');
}
}
} catch (e) {
print('❌ Erreur lors de l\'échange de tokens: $e');
_updateAuthState(const AuthState.unauthenticated());
rethrow;
}
}
// Méthodes utilitaires PKCE
String _generateCodeVerifier() {
final random = Random.secure();
final bytes = List<int>.generate(32, (i) => random.nextInt(256));
return base64Url.encode(bytes).replaceAll('=', '');
}
String _generateCodeChallenge(String codeVerifier) {
final bytes = utf8.encode(codeVerifier);
final digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
String _generateRandomString(int length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
final random = Random.secure();
return List.generate(length, (index) => chars[random.nextInt(chars.length)]).join();
}
Future<UserInfo?> _getUserInfoFromToken(String accessToken) async {
try {
final decodedToken = JwtDecoder.decode(accessToken);
final roles = List<String>.from(decodedToken['realm_access']?['roles'] ?? []);
final primaryRole = roles.isNotEmpty ? roles.first : 'membre';
return UserInfo(
id: decodedToken['sub'] ?? '',
email: decodedToken['email'] ?? '',
firstName: decodedToken['given_name'] ?? '',
lastName: decodedToken['family_name'] ?? '',
role: primaryRole,
roles: roles,
isActive: true,
);
} catch (e) {
print('❌ Erreur lors de l\'extraction des infos utilisateur: $e');
return null;
}
}
Future<void> _storeTokens(Map<String, dynamic> tokens) async {
await _secureStorage.write(key: 'access_token', value: tokens['access_token']);
await _secureStorage.write(key: 'refresh_token', value: tokens['refresh_token']);
if (tokens['id_token'] != null) {
await _secureStorage.write(key: 'id_token', value: tokens['id_token']);
}
}
Future<bool> _refreshTokens() async {
try {
final refreshToken = await _secureStorage.read(key: 'refresh_token');
if (refreshToken == null) return false;
final response = await _dio.post(
'$_keycloakBaseUrl/realms/$_realm/protocol/openid-connect/token',
data: {
'grant_type': 'refresh_token',
'client_id': _clientId,
'refresh_token': refreshToken,
},
options: Options(contentType: Headers.formUrlEncodedContentType),
);
if (response.statusCode == 200) {
await _storeTokens(response.data);
final userInfo = await _getUserInfoFromToken(response.data['access_token']);
if (userInfo != null) {
final expiresAt = DateTime.fromMillisecondsSinceEpoch(
JwtDecoder.decode(response.data['access_token'])['exp'] * 1000
);
_updateAuthState(AuthState.authenticated(
user: userInfo,
accessToken: response.data['access_token'],
refreshToken: response.data['refresh_token'],
expiresAt: expiresAt,
));
return true;
}
}
} catch (e) {
print('❌ Erreur lors du refresh: $e');
}
return false;
}
Future<void> logout() async {
print('🚪 Déconnexion...');
await _clearTokens();
_updateAuthState(const AuthState.unauthenticated());
}
Future<void> _clearTokens() async {
await _secureStorage.delete(key: 'access_token');
await _secureStorage.delete(key: 'refresh_token');
await _secureStorage.delete(key: 'id_token');
}
void _updateAuthState(AuthState newState) {
_currentState = newState;
_authStateController.add(newState);
}
void dispose() {
_authStateController.close();
}
}
// Page WebView pour l'authentification
class KeycloakWebViewPage extends StatefulWidget {
final String authUrl;
final String redirectUrl;
const KeycloakWebViewPage({
Key? key,
required this.authUrl,
required this.redirectUrl,
}) : super(key: key);
@override
State<KeycloakWebViewPage> createState() => _KeycloakWebViewPageState();
}
class _KeycloakWebViewPageState extends State<KeycloakWebViewPage> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
_initializeWebView();
}
void _initializeWebView() {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setUserAgent('Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36')
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (NavigationRequest request) {
print('🌐 Navigation vers: ${request.url}');
if (request.url.startsWith(widget.redirectUrl)) {
// Extraction du code d'autorisation
final uri = Uri.parse(request.url);
final code = uri.queryParameters['code'];
if (code != null) {
print('✅ Code d\'autorisation reçu: $code');
Navigator.of(context).pop(code);
} else {
print('❌ Aucun code d\'autorisation trouvé');
Navigator.of(context).pop();
}
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
onWebResourceError: (WebResourceError error) {
print('❌ Erreur WebView: ${error.description}');
print('❌ Code d\'erreur: ${error.errorCode}');
print('❌ URL qui a échoué: ${error.url}');
},
),
);
// Chargement avec gestion d'erreur
_loadUrlWithRetry();
}
Future<void> _loadUrlWithRetry() async {
try {
await _controller.loadRequest(Uri.parse(widget.authUrl));
} catch (e) {
print('❌ Erreur lors du chargement: $e');
// Retry avec une approche différente si nécessaire
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Connexion Keycloak'),
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
),
body: WebViewWidget(controller: _controller),
);
}
}

View File

@@ -0,0 +1,314 @@
import 'package:flutter/foundation.dart';
import '../models/user_info.dart';
import 'auth_service.dart';
/// Service de gestion des permissions et rôles utilisateurs
/// Basé sur le système de rôles du serveur UnionFlow
class PermissionService {
static final PermissionService _instance = PermissionService._internal();
factory PermissionService() => _instance;
PermissionService._internal();
// Pour l'instant, on simule un utilisateur admin pour les tests
// TODO: Intégrer avec le vrai AuthService une fois l'authentification implémentée
AuthService? _authService;
// Simulation d'un utilisateur admin pour les tests
final UserInfo _mockUser = const UserInfo(
id: 'admin-001',
email: 'admin@unionflow.ci',
firstName: 'Administrateur',
lastName: 'Test',
role: 'ADMIN',
isActive: true,
);
/// Rôles système disponibles
static const String roleAdmin = 'ADMIN';
static const String roleSuperAdmin = 'SUPER_ADMIN';
static const String roleGestionnaireMembre = 'GESTIONNAIRE_MEMBRE';
static const String roleTresorier = 'TRESORIER';
static const String roleGestionnaireEvenement = 'GESTIONNAIRE_EVENEMENT';
static const String roleGestionnaireAide = 'GESTIONNAIRE_AIDE';
static const String roleGestionnaireFinance = 'GESTIONNAIRE_FINANCE';
static const String roleMembre = 'MEMBER';
static const String rolePresident = 'PRESIDENT';
/// Obtient l'utilisateur actuellement connecté
UserInfo? get currentUser => _authService?.currentUser ?? _mockUser;
/// Vérifie si l'utilisateur est authentifié
bool get isAuthenticated => _authService?.isAuthenticated ?? true;
/// Obtient le rôle de l'utilisateur actuel
String? get currentUserRole => currentUser?.role.toUpperCase();
/// Vérifie si l'utilisateur a un rôle spécifique
bool hasRole(String role) {
if (!isAuthenticated || currentUserRole == null) {
return false;
}
return currentUserRole == role.toUpperCase();
}
/// Vérifie si l'utilisateur a un des rôles spécifiés
bool hasAnyRole(List<String> roles) {
if (!isAuthenticated || currentUserRole == null) {
return false;
}
return roles.any((role) => currentUserRole == role.toUpperCase());
}
/// Vérifie si l'utilisateur est un administrateur
bool get isAdmin => hasRole(roleAdmin);
/// Vérifie si l'utilisateur est un super administrateur
bool get isSuperAdmin => hasRole(roleSuperAdmin);
/// Vérifie si l'utilisateur est un membre simple
bool get isMember => hasRole(roleMembre);
/// Vérifie si l'utilisateur est un gestionnaire
bool get isGestionnaire => hasAnyRole([
roleGestionnaireMembre,
roleGestionnaireEvenement,
roleGestionnaireAide,
roleGestionnaireFinance,
]);
/// Vérifie si l'utilisateur est un trésorier
bool get isTresorier => hasRole(roleTresorier);
/// Vérifie si l'utilisateur est un président
bool get isPresident => hasRole(rolePresident);
// ========== PERMISSIONS SPÉCIFIQUES AUX MEMBRES ==========
/// Peut gérer les membres (créer, modifier, supprimer)
bool get canManageMembers {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleGestionnaireMembre, rolePresident]);
}
/// Peut créer de nouveaux membres
bool get canCreateMembers {
return canManageMembers;
}
/// Peut modifier les informations des membres
bool get canEditMembers {
return canManageMembers;
}
/// Peut supprimer/désactiver des membres
bool get canDeleteMembers {
return hasAnyRole([roleAdmin, roleSuperAdmin, rolePresident]);
}
/// Peut voir les détails complets des membres
bool get canViewMemberDetails {
return hasAnyRole([
roleAdmin,
roleSuperAdmin,
roleGestionnaireMembre,
roleTresorier,
rolePresident,
]);
}
/// Peut voir les informations de contact des membres
bool get canViewMemberContacts {
return canViewMemberDetails;
}
/// Peut exporter les données des membres
bool get canExportMembers {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleGestionnaireMembre]);
}
/// Peut importer des données de membres
bool get canImportMembers {
return hasAnyRole([roleAdmin, roleSuperAdmin]);
}
/// Peut appeler les membres
bool get canCallMembers {
return canViewMemberContacts;
}
/// Peut envoyer des messages aux membres
bool get canMessageMembers {
return canViewMemberContacts;
}
/// Peut voir les statistiques des membres
bool get canViewMemberStats {
return hasAnyRole([
roleAdmin,
roleSuperAdmin,
roleGestionnaireMembre,
roleTresorier,
rolePresident,
]);
}
/// Peut valider les nouveaux membres
bool get canValidateMembers {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleGestionnaireMembre]);
}
// ========== PERMISSIONS GÉNÉRALES ==========
/// Peut gérer les finances
bool get canManageFinances {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleTresorier, roleGestionnaireFinance]);
}
/// Peut gérer les événements
bool get canManageEvents {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleGestionnaireEvenement]);
}
/// Peut gérer les aides
bool get canManageAides {
return hasAnyRole([roleAdmin, roleSuperAdmin, roleGestionnaireAide]);
}
/// Peut voir les rapports
bool get canViewReports {
return hasAnyRole([
roleAdmin,
roleSuperAdmin,
roleGestionnaireMembre,
roleTresorier,
rolePresident,
]);
}
/// Peut gérer l'organisation
bool get canManageOrganization {
return hasAnyRole([roleAdmin, roleSuperAdmin]);
}
// ========== MÉTHODES UTILITAIRES ==========
/// Obtient le nom d'affichage du rôle
String getRoleDisplayName(String? role) {
if (role == null) return 'Invité';
switch (role.toUpperCase()) {
case roleAdmin:
return 'Administrateur';
case roleSuperAdmin:
return 'Super Administrateur';
case roleGestionnaireMembre:
return 'Gestionnaire Membres';
case roleTresorier:
return 'Trésorier';
case roleGestionnaireEvenement:
return 'Gestionnaire Événements';
case roleGestionnaireAide:
return 'Gestionnaire Aides';
case roleGestionnaireFinance:
return 'Gestionnaire Finances';
case rolePresident:
return 'Président';
case roleMembre:
return 'Membre';
default:
return role;
}
}
/// Obtient la couleur associée au rôle
String getRoleColor(String? role) {
if (role == null) return '#9E9E9E';
switch (role.toUpperCase()) {
case roleAdmin:
return '#FF5722';
case roleSuperAdmin:
return '#E91E63';
case roleGestionnaireMembre:
return '#2196F3';
case roleTresorier:
return '#4CAF50';
case roleGestionnaireEvenement:
return '#FF9800';
case roleGestionnaireAide:
return '#9C27B0';
case roleGestionnaireFinance:
return '#00BCD4';
case rolePresident:
return '#FFD700';
case roleMembre:
return '#607D8B';
default:
return '#9E9E9E';
}
}
/// Obtient l'icône associée au rôle
String getRoleIcon(String? role) {
if (role == null) return 'person';
switch (role.toUpperCase()) {
case roleAdmin:
return 'admin_panel_settings';
case roleSuperAdmin:
return 'security';
case roleGestionnaireMembre:
return 'people';
case roleTresorier:
return 'account_balance';
case roleGestionnaireEvenement:
return 'event';
case roleGestionnaireAide:
return 'volunteer_activism';
case roleGestionnaireFinance:
return 'monetization_on';
case rolePresident:
return 'star';
case roleMembre:
return 'person';
default:
return 'person';
}
}
/// Vérifie les permissions et lance une exception si non autorisé
void requirePermission(bool hasPermission, [String? message]) {
if (!hasPermission) {
throw PermissionDeniedException(
message ?? 'Vous n\'avez pas les permissions nécessaires pour cette action'
);
}
}
/// Vérifie les permissions et retourne un message d'erreur si non autorisé
String? checkPermission(bool hasPermission, [String? message]) {
if (!hasPermission) {
return message ?? 'Permissions insuffisantes';
}
return null;
}
/// Log des actions pour audit (en mode debug uniquement)
void logAction(String action, {Map<String, dynamic>? details}) {
if (kDebugMode) {
print('🔐 PermissionService: $action by ${currentUser?.fullName} ($currentUserRole)');
if (details != null) {
print(' Details: $details');
}
}
}
}
/// Exception lancée quand une permission est refusée
class PermissionDeniedException implements Exception {
final String message;
const PermissionDeniedException(this.message);
@override
String toString() => 'PermissionDeniedException: $message';
}

View File

@@ -1,6 +1,6 @@
class AppConstants {
// API Configuration
static const String baseUrl = 'http://192.168.1.13:8080'; // Backend UnionFlow
static const String baseUrl = 'http://192.168.1.11:8080'; // Backend UnionFlow
static const String apiVersion = '/api';
// Timeout

View File

@@ -15,6 +15,8 @@ import 'package:unionflow_mobile_apps/core/auth/services/auth_api_service.dart'
as _i705;
import 'package:unionflow_mobile_apps/core/auth/services/auth_service.dart'
as _i423;
import 'package:unionflow_mobile_apps/core/auth/services/keycloak_webview_auth_service.dart'
as _i68;
import 'package:unionflow_mobile_apps/core/auth/storage/secure_token_storage.dart'
as _i394;
import 'package:unionflow_mobile_apps/core/network/auth_interceptor.dart'
@@ -27,6 +29,12 @@ import 'package:unionflow_mobile_apps/features/cotisations/domain/repositories/c
as _i961;
import 'package:unionflow_mobile_apps/features/cotisations/presentation/bloc/cotisations_bloc.dart'
as _i919;
import 'package:unionflow_mobile_apps/features/evenements/data/repositories/evenement_repository_impl.dart'
as _i947;
import 'package:unionflow_mobile_apps/features/evenements/domain/repositories/evenement_repository.dart'
as _i351;
import 'package:unionflow_mobile_apps/features/evenements/presentation/bloc/evenement_bloc.dart'
as _i1001;
import 'package:unionflow_mobile_apps/features/members/data/repositories/membre_repository_impl.dart'
as _i108;
import 'package:unionflow_mobile_apps/features/members/domain/repositories/membre_repository.dart'
@@ -45,29 +53,34 @@ extension GetItInjectableX on _i174.GetIt {
environment,
environmentFilter,
);
gh.singleton<_i68.KeycloakWebViewAuthService>(
() => _i68.KeycloakWebViewAuthService());
gh.singleton<_i394.SecureTokenStorage>(() => _i394.SecureTokenStorage());
gh.singleton<_i772.AuthInterceptor>(() => _i772.AuthInterceptor());
gh.singleton<_i978.DioClient>(() => _i978.DioClient());
gh.singleton<_i705.AuthApiService>(
() => _i705.AuthApiService(gh<_i978.DioClient>()));
gh.singleton<_i238.ApiService>(
() => _i238.ApiService(gh<_i978.DioClient>()));
gh.singleton<_i772.AuthInterceptor>(
() => _i772.AuthInterceptor(gh<_i394.SecureTokenStorage>()));
gh.lazySingleton<_i961.CotisationRepository>(
() => _i991.CotisationRepositoryImpl(gh<_i238.ApiService>()));
gh.lazySingleton<_i930.MembreRepository>(
() => _i108.MembreRepositoryImpl(gh<_i238.ApiService>()));
gh.factory<_i41.MembresBloc>(
() => _i41.MembresBloc(gh<_i930.MembreRepository>()));
gh.singleton<_i423.AuthService>(() => _i423.AuthService(
gh<_i394.SecureTokenStorage>(),
gh<_i705.AuthApiService>(),
gh<_i772.AuthInterceptor>(),
gh<_i978.DioClient>(),
));
gh.singleton<_i635.AuthBloc>(() => _i635.AuthBloc(gh<_i423.AuthService>()));
gh.lazySingleton<_i961.CotisationRepository>(
() => _i991.CotisationRepositoryImpl(gh<_i238.ApiService>()));
gh.lazySingleton<_i351.EvenementRepository>(
() => _i947.EvenementRepositoryImpl(gh<_i238.ApiService>()));
gh.lazySingleton<_i930.MembreRepository>(
() => _i108.MembreRepositoryImpl(gh<_i238.ApiService>()));
gh.factory<_i1001.EvenementBloc>(
() => _i1001.EvenementBloc(gh<_i351.EvenementRepository>()));
gh.factory<_i41.MembresBloc>(
() => _i41.MembresBloc(gh<_i930.MembreRepository>()));
gh.factory<_i919.CotisationsBloc>(
() => _i919.CotisationsBloc(gh<_i961.CotisationRepository>()));
gh.singleton<_i635.AuthBloc>(() => _i635.AuthBloc(gh<_i423.AuthService>()));
return this;
}
}

View File

@@ -0,0 +1,486 @@
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import '../failures/failures.dart';
import '../../shared/theme/app_theme.dart';
/// Service centralisé de gestion des erreurs
class ErrorHandler {
static const String _tag = 'ErrorHandler';
/// Gère les erreurs et affiche les messages appropriés à l'utilisateur
static void handleError(
BuildContext context,
dynamic error, {
String? customMessage,
VoidCallback? onRetry,
bool showSnackBar = true,
Duration duration = const Duration(seconds: 4),
}) {
final errorInfo = _analyzeError(error);
if (showSnackBar) {
_showErrorSnackBar(
context,
customMessage ?? errorInfo.userMessage,
errorInfo.type,
onRetry: onRetry,
duration: duration,
);
}
// Log l'erreur pour le debugging
_logError(errorInfo);
}
/// Affiche une boîte de dialogue d'erreur pour les erreurs critiques
static Future<void> showErrorDialog(
BuildContext context,
dynamic error, {
String? title,
String? customMessage,
VoidCallback? onRetry,
VoidCallback? onCancel,
}) async {
final errorInfo = _analyzeError(error);
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Icon(
_getErrorIcon(errorInfo.type),
color: _getErrorColor(errorInfo.type),
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
title ?? _getErrorTitle(errorInfo.type),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
customMessage ?? errorInfo.userMessage,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
if (errorInfo.suggestions.isNotEmpty) ...[
const SizedBox(height: 16),
const Text(
'Suggestions :',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
...errorInfo.suggestions.map((suggestion) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('', style: TextStyle(color: AppTheme.textSecondary)),
Expanded(
child: Text(
suggestion,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
),
],
),
)),
],
],
),
actions: [
if (onCancel != null)
TextButton(
onPressed: () {
Navigator.of(context).pop();
onCancel();
},
child: const Text('Annuler'),
),
if (onRetry != null)
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
onRetry();
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
child: const Text('Réessayer'),
)
else
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
child: const Text('OK'),
),
],
);
},
);
}
/// Analyse l'erreur et retourne les informations structurées
static ErrorInfo _analyzeError(dynamic error) {
if (error is DioException) {
return _analyzeDioError(error);
} else if (error is Failure) {
return _analyzeFailure(error);
} else if (error is Exception) {
return _analyzeException(error);
} else {
return ErrorInfo(
type: ErrorType.unknown,
userMessage: 'Une erreur inattendue s\'est produite',
technicalMessage: error.toString(),
suggestions: ['Veuillez réessayer plus tard'],
);
}
}
/// Analyse les erreurs Dio (réseau)
static ErrorInfo _analyzeDioError(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return ErrorInfo(
type: ErrorType.network,
userMessage: 'Délai d\'attente dépassé',
technicalMessage: error.message ?? '',
suggestions: [
'Vérifiez votre connexion internet',
'Réessayez dans quelques instants',
],
);
case DioExceptionType.connectionError:
return ErrorInfo(
type: ErrorType.network,
userMessage: 'Problème de connexion',
technicalMessage: error.message ?? '',
suggestions: [
'Vérifiez votre connexion internet',
'Vérifiez que le serveur est accessible',
],
);
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
switch (statusCode) {
case 400:
return ErrorInfo(
type: ErrorType.validation,
userMessage: 'Données invalides',
technicalMessage: error.response?.data?.toString() ?? '',
suggestions: ['Vérifiez les informations saisies'],
);
case 401:
return ErrorInfo(
type: ErrorType.authentication,
userMessage: 'Session expirée',
technicalMessage: 'Unauthorized',
suggestions: ['Reconnectez-vous à l\'application'],
);
case 403:
return ErrorInfo(
type: ErrorType.authorization,
userMessage: 'Accès non autorisé',
technicalMessage: 'Forbidden',
suggestions: ['Contactez votre administrateur'],
);
case 404:
return ErrorInfo(
type: ErrorType.notFound,
userMessage: 'Ressource non trouvée',
technicalMessage: 'Not Found',
suggestions: ['La ressource demandée n\'existe plus'],
);
case 500:
return ErrorInfo(
type: ErrorType.server,
userMessage: 'Erreur serveur',
technicalMessage: 'Internal Server Error',
suggestions: [
'Réessayez dans quelques instants',
'Contactez le support si le problème persiste',
],
);
default:
return ErrorInfo(
type: ErrorType.server,
userMessage: 'Erreur serveur (Code: $statusCode)',
technicalMessage: error.response?.data?.toString() ?? '',
suggestions: ['Réessayez plus tard'],
);
}
case DioExceptionType.cancel:
return ErrorInfo(
type: ErrorType.cancelled,
userMessage: 'Opération annulée',
technicalMessage: 'Request cancelled',
suggestions: [],
);
default:
return ErrorInfo(
type: ErrorType.unknown,
userMessage: 'Erreur de communication',
technicalMessage: error.message ?? '',
suggestions: ['Réessayez plus tard'],
);
}
}
/// Analyse les erreurs de type Failure
static ErrorInfo _analyzeFailure(Failure failure) {
switch (failure.runtimeType) {
case NetworkFailure:
return ErrorInfo(
type: ErrorType.network,
userMessage: 'Problème de réseau',
technicalMessage: failure.message,
suggestions: [
'Vérifiez votre connexion internet',
'Réessayez dans quelques instants',
],
);
case ServerFailure:
return ErrorInfo(
type: ErrorType.server,
userMessage: 'Erreur serveur',
technicalMessage: failure.message,
suggestions: [
'Réessayez dans quelques instants',
'Contactez le support si le problème persiste',
],
);
case ValidationFailure:
return ErrorInfo(
type: ErrorType.validation,
userMessage: 'Données invalides',
technicalMessage: failure.message,
suggestions: ['Vérifiez les informations saisies'],
);
case AuthFailure:
return ErrorInfo(
type: ErrorType.authentication,
userMessage: 'Problème d\'authentification',
technicalMessage: failure.message,
suggestions: ['Reconnectez-vous à l\'application'],
);
default:
return ErrorInfo(
type: ErrorType.unknown,
userMessage: failure.message,
technicalMessage: failure.message,
suggestions: ['Réessayez plus tard'],
);
}
}
/// Analyse les exceptions génériques
static ErrorInfo _analyzeException(Exception exception) {
final message = exception.toString();
if (message.contains('connexion') || message.contains('network')) {
return ErrorInfo(
type: ErrorType.network,
userMessage: 'Problème de connexion',
technicalMessage: message,
suggestions: ['Vérifiez votre connexion internet'],
);
} else if (message.contains('timeout')) {
return ErrorInfo(
type: ErrorType.network,
userMessage: 'Délai d\'attente dépassé',
technicalMessage: message,
suggestions: ['Réessayez dans quelques instants'],
);
} else {
return ErrorInfo(
type: ErrorType.unknown,
userMessage: 'Une erreur s\'est produite',
technicalMessage: message,
suggestions: ['Réessayez plus tard'],
);
}
}
/// Affiche une SnackBar d'erreur avec style approprié
static void _showErrorSnackBar(
BuildContext context,
String message,
ErrorType type, {
VoidCallback? onRetry,
Duration duration = const Duration(seconds: 4),
}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(
_getErrorIcon(type),
color: Colors.white,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
),
],
),
backgroundColor: _getErrorColor(type),
duration: duration,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
action: onRetry != null
? SnackBarAction(
label: 'Réessayer',
textColor: Colors.white,
onPressed: onRetry,
)
: null,
),
);
}
/// Retourne l'icône appropriée pour le type d'erreur
static IconData _getErrorIcon(ErrorType type) {
switch (type) {
case ErrorType.network:
return Icons.wifi_off;
case ErrorType.server:
return Icons.error_outline;
case ErrorType.validation:
return Icons.warning_amber;
case ErrorType.authentication:
return Icons.lock_outline;
case ErrorType.authorization:
return Icons.block;
case ErrorType.notFound:
return Icons.search_off;
case ErrorType.cancelled:
return Icons.cancel_outlined;
case ErrorType.unknown:
default:
return Icons.error_outline;
}
}
/// Retourne la couleur appropriée pour le type d'erreur
static Color _getErrorColor(ErrorType type) {
switch (type) {
case ErrorType.network:
return AppTheme.warningColor;
case ErrorType.server:
return AppTheme.errorColor;
case ErrorType.validation:
return AppTheme.warningColor;
case ErrorType.authentication:
return AppTheme.errorColor;
case ErrorType.authorization:
return AppTheme.errorColor;
case ErrorType.notFound:
return AppTheme.infoColor;
case ErrorType.cancelled:
return AppTheme.textSecondary;
case ErrorType.unknown:
default:
return AppTheme.errorColor;
}
}
/// Retourne le titre approprié pour le type d'erreur
static String _getErrorTitle(ErrorType type) {
switch (type) {
case ErrorType.network:
return 'Problème de connexion';
case ErrorType.server:
return 'Erreur serveur';
case ErrorType.validation:
return 'Données invalides';
case ErrorType.authentication:
return 'Authentification requise';
case ErrorType.authorization:
return 'Accès non autorisé';
case ErrorType.notFound:
return 'Ressource introuvable';
case ErrorType.cancelled:
return 'Opération annulée';
case ErrorType.unknown:
default:
return 'Erreur';
}
}
/// Log l'erreur pour le debugging
static void _logError(ErrorInfo errorInfo) {
debugPrint('[$_tag] ${errorInfo.type.name}: ${errorInfo.technicalMessage}');
}
}
/// Types d'erreurs supportés
enum ErrorType {
network,
server,
validation,
authentication,
authorization,
notFound,
cancelled,
unknown,
}
/// Informations structurées sur une erreur
class ErrorInfo {
final ErrorType type;
final String userMessage;
final String technicalMessage;
final List<String> suggestions;
const ErrorInfo({
required this.type,
required this.userMessage,
required this.technicalMessage,
required this.suggestions,
});
}

View File

@@ -0,0 +1,271 @@
/// Classes d'échec pour la gestion d'erreurs structurée
abstract class Failure {
final String message;
final String? code;
final Map<String, dynamic>? details;
const Failure({
required this.message,
this.code,
this.details,
});
@override
String toString() => 'Failure(message: $message, code: $code)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Failure &&
other.message == message &&
other.code == code;
}
@override
int get hashCode => message.hashCode ^ code.hashCode;
}
/// Échec réseau (problèmes de connectivité, timeout, etc.)
class NetworkFailure extends Failure {
const NetworkFailure({
required super.message,
super.code,
super.details,
});
factory NetworkFailure.noConnection() {
return const NetworkFailure(
message: 'Aucune connexion internet disponible',
code: 'NO_CONNECTION',
);
}
factory NetworkFailure.timeout() {
return const NetworkFailure(
message: 'Délai d\'attente dépassé',
code: 'TIMEOUT',
);
}
factory NetworkFailure.serverUnreachable() {
return const NetworkFailure(
message: 'Serveur inaccessible',
code: 'SERVER_UNREACHABLE',
);
}
}
/// Échec serveur (erreurs HTTP 5xx, erreurs API, etc.)
class ServerFailure extends Failure {
final int? statusCode;
const ServerFailure({
required super.message,
super.code,
super.details,
this.statusCode,
});
factory ServerFailure.internalError() {
return const ServerFailure(
message: 'Erreur interne du serveur',
code: 'INTERNAL_ERROR',
statusCode: 500,
);
}
factory ServerFailure.serviceUnavailable() {
return const ServerFailure(
message: 'Service temporairement indisponible',
code: 'SERVICE_UNAVAILABLE',
statusCode: 503,
);
}
factory ServerFailure.badGateway() {
return const ServerFailure(
message: 'Passerelle défaillante',
code: 'BAD_GATEWAY',
statusCode: 502,
);
}
}
/// Échec de validation (données invalides, contraintes non respectées)
class ValidationFailure extends Failure {
final Map<String, List<String>>? fieldErrors;
const ValidationFailure({
required super.message,
super.code,
super.details,
this.fieldErrors,
});
factory ValidationFailure.invalidData(String field, String error) {
return ValidationFailure(
message: 'Données invalides',
code: 'INVALID_DATA',
fieldErrors: {field: [error]},
);
}
factory ValidationFailure.requiredField(String field) {
return ValidationFailure(
message: 'Champ requis manquant',
code: 'REQUIRED_FIELD',
fieldErrors: {field: ['Ce champ est requis']},
);
}
factory ValidationFailure.multipleErrors(Map<String, List<String>> errors) {
return ValidationFailure(
message: 'Plusieurs erreurs de validation',
code: 'MULTIPLE_ERRORS',
fieldErrors: errors,
);
}
}
/// Échec d'authentification (login, permissions, tokens expirés)
class AuthFailure extends Failure {
const AuthFailure({
required super.message,
super.code,
super.details,
});
factory AuthFailure.invalidCredentials() {
return const AuthFailure(
message: 'Identifiants invalides',
code: 'INVALID_CREDENTIALS',
);
}
factory AuthFailure.tokenExpired() {
return const AuthFailure(
message: 'Session expirée, veuillez vous reconnecter',
code: 'TOKEN_EXPIRED',
);
}
factory AuthFailure.insufficientPermissions() {
return const AuthFailure(
message: 'Permissions insuffisantes',
code: 'INSUFFICIENT_PERMISSIONS',
);
}
factory AuthFailure.accountLocked() {
return const AuthFailure(
message: 'Compte verrouillé',
code: 'ACCOUNT_LOCKED',
);
}
}
/// Échec de données (ressource non trouvée, conflit, etc.)
class DataFailure extends Failure {
const DataFailure({
required super.message,
super.code,
super.details,
});
factory DataFailure.notFound(String resource) {
return DataFailure(
message: '$resource non trouvé(e)',
code: 'NOT_FOUND',
details: {'resource': resource},
);
}
factory DataFailure.alreadyExists(String resource) {
return DataFailure(
message: '$resource existe déjà',
code: 'ALREADY_EXISTS',
details: {'resource': resource},
);
}
factory DataFailure.conflict(String reason) {
return DataFailure(
message: 'Conflit de données : $reason',
code: 'CONFLICT',
details: {'reason': reason},
);
}
}
/// Échec de cache (données expirées, cache corrompu)
class CacheFailure extends Failure {
const CacheFailure({
required super.message,
super.code,
super.details,
});
factory CacheFailure.expired() {
return const CacheFailure(
message: 'Données en cache expirées',
code: 'CACHE_EXPIRED',
);
}
factory CacheFailure.corrupted() {
return const CacheFailure(
message: 'Cache corrompu',
code: 'CACHE_CORRUPTED',
);
}
}
/// Échec de fichier (lecture, écriture, format)
class FileFailure extends Failure {
const FileFailure({
required super.message,
super.code,
super.details,
});
factory FileFailure.notFound(String filePath) {
return FileFailure(
message: 'Fichier non trouvé',
code: 'FILE_NOT_FOUND',
details: {'filePath': filePath},
);
}
factory FileFailure.accessDenied(String filePath) {
return FileFailure(
message: 'Accès au fichier refusé',
code: 'ACCESS_DENIED',
details: {'filePath': filePath},
);
}
factory FileFailure.invalidFormat(String expectedFormat) {
return FileFailure(
message: 'Format de fichier invalide',
code: 'INVALID_FORMAT',
details: {'expectedFormat': expectedFormat},
);
}
}
/// Échec générique pour les cas non spécifiés
class UnknownFailure extends Failure {
const UnknownFailure({
required super.message,
super.code,
super.details,
});
factory UnknownFailure.fromException(Exception exception) {
return UnknownFailure(
message: 'Erreur inattendue : ${exception.toString()}',
code: 'UNKNOWN_ERROR',
details: {'exception': exception.toString()},
);
}
}

View File

@@ -0,0 +1,459 @@
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();
});
}
}

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,

View File

@@ -1,19 +1,19 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import '../auth/storage/secure_token_storage.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Interceptor pour gérer l'authentification automatique
@singleton
class AuthInterceptor extends Interceptor {
final SecureTokenStorage _tokenStorage;
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
// Callback pour déclencher le refresh token
void Function()? onTokenRefreshNeeded;
// Callback pour déconnecter l'utilisateur
void Function()? onAuthenticationFailed;
AuthInterceptor(this._tokenStorage);
AuthInterceptor();
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
@@ -25,21 +25,13 @@ class AuthInterceptor extends Interceptor {
try {
// Récupérer le token d'accès
final accessToken = await _tokenStorage.getAccessToken();
final accessToken = await _secureStorage.read(key: 'access_token');
if (accessToken != null) {
// Vérifier si le token expire bientôt
final isExpiringSoon = await _tokenStorage.isAccessTokenExpiringSoon();
if (isExpiringSoon) {
// Déclencher le refresh token si nécessaire
onTokenRefreshNeeded?.call();
}
// Ajouter le token à l'en-tête Authorization
options.headers['Authorization'] = 'Bearer $accessToken';
}
handler.next(options);
} catch (e) {
// En cas d'erreur, continuer sans token
@@ -69,39 +61,16 @@ class AuthInterceptor extends Interceptor {
/// Gère les erreurs 401 (Non autorisé)
Future<void> _handle401Error(DioException err, ErrorInterceptorHandler handler) async {
try {
// Vérifier si on a un refresh token valide
final refreshToken = await _tokenStorage.getRefreshToken();
final refreshExpiresAt = await _tokenStorage.getRefreshTokenExpirationDate();
if (refreshToken != null &&
refreshExpiresAt != null &&
DateTime.now().isBefore(refreshExpiresAt)) {
// Tentative de refresh du token
onTokenRefreshNeeded?.call();
// Attendre un peu pour laisser le temps au refresh
await Future.delayed(const Duration(milliseconds: 100));
// Retry de la requête originale avec le nouveau token
final newAccessToken = await _tokenStorage.getAccessToken();
if (newAccessToken != null) {
final newRequest = await _retryRequest(err.requestOptions, newAccessToken);
handler.resolve(newRequest);
return;
}
}
// Si le refresh n'est pas possible ou a échoué, déconnecter l'utilisateur
await _tokenStorage.clearAuthData();
// Déclencher la déconnexion automatique
onAuthenticationFailed?.call();
// Nettoyer les tokens
await _secureStorage.deleteAll();
} catch (e) {
print('Erreur lors de la gestion de l\'erreur 401: $e');
await _tokenStorage.clearAuthData();
onAuthenticationFailed?.call();
}
handler.next(err);
}
@@ -113,28 +82,7 @@ class AuthInterceptor extends Interceptor {
handler.next(err);
}
/// Retry une requête avec un nouveau token
Future<Response> _retryRequest(RequestOptions options, String newAccessToken) async {
final dio = Dio();
// Copier les options originales
final newOptions = Options(
method: options.method,
headers: {
...options.headers,
'Authorization': 'Bearer $newAccessToken',
},
extra: {'skipAuth': true}, // Éviter la récursion infinie
);
// Effectuer la nouvelle requête
return await dio.request(
options.path,
data: options.data,
queryParameters: options.queryParameters,
options: newOptions,
);
}
/// Détermine si l'authentification doit être ignorée pour une requête
bool _shouldSkipAuth(RequestOptions options) {

View File

@@ -19,7 +19,7 @@ class DioClient {
void _configureOptions() {
_dio.options = BaseOptions(
// URL de base de l'API
baseUrl: 'http://192.168.1.13:8080', // Adresse de votre API Quarkus
baseUrl: 'http://192.168.1.11:8080', // Adresse de votre API Quarkus
// Timeouts
connectTimeout: const Duration(seconds: 30),

View File

@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import '../models/membre_model.dart';
import '../models/cotisation_model.dart';
import '../models/evenement_model.dart';
import '../models/wave_checkout_session_model.dart';
import '../network/dio_client.dart';
@@ -87,19 +88,47 @@ class ApiService {
'/api/membres/recherche',
queryParameters: {'q': query},
);
if (response.data is List) {
return (response.data as List)
.map((json) => MembreModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour la recherche');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la recherche de membres');
}
}
/// Recherche avancée des membres avec filtres multiples
Future<List<MembreModel>> advancedSearchMembres(Map<String, dynamic> filters) async {
try {
// Nettoyer les filtres vides
final cleanFilters = <String, dynamic>{};
filters.forEach((key, value) {
if (value != null && value.toString().isNotEmpty) {
cleanFilters[key] = value;
}
});
final response = await _dio.get(
'/api/membres/recherche-avancee',
queryParameters: cleanFilters,
);
if (response.data is List) {
return (response.data as List)
.map((json) => MembreModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour la recherche avancée');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la recherche avancée de membres');
}
}
/// Récupère les statistiques des membres
Future<Map<String, dynamic>> getMembresStats() async {
try {
@@ -397,4 +426,218 @@ class ApiService {
return Exception(defaultMessage);
}
}
// ========================================
// ÉVÉNEMENTS
// ========================================
/// Récupère la liste des événements à venir (optimisé mobile)
Future<List<EvenementModel>> getEvenementsAVenir({
int page = 0,
int size = 10,
}) async {
try {
final response = await _dio.get(
'/api/evenements/a-venir',
queryParameters: {
'page': page,
'size': size,
},
);
if (response.data is List) {
return (response.data as List)
.map((json) => EvenementModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour les événements à venir');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des événements à venir');
}
}
/// Récupère la liste des événements publics (sans authentification)
Future<List<EvenementModel>> getEvenementsPublics({
int page = 0,
int size = 20,
}) async {
try {
final response = await _dio.get(
'/api/evenements/publics',
queryParameters: {
'page': page,
'size': size,
},
);
if (response.data is List) {
return (response.data as List)
.map((json) => EvenementModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour les événements publics');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des événements publics');
}
}
/// Récupère tous les événements avec pagination
Future<List<EvenementModel>> getEvenements({
int page = 0,
int size = 20,
String sortField = 'dateDebut',
String sortDirection = 'asc',
}) async {
try {
final response = await _dio.get(
'/api/evenements',
queryParameters: {
'page': page,
'size': size,
'sort': sortField,
'direction': sortDirection,
},
);
if (response.data is List) {
return (response.data as List)
.map((json) => EvenementModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour la liste des événements');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des événements');
}
}
/// Récupère un événement par son ID
Future<EvenementModel> getEvenementById(String id) async {
try {
final response = await _dio.get('/api/evenements/$id');
return EvenementModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération de l\'événement');
}
}
/// Recherche d'événements par terme
Future<List<EvenementModel>> rechercherEvenements(
String terme, {
int page = 0,
int size = 20,
}) async {
try {
final response = await _dio.get(
'/api/evenements/recherche',
queryParameters: {
'q': terme,
'page': page,
'size': size,
},
);
if (response.data is List) {
return (response.data as List)
.map((json) => EvenementModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour la recherche d\'événements');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la recherche d\'événements');
}
}
/// Récupère les événements par type
Future<List<EvenementModel>> getEvenementsByType(
TypeEvenement type, {
int page = 0,
int size = 20,
}) async {
try {
final response = await _dio.get(
'/api/evenements/type/${type.name.toUpperCase()}',
queryParameters: {
'page': page,
'size': size,
},
);
if (response.data is List) {
return (response.data as List)
.map((json) => EvenementModel.fromJson(json as Map<String, dynamic>))
.toList();
}
throw Exception('Format de réponse invalide pour les événements par type');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des événements par type');
}
}
/// Crée un nouvel événement
Future<EvenementModel> createEvenement(EvenementModel evenement) async {
try {
final response = await _dio.post(
'/api/evenements',
data: evenement.toJson(),
);
return EvenementModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la création de l\'événement');
}
}
/// Met à jour un événement existant
Future<EvenementModel> updateEvenement(String id, EvenementModel evenement) async {
try {
final response = await _dio.put(
'/api/evenements/$id',
data: evenement.toJson(),
);
return EvenementModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la mise à jour de l\'événement');
}
}
/// Supprime un événement
Future<void> deleteEvenement(String id) async {
try {
await _dio.delete('/api/evenements/$id');
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la suppression de l\'événement');
}
}
/// Change le statut d'un événement
Future<EvenementModel> changerStatutEvenement(
String id,
StatutEvenement nouveauStatut,
) async {
try {
final response = await _dio.patch(
'/api/evenements/$id/statut',
queryParameters: {
'statut': nouveauStatut.name.toUpperCase(),
},
);
return EvenementModel.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors du changement de statut');
}
}
/// Récupère les statistiques des événements
Future<Map<String, dynamic>> getStatistiquesEvenements() async {
try {
final response = await _dio.get('/api/evenements/statistiques');
return response.data as Map<String, dynamic>;
} on DioException catch (e) {
throw _handleDioException(e, 'Erreur lors de la récupération des statistiques');
}
}
}

View File

@@ -0,0 +1,258 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:permission_handler/permission_handler.dart';
import '../models/membre_model.dart';
import '../../shared/theme/app_theme.dart';
/// Service de gestion des communications (appels, SMS, emails)
/// Gère les permissions et l'intégration avec les applications natives
class CommunicationService {
static final CommunicationService _instance = CommunicationService._internal();
factory CommunicationService() => _instance;
CommunicationService._internal();
/// Effectue un appel téléphonique vers un membre
Future<bool> callMember(BuildContext context, MembreModel membre) async {
try {
// Vérifier si le numéro de téléphone est valide
if (membre.telephone.isEmpty) {
_showErrorSnackBar(context, 'Numéro de téléphone non disponible pour ${membre.nomComplet}');
return false;
}
// Nettoyer le numéro de téléphone
final cleanPhone = _cleanPhoneNumber(membre.telephone);
if (cleanPhone.isEmpty) {
_showErrorSnackBar(context, 'Numéro de téléphone invalide pour ${membre.nomComplet}');
return false;
}
// Vérifier les permissions sur Android
if (Platform.isAndroid) {
final phonePermission = await Permission.phone.status;
if (phonePermission.isDenied) {
final result = await Permission.phone.request();
if (result.isDenied) {
_showPermissionDeniedDialog(context, 'Téléphone', 'effectuer des appels');
return false;
}
}
}
// Construire l'URL d'appel
final phoneUrl = Uri.parse('tel:$cleanPhone');
// Vérifier si l'application peut gérer les appels
if (await canLaunchUrl(phoneUrl)) {
// Feedback haptique
HapticFeedback.mediumImpact();
// Lancer l'appel
final success = await launchUrl(phoneUrl);
if (success) {
_showSuccessSnackBar(context, 'Appel lancé vers ${membre.nomComplet}');
// Log de l'action pour audit
debugPrint('📞 Appel effectué vers ${membre.nomComplet} (${membre.telephone})');
return true;
} else {
_showErrorSnackBar(context, 'Impossible de lancer l\'appel vers ${membre.nomComplet}');
return false;
}
} else {
_showErrorSnackBar(context, 'Application d\'appel non disponible sur cet appareil');
return false;
}
} catch (e) {
debugPrint('❌ Erreur lors de l\'appel vers ${membre.nomComplet}: $e');
_showErrorSnackBar(context, 'Erreur lors de l\'appel vers ${membre.nomComplet}');
return false;
}
}
/// Envoie un SMS à un membre
Future<bool> sendSMS(BuildContext context, MembreModel membre, {String? message}) async {
try {
// Vérifier si le numéro de téléphone est valide
if (membre.telephone.isEmpty) {
_showErrorSnackBar(context, 'Numéro de téléphone non disponible pour ${membre.nomComplet}');
return false;
}
// Nettoyer le numéro de téléphone
final cleanPhone = _cleanPhoneNumber(membre.telephone);
if (cleanPhone.isEmpty) {
_showErrorSnackBar(context, 'Numéro de téléphone invalide pour ${membre.nomComplet}');
return false;
}
// Construire l'URL SMS
String smsUrl = 'sms:$cleanPhone';
if (message != null && message.isNotEmpty) {
final encodedMessage = Uri.encodeComponent(message);
smsUrl += '?body=$encodedMessage';
}
final smsUri = Uri.parse(smsUrl);
// Vérifier si l'application peut gérer les SMS
if (await canLaunchUrl(smsUri)) {
// Feedback haptique
HapticFeedback.lightImpact();
// Lancer l'application SMS
final success = await launchUrl(smsUri);
if (success) {
_showSuccessSnackBar(context, 'SMS ouvert pour ${membre.nomComplet}');
// Log de l'action pour audit
debugPrint('💬 SMS ouvert pour ${membre.nomComplet} (${membre.telephone})');
return true;
} else {
_showErrorSnackBar(context, 'Impossible d\'ouvrir l\'application SMS');
return false;
}
} else {
_showErrorSnackBar(context, 'Application SMS non disponible sur cet appareil');
return false;
}
} catch (e) {
debugPrint('❌ Erreur lors de l\'envoi SMS vers ${membre.nomComplet}: $e');
_showErrorSnackBar(context, 'Erreur lors de l\'envoi SMS vers ${membre.nomComplet}');
return false;
}
}
/// Envoie un email à un membre
Future<bool> sendEmail(BuildContext context, MembreModel membre, {String? subject, String? body}) async {
try {
// Vérifier si l'email est valide
if (membre.email.isEmpty) {
_showErrorSnackBar(context, 'Adresse email non disponible pour ${membre.nomComplet}');
return false;
}
// Construire l'URL email
String emailUrl = 'mailto:${membre.email}';
final params = <String>[];
if (subject != null && subject.isNotEmpty) {
params.add('subject=${Uri.encodeComponent(subject)}');
}
if (body != null && body.isNotEmpty) {
params.add('body=${Uri.encodeComponent(body)}');
}
if (params.isNotEmpty) {
emailUrl += '?${params.join('&')}';
}
final emailUri = Uri.parse(emailUrl);
// Vérifier si l'application peut gérer les emails
if (await canLaunchUrl(emailUri)) {
// Feedback haptique
HapticFeedback.lightImpact();
// Lancer l'application email
final success = await launchUrl(emailUri);
if (success) {
_showSuccessSnackBar(context, 'Email ouvert pour ${membre.nomComplet}');
// Log de l'action pour audit
debugPrint('📧 Email ouvert pour ${membre.nomComplet} (${membre.email})');
return true;
} else {
_showErrorSnackBar(context, 'Impossible d\'ouvrir l\'application email');
return false;
}
} else {
_showErrorSnackBar(context, 'Application email non disponible sur cet appareil');
return false;
}
} catch (e) {
debugPrint('❌ Erreur lors de l\'envoi email vers ${membre.nomComplet}: $e');
_showErrorSnackBar(context, 'Erreur lors de l\'envoi email vers ${membre.nomComplet}');
return false;
}
}
/// Nettoie un numéro de téléphone en supprimant les caractères non numériques
String _cleanPhoneNumber(String phone) {
// Garder seulement les chiffres et le signe +
final cleaned = phone.replaceAll(RegExp(r'[^\d+]'), '');
// Vérifier que le numéro n'est pas vide après nettoyage
if (cleaned.isEmpty) return '';
// Si le numéro commence par +, le garder tel quel
if (cleaned.startsWith('+')) return cleaned;
// Si le numéro commence par 00, le remplacer par +
if (cleaned.startsWith('00')) {
return '+${cleaned.substring(2)}';
}
return cleaned;
}
/// Affiche un SnackBar de succès
void _showSuccessSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: AppTheme.successColor,
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
/// Affiche un SnackBar d'erreur
void _showErrorSnackBar(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
),
);
}
/// Affiche une dialog pour les permissions refusées
void _showPermissionDeniedDialog(BuildContext context, String permission, String action) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Permission $permission requise'),
content: Text(
'L\'application a besoin de la permission $permission pour $action. '
'Veuillez autoriser cette permission dans les paramètres de l\'application.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
openAppSettings();
},
child: const Text('Paramètres'),
),
],
),
);
}
}

View File

@@ -0,0 +1,775 @@
import 'dart:io';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:excel/excel.dart';
import 'package:csv/csv.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:path_provider/path_provider.dart';
import 'package:file_picker/file_picker.dart';
import 'package:share_plus/share_plus.dart';
import '../models/membre_model.dart';
import '../../shared/theme/app_theme.dart';
/// Options d'export
class ExportOptions {
final String format;
final bool includePersonalInfo;
final bool includeContactInfo;
final bool includeAdhesionInfo;
final bool includeStatistics;
final bool includeInactiveMembers;
const ExportOptions({
required this.format,
this.includePersonalInfo = true,
this.includeContactInfo = true,
this.includeAdhesionInfo = true,
this.includeStatistics = false,
this.includeInactiveMembers = true,
});
}
/// Service de gestion de l'export et import des données
/// Supporte les formats Excel, CSV, PDF et JSON
class ExportImportService {
static final ExportImportService _instance = ExportImportService._internal();
factory ExportImportService() => _instance;
ExportImportService._internal();
/// Exporte une liste de membres selon les options spécifiées
Future<String?> exportMembers(
BuildContext context,
List<MembreModel> members,
ExportOptions options,
) async {
try {
// Filtrer les membres selon les options
List<MembreModel> filteredMembers = members;
if (!options.includeInactiveMembers) {
filteredMembers = filteredMembers.where((m) => m.actif).toList();
}
// Générer le fichier selon le format
String? filePath;
switch (options.format.toLowerCase()) {
case 'excel':
filePath = await _exportToExcel(filteredMembers, options);
break;
case 'csv':
filePath = await _exportToCsv(filteredMembers, options);
break;
case 'pdf':
filePath = await _exportToPdf(filteredMembers, options);
break;
case 'json':
filePath = await _exportToJson(filteredMembers, options);
break;
default:
throw Exception('Format d\'export non supporté: ${options.format}');
}
if (filePath != null) {
// Feedback haptique
HapticFeedback.mediumImpact();
// Afficher le résultat
_showExportSuccess(context, filteredMembers.length, options.format, filePath);
// Log de l'action
debugPrint('📤 Export réussi: ${filteredMembers.length} membres en ${options.format.toUpperCase()} -> $filePath');
return filePath;
} else {
_showExportError(context, 'Impossible de créer le fichier d\'export');
return null;
}
} catch (e) {
debugPrint('❌ Erreur lors de l\'export: $e');
_showExportError(context, 'Erreur lors de l\'export: ${e.toString()}');
return null;
}
}
/// Exporte vers Excel
Future<String?> _exportToExcel(List<MembreModel> members, ExportOptions options) async {
try {
final excel = Excel.createExcel();
final sheet = excel['Membres'];
// Supprimer la feuille par défaut
excel.delete('Sheet1');
// En-têtes
final headers = _buildHeaders(options);
for (int i = 0; i < headers.length; i++) {
sheet.cell(CellIndex.indexByColumnRow(columnIndex: i, rowIndex: 0)).value =
TextCellValue(headers[i]);
}
// Données
for (int rowIndex = 0; rowIndex < members.length; rowIndex++) {
final member = members[rowIndex];
final rowData = _buildRowData(member, options);
for (int colIndex = 0; colIndex < rowData.length; colIndex++) {
sheet.cell(CellIndex.indexByColumnRow(columnIndex: colIndex, rowIndex: rowIndex + 1)).value =
TextCellValue(rowData[colIndex]);
}
}
// Sauvegarder le fichier
final directory = await getApplicationDocumentsDirectory();
final fileName = 'membres_${DateTime.now().millisecondsSinceEpoch}.xlsx';
final filePath = '${directory.path}/$fileName';
final file = File(filePath);
await file.writeAsBytes(excel.encode()!);
return filePath;
} catch (e) {
debugPrint('❌ Erreur export Excel: $e');
return null;
}
}
/// Exporte vers CSV
Future<String?> _exportToCsv(List<MembreModel> members, ExportOptions options) async {
try {
final headers = _buildHeaders(options);
final rows = <List<String>>[headers];
for (final member in members) {
rows.add(_buildRowData(member, options));
}
final csvData = const ListToCsvConverter().convert(rows);
// Sauvegarder le fichier
final directory = await getApplicationDocumentsDirectory();
final fileName = 'membres_${DateTime.now().millisecondsSinceEpoch}.csv';
final filePath = '${directory.path}/$fileName';
final file = File(filePath);
await file.writeAsString(csvData, encoding: utf8);
return filePath;
} catch (e) {
debugPrint('❌ Erreur export CSV: $e');
return null;
}
}
/// Exporte vers PDF
Future<String?> _exportToPdf(List<MembreModel> members, ExportOptions options) async {
try {
final pdf = pw.Document();
// Créer le contenu PDF
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
build: (pw.Context context) {
return [
pw.Header(
level: 0,
child: pw.Text(
'Liste des Membres UnionFlow',
style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold),
),
),
pw.SizedBox(height: 20),
pw.Text(
'Exporté le ${DateTime.now().day}/${DateTime.now().month}/${DateTime.now().year} à ${DateTime.now().hour}:${DateTime.now().minute}',
style: const pw.TextStyle(fontSize: 12),
),
pw.SizedBox(height: 20),
pw.Table.fromTextArray(
headers: _buildHeaders(options),
data: members.map((member) => _buildRowData(member, options)).toList(),
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
cellStyle: const pw.TextStyle(fontSize: 10),
cellAlignment: pw.Alignment.centerLeft,
),
];
},
),
);
// Sauvegarder le fichier
final directory = await getApplicationDocumentsDirectory();
final fileName = 'membres_${DateTime.now().millisecondsSinceEpoch}.pdf';
final filePath = '${directory.path}/$fileName';
final file = File(filePath);
await file.writeAsBytes(await pdf.save());
return filePath;
} catch (e) {
debugPrint('❌ Erreur export PDF: $e');
return null;
}
}
/// Exporte vers JSON
Future<String?> _exportToJson(List<MembreModel> members, ExportOptions options) async {
try {
final data = {
'exportInfo': {
'date': DateTime.now().toIso8601String(),
'format': 'JSON',
'totalMembers': members.length,
'options': {
'includePersonalInfo': options.includePersonalInfo,
'includeContactInfo': options.includeContactInfo,
'includeAdhesionInfo': options.includeAdhesionInfo,
'includeStatistics': options.includeStatistics,
'includeInactiveMembers': options.includeInactiveMembers,
},
},
'members': members.map((member) => _buildJsonData(member, options)).toList(),
};
final jsonString = const JsonEncoder.withIndent(' ').convert(data);
// Sauvegarder le fichier
final directory = await getApplicationDocumentsDirectory();
final fileName = 'membres_${DateTime.now().millisecondsSinceEpoch}.json';
final filePath = '${directory.path}/$fileName';
final file = File(filePath);
await file.writeAsString(jsonString, encoding: utf8);
return filePath;
} catch (e) {
debugPrint('❌ Erreur export JSON: $e');
return null;
}
}
/// Construit les en-têtes selon les options
List<String> _buildHeaders(ExportOptions options) {
final headers = <String>[];
if (options.includePersonalInfo) {
headers.addAll(['Numéro', 'Nom', 'Prénom', 'Date de naissance', 'Profession']);
}
if (options.includeContactInfo) {
headers.addAll(['Téléphone', 'Email', 'Adresse', 'Ville', 'Code postal', 'Pays']);
}
if (options.includeAdhesionInfo) {
headers.addAll(['Date d\'adhésion', 'Statut', 'Actif']);
}
if (options.includeStatistics) {
headers.addAll(['Âge', 'Ancienneté (jours)', 'Date création', 'Date modification']);
}
return headers;
}
/// Construit les données d'une ligne selon les options
List<String> _buildRowData(MembreModel member, ExportOptions options) {
final rowData = <String>[];
if (options.includePersonalInfo) {
rowData.addAll([
member.numeroMembre,
member.nom,
member.prenom,
member.dateNaissance?.toIso8601String().split('T')[0] ?? '',
member.profession ?? '',
]);
}
if (options.includeContactInfo) {
rowData.addAll([
member.telephone,
member.email,
member.adresse ?? '',
member.ville ?? '',
member.codePostal ?? '',
member.pays ?? '',
]);
}
if (options.includeAdhesionInfo) {
rowData.addAll([
member.dateAdhesion.toIso8601String().split('T')[0],
member.statut,
member.actif ? 'Oui' : 'Non',
]);
}
if (options.includeStatistics) {
final age = member.age.toString();
final anciennete = DateTime.now().difference(member.dateAdhesion).inDays.toString();
final dateCreation = member.dateCreation.toIso8601String().split('T')[0];
final dateModification = member.dateModification?.toIso8601String().split('T')[0] ?? 'N/A';
rowData.addAll([age, anciennete, dateCreation, dateModification]);
}
return rowData;
}
/// Construit les données JSON selon les options
Map<String, dynamic> _buildJsonData(MembreModel member, ExportOptions options) {
final data = <String, dynamic>{};
if (options.includePersonalInfo) {
data.addAll({
'numeroMembre': member.numeroMembre,
'nom': member.nom,
'prenom': member.prenom,
'dateNaissance': member.dateNaissance?.toIso8601String(),
'profession': member.profession,
});
}
if (options.includeContactInfo) {
data.addAll({
'telephone': member.telephone,
'email': member.email,
'adresse': member.adresse,
'ville': member.ville,
'codePostal': member.codePostal,
'pays': member.pays,
});
}
if (options.includeAdhesionInfo) {
data.addAll({
'dateAdhesion': member.dateAdhesion.toIso8601String(),
'statut': member.statut,
'actif': member.actif,
});
}
if (options.includeStatistics) {
data.addAll({
'age': member.age,
'ancienneteEnJours': DateTime.now().difference(member.dateAdhesion).inDays,
'dateCreation': member.dateCreation.toIso8601String(),
'dateModification': member.dateModification?.toIso8601String(),
});
}
return data;
}
/// Affiche le succès de l'export
void _showExportSuccess(BuildContext context, int count, String format, String filePath) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Export ${format.toUpperCase()} réussi: $count membres',
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
],
),
backgroundColor: AppTheme.successColor,
duration: const Duration(seconds: 4),
action: SnackBarAction(
label: 'Partager',
textColor: Colors.white,
onPressed: () => _shareFile(filePath),
),
),
);
}
/// Affiche l'erreur d'export
void _showExportError(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error, color: Colors.white, size: 20),
const SizedBox(width: 8),
Expanded(child: Text(message)),
],
),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 5),
),
);
}
/// Partage un fichier
Future<void> _shareFile(String filePath) async {
try {
await Share.shareXFiles([XFile(filePath)]);
} catch (e) {
debugPrint('❌ Erreur lors du partage: $e');
}
}
/// Importe des membres depuis un fichier
Future<List<MembreModel>?> importMembers(BuildContext context) async {
try {
// Sélectionner le fichier
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['xlsx', 'csv', 'json'],
allowMultiple: false,
);
if (result == null || result.files.isEmpty) {
return null;
}
final file = result.files.first;
final filePath = file.path;
if (filePath == null) {
_showImportError(context, 'Impossible de lire le fichier sélectionné');
return null;
}
// Importer selon l'extension
List<MembreModel>? importedMembers;
final extension = file.extension?.toLowerCase();
switch (extension) {
case 'xlsx':
importedMembers = await _importFromExcel(filePath);
break;
case 'csv':
importedMembers = await _importFromCsv(filePath);
break;
case 'json':
importedMembers = await _importFromJson(filePath);
break;
default:
_showImportError(context, 'Format de fichier non supporté: $extension');
return null;
}
if (importedMembers != null && importedMembers.isNotEmpty) {
// Feedback haptique
HapticFeedback.mediumImpact();
// Afficher le résultat
_showImportSuccess(context, importedMembers.length, extension!);
// Log de l'action
debugPrint('📥 Import réussi: ${importedMembers.length} membres depuis ${extension.toUpperCase()}');
return importedMembers;
} else {
_showImportError(context, 'Aucun membre valide trouvé dans le fichier');
return null;
}
} catch (e) {
debugPrint('❌ Erreur lors de l\'import: $e');
_showImportError(context, 'Erreur lors de l\'import: ${e.toString()}');
return null;
}
}
/// Importe depuis Excel
Future<List<MembreModel>?> _importFromExcel(String filePath) async {
try {
final file = File(filePath);
final bytes = await file.readAsBytes();
final excel = Excel.decodeBytes(bytes);
final sheet = excel.tables.values.first;
if (sheet == null || sheet.rows.isEmpty) {
return null;
}
final members = <MembreModel>[];
// Ignorer la première ligne (en-têtes)
for (int i = 1; i < sheet.rows.length; i++) {
final row = sheet.rows[i];
if (row.isEmpty) continue;
try {
final member = _parseRowToMember(row.map((cell) => cell?.value?.toString() ?? '').toList());
if (member != null) {
members.add(member);
}
} catch (e) {
debugPrint('⚠️ Erreur ligne $i: $e');
}
}
return members;
} catch (e) {
debugPrint('❌ Erreur import Excel: $e');
return null;
}
}
/// Importe depuis CSV
Future<List<MembreModel>?> _importFromCsv(String filePath) async {
try {
final file = File(filePath);
final content = await file.readAsString(encoding: utf8);
final rows = const CsvToListConverter().convert(content);
if (rows.isEmpty) {
return null;
}
final members = <MembreModel>[];
// Ignorer la première ligne (en-têtes)
for (int i = 1; i < rows.length; i++) {
final row = rows[i];
if (row.isEmpty) continue;
try {
final member = _parseRowToMember(row.map((cell) => cell?.toString() ?? '').toList());
if (member != null) {
members.add(member);
}
} catch (e) {
debugPrint('⚠️ Erreur ligne $i: $e');
}
}
return members;
} catch (e) {
debugPrint('❌ Erreur import CSV: $e');
return null;
}
}
/// Importe depuis JSON
Future<List<MembreModel>?> _importFromJson(String filePath) async {
try {
final file = File(filePath);
final content = await file.readAsString(encoding: utf8);
final data = jsonDecode(content) as Map<String, dynamic>;
final membersData = data['members'] as List<dynamic>?;
if (membersData == null || membersData.isEmpty) {
return null;
}
final members = <MembreModel>[];
for (final memberData in membersData) {
try {
final member = _parseJsonToMember(memberData as Map<String, dynamic>);
if (member != null) {
members.add(member);
}
} catch (e) {
debugPrint('⚠️ Erreur membre JSON: $e');
}
}
return members;
} catch (e) {
debugPrint('❌ Erreur import JSON: $e');
return null;
}
}
/// Affiche le succès de l'import
void _showImportSuccess(BuildContext context, int count, String format) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle, color: Colors.white, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Import ${format.toUpperCase()} réussi: $count membres',
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
],
),
backgroundColor: AppTheme.successColor,
duration: const Duration(seconds: 4),
),
);
}
/// Affiche l'erreur d'import
void _showImportError(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error, color: Colors.white, size: 20),
const SizedBox(width: 8),
Expanded(child: Text(message)),
],
),
backgroundColor: AppTheme.errorColor,
duration: const Duration(seconds: 5),
),
);
}
/// Parse une ligne de données vers un MembreModel
MembreModel? _parseRowToMember(List<String> row) {
if (row.length < 7) return null; // Minimum requis
try {
// Parser la date de naissance
DateTime? dateNaissance;
if (row.length > 3 && row[3].isNotEmpty) {
try {
dateNaissance = DateTime.parse(row[3]);
} catch (e) {
// Essayer d'autres formats de date
try {
final parts = row[3].split('/');
if (parts.length == 3) {
dateNaissance = DateTime(int.parse(parts[2]), int.parse(parts[1]), int.parse(parts[0]));
}
} catch (e) {
debugPrint('⚠️ Format de date non reconnu: ${row[3]}');
}
}
}
// Parser la date d'adhésion
DateTime dateAdhesion = DateTime.now();
if (row.length > 12 && row[12].isNotEmpty) {
try {
dateAdhesion = DateTime.parse(row[12]);
} catch (e) {
try {
final parts = row[12].split('/');
if (parts.length == 3) {
dateAdhesion = DateTime(int.parse(parts[2]), int.parse(parts[1]), int.parse(parts[0]));
}
} catch (e) {
debugPrint('⚠️ Format de date d\'adhésion non reconnu: ${row[12]}');
}
}
}
return MembreModel(
id: 'import_${DateTime.now().millisecondsSinceEpoch}_${row.hashCode}',
numeroMembre: row[0].isNotEmpty ? row[0] : 'AUTO-${DateTime.now().millisecondsSinceEpoch}',
nom: row[1],
prenom: row[2],
email: row.length > 8 ? row[8] : '',
telephone: row.length > 7 ? row[7] : '',
dateNaissance: dateNaissance,
profession: row.length > 6 ? row[6] : null,
adresse: row.length > 9 ? row[9] : null,
ville: row.length > 10 ? row[10] : null,
pays: row.length > 11 ? row[11] : 'Côte d\'Ivoire',
statut: row.length > 13 ? (row[13].toLowerCase() == 'actif' ? 'ACTIF' : 'INACTIF') : 'ACTIF',
dateAdhesion: dateAdhesion,
dateCreation: DateTime.now(),
actif: row.length > 13 ? (row[13].toLowerCase() == 'actif' || row[13].toLowerCase() == 'true') : true,
version: 1,
);
} catch (e) {
debugPrint('⚠️ Erreur parsing ligne: $e');
return null;
}
}
/// Parse des données JSON vers un MembreModel
MembreModel? _parseJsonToMember(Map<String, dynamic> data) {
try {
// Parser la date de naissance
DateTime? dateNaissance;
if (data['dateNaissance'] != null) {
try {
if (data['dateNaissance'] is String) {
dateNaissance = DateTime.parse(data['dateNaissance']);
} else if (data['dateNaissance'] is DateTime) {
dateNaissance = data['dateNaissance'];
}
} catch (e) {
debugPrint('⚠️ Format de date de naissance JSON non reconnu: ${data['dateNaissance']}');
}
}
// Parser la date d'adhésion
DateTime dateAdhesion = DateTime.now();
if (data['dateAdhesion'] != null) {
try {
if (data['dateAdhesion'] is String) {
dateAdhesion = DateTime.parse(data['dateAdhesion']);
} else if (data['dateAdhesion'] is DateTime) {
dateAdhesion = data['dateAdhesion'];
}
} catch (e) {
debugPrint('⚠️ Format de date d\'adhésion JSON non reconnu: ${data['dateAdhesion']}');
}
}
// Parser la date de création
DateTime dateCreation = DateTime.now();
if (data['dateCreation'] != null) {
try {
if (data['dateCreation'] is String) {
dateCreation = DateTime.parse(data['dateCreation']);
} else if (data['dateCreation'] is DateTime) {
dateCreation = data['dateCreation'];
}
} catch (e) {
debugPrint('⚠️ Format de date de création JSON non reconnu: ${data['dateCreation']}');
}
}
return MembreModel(
id: data['id'] ?? 'import_${DateTime.now().millisecondsSinceEpoch}_${data.hashCode}',
numeroMembre: data['numeroMembre'] ?? 'AUTO-${DateTime.now().millisecondsSinceEpoch}',
nom: data['nom'] ?? '',
prenom: data['prenom'] ?? '',
email: data['email'] ?? '',
telephone: data['telephone'] ?? '',
dateNaissance: dateNaissance,
profession: data['profession'],
adresse: data['adresse'],
ville: data['ville'],
pays: data['pays'] ?? 'Côte d\'Ivoire',
statut: data['statut'] ?? 'ACTIF',
dateAdhesion: dateAdhesion,
dateCreation: dateCreation,
actif: data['actif'] ?? true,
version: data['version'] ?? 1,
);
} catch (e) {
debugPrint('⚠️ Erreur parsing JSON: $e');
return null;
}
}
/// Valide un membre importé
bool _validateImportedMember(MembreModel member) {
// Validation basique
if (member.nom.isEmpty || member.prenom.isEmpty) {
return false;
}
// Validation email si fourni
if (member.email.isNotEmpty && !RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(member.email)) {
return false;
}
// Validation téléphone si fourni
if (member.telephone.isNotEmpty && !RegExp(r'^\+?[\d\s\-\(\)]{8,}$').hasMatch(member.telephone)) {
return false;
}
return true;
}
}

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;
},
),
],
);
}
}