first commit

This commit is contained in:
DahoudG
2025-08-20 21:00:35 +00:00
commit b2a23bdf89
583 changed files with 243074 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/auth/bloc/auth_bloc.dart';
import 'core/auth/models/auth_state.dart';
import 'features/splash/presentation/pages/splash_screen.dart';
import 'features/auth/presentation/pages/login_page.dart';
import 'features/navigation/presentation/pages/main_navigation.dart';
/// Wrapper principal de l'application qui gère la navigation basée sur l'état d'authentification
class AppWrapper extends StatelessWidget {
const AppWrapper({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
switch (state.status) {
case AuthStatus.unknown:
case AuthStatus.checking:
// Afficher l'écran de chargement pendant l'initialisation
return const SplashScreen();
case AuthStatus.authenticated:
// Utilisateur connecté -> Navigation principale
return const MainNavigation();
case AuthStatus.unauthenticated:
case AuthStatus.error:
case AuthStatus.expired:
// Utilisateur non connecté -> Écran de connexion
return const LoginPage();
}
},
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/auth/bloc/temp_auth_bloc.dart';
import 'core/auth/models/auth_state.dart';
import 'features/splash/presentation/pages/splash_screen.dart';
import 'features/auth/presentation/pages/login_page_temp.dart';
import 'features/navigation/presentation/pages/main_navigation.dart';
/// Wrapper temporaire de l'application
class AppTempWrapper extends StatelessWidget {
const AppTempWrapper({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<TempAuthBloc, AuthState>(
builder: (context, state) {
switch (state.status) {
case AuthStatus.unknown:
case AuthStatus.checking:
return const SplashScreen();
case AuthStatus.authenticated:
return const MainNavigation();
case AuthStatus.unauthenticated:
case AuthStatus.error:
case AuthStatus.expired:
return const TempLoginPage();
}
},
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'main_ultra_simple.dart';
import 'core/auth/models/auth_state.dart';
import 'features/splash/presentation/pages/splash_screen.dart';
import 'features/auth/presentation/pages/login_page_temp.dart';
import 'features/navigation/presentation/pages/main_navigation.dart';
/// Wrapper ultra-simple de l'application
class UltraSimpleAppWrapper extends StatelessWidget {
const UltraSimpleAppWrapper({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<UltraSimpleAuthBloc, AuthState>(
builder: (context, state) {
switch (state.status) {
case AuthStatus.unknown:
case AuthStatus.checking:
return const SplashScreen();
case AuthStatus.authenticated:
return const MainNavigation();
case AuthStatus.unauthenticated:
case AuthStatus.error:
case AuthStatus.expired:
return const TempLoginPage();
}
},
);
}
}

View File

@@ -0,0 +1,203 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../models/auth_state.dart';
import '../services/auth_service.dart';
import '../services/auth_api_service.dart';
import 'auth_event.dart';
/// BLoC pour gérer l'authentification
@singleton
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthService _authService;
late StreamSubscription<AuthState> _authStateSubscription;
AuthBloc(this._authService) : super(const AuthState.unknown()) {
// Écouter les changements d'état du service
_authStateSubscription = _authService.authStateStream.listen(
(authState) => add(AuthStateChanged(authState)),
);
// Gestionnaires d'événements
on<AuthInitializeRequested>(_onInitializeRequested);
on<AuthLoginRequested>(_onLoginRequested);
on<AuthLogoutRequested>(_onLogoutRequested);
on<AuthTokenRefreshRequested>(_onTokenRefreshRequested);
on<AuthSessionExpired>(_onSessionExpired);
on<AuthStatusCheckRequested>(_onStatusCheckRequested);
on<AuthErrorCleared>(_onErrorCleared);
on<AuthStateChanged>(_onStateChanged);
}
/// Initialisation de l'authentification
Future<void> _onInitializeRequested(
AuthInitializeRequested event,
Emitter<AuthState> emit,
) async {
emit(const AuthState.checking());
try {
await _authService.initialize();
} catch (e) {
emit(AuthState.error('Erreur d\'initialisation: $e'));
}
}
/// Gestion de la connexion
Future<void> _onLoginRequested(
AuthLoginRequested event,
Emitter<AuthState> emit,
) async {
emit(state.copyWith(isLoading: true, errorMessage: null));
try {
await _authService.login(event.loginRequest);
// L'état sera mis à jour par le stream du service
} on AuthApiException catch (e) {
emit(state.copyWith(
isLoading: false,
errorMessage: e.message,
));
} catch (e) {
emit(state.copyWith(
isLoading: false,
errorMessage: 'Erreur de connexion: $e',
));
}
}
/// Gestion de la déconnexion
Future<void> _onLogoutRequested(
AuthLogoutRequested event,
Emitter<AuthState> emit,
) async {
emit(state.copyWith(isLoading: true));
try {
await _authService.logout();
// L'état sera mis à jour par le stream du service
} catch (e) {
// Même en cas d'erreur, on considère que la déconnexion locale a réussi
emit(const AuthState.unauthenticated());
}
}
/// Gestion du rafraîchissement de token
Future<void> _onTokenRefreshRequested(
AuthTokenRefreshRequested event,
Emitter<AuthState> emit,
) async {
// Le rafraîchissement est géré automatiquement par le service
// Cet événement peut être utilisé pour forcer un rafraîchissement manuel
try {
// Le service gère déjà le rafraîchissement automatique
// On peut ajouter ici une logique spécifique si nécessaire
} catch (e) {
emit(AuthState.error('Erreur lors du rafraîchissement: $e'));
}
}
/// Gestion de l'expiration de session
Future<void> _onSessionExpired(
AuthSessionExpired event,
Emitter<AuthState> emit,
) async {
emit(const AuthState.expired());
// Optionnel: essayer un rafraîchissement automatique
try {
await _authService.logout();
} catch (e) {
// Ignorer les erreurs de déconnexion lors de l'expiration
}
}
/// Vérification du statut d'authentification
Future<void> _onStatusCheckRequested(
AuthStatusCheckRequested event,
Emitter<AuthState> emit,
) async {
// Utiliser l'état actuel du service
final currentServiceState = _authService.currentState;
if (currentServiceState != state) {
emit(currentServiceState);
}
}
/// Nettoyage des erreurs
void _onErrorCleared(
AuthErrorCleared event,
Emitter<AuthState> emit,
) {
if (state.errorMessage != null) {
emit(state.copyWith(errorMessage: null));
}
}
/// Mise à jour depuis le service d'authentification
void _onStateChanged(
AuthStateChanged event,
Emitter<AuthState> emit,
) {
final newState = event.authState as AuthState;
// Émettre le nouvel état seulement s'il a changé
if (newState != state) {
emit(newState);
}
}
/// Vérifie si l'utilisateur est connecté
bool get isAuthenticated => state.isAuthenticated;
/// Récupère l'utilisateur actuel
get currentUser => state.user;
/// Vérifie si l'utilisateur a un rôle spécifique
bool hasRole(String role) {
return _authService.hasRole(role);
}
/// Vérifie si l'utilisateur a un des rôles spécifiés
bool hasAnyRole(List<String> roles) {
return _authService.hasAnyRole(roles);
}
/// Vérifie si la session expire bientôt
bool get isSessionExpiringSoon => state.isExpiringSoon;
/// Récupère le message d'erreur formaté
String? get errorMessage {
final error = state.errorMessage;
if (error == null) return null;
// Formatage des messages d'erreur pour l'utilisateur
if (error.contains('network') || error.contains('connexion')) {
return 'Problème de connexion. Vérifiez votre réseau.';
}
if (error.contains('401') || error.contains('Identifiants')) {
return 'Email ou mot de passe incorrect.';
}
if (error.contains('403')) {
return 'Accès non autorisé.';
}
if (error.contains('timeout')) {
return 'Délai d\'attente dépassé. Réessayez.';
}
if (error.contains('server') || error.contains('500')) {
return 'Erreur serveur temporaire. Réessayez plus tard.';
}
return error;
}
@override
Future<void> close() {
_authStateSubscription.cancel();
return super.close();
}
}

View File

@@ -0,0 +1,60 @@
import 'package:equatable/equatable.dart';
import '../models/login_request.dart';
/// Événements d'authentification
abstract class AuthEvent extends Equatable {
const AuthEvent();
@override
List<Object?> get props => [];
}
/// Initialiser l'authentification
class AuthInitializeRequested extends AuthEvent {
const AuthInitializeRequested();
}
/// Demande de connexion
class AuthLoginRequested extends AuthEvent {
final LoginRequest loginRequest;
const AuthLoginRequested(this.loginRequest);
@override
List<Object> get props => [loginRequest];
}
/// Demande de déconnexion
class AuthLogoutRequested extends AuthEvent {
const AuthLogoutRequested();
}
/// Demande de rafraîchissement de token
class AuthTokenRefreshRequested extends AuthEvent {
const AuthTokenRefreshRequested();
}
/// Session expirée
class AuthSessionExpired extends AuthEvent {
const AuthSessionExpired();
}
/// Vérification de l'état d'authentification
class AuthStatusCheckRequested extends AuthEvent {
const AuthStatusCheckRequested();
}
/// Réinitialisation de l'erreur
class AuthErrorCleared extends AuthEvent {
const AuthErrorCleared();
}
/// Changement d'état depuis le service
class AuthStateChanged extends AuthEvent {
final dynamic authState;
const AuthStateChanged(this.authState);
@override
List<Object?> get props => [authState];
}

View File

@@ -0,0 +1,74 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../models/auth_state.dart';
import '../services/temp_auth_service.dart';
import 'auth_event.dart';
/// BLoC temporaire pour test sans injection de dépendances
class TempAuthBloc extends Bloc<AuthEvent, AuthState> {
final TempAuthService _authService;
late StreamSubscription<AuthState> _authStateSubscription;
TempAuthBloc(this._authService) : super(const AuthState.unknown()) {
_authStateSubscription = _authService.authStateStream.listen(
(authState) => add(AuthStateChanged(authState)),
);
on<AuthInitializeRequested>(_onInitializeRequested);
on<AuthLoginRequested>(_onLoginRequested);
on<AuthLogoutRequested>(_onLogoutRequested);
on<AuthErrorCleared>(_onErrorCleared);
on<AuthStateChanged>(_onStateChanged);
}
Future<void> _onInitializeRequested(
AuthInitializeRequested event,
Emitter<AuthState> emit,
) async {
await _authService.initialize();
}
Future<void> _onLoginRequested(
AuthLoginRequested event,
Emitter<AuthState> emit,
) async {
try {
await _authService.login(event.loginRequest);
} catch (e) {
emit(AuthState.error(e.toString()));
}
}
Future<void> _onLogoutRequested(
AuthLogoutRequested event,
Emitter<AuthState> emit,
) async {
await _authService.logout();
}
void _onErrorCleared(
AuthErrorCleared event,
Emitter<AuthState> emit,
) {
if (state.errorMessage != null) {
emit(state.copyWith(errorMessage: null));
}
}
void _onStateChanged(
AuthStateChanged event,
Emitter<AuthState> emit,
) {
final newState = event.authState as AuthState;
if (newState != state) {
emit(newState);
}
}
@override
Future<void> close() {
_authStateSubscription.cancel();
_authService.dispose();
return super.close();
}
}

View File

@@ -0,0 +1,140 @@
import 'package:equatable/equatable.dart';
import 'user_info.dart';
/// États d'authentification possibles
enum AuthStatus {
unknown, // État initial
checking, // Vérification en cours
authenticated,// Utilisateur connecté
unauthenticated, // Utilisateur non connecté
expired, // Session expirée
error, // Erreur d'authentification
}
/// État d'authentification de l'application
class AuthState extends Equatable {
final AuthStatus status;
final UserInfo? user;
final String? accessToken;
final String? refreshToken;
final DateTime? expiresAt;
final String? errorMessage;
final bool isLoading;
const AuthState({
this.status = AuthStatus.unknown,
this.user,
this.accessToken,
this.refreshToken,
this.expiresAt,
this.errorMessage,
this.isLoading = false,
});
/// État initial inconnu
const AuthState.unknown() : this(status: AuthStatus.unknown);
/// État de vérification
const AuthState.checking() : this(
status: AuthStatus.checking,
isLoading: true,
);
/// État authentifié
const AuthState.authenticated({
required UserInfo user,
required String accessToken,
required String refreshToken,
required DateTime expiresAt,
}) : this(
status: AuthStatus.authenticated,
user: user,
accessToken: accessToken,
refreshToken: refreshToken,
expiresAt: expiresAt,
isLoading: false,
);
/// État non authentifié
const AuthState.unauthenticated({String? errorMessage}) : this(
status: AuthStatus.unauthenticated,
errorMessage: errorMessage,
isLoading: false,
);
/// État de session expirée
const AuthState.expired() : this(
status: AuthStatus.expired,
isLoading: false,
);
/// État d'erreur
const AuthState.error(String errorMessage) : this(
status: AuthStatus.error,
errorMessage: errorMessage,
isLoading: false,
);
/// Vérifie si l'utilisateur est connecté
bool get isAuthenticated => status == AuthStatus.authenticated;
/// Vérifie si la session est valide
bool get isSessionValid {
if (!isAuthenticated || expiresAt == null) return false;
return DateTime.now().isBefore(expiresAt!);
}
/// Vérifie si la session expire bientôt
bool get isExpiringSoon {
if (!isAuthenticated || expiresAt == null) return false;
final threshold = DateTime.now().add(const Duration(minutes: 5));
return expiresAt!.isBefore(threshold);
}
/// Crée une copie avec des modifications
AuthState copyWith({
AuthStatus? status,
UserInfo? user,
String? accessToken,
String? refreshToken,
DateTime? expiresAt,
String? errorMessage,
bool? isLoading,
}) {
return AuthState(
status: status ?? this.status,
user: user ?? this.user,
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
expiresAt: expiresAt ?? this.expiresAt,
errorMessage: errorMessage ?? this.errorMessage,
isLoading: isLoading ?? this.isLoading,
);
}
/// Crée une copie en effaçant les données sensibles
AuthState clearSensitiveData() {
return AuthState(
status: status,
user: user,
errorMessage: errorMessage,
isLoading: isLoading,
);
}
@override
List<Object?> get props => [
status,
user,
accessToken,
refreshToken,
expiresAt,
errorMessage,
isLoading,
];
@override
String toString() {
return 'AuthState(status: $status, user: ${user?.email}, isLoading: $isLoading, error: $errorMessage)';
}
}

View File

@@ -0,0 +1,50 @@
import 'package:equatable/equatable.dart';
/// Modèle de requête de connexion
class LoginRequest extends Equatable {
final String email;
final String password;
final bool rememberMe;
const LoginRequest({
required this.email,
required this.password,
this.rememberMe = false,
});
Map<String, dynamic> toJson() {
return {
'email': email,
'password': password,
'rememberMe': rememberMe,
};
}
factory LoginRequest.fromJson(Map<String, dynamic> json) {
return LoginRequest(
email: json['email'] ?? '',
password: json['password'] ?? '',
rememberMe: json['rememberMe'] ?? false,
);
}
LoginRequest copyWith({
String? email,
String? password,
bool? rememberMe,
}) {
return LoginRequest(
email: email ?? this.email,
password: password ?? this.password,
rememberMe: rememberMe ?? this.rememberMe,
);
}
@override
List<Object?> get props => [email, password, rememberMe];
@override
String toString() {
return 'LoginRequest(email: $email, rememberMe: $rememberMe)';
}
}

View File

@@ -0,0 +1,96 @@
import 'package:equatable/equatable.dart';
import 'user_info.dart';
/// Modèle de réponse de connexion
class LoginResponse extends Equatable {
final String accessToken;
final String refreshToken;
final String tokenType;
final DateTime expiresAt;
final DateTime refreshExpiresAt;
final UserInfo user;
const LoginResponse({
required this.accessToken,
required this.refreshToken,
required this.tokenType,
required this.expiresAt,
required this.refreshExpiresAt,
required this.user,
});
/// Vérifie si le token d'accès est expiré
bool get isAccessTokenExpired {
return DateTime.now().isAfter(expiresAt);
}
/// Vérifie si le refresh token est expiré
bool get isRefreshTokenExpired {
return DateTime.now().isAfter(refreshExpiresAt);
}
/// Vérifie si le token expire dans les prochaines minutes
bool isExpiringSoon({int minutes = 5}) {
final threshold = DateTime.now().add(Duration(minutes: minutes));
return expiresAt.isBefore(threshold);
}
factory LoginResponse.fromJson(Map<String, dynamic> json) {
return LoginResponse(
accessToken: json['accessToken'] ?? '',
refreshToken: json['refreshToken'] ?? '',
tokenType: json['tokenType'] ?? 'Bearer',
expiresAt: json['expiresAt'] != null
? DateTime.parse(json['expiresAt'])
: DateTime.now().add(const Duration(minutes: 15)),
refreshExpiresAt: json['refreshExpiresAt'] != null
? DateTime.parse(json['refreshExpiresAt'])
: DateTime.now().add(const Duration(days: 7)),
user: UserInfo.fromJson(json['user'] ?? {}),
);
}
Map<String, dynamic> toJson() {
return {
'accessToken': accessToken,
'refreshToken': refreshToken,
'tokenType': tokenType,
'expiresAt': expiresAt.toIso8601String(),
'refreshExpiresAt': refreshExpiresAt.toIso8601String(),
'user': user.toJson(),
};
}
LoginResponse copyWith({
String? accessToken,
String? refreshToken,
String? tokenType,
DateTime? expiresAt,
DateTime? refreshExpiresAt,
UserInfo? user,
}) {
return LoginResponse(
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
tokenType: tokenType ?? this.tokenType,
expiresAt: expiresAt ?? this.expiresAt,
refreshExpiresAt: refreshExpiresAt ?? this.refreshExpiresAt,
user: user ?? this.user,
);
}
@override
List<Object?> get props => [
accessToken,
refreshToken,
tokenType,
expiresAt,
refreshExpiresAt,
user,
];
@override
String toString() {
return 'LoginResponse(tokenType: $tokenType, user: ${user.email}, expiresAt: $expiresAt)';
}
}

View File

@@ -0,0 +1,5 @@
// Export all auth models
export 'auth_state.dart';
export 'login_request.dart';
export 'login_response.dart';
export 'user_info.dart';

View File

@@ -0,0 +1,90 @@
import 'package:equatable/equatable.dart';
/// Modèle des informations utilisateur
class UserInfo extends Equatable {
final String id;
final String email;
final String firstName;
final String lastName;
final String role;
final String? profilePicture;
final bool isActive;
const UserInfo({
required this.id,
required this.email,
required this.firstName,
required this.lastName,
required this.role,
this.profilePicture,
required this.isActive,
});
String get fullName => '$firstName $lastName';
String get initials {
final f = firstName.isNotEmpty ? firstName[0] : '';
final l = lastName.isNotEmpty ? lastName[0] : '';
return '$f$l'.toUpperCase();
}
factory UserInfo.fromJson(Map<String, dynamic> json) {
return UserInfo(
id: json['id'] ?? '',
email: json['email'] ?? '',
firstName: json['firstName'] ?? '',
lastName: json['lastName'] ?? '',
role: json['role'] ?? 'membre',
profilePicture: json['profilePicture'],
isActive: json['isActive'] ?? true,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'firstName': firstName,
'lastName': lastName,
'role': role,
'profilePicture': profilePicture,
'isActive': isActive,
};
}
UserInfo copyWith({
String? id,
String? email,
String? firstName,
String? lastName,
String? role,
String? profilePicture,
bool? isActive,
}) {
return UserInfo(
id: id ?? this.id,
email: email ?? this.email,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
role: role ?? this.role,
profilePicture: profilePicture ?? this.profilePicture,
isActive: isActive ?? this.isActive,
);
}
@override
List<Object?> get props => [
id,
email,
firstName,
lastName,
role,
profilePicture,
isActive,
];
@override
String toString() {
return 'UserInfo(id: $id, email: $email, fullName: $fullName, role: $role, isActive: $isActive)';
}
}

View File

@@ -0,0 +1,306 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import '../../../core/network/dio_client.dart';
import '../models/login_request.dart';
import '../models/login_response.dart';
/// Service API pour l'authentification
@singleton
class AuthApiService {
final DioClient _dioClient;
late final Dio _dio;
AuthApiService(this._dioClient) {
_dio = _dioClient.dio;
}
/// Effectue la connexion utilisateur
Future<LoginResponse> login(LoginRequest request) async {
try {
final response = await _dio.post(
'/api/auth/login',
data: request.toJson(),
options: Options(
headers: {
'Content-Type': 'application/json',
},
// Désactiver l'interceptor d'auth pour cette requête
extra: {'skipAuth': true},
),
);
if (response.statusCode == 200) {
return LoginResponse.fromJson(response.data);
} else {
throw AuthApiException(
'Erreur de connexion',
statusCode: response.statusCode,
response: response.data,
);
}
} on DioException catch (e) {
throw _handleDioException(e);
} catch (e) {
throw AuthApiException('Erreur inattendue lors de la connexion: $e');
}
}
/// Rafraîchit le token d'accès
Future<LoginResponse> refreshToken(String refreshToken) async {
try {
final response = await _dio.post(
'/api/auth/refresh',
data: {'refreshToken': refreshToken},
options: Options(
headers: {
'Content-Type': 'application/json',
},
extra: {'skipAuth': true},
),
);
if (response.statusCode == 200) {
return LoginResponse.fromJson(response.data);
} else {
throw AuthApiException(
'Erreur lors du rafraîchissement du token',
statusCode: response.statusCode,
response: response.data,
);
}
} on DioException catch (e) {
throw _handleDioException(e);
} catch (e) {
throw AuthApiException('Erreur inattendue lors du rafraîchissement: $e');
}
}
/// Effectue la déconnexion
Future<void> logout(String? refreshToken) async {
try {
await _dio.post(
'/api/auth/logout',
data: refreshToken != null ? {'refreshToken': refreshToken} : {},
options: Options(
headers: {
'Content-Type': 'application/json',
},
extra: {'skipAuth': true},
),
);
} on DioException catch (e) {
// Ignorer les erreurs de déconnexion côté serveur
// La déconnexion locale est plus importante
print('Erreur lors de la déconnexion serveur: ${e.message}');
} catch (e) {
print('Erreur inattendue lors de la déconnexion: $e');
}
}
/// Valide un token côté serveur
Future<bool> validateToken(String accessToken) async {
try {
final response = await _dio.get(
'/api/auth/validate',
options: Options(
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
},
extra: {'skipAuth': true},
),
);
return response.statusCode == 200;
} on DioException catch (e) {
if (e.response?.statusCode == 401) {
return false;
}
throw _handleDioException(e);
} catch (e) {
throw AuthApiException('Erreur lors de la validation du token: $e');
}
}
/// Récupère les informations de l'API d'authentification
Future<Map<String, dynamic>> getAuthInfo() async {
try {
final response = await _dio.get(
'/api/auth/info',
options: Options(
extra: {'skipAuth': true},
),
);
if (response.statusCode == 200) {
return response.data as Map<String, dynamic>;
} else {
throw AuthApiException(
'Erreur lors de la récupération des informations',
statusCode: response.statusCode,
);
}
} on DioException catch (e) {
throw _handleDioException(e);
} catch (e) {
throw AuthApiException('Erreur inattendue: $e');
}
}
/// Gestion centralisée des erreurs Dio
AuthApiException _handleDioException(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return AuthApiException(
'Délai d\'attente dépassé. Vérifiez votre connexion internet.',
type: AuthErrorType.timeout,
);
case DioExceptionType.connectionError:
return AuthApiException(
'Impossible de se connecter au serveur. Vérifiez votre connexion internet.',
type: AuthErrorType.network,
);
case DioExceptionType.badResponse:
final statusCode = e.response?.statusCode;
final data = e.response?.data;
switch (statusCode) {
case 400:
return AuthApiException(
_extractErrorMessage(data) ?? 'Données de requête invalides',
statusCode: statusCode,
type: AuthErrorType.validation,
response: data,
);
case 401:
return AuthApiException(
_extractErrorMessage(data) ?? 'Identifiants invalides',
statusCode: statusCode,
type: AuthErrorType.unauthorized,
response: data,
);
case 403:
return AuthApiException(
_extractErrorMessage(data) ?? 'Accès interdit',
statusCode: statusCode,
type: AuthErrorType.forbidden,
response: data,
);
case 429:
return AuthApiException(
'Trop de tentatives. Veuillez réessayer plus tard.',
statusCode: statusCode,
type: AuthErrorType.rateLimited,
response: data,
);
case 500:
case 502:
case 503:
case 504:
return AuthApiException(
'Erreur serveur temporaire. Veuillez réessayer.',
statusCode: statusCode,
type: AuthErrorType.server,
response: data,
);
default:
return AuthApiException(
_extractErrorMessage(data) ?? 'Erreur serveur inconnue',
statusCode: statusCode,
response: data,
);
}
case DioExceptionType.cancel:
return AuthApiException(
'Requête annulée',
type: AuthErrorType.cancelled,
);
default:
return AuthApiException(
'Erreur réseau: ${e.message}',
type: AuthErrorType.unknown,
);
}
}
/// Extrait le message d'erreur de la réponse
String? _extractErrorMessage(dynamic data) {
if (data == null) return null;
if (data is Map<String, dynamic>) {
return data['message'] ?? data['error'] ?? data['detail'];
}
if (data is String) {
try {
final json = jsonDecode(data) as Map<String, dynamic>;
return json['message'] ?? json['error'] ?? json['detail'];
} catch (_) {
return data;
}
}
return null;
}
}
/// Types d'erreurs d'authentification
enum AuthErrorType {
validation,
unauthorized,
forbidden,
timeout,
network,
server,
rateLimited,
cancelled,
unknown,
}
/// Exception spécifique à l'API d'authentification
class AuthApiException implements Exception {
final String message;
final int? statusCode;
final AuthErrorType type;
final dynamic response;
const AuthApiException(
this.message, {
this.statusCode,
this.type = AuthErrorType.unknown,
this.response,
});
bool get isNetworkError =>
type == AuthErrorType.network ||
type == AuthErrorType.timeout;
bool get isServerError => type == AuthErrorType.server;
bool get isClientError =>
type == AuthErrorType.validation ||
type == AuthErrorType.unauthorized ||
type == AuthErrorType.forbidden;
bool get isRetryable =>
isNetworkError ||
isServerError ||
type == AuthErrorType.rateLimited;
@override
String toString() {
return 'AuthApiException: $message ${statusCode != null ? '(Status: $statusCode)' : ''}';
}
}

View File

@@ -0,0 +1,318 @@
import 'dart:async';
import 'package:injectable/injectable.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import '../models/auth_state.dart';
import '../models/login_request.dart';
import '../models/login_response.dart';
import '../models/user_info.dart';
import '../storage/secure_token_storage.dart';
import 'auth_api_service.dart';
import '../../network/auth_interceptor.dart';
import '../../network/dio_client.dart';
/// Service principal d'authentification
@singleton
class AuthService {
final SecureTokenStorage _tokenStorage;
final AuthApiService _apiService;
final AuthInterceptor _authInterceptor;
final DioClient _dioClient;
// Stream controllers pour notifier les changements d'état
final _authStateController = StreamController<AuthState>.broadcast();
final _tokenRefreshController = StreamController<void>.broadcast();
// Timers pour la gestion automatique des tokens
Timer? _tokenRefreshTimer;
Timer? _sessionExpiryTimer;
// État actuel
AuthState _currentState = const AuthState.unknown();
AuthService(
this._tokenStorage,
this._apiService,
this._authInterceptor,
this._dioClient,
) {
_initializeAuthInterceptor();
}
// Getters
Stream<AuthState> get authStateStream => _authStateController.stream;
AuthState get currentState => _currentState;
bool get isAuthenticated => _currentState.isAuthenticated;
UserInfo? get currentUser => _currentState.user;
/// Initialise l'interceptor d'authentification
void _initializeAuthInterceptor() {
_authInterceptor.setCallbacks(
onTokenRefreshNeeded: () => _refreshTokenSilently(),
onAuthenticationFailed: () => logout(),
);
_dioClient.addAuthInterceptor(_authInterceptor);
}
/// Initialise le service d'authentification
Future<void> initialize() async {
_updateState(const AuthState.checking());
try {
// Vérifier si des tokens existent
final hasTokens = await _tokenStorage.hasAuthData();
if (!hasTokens) {
_updateState(const AuthState.unauthenticated());
return;
}
// Récupérer les données d'authentification
final authData = await _tokenStorage.getAuthData();
if (authData == null) {
_updateState(const AuthState.unauthenticated());
return;
}
// Vérifier si les tokens sont expirés
if (authData.isRefreshTokenExpired) {
await _tokenStorage.clearAuthData();
_updateState(const AuthState.unauthenticated());
return;
}
// Si le token d'accès est expiré, essayer de le rafraîchir
if (authData.isAccessTokenExpired) {
await _refreshToken();
return;
}
// Valider le token côté serveur
final isValid = await _validateTokenWithServer(authData.accessToken);
if (!isValid) {
await _refreshToken();
return;
}
// Tout est OK, restaurer la session
_updateState(AuthState.authenticated(
user: authData.user,
accessToken: authData.accessToken,
refreshToken: authData.refreshToken,
expiresAt: authData.expiresAt,
));
_scheduleTokenRefresh();
_scheduleSessionExpiry();
} catch (e) {
print('Erreur lors de l\'initialisation de l\'auth: $e');
await _tokenStorage.clearAuthData();
_updateState(AuthState.error('Erreur d\'initialisation: $e'));
}
}
/// Connecte un utilisateur
Future<void> login(LoginRequest request) async {
_updateState(_currentState.copyWith(isLoading: true));
try {
// Appel API de connexion
final response = await _apiService.login(request);
// Sauvegarder les tokens
await _tokenStorage.saveAuthData(response);
// Mettre à jour l'état
_updateState(AuthState.authenticated(
user: response.user,
accessToken: response.accessToken,
refreshToken: response.refreshToken,
expiresAt: response.expiresAt,
));
// Programmer les rafraîchissements
_scheduleTokenRefresh();
_scheduleSessionExpiry();
} on AuthApiException catch (e) {
_updateState(AuthState.error(e.message));
rethrow;
} catch (e) {
final errorMessage = 'Erreur de connexion: $e';
_updateState(AuthState.error(errorMessage));
rethrow;
}
}
/// Déconnecte l'utilisateur
Future<void> logout() async {
try {
// Récupérer le refresh token pour l'invalider côté serveur
final refreshToken = await _tokenStorage.getRefreshToken();
// Appel API de déconnexion (optionnel)
await _apiService.logout(refreshToken);
} catch (e) {
print('Erreur lors de la déconnexion serveur: $e');
}
// Nettoyage local (toujours fait)
await _tokenStorage.clearAuthData();
_cancelTimers();
_updateState(const AuthState.unauthenticated());
}
/// Rafraîchit le token d'accès
Future<void> _refreshToken() async {
try {
final refreshToken = await _tokenStorage.getRefreshToken();
if (refreshToken == null) {
throw Exception('Aucun refresh token disponible');
}
// Vérifier si le refresh token est expiré
final refreshExpiresAt = await _tokenStorage.getRefreshTokenExpirationDate();
if (refreshExpiresAt != null && DateTime.now().isAfter(refreshExpiresAt)) {
throw Exception('Refresh token expiré');
}
// Appel API de refresh
final response = await _apiService.refreshToken(refreshToken);
// Mettre à jour le stockage
await _tokenStorage.updateAccessToken(response.accessToken, response.expiresAt);
// Mettre à jour l'état
if (_currentState.isAuthenticated) {
_updateState(_currentState.copyWith(
accessToken: response.accessToken,
expiresAt: response.expiresAt,
));
} else {
_updateState(AuthState.authenticated(
user: response.user,
accessToken: response.accessToken,
refreshToken: response.refreshToken,
expiresAt: response.expiresAt,
));
}
// Reprogrammer les timers
_scheduleTokenRefresh();
} catch (e) {
print('Erreur lors du rafraîchissement du token: $e');
await logout();
}
}
/// Rafraîchit le token silencieusement (sans changer l'état de loading)
Future<void> _refreshTokenSilently() async {
try {
await _refreshToken();
_tokenRefreshController.add(null);
} catch (e) {
print('Erreur lors du rafraîchissement silencieux: $e');
}
}
/// Valide un token côté serveur
Future<bool> _validateTokenWithServer(String accessToken) async {
try {
return await _apiService.validateToken(accessToken);
} catch (e) {
print('Erreur lors de la validation du token: $e');
return false;
}
}
/// Programme le rafraîchissement automatique du token
void _scheduleTokenRefresh() {
_tokenRefreshTimer?.cancel();
if (!_currentState.isAuthenticated || _currentState.expiresAt == null) {
return;
}
// Rafraîchir 5 minutes avant l'expiration
final refreshTime = _currentState.expiresAt!.subtract(const Duration(minutes: 5));
final delay = refreshTime.difference(DateTime.now());
if (delay.isNegative) {
// Le token expire bientôt, rafraîchir immédiatement
_refreshTokenSilently();
return;
}
_tokenRefreshTimer = Timer(delay, () => _refreshTokenSilently());
}
/// Programme l'expiration de la session
void _scheduleSessionExpiry() {
_sessionExpiryTimer?.cancel();
if (!_currentState.isAuthenticated || _currentState.expiresAt == null) {
return;
}
final delay = _currentState.expiresAt!.difference(DateTime.now());
if (delay.isNegative) {
logout();
return;
}
_sessionExpiryTimer = Timer(delay, () {
_updateState(const AuthState.expired());
});
}
/// Annule tous les timers
void _cancelTimers() {
_tokenRefreshTimer?.cancel();
_sessionExpiryTimer?.cancel();
_tokenRefreshTimer = null;
_sessionExpiryTimer = null;
}
/// Met à jour l'état et notifie les listeners
void _updateState(AuthState newState) {
_currentState = newState;
_authStateController.add(newState);
}
/// Nettoie les ressources
void dispose() {
_cancelTimers();
_authStateController.close();
_tokenRefreshController.close();
}
/// Vérifie les permissions de l'utilisateur
bool hasRole(String role) {
return _currentState.user?.role == role;
}
/// Vérifie si l'utilisateur a un des rôles spécifiés
bool hasAnyRole(List<String> roles) {
final userRole = _currentState.user?.role;
return userRole != null && roles.contains(userRole);
}
/// Décode un token JWT (utilitaire)
Map<String, dynamic>? decodeToken(String token) {
try {
return JwtDecoder.decode(token);
} catch (e) {
print('Erreur lors du décodage du token: $e');
return null;
}
}
/// Vérifie si un token est expiré
bool isTokenExpired(String token) {
try {
return JwtDecoder.isExpired(token);
} catch (e) {
return true;
}
}
}

View File

@@ -0,0 +1,70 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/auth_state.dart';
import '../models/login_request.dart';
import '../models/user_info.dart';
/// Service d'authentification temporaire pour test sans dépendances
class TempAuthService {
final _authStateController = StreamController<AuthState>.broadcast();
AuthState _currentState = const AuthState.unknown();
Stream<AuthState> get authStateStream => _authStateController.stream;
AuthState get currentState => _currentState;
bool get isAuthenticated => _currentState.isAuthenticated;
UserInfo? get currentUser => _currentState.user;
Future<void> initialize() async {
_updateState(const AuthState.checking());
// Simuler une vérification
await Future.delayed(const Duration(seconds: 2));
_updateState(const AuthState.unauthenticated());
}
Future<void> login(LoginRequest request) async {
_updateState(_currentState.copyWith(isLoading: true));
try {
// Simulation d'appel API
await Future.delayed(const Duration(seconds: 1));
// Vérification simple pour la démo
if (request.email == 'admin@unionflow.dev' && request.password == 'admin123') {
final user = UserInfo(
id: '1',
email: request.email,
firstName: 'Admin',
lastName: 'UnionFlow',
role: 'admin',
isActive: true,
);
_updateState(AuthState.authenticated(
user: user,
accessToken: 'fake_access_token',
refreshToken: 'fake_refresh_token',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
));
} else {
throw Exception('Identifiants invalides');
}
} catch (e) {
_updateState(AuthState.error(e.toString()));
}
}
Future<void> logout() async {
_updateState(const AuthState.unauthenticated());
}
void _updateState(AuthState newState) {
_currentState = newState;
_authStateController.add(newState);
}
void dispose() {
_authStateController.close();
}
}

View File

@@ -0,0 +1,86 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/auth_state.dart';
import '../models/login_request.dart';
import '../models/user_info.dart';
/// Service d'authentification ultra-simple sans aucune dépendance externe
class UltraSimpleAuthService {
final _authStateController = StreamController<AuthState>.broadcast();
AuthState _currentState = const AuthState.unknown();
Stream<AuthState> get authStateStream => _authStateController.stream;
AuthState get currentState => _currentState;
bool get isAuthenticated => _currentState.isAuthenticated;
UserInfo? get currentUser => _currentState.user;
Future<void> initialize() async {
_updateState(const AuthState.checking());
// Simuler une vérification
await Future.delayed(const Duration(seconds: 2));
_updateState(const AuthState.unauthenticated());
}
Future<void> login(LoginRequest request) async {
_updateState(_currentState.copyWith(isLoading: true));
try {
// Simulation d'appel API
await Future.delayed(const Duration(seconds: 1));
// Vérification simple pour la démo
if (request.email == 'admin@unionflow.dev' && request.password == 'admin123') {
final user = UserInfo(
id: '1',
email: request.email,
firstName: 'Admin',
lastName: 'UnionFlow',
role: 'admin',
isActive: true,
);
_updateState(AuthState.authenticated(
user: user,
accessToken: 'fake_access_token_${DateTime.now().millisecondsSinceEpoch}',
refreshToken: 'fake_refresh_token_${DateTime.now().millisecondsSinceEpoch}',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
));
} else if (request.email == 'president@lions.org' && request.password == 'admin123') {
final user = UserInfo(
id: '2',
email: request.email,
firstName: 'Jean',
lastName: 'Dupont',
role: 'président',
isActive: true,
);
_updateState(AuthState.authenticated(
user: user,
accessToken: 'fake_access_token_${DateTime.now().millisecondsSinceEpoch}',
refreshToken: 'fake_refresh_token_${DateTime.now().millisecondsSinceEpoch}',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
));
} else {
throw Exception('Identifiants invalides');
}
} catch (e) {
_updateState(AuthState.error(e.toString()));
}
}
Future<void> logout() async {
_updateState(const AuthState.unauthenticated());
}
void _updateState(AuthState newState) {
_currentState = newState;
_authStateController.add(newState);
}
void dispose() {
_authStateController.close();
}
}

View File

@@ -0,0 +1,117 @@
import 'dart:convert';
import '../models/login_response.dart';
import '../models/user_info.dart';
/// Service de stockage en mémoire des tokens (temporaire pour contourner Java 21)
class MemoryTokenStorage {
static final MemoryTokenStorage _instance = MemoryTokenStorage._internal();
factory MemoryTokenStorage() => _instance;
MemoryTokenStorage._internal();
// Stockage en mémoire
final Map<String, String> _storage = {};
static const String _accessTokenKey = 'access_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _userInfoKey = 'user_info';
static const String _expiresAtKey = 'expires_at';
static const String _refreshExpiresAtKey = 'refresh_expires_at';
/// Sauvegarde les données d'authentification
Future<void> saveAuthData(LoginResponse loginResponse) async {
try {
_storage[_accessTokenKey] = loginResponse.accessToken;
_storage[_refreshTokenKey] = loginResponse.refreshToken;
_storage[_userInfoKey] = jsonEncode(loginResponse.user.toJson());
_storage[_expiresAtKey] = loginResponse.expiresAt.toIso8601String();
_storage[_refreshExpiresAtKey] = loginResponse.refreshExpiresAt.toIso8601String();
} catch (e) {
throw StorageException('Erreur lors de la sauvegarde des données d\'authentification: $e');
}
}
/// Récupère le token d'accès
Future<String?> getAccessToken() async {
return _storage[_accessTokenKey];
}
/// Récupère le refresh token
Future<String?> getRefreshToken() async {
return _storage[_refreshTokenKey];
}
/// Récupère les informations utilisateur
Future<UserInfo?> getUserInfo() async {
try {
final userJson = _storage[_userInfoKey];
if (userJson == null) return null;
final userMap = jsonDecode(userJson) as Map<String, dynamic>;
return UserInfo.fromJson(userMap);
} catch (e) {
throw StorageException('Erreur lors de la récupération des informations utilisateur: $e');
}
}
/// Récupère la date d'expiration du token d'accès
Future<DateTime?> getTokenExpirationDate() async {
try {
final expiresAtString = _storage[_expiresAtKey];
if (expiresAtString == null) return null;
return DateTime.parse(expiresAtString);
} catch (e) {
throw StorageException('Erreur lors de la récupération de la date d\'expiration: $e');
}
}
/// Récupère la date d'expiration du refresh token
Future<DateTime?> getRefreshTokenExpirationDate() async {
try {
final expiresAtString = _storage[_refreshExpiresAtKey];
if (expiresAtString == null) return null;
return DateTime.parse(expiresAtString);
} catch (e) {
throw StorageException('Erreur lors de la récupération de la date d\'expiration du refresh token: $e');
}
}
/// Vérifie si l'utilisateur est authentifié
Future<bool> hasValidToken() async {
final token = await getAccessToken();
if (token == null) return false;
final expirationDate = await getTokenExpirationDate();
if (expirationDate == null) return false;
return DateTime.now().isBefore(expirationDate);
}
/// Efface toutes les données d'authentification
Future<void> clearAll() async {
_storage.clear();
}
/// Met à jour uniquement les tokens
Future<void> updateTokens({
required String accessToken,
required String refreshToken,
required DateTime expiresAt,
required DateTime refreshExpiresAt,
}) async {
_storage[_accessTokenKey] = accessToken;
_storage[_refreshTokenKey] = refreshToken;
_storage[_expiresAtKey] = expiresAt.toIso8601String();
_storage[_refreshExpiresAtKey] = refreshExpiresAt.toIso8601String();
}
}
/// Exception personnalisée pour les erreurs de stockage
class StorageException implements Exception {
final String message;
StorageException(this.message);
@override
String toString() => 'StorageException: $message';
}

View File

@@ -0,0 +1,246 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:injectable/injectable.dart';
import '../models/login_response.dart';
import '../models/user_info.dart';
/// Service de stockage sécurisé des tokens d'authentification
@singleton
class SecureTokenStorage {
static const String _accessTokenKey = 'access_token';
static const String _refreshTokenKey = 'refresh_token';
static const String _userInfoKey = 'user_info';
static const String _expiresAtKey = 'expires_at';
static const String _refreshExpiresAtKey = 'refresh_expires_at';
static const String _biometricEnabledKey = 'biometric_enabled';
// Utilise SharedPreferences temporairement pour Android
Future<SharedPreferences> get _prefs => SharedPreferences.getInstance();
/// Sauvegarde les données d'authentification
Future<void> saveAuthData(LoginResponse loginResponse) async {
try {
final prefs = await _prefs;
await Future.wait([
prefs.setString(_accessTokenKey, loginResponse.accessToken),
prefs.setString(_refreshTokenKey, loginResponse.refreshToken),
prefs.setString(_userInfoKey, jsonEncode(loginResponse.user.toJson())),
prefs.setString(_expiresAtKey, loginResponse.expiresAt.toIso8601String()),
prefs.setString(_refreshExpiresAtKey, loginResponse.refreshExpiresAt.toIso8601String()),
]);
} catch (e) {
throw StorageException('Erreur lors de la sauvegarde des données d\'authentification: $e');
}
}
/// Récupère le token d'accès
Future<String?> getAccessToken() async {
try {
final prefs = await _prefs;
return prefs.getString(_accessTokenKey);
} catch (e) {
throw StorageException('Erreur lors de la récupération du token d\'accès: $e');
}
}
/// Récupère le refresh token
Future<String?> getRefreshToken() async {
try {
final prefs = await _prefs;
return prefs.getString(_refreshTokenKey);
} catch (e) {
throw StorageException('Erreur lors de la récupération du refresh token: $e');
}
}
/// Récupère les informations utilisateur
Future<UserInfo?> getUserInfo() async {
try {
final prefs = await _prefs;
final userJson = prefs.getString(_userInfoKey);
if (userJson == null) return null;
final userMap = jsonDecode(userJson) as Map<String, dynamic>;
return UserInfo.fromJson(userMap);
} catch (e) {
throw StorageException('Erreur lors de la récupération des informations utilisateur: $e');
}
}
/// Récupère la date d'expiration du token d'accès
Future<DateTime?> getTokenExpirationDate() async {
try {
final prefs = await _prefs;
final expiresAtString = prefs.getString(_expiresAtKey);
if (expiresAtString == null) return null;
return DateTime.parse(expiresAtString);
} catch (e) {
throw StorageException('Erreur lors de la récupération de la date d\'expiration: $e');
}
}
/// Récupère la date d'expiration du refresh token
Future<DateTime?> getRefreshTokenExpirationDate() async {
try {
final expiresAtString = await _storage.read(key: _refreshExpiresAtKey);
if (expiresAtString == null) return null;
return DateTime.parse(expiresAtString);
} catch (e) {
throw StorageException('Erreur lors de la récupération de la date d\'expiration du refresh token: $e');
}
}
/// Récupère toutes les données d'authentification
Future<LoginResponse?> getAuthData() async {
try {
final results = await Future.wait([
getAccessToken(),
getRefreshToken(),
getUserInfo(),
getTokenExpirationDate(),
getRefreshTokenExpirationDate(),
]);
final accessToken = results[0] as String?;
final refreshToken = results[1] as String?;
final userInfo = results[2] as UserInfo?;
final expiresAt = results[3] as DateTime?;
final refreshExpiresAt = results[4] as DateTime?;
if (accessToken == null ||
refreshToken == null ||
userInfo == null ||
expiresAt == null ||
refreshExpiresAt == null) {
return null;
}
return LoginResponse(
accessToken: accessToken,
refreshToken: refreshToken,
tokenType: 'Bearer',
expiresAt: expiresAt,
refreshExpiresAt: refreshExpiresAt,
user: userInfo,
);
} catch (e) {
throw StorageException('Erreur lors de la récupération des données d\'authentification: $e');
}
}
/// Met à jour le token d'accès
Future<void> updateAccessToken(String accessToken, DateTime expiresAt) async {
try {
await Future.wait([
_storage.write(key: _accessTokenKey, value: accessToken),
_storage.write(key: _expiresAtKey, value: expiresAt.toIso8601String()),
]);
} catch (e) {
throw StorageException('Erreur lors de la mise à jour du token d\'accès: $e');
}
}
/// Vérifie si les données d'authentification existent
Future<bool> hasAuthData() async {
try {
final accessToken = await _storage.read(key: _accessTokenKey);
final refreshToken = await _storage.read(key: _refreshTokenKey);
return accessToken != null && refreshToken != null;
} catch (e) {
return false;
}
}
/// Vérifie si les tokens sont expirés
Future<bool> areTokensExpired() async {
try {
final expiresAt = await getTokenExpirationDate();
final refreshExpiresAt = await getRefreshTokenExpirationDate();
if (expiresAt == null || refreshExpiresAt == null) return true;
final now = DateTime.now();
return refreshExpiresAt.isBefore(now);
} catch (e) {
return true;
}
}
/// Vérifie si le token d'accès expire bientôt
Future<bool> isAccessTokenExpiringSoon({int minutes = 5}) async {
try {
final expiresAt = await getTokenExpirationDate();
if (expiresAt == null) return true;
final threshold = DateTime.now().add(Duration(minutes: minutes));
return expiresAt.isBefore(threshold);
} catch (e) {
return true;
}
}
/// Efface toutes les données d'authentification
Future<void> clearAuthData() async {
try {
await Future.wait([
_storage.delete(key: _accessTokenKey),
_storage.delete(key: _refreshTokenKey),
_storage.delete(key: _userInfoKey),
_storage.delete(key: _expiresAtKey),
_storage.delete(key: _refreshExpiresAtKey),
]);
} catch (e) {
throw StorageException('Erreur lors de l\'effacement des données d\'authentification: $e');
}
}
/// Active/désactive l'authentification biométrique
Future<void> setBiometricEnabled(bool enabled) async {
try {
await _storage.write(key: _biometricEnabledKey, value: enabled.toString());
} catch (e) {
throw StorageException('Erreur lors de la configuration biométrique: $e');
}
}
/// Vérifie si l'authentification biométrique est activée
Future<bool> isBiometricEnabled() async {
try {
final enabled = await _storage.read(key: _biometricEnabledKey);
return enabled == 'true';
} catch (e) {
return false;
}
}
/// Efface toutes les données stockées
Future<void> clearAll() async {
try {
await _storage.deleteAll();
} catch (e) {
throw StorageException('Erreur lors de l\'effacement de toutes les données: $e');
}
}
/// Vérifie si le stockage sécurisé est disponible
Future<bool> isAvailable() async {
try {
await _storage.containsKey(key: 'test');
return true;
} catch (e) {
return false;
}
}
}
/// Exception liée au stockage
class StorageException implements Exception {
final String message;
const StorageException(this.message);
@override
String toString() => 'StorageException: $message';
}

View File

@@ -0,0 +1,74 @@
class AppConstants {
// API Configuration
static const String baseUrl = 'http://localhost:8099'; // Backend UnionFlow
static const String apiVersion = '/api/v1';
// Timeout
static const Duration connectTimeout = Duration(seconds: 30);
static const Duration receiveTimeout = Duration(seconds: 30);
// Storage Keys
static const String authTokenKey = 'auth_token';
static const String refreshTokenKey = 'refresh_token';
static const String userDataKey = 'user_data';
static const String appSettingsKey = 'app_settings';
// API Endpoints
static const String loginEndpoint = '/auth/login';
static const String refreshEndpoint = '/auth/refresh';
static const String membresEndpoint = '/membres';
static const String cotisationsEndpoint = '/finance/cotisations';
static const String evenementsEndpoint = '/evenements';
static const String statistiquesEndpoint = '/statistiques';
// App Configuration
static const String appName = 'UnionFlow';
static const String appVersion = '2.0.0';
static const int maxRetryAttempts = 3;
// Pagination
static const int defaultPageSize = 20;
static const int maxPageSize = 100;
// File Upload
static const int maxFileSize = 10 * 1024 * 1024; // 10MB
static const List<String> allowedImageTypes = ['jpg', 'jpeg', 'png', 'gif'];
static const List<String> allowedDocumentTypes = ['pdf', 'doc', 'docx'];
// Chart Colors
static const List<String> chartColors = [
'#2196F3', '#4CAF50', '#FF9800', '#F44336',
'#9C27B0', '#00BCD4', '#8BC34A', '#FFEB3B'
];
}
class ApiEndpoints {
// Authentication
static const String login = '/auth/login';
static const String logout = '/auth/logout';
static const String register = '/auth/register';
static const String refreshToken = '/auth/refresh';
static const String forgotPassword = '/auth/forgot-password';
// Membres
static const String membres = '/membres';
static const String membreProfile = '/membres/profile';
static const String membreSearch = '/membres/search';
static const String membreStats = '/membres/statistiques';
// Cotisations
static const String cotisations = '/finance/cotisations';
static const String cotisationsPay = '/finance/cotisations/payer';
static const String cotisationsHistory = '/finance/cotisations/historique';
static const String cotisationsStats = '/finance/cotisations/statistiques';
// Événements
static const String evenements = '/evenements';
static const String evenementParticipants = '/evenements/{id}/participants';
static const String evenementDocuments = '/evenements/{id}/documents';
// Dashboard
static const String dashboardStats = '/dashboard/statistiques';
static const String dashboardCharts = '/dashboard/charts';
static const String dashboardNotifications = '/dashboard/notifications';
}

View File

@@ -0,0 +1,45 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// **************************************************************************
// InjectableConfigGenerator
// **************************************************************************
// ignore_for_file: unnecessary_lambdas
// ignore_for_file: lines_longer_than_80_chars
// coverage:ignore-file
import 'package:get_it/get_it.dart' as _i1;
import 'package:injectable/injectable.dart' as _i2;
import '../auth/bloc/auth_bloc.dart' as _i7;
import '../auth/services/auth_api_service.dart' as _i4;
import '../auth/services/auth_service.dart' as _i6;
import '../auth/storage/secure_token_storage.dart' as _i3;
import '../network/auth_interceptor.dart' as _i5;
import '../network/dio_client.dart' as _i8;
extension GetItInjectableX on _i1.GetIt {
// initializes the registration of main-scope dependencies inside of GetIt
Future<_i1.GetIt> init({
String? environment,
_i2.EnvironmentFilter? environmentFilter,
}) async {
final gh = _i2.GetItHelper(
this,
environment,
environmentFilter,
);
gh.singleton<_i3.SecureTokenStorage>(() => _i3.SecureTokenStorage());
gh.singleton<_i8.DioClient>(() => _i8.DioClient());
gh.singleton<_i5.AuthInterceptor>(() => _i5.AuthInterceptor(gh<_i3.SecureTokenStorage>()));
gh.singleton<_i4.AuthApiService>(() => _i4.AuthApiService(gh<_i8.DioClient>()));
gh.singleton<_i6.AuthService>(() => _i6.AuthService(
gh<_i3.SecureTokenStorage>(),
gh<_i4.AuthApiService>(),
gh<_i5.AuthInterceptor>(),
gh<_i8.DioClient>(),
));
gh.singleton<_i7.AuthBloc>(() => _i7.AuthBloc(gh<_i6.AuthService>()));
return this;
}
}

View File

@@ -0,0 +1,19 @@
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'injection.config.dart';
/// Instance globale de GetIt pour l'injection de dépendances
final GetIt getIt = GetIt.instance;
/// Configure l'injection de dépendances
@InjectableInit()
Future<void> configureDependencies() async {
await getIt.init();
}
/// Réinitialise les dépendances (utile pour les tests)
Future<void> resetDependencies() async {
await getIt.reset();
await configureDependencies();
}

View File

@@ -0,0 +1,122 @@
import 'package:equatable/equatable.dart';
abstract class Failure extends Equatable {
const Failure({required this.message, this.code});
final String message;
final String? code;
@override
List<Object?> get props => [message, code];
}
class ServerFailure extends Failure {
const ServerFailure({
required super.message,
super.code,
this.statusCode,
});
final int? statusCode;
@override
List<Object?> get props => [message, code, statusCode];
}
class NetworkFailure extends Failure {
const NetworkFailure({
required super.message,
super.code = 'NETWORK_ERROR',
});
}
class AuthFailure extends Failure {
const AuthFailure({
required super.message,
super.code = 'AUTH_ERROR',
});
}
class ValidationFailure extends Failure {
const ValidationFailure({
required super.message,
super.code = 'VALIDATION_ERROR',
this.field,
});
final String? field;
@override
List<Object?> get props => [message, code, field];
}
class CacheFailure extends Failure {
const CacheFailure({
required super.message,
super.code = 'CACHE_ERROR',
});
}
class UnknownFailure extends Failure {
const UnknownFailure({
required super.message,
super.code = 'UNKNOWN_ERROR',
});
}
// Extension pour convertir les exceptions en failures
extension ExceptionToFailure on Exception {
Failure toFailure() {
if (this is NetworkException) {
final ex = this as NetworkException;
return NetworkFailure(message: ex.message);
} else if (this is ServerException) {
final ex = this as ServerException;
return ServerFailure(
message: ex.message,
statusCode: ex.statusCode,
);
} else if (this is AuthException) {
final ex = this as AuthException;
return AuthFailure(message: ex.message);
} else if (this is ValidationException) {
final ex = this as ValidationException;
return ValidationFailure(
message: ex.message,
field: ex.field,
);
} else if (this is CacheException) {
final ex = this as CacheException;
return CacheFailure(message: ex.message);
}
return UnknownFailure(message: toString());
}
}
// Exceptions personnalisées
class NetworkException implements Exception {
const NetworkException(this.message);
final String message;
}
class ServerException implements Exception {
const ServerException(this.message, {this.statusCode});
final String message;
final int? statusCode;
}
class AuthException implements Exception {
const AuthException(this.message);
final String message;
}
class ValidationException implements Exception {
const ValidationException(this.message, {this.field});
final String message;
final String? field;
}
class CacheException implements Exception {
const CacheException(this.message);
final String message;
}

View File

@@ -0,0 +1,167 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import '../auth/storage/secure_token_storage.dart';
/// Interceptor pour gérer l'authentification automatique
@singleton
class AuthInterceptor extends Interceptor {
final SecureTokenStorage _tokenStorage;
// Callback pour déclencher le refresh token
void Function()? onTokenRefreshNeeded;
// Callback pour déconnecter l'utilisateur
void Function()? onAuthenticationFailed;
AuthInterceptor(this._tokenStorage);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
// Ignorer l'authentification pour certaines routes
if (_shouldSkipAuth(options)) {
handler.next(options);
return;
}
try {
// Récupérer le token d'accès
final accessToken = await _tokenStorage.getAccessToken();
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
print('Erreur lors de la récupération du token: $e');
handler.next(options);
}
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
// Traitement des réponses réussies
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
// Gestion des erreurs d'authentification
if (err.response?.statusCode == 401) {
await _handle401Error(err, handler);
} else if (err.response?.statusCode == 403) {
await _handle403Error(err, handler);
} else {
handler.next(err);
}
}
/// 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();
onAuthenticationFailed?.call();
} catch (e) {
print('Erreur lors de la gestion de l\'erreur 401: $e');
await _tokenStorage.clearAuthData();
onAuthenticationFailed?.call();
}
handler.next(err);
}
/// Gère les erreurs 403 (Interdit)
Future<void> _handle403Error(DioException err, ErrorInterceptorHandler handler) async {
// L'utilisateur n'a pas les permissions suffisantes
// On peut logger cela ou rediriger vers une page d'erreur
print('Accès interdit (403) pour: ${err.requestOptions.path}');
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) {
// Ignorer l'auth pour les routes publiques
final publicPaths = [
'/api/auth/login',
'/api/auth/refresh',
'/api/auth/info',
'/api/auth/register',
'/api/health',
];
// Vérifier si le path est dans la liste des routes publiques
final isPublicPath = publicPaths.any((path) => options.path.contains(path));
// Vérifier si l'option skipAuth est activée
final skipAuth = options.extra['skipAuth'] == true;
return isPublicPath || skipAuth;
}
/// Configuration des callbacks
void setCallbacks({
void Function()? onTokenRefreshNeeded,
void Function()? onAuthenticationFailed,
}) {
this.onTokenRefreshNeeded = onTokenRefreshNeeded;
this.onAuthenticationFailed = onAuthenticationFailed;
}
}

View File

@@ -0,0 +1,113 @@
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
import 'auth_interceptor.dart';
/// Configuration centralisée du client HTTP Dio
@singleton
class DioClient {
late final Dio _dio;
DioClient() {
_dio = Dio();
_setupInterceptors();
_configureOptions();
}
Dio get dio => _dio;
void _configureOptions() {
_dio.options = BaseOptions(
// URL de base de l'API
baseUrl: 'http://localhost:8081', // Adresse de votre API Quarkus
// Timeouts
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
// Headers par défaut
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'UnionFlow-Mobile/1.0.0',
},
// Validation des codes de statut
validateStatus: (status) {
return status != null && status < 500;
},
// Suivre les redirections
followRedirects: true,
maxRedirects: 3,
// Politique de persistance des cookies
persistentConnection: true,
// Format de réponse par défaut
responseType: ResponseType.json,
);
}
void _setupInterceptors() {
// Interceptor de logging (seulement en debug)
_dio.interceptors.add(
PrettyDioLogger(
requestHeader: true,
requestBody: true,
responseBody: true,
responseHeader: false,
error: true,
compact: true,
maxWidth: 90,
filter: (options, args) {
// Ne pas logger les mots de passe
if (options.path.contains('/auth/login')) {
return false;
}
return true;
},
),
);
// Interceptor d'authentification (sera injecté plus tard)
// Il sera ajouté dans AuthService pour éviter les dépendances circulaires
}
/// Ajoute l'interceptor d'authentification
void addAuthInterceptor(AuthInterceptor authInterceptor) {
_dio.interceptors.add(authInterceptor);
}
/// Configure l'URL de base
void setBaseUrl(String baseUrl) {
_dio.options.baseUrl = baseUrl;
}
/// Ajoute un header global
void addHeader(String key, String value) {
_dio.options.headers[key] = value;
}
/// Supprime un header global
void removeHeader(String key) {
_dio.options.headers.remove(key);
}
/// Configure les timeouts
void setTimeout({
Duration? connect,
Duration? receive,
Duration? send,
}) {
if (connect != null) _dio.options.connectTimeout = connect;
if (receive != null) _dio.options.receiveTimeout = receive;
if (send != null) _dio.options.sendTimeout = send;
}
/// Nettoie et ferme le client
void dispose() {
_dio.close();
}
}

View File

@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
/// Utilitaires pour rendre l'app responsive
class ResponsiveUtils {
static late MediaQueryData _mediaQueryData;
static late double screenWidth;
static late double screenHeight;
static late double blockSizeHorizontal;
static late double blockSizeVertical;
static late double safeAreaHorizontal;
static late double safeAreaVertical;
static late double safeBlockHorizontal;
static late double safeBlockVertical;
static late double textScaleFactor;
static void init(BuildContext context) {
_mediaQueryData = MediaQuery.of(context);
screenWidth = _mediaQueryData.size.width;
screenHeight = _mediaQueryData.size.height;
blockSizeHorizontal = screenWidth / 100;
blockSizeVertical = screenHeight / 100;
final safeAreaPadding = _mediaQueryData.padding;
safeAreaHorizontal = screenWidth - safeAreaPadding.left - safeAreaPadding.right;
safeAreaVertical = screenHeight - safeAreaPadding.top - safeAreaPadding.bottom;
safeBlockHorizontal = safeAreaHorizontal / 100;
safeBlockVertical = safeAreaVertical / 100;
textScaleFactor = _mediaQueryData.textScaleFactor;
}
// Responsive width
static double wp(double percentage) => blockSizeHorizontal * percentage;
// Responsive height
static double hp(double percentage) => blockSizeVertical * percentage;
// Responsive font size (basé sur la largeur)
static double fs(double percentage) => safeBlockHorizontal * percentage;
// Responsive spacing
static double sp(double percentage) => safeBlockHorizontal * percentage;
// Responsive padding/margin
static EdgeInsets paddingAll(double percentage) =>
EdgeInsets.all(sp(percentage));
static EdgeInsets paddingSymmetric({double? horizontal, double? vertical}) =>
EdgeInsets.symmetric(
horizontal: horizontal != null ? sp(horizontal) : 0,
vertical: vertical != null ? hp(vertical) : 0,
);
static EdgeInsets paddingOnly({
double? left,
double? top,
double? right,
double? bottom,
}) =>
EdgeInsets.only(
left: left != null ? sp(left) : 0,
top: top != null ? hp(top) : 0,
right: right != null ? sp(right) : 0,
bottom: bottom != null ? hp(bottom) : 0,
);
// Adaptive values based on screen size
static double adaptive({
required double small, // < 600px (phones)
required double medium, // 600-900px (tablets)
required double large, // > 900px (desktop)
}) {
if (screenWidth < 600) return small;
if (screenWidth < 900) return medium;
return large;
}
// Check device type
static bool get isMobile => screenWidth < 600;
static bool get isTablet => screenWidth >= 600 && screenWidth < 900;
static bool get isDesktop => screenWidth >= 900;
// Responsive border radius
static BorderRadius borderRadius(double percentage) =>
BorderRadius.circular(sp(percentage));
// Responsive icon size
static double iconSize(double percentage) =>
adaptive(
small: sp(percentage),
medium: sp(percentage * 0.9),
large: sp(percentage * 0.8),
);
}
// Extension pour faciliter l'utilisation
extension ResponsiveExtension on num {
// Width percentage
double get wp => ResponsiveUtils.wp(toDouble());
// Height percentage
double get hp => ResponsiveUtils.hp(toDouble());
// Font size
double get fs => ResponsiveUtils.fs(toDouble());
// Spacing
double get sp => ResponsiveUtils.sp(toDouble());
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import 'welcome_screen.dart';
class AuthWrapper extends StatefulWidget {
const AuthWrapper({super.key});
@override
State<AuthWrapper> createState() => _AuthWrapperState();
}
class _AuthWrapperState extends State<AuthWrapper> {
bool _isLoading = true;
bool _isAuthenticated = false;
@override
void initState() {
super.initState();
_checkAuthenticationStatus();
}
Future<void> _checkAuthenticationStatus() async {
// Simulation de vérification d'authentification
// En production : vérifier le token JWT, SharedPreferences, etc.
await Future.delayed(const Duration(milliseconds: 500));
setState(() {
_isLoading = false;
// Pour le moment, toujours false (pas d'utilisateur connecté)
_isAuthenticated = false;
});
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return _buildLoadingScreen();
}
if (_isAuthenticated) {
// TODO: Retourner vers la navigation principale
return _buildLoadingScreen(); // Temporaire
} else {
return const WelcomeScreen();
}
}
Widget _buildLoadingScreen() {
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppTheme.primaryColor,
AppTheme.primaryDark,
],
),
),
child: const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
),
);
}
}

View File

@@ -0,0 +1,489 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/custom_text_field.dart';
import '../../../../shared/widgets/loading_button.dart';
class ForgotPasswordScreen extends StatefulWidget {
const ForgotPasswordScreen({super.key});
@override
State<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
with TickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
late AnimationController _fadeController;
late AnimationController _slideController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
bool _isLoading = false;
bool _emailSent = false;
@override
void initState() {
super.initState();
_initializeAnimations();
_startAnimations();
}
void _initializeAnimations() {
_fadeController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_slideController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _fadeController,
curve: Curves.easeInOut,
));
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController,
curve: Curves.easeOutCubic,
));
}
void _startAnimations() async {
await Future.delayed(const Duration(milliseconds: 100));
_fadeController.forward();
_slideController.forward();
}
@override
void dispose() {
_emailController.dispose();
_fadeController.dispose();
_slideController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, color: AppTheme.textPrimary),
onPressed: () => Navigator.of(context).pop(),
),
),
body: SafeArea(
child: AnimatedBuilder(
animation: _fadeAnimation,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: SlideTransition(
position: _slideAnimation,
child: _emailSent ? _buildSuccessView() : _buildFormView(),
),
),
);
},
),
),
);
}
Widget _buildFormView() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
const SizedBox(height: 40),
_buildInstructions(),
const SizedBox(height: 32),
_buildForm(),
const SizedBox(height: 32),
_buildSendButton(),
const SizedBox(height: 24),
_buildBackToLogin(),
],
);
}
Widget _buildSuccessView() {
return Column(
children: [
const SizedBox(height: 60),
// Icône de succès
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: AppTheme.successColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(60),
border: Border.all(
color: AppTheme.successColor.withOpacity(0.3),
width: 2,
),
),
child: const Icon(
Icons.mark_email_read_rounded,
size: 60,
color: AppTheme.successColor,
),
),
const SizedBox(height: 32),
// Titre de succès
const Text(
'Email envoyé !',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Message de succès
Text(
'Nous avons envoyé un lien de réinitialisation à :',
style: TextStyle(
fontSize: 16,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_emailController.text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.primaryColor,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// Instructions
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppTheme.infoColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.infoColor.withOpacity(0.2),
),
),
child: Column(
children: [
const Icon(
Icons.info_outline,
color: AppTheme.infoColor,
size: 24,
),
const SizedBox(height: 12),
const Text(
'Instructions',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Text(
'1. Vérifiez votre boîte email (et vos spams)\n'
'2. Cliquez sur le lien de réinitialisation\n'
'3. Créez un nouveau mot de passe sécurisé',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
height: 1.5,
),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: 32),
// Boutons d'action
Column(
children: [
LoadingButton(
onPressed: _handleResendEmail,
text: 'Renvoyer l\'email',
width: double.infinity,
height: 48,
backgroundColor: AppTheme.secondaryColor,
),
const SizedBox(height: 12),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text(
'Retour à la connexion',
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
);
}
Widget _buildHeader() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Icône
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: AppTheme.warningColor,
borderRadius: BorderRadius.circular(15),
),
child: const Icon(
Icons.lock_reset_rounded,
color: Colors.white,
size: 30,
),
),
const SizedBox(height: 24),
// Titre
const Text(
'Mot de passe oublié ?',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
// Sous-titre
Text(
'Pas de problème ! Nous allons vous aider à le récupérer.',
style: TextStyle(
fontSize: 16,
color: AppTheme.textSecondary,
),
),
],
);
}
Widget _buildInstructions() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.primaryColor.withOpacity(0.1),
),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.email_outlined,
color: AppTheme.primaryColor,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Comment ça marche ?',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
'Saisissez votre email et nous vous enverrons un lien sécurisé pour réinitialiser votre mot de passe.',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
height: 1.4,
),
),
],
),
),
],
),
);
}
Widget _buildForm() {
return Form(
key: _formKey,
child: CustomTextField(
controller: _emailController,
label: 'Adresse email',
hintText: 'votre.email@exemple.com',
prefixIcon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
validator: _validateEmail,
onFieldSubmitted: (_) => _handleSendResetEmail(),
autofocus: true,
),
);
}
Widget _buildSendButton() {
return LoadingButton(
onPressed: _handleSendResetEmail,
isLoading: _isLoading,
text: 'Envoyer le lien de réinitialisation',
width: double.infinity,
height: 56,
);
}
Widget _buildBackToLogin() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Vous vous souvenez de votre mot de passe ? ',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text(
'Se connecter',
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
);
}
String? _validateEmail(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir votre adresse email';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return 'Veuillez saisir une adresse email valide';
}
return null;
}
Future<void> _handleSendResetEmail() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
});
try {
// Simulation d'envoi d'email
await Future.delayed(const Duration(seconds: 2));
// Vibration de succès
HapticFeedback.lightImpact();
// Transition vers la vue de succès
setState(() {
_emailSent = true;
_isLoading = false;
});
} catch (e) {
// Gestion d'erreur
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de l\'envoi: ${e.toString()}'),
backgroundColor: AppTheme.errorColor,
behavior: SnackBarBehavior.floating,
),
);
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _handleResendEmail() async {
try {
// Simulation de renvoi d'email
await Future.delayed(const Duration(seconds: 1));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Email renvoyé avec succès !'),
backgroundColor: AppTheme.successColor,
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors du renvoi: ${e.toString()}'),
backgroundColor: AppTheme.errorColor,
behavior: SnackBarBehavior.floating,
),
);
}
}
}
}

View File

@@ -0,0 +1,321 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/auth/bloc/auth_bloc.dart';
import '../../../../core/auth/bloc/auth_event.dart';
import '../../../../core/auth/models/auth_state.dart';
import '../../../../core/auth/models/login_request.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/buttons/buttons.dart';
import '../widgets/login_form.dart';
import '../widgets/login_header.dart';
import '../widgets/login_footer.dart';
/// Écran de connexion avec interface sophistiquée
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage>
with TickerProviderStateMixin {
late AnimationController _animationController;
late AnimationController _shakeController;
late Animation<double> _fadeAnimation;
late Animation<double> _slideAnimation;
late Animation<double> _shakeAnimation;
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
bool _rememberMe = false;
bool _isLoading = false;
@override
void initState() {
super.initState();
_setupAnimations();
_startEntryAnimation();
}
void _setupAnimations() {
_animationController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
);
_shakeController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.0, 0.6, curve: Curves.easeOut),
));
_slideAnimation = Tween<double>(
begin: 50.0,
end: 0.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
));
_shakeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _shakeController,
curve: Curves.elasticInOut,
));
}
void _startEntryAnimation() {
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
_animationController.forward();
}
});
}
@override
void dispose() {
_animationController.dispose();
_shakeController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
body: BlocListener<AuthBloc, AuthState>(
listener: _handleAuthStateChange,
child: SafeArea(
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return FadeTransition(
opacity: _fadeAnimation,
child: Transform.translate(
offset: Offset(0, _slideAnimation.value),
child: _buildLoginContent(),
),
);
},
),
),
),
);
}
Widget _buildLoginContent() {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
children: [
const SizedBox(height: 60),
// Header avec logo et titre
LoginHeader(
onAnimationComplete: () {},
),
const SizedBox(height: 60),
// Formulaire de connexion
AnimatedBuilder(
animation: _shakeAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(
_shakeAnimation.value * 10 *
(1 - _shakeAnimation.value) *
(1 - _shakeAnimation.value),
0,
),
child: LoginForm(
formKey: _formKey,
emailController: _emailController,
passwordController: _passwordController,
obscurePassword: _obscurePassword,
rememberMe: _rememberMe,
isLoading: _isLoading,
onObscureToggle: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
HapticFeedback.selectionClick();
},
onRememberMeToggle: (value) {
setState(() {
_rememberMe = value;
});
HapticFeedback.selectionClick();
},
onSubmit: _handleLogin,
),
),
),
),
const SizedBox(height: 40),
// Footer avec liens et informations
const LoginFooter(),
const SizedBox(height: 20),
],
),
);
}
void _handleAuthStateChange(BuildContext context, AuthState state) {
setState(() {
_isLoading = state.isLoading;
});
if (state.status == AuthStatus.authenticated) {
// Connexion réussie - navigation gérée par l'app principal
_showSuccessMessage();
HapticFeedback.heavyImpact();
} else if (state.status == AuthStatus.error) {
// Erreur de connexion
_handleLoginError(state.errorMessage ?? 'Erreur inconnue');
} else if (state.status == AuthStatus.unauthenticated && state.errorMessage != null) {
// Échec de connexion
_handleLoginError(state.errorMessage!);
}
}
void _handleLogin() {
if (!_formKey.currentState!.validate()) {
_triggerShakeAnimation();
HapticFeedback.mediumImpact();
return;
}
final email = _emailController.text.trim();
final password = _passwordController.text;
if (email.isEmpty || password.isEmpty) {
_showErrorMessage('Veuillez remplir tous les champs');
_triggerShakeAnimation();
return;
}
// Déclencher la connexion
final loginRequest = LoginRequest(
email: email,
password: password,
rememberMe: _rememberMe,
);
context.read<AuthBloc>().add(AuthLoginRequested(loginRequest));
// Feedback haptique
HapticFeedback.lightImpact();
}
void _handleLoginError(String errorMessage) {
_showErrorMessage(errorMessage);
_triggerShakeAnimation();
HapticFeedback.mediumImpact();
// Effacer l'erreur après affichage
Future.delayed(const Duration(seconds: 3), () {
if (mounted) {
context.read<AuthBloc>().add(const AuthErrorCleared());
}
});
}
void _triggerShakeAnimation() {
_shakeController.reset();
_shakeController.forward();
}
void _showSuccessMessage() {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(
Icons.check_circle,
color: Colors.white,
size: 24,
),
const SizedBox(width: 12),
const Text(
'Connexion réussie !',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
],
),
backgroundColor: AppTheme.successColor,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 2),
),
);
}
void _showErrorMessage(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(
Icons.error_outline,
color: Colors.white,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
),
backgroundColor: AppTheme.errorColor,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 4),
action: SnackBarAction(
label: 'OK',
textColor: Colors.white,
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
),
);
}
}

View File

@@ -0,0 +1,478 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/auth/bloc/temp_auth_bloc.dart';
import '../../../../core/auth/bloc/auth_event.dart';
import '../../../../core/auth/models/auth_state.dart';
import '../../../../core/auth/models/login_request.dart';
import '../../../../shared/theme/app_theme.dart';
import '../widgets/login_header.dart';
import '../widgets/login_footer.dart';
/// Écran de connexion temporaire simplifié
class TempLoginPage extends StatefulWidget {
const TempLoginPage({super.key});
@override
State<TempLoginPage> createState() => _TempLoginPageState();
}
class _TempLoginPageState extends State<TempLoginPage>
with TickerProviderStateMixin {
late AnimationController _animationController;
late AnimationController _shakeController;
late Animation<double> _fadeAnimation;
late Animation<double> _slideAnimation;
late Animation<double> _shakeAnimation;
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(text: 'admin@unionflow.dev');
final _passwordController = TextEditingController(text: 'admin123');
bool _obscurePassword = true;
bool _rememberMe = false;
bool _isLoading = false;
@override
void initState() {
super.initState();
_setupAnimations();
_startEntryAnimation();
}
void _setupAnimations() {
_animationController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
);
_shakeController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.0, 0.6, curve: Curves.easeOut),
));
_slideAnimation = Tween<double>(
begin: 50.0,
end: 0.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
));
_shakeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _shakeController,
curve: Curves.elasticInOut,
));
}
void _startEntryAnimation() {
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
_animationController.forward();
}
});
}
@override
void dispose() {
_animationController.dispose();
_shakeController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
body: BlocListener<TempAuthBloc, AuthState>(
listener: _handleAuthStateChange,
child: SafeArea(
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return FadeTransition(
opacity: _fadeAnimation,
child: Transform.translate(
offset: Offset(0, _slideAnimation.value),
child: _buildLoginContent(),
),
);
},
),
),
),
);
}
Widget _buildLoginContent() {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
children: [
const SizedBox(height: 60),
// Header avec logo et titre
const LoginHeader(),
const SizedBox(height: 60),
// Formulaire de connexion
AnimatedBuilder(
animation: _shakeAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(
_shakeAnimation.value * 10 *
(1 - _shakeAnimation.value) *
(1 - _shakeAnimation.value),
0,
),
child: _buildLoginForm(),
);
},
),
const SizedBox(height: 40),
// Footer avec liens et informations
const LoginFooter(),
const SizedBox(height: 20),
],
),
);
}
Widget _buildLoginForm() {
return Form(
key: _formKey,
child: Column(
children: [
// Champ email
_buildEmailField(),
const SizedBox(height: 20),
// Champ mot de passe
_buildPasswordField(),
const SizedBox(height: 16),
// Options
_buildOptionsRow(),
const SizedBox(height: 32),
// Bouton de connexion
_buildLoginButton(),
],
),
);
}
Widget _buildEmailField() {
return TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
enabled: !_isLoading,
decoration: InputDecoration(
labelText: 'Adresse email',
hintText: 'votre.email@exemple.com',
prefixIcon: Icon(
Icons.email_outlined,
color: AppTheme.primaryColor,
),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: AppTheme.primaryColor,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir votre email';
}
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
return 'Format d\'email invalide';
}
return null;
},
);
}
Widget _buildPasswordField() {
return TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
enabled: !_isLoading,
onFieldSubmitted: (_) => _handleLogin(),
decoration: InputDecoration(
labelText: 'Mot de passe',
hintText: 'Saisissez votre mot de passe',
prefixIcon: Icon(
Icons.lock_outlined,
color: AppTheme.primaryColor,
),
suffixIcon: IconButton(
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
HapticFeedback.selectionClick();
},
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: AppTheme.primaryColor,
),
),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: AppTheme.primaryColor,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir votre mot de passe';
}
if (value.length < 6) {
return 'Le mot de passe doit contenir au moins 6 caractères';
}
return null;
},
);
}
Widget _buildOptionsRow() {
return Row(
children: [
// Se souvenir de moi
Expanded(
child: GestureDetector(
onTap: () {
setState(() {
_rememberMe = !_rememberMe;
});
HapticFeedback.selectionClick();
},
child: Row(
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: _rememberMe
? AppTheme.primaryColor
: AppTheme.textSecondary,
width: 2,
),
color: _rememberMe
? AppTheme.primaryColor
: Colors.transparent,
),
child: _rememberMe
? const Icon(
Icons.check,
size: 14,
color: Colors.white,
)
: null,
),
const SizedBox(width: 8),
Text(
'Se souvenir de moi',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
// Compte de test
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.infoColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Compte de test',
style: TextStyle(
fontSize: 12,
color: AppTheme.infoColor,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
Widget _buildLoginButton() {
return SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 4,
),
child: _isLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.login, size: 20),
const SizedBox(width: 8),
const Text(
'Se connecter',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
void _handleAuthStateChange(BuildContext context, AuthState state) {
setState(() {
_isLoading = state.isLoading;
});
if (state.status == AuthStatus.authenticated) {
_showSuccessMessage();
HapticFeedback.heavyImpact();
} else if (state.status == AuthStatus.error) {
_handleLoginError(state.errorMessage ?? 'Erreur inconnue');
}
}
void _handleLogin() {
if (!_formKey.currentState!.validate()) {
_triggerShakeAnimation();
HapticFeedback.mediumImpact();
return;
}
final email = _emailController.text.trim();
final password = _passwordController.text;
final loginRequest = LoginRequest(
email: email,
password: password,
rememberMe: _rememberMe,
);
context.read<TempAuthBloc>().add(AuthLoginRequested(loginRequest));
HapticFeedback.lightImpact();
}
void _handleLoginError(String errorMessage) {
_showErrorMessage(errorMessage);
_triggerShakeAnimation();
HapticFeedback.mediumImpact();
}
void _triggerShakeAnimation() {
_shakeController.reset();
_shakeController.forward();
}
void _showSuccessMessage() {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Row(
children: [
Icon(Icons.check_circle, color: Colors.white),
SizedBox(width: 12),
Text('Connexion réussie !'),
],
),
backgroundColor: AppTheme.successColor,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
void _showErrorMessage(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
Expanded(child: Text(message)),
],
),
backgroundColor: AppTheme.errorColor,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
}

View File

@@ -0,0 +1,517 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/custom_text_field.dart';
import '../../../../shared/widgets/loading_button.dart';
import '../../../navigation/presentation/pages/main_navigation.dart';
import 'forgot_password_screen.dart';
import 'register_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen>
with TickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
late AnimationController _fadeController;
late AnimationController _slideController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
bool _isLoading = false;
bool _obscurePassword = true;
bool _rememberMe = false;
@override
void initState() {
super.initState();
_initializeAnimations();
_startAnimations();
}
void _initializeAnimations() {
_fadeController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_slideController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _fadeController,
curve: Curves.easeInOut,
));
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController,
curve: Curves.easeOutCubic,
));
}
void _startAnimations() async {
await Future.delayed(const Duration(milliseconds: 100));
_fadeController.forward();
_slideController.forward();
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_fadeController.dispose();
_slideController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, color: AppTheme.textPrimary),
onPressed: () => Navigator.of(context).pop(),
),
),
body: SafeArea(
child: AnimatedBuilder(
animation: _fadeAnimation,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: SlideTransition(
position: _slideAnimation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
const SizedBox(height: 40),
_buildLoginForm(),
const SizedBox(height: 24),
_buildForgotPassword(),
const SizedBox(height: 32),
_buildLoginButton(),
const SizedBox(height: 24),
_buildDivider(),
const SizedBox(height: 24),
_buildSocialLogin(),
const SizedBox(height: 32),
_buildSignUpLink(),
],
),
),
),
);
},
),
),
);
}
Widget _buildHeader() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Logo petit
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: AppTheme.primaryColor,
borderRadius: BorderRadius.circular(15),
),
child: const Icon(
Icons.groups_rounded,
color: Colors.white,
size: 30,
),
),
const SizedBox(height: 24),
// Titre
const Text(
'Bienvenue !',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
// Sous-titre
Text(
'Connectez-vous à votre compte UnionFlow',
style: TextStyle(
fontSize: 16,
color: AppTheme.textSecondary,
),
),
],
);
}
Widget _buildLoginForm() {
return Form(
key: _formKey,
child: Column(
children: [
// Champ Email
CustomTextField(
controller: _emailController,
label: 'Adresse email',
hintText: 'votre.email@exemple.com',
prefixIcon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: _validateEmail,
onFieldSubmitted: (_) => FocusScope.of(context).nextFocus(),
),
const SizedBox(height: 16),
// Champ Mot de passe
CustomTextField(
controller: _passwordController,
label: 'Mot de passe',
hintText: 'Votre mot de passe',
prefixIcon: Icons.lock_outline,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
validator: _validatePassword,
onFieldSubmitted: (_) => _handleLogin(),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
color: AppTheme.textHint,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
const SizedBox(height: 16),
// Remember me
Row(
children: [
Checkbox(
value: _rememberMe,
onChanged: (value) {
setState(() {
_rememberMe = value ?? false;
});
},
activeColor: AppTheme.primaryColor,
),
const Text(
'Se souvenir de moi',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
),
],
),
],
),
);
}
Widget _buildForgotPassword() {
return Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => _navigateToForgotPassword(),
child: const Text(
'Mot de passe oublié ?',
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
),
),
),
);
}
Widget _buildLoginButton() {
return LoadingButton(
onPressed: _handleLogin,
isLoading: _isLoading,
text: 'Se connecter',
width: double.infinity,
height: 56,
);
}
Widget _buildDivider() {
return Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'ou',
style: TextStyle(
color: AppTheme.textHint,
fontSize: 14,
),
),
),
const Expanded(child: Divider()),
],
);
}
Widget _buildSocialLogin() {
return Column(
children: [
// Google Login
SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton.icon(
onPressed: () => _handleGoogleLogin(),
icon: Image.asset(
'assets/icons/google.png',
width: 20,
height: 20,
errorBuilder: (context, error, stackTrace) => const Icon(
Icons.g_mobiledata,
color: Colors.red,
size: 20,
),
),
label: const Text('Continuer avec Google'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.textPrimary,
side: const BorderSide(color: AppTheme.borderColor),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 12),
// Microsoft Login
SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton.icon(
onPressed: () => _handleMicrosoftLogin(),
icon: const Icon(
Icons.business,
color: Color(0xFF00A4EF),
size: 20,
),
label: const Text('Continuer avec Microsoft'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.textPrimary,
side: const BorderSide(color: AppTheme.borderColor),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
);
}
Widget _buildSignUpLink() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Pas encore de compte ? ',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
),
TextButton(
onPressed: () => _navigateToRegister(),
child: const Text(
'S\'inscrire',
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
);
}
String? _validateEmail(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir votre adresse email';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return 'Veuillez saisir une adresse email valide';
}
return null;
}
String? _validatePassword(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir votre mot de passe';
}
if (value.length < 6) {
return 'Le mot de passe doit contenir au moins 6 caractères';
}
return null;
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
});
try {
// Simulation d'authentification
await Future.delayed(const Duration(seconds: 2));
// Vibration de succès
HapticFeedback.lightImpact();
// Navigation vers le dashboard
if (mounted) {
Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const MainNavigation(),
transitionDuration: const Duration(milliseconds: 600),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(1.0, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
)),
child: child,
),
);
},
),
);
}
} catch (e) {
// Gestion d'erreur
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur de connexion: ${e.toString()}'),
backgroundColor: AppTheme.errorColor,
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
void _handleGoogleLogin() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Connexion Google - En cours de développement'),
backgroundColor: AppTheme.infoColor,
behavior: SnackBarBehavior.floating,
),
);
}
void _handleMicrosoftLogin() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Connexion Microsoft - En cours de développement'),
backgroundColor: AppTheme.infoColor,
behavior: SnackBarBehavior.floating,
),
);
}
void _navigateToForgotPassword() {
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const ForgotPasswordScreen(),
transitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1.0, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
)),
child: child,
);
},
),
);
}
void _navigateToRegister() {
Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const RegisterScreen(),
transitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1.0, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
)),
child: child,
);
},
),
);
}
}

View File

@@ -0,0 +1,624 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/custom_text_field.dart';
import '../../../../shared/widgets/loading_button.dart';
import 'login_screen.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen>
with TickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
late AnimationController _fadeController;
late AnimationController _slideController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
bool _isLoading = false;
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
bool _acceptTerms = false;
bool _acceptNewsletter = false;
@override
void initState() {
super.initState();
_initializeAnimations();
_startAnimations();
}
void _initializeAnimations() {
_fadeController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_slideController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _fadeController,
curve: Curves.easeInOut,
));
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController,
curve: Curves.easeOutCubic,
));
}
void _startAnimations() async {
await Future.delayed(const Duration(milliseconds: 100));
_fadeController.forward();
_slideController.forward();
}
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_fadeController.dispose();
_slideController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, color: AppTheme.textPrimary),
onPressed: () => Navigator.of(context).pop(),
),
),
body: SafeArea(
child: AnimatedBuilder(
animation: _fadeAnimation,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: SlideTransition(
position: _slideAnimation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
const SizedBox(height: 32),
_buildRegistrationForm(),
const SizedBox(height: 24),
_buildTermsAndConditions(),
const SizedBox(height: 32),
_buildRegisterButton(),
const SizedBox(height: 24),
_buildLoginLink(),
],
),
),
),
);
},
),
),
);
}
Widget _buildHeader() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Logo petit
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: AppTheme.primaryColor,
borderRadius: BorderRadius.circular(15),
),
child: const Icon(
Icons.person_add_rounded,
color: Colors.white,
size: 30,
),
),
const SizedBox(height: 24),
// Titre
const Text(
'Créer un compte',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
// Sous-titre
Text(
'Rejoignez UnionFlow et gérez votre association',
style: TextStyle(
fontSize: 16,
color: AppTheme.textSecondary,
),
),
],
);
}
Widget _buildRegistrationForm() {
return Form(
key: _formKey,
child: Column(
children: [
// Nom et Prénom
Row(
children: [
Expanded(
child: CustomTextField(
controller: _firstNameController,
label: 'Prénom',
hintText: 'Jean',
prefixIcon: Icons.person_outline,
textInputAction: TextInputAction.next,
validator: _validateFirstName,
onFieldSubmitted: (_) => FocusScope.of(context).nextFocus(),
),
),
const SizedBox(width: 16),
Expanded(
child: CustomTextField(
controller: _lastNameController,
label: 'Nom',
hintText: 'Dupont',
prefixIcon: Icons.person_outline,
textInputAction: TextInputAction.next,
validator: _validateLastName,
onFieldSubmitted: (_) => FocusScope.of(context).nextFocus(),
),
),
],
),
const SizedBox(height: 16),
// Email
CustomTextField(
controller: _emailController,
label: 'Adresse email',
hintText: 'jean.dupont@exemple.com',
prefixIcon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
validator: _validateEmail,
onFieldSubmitted: (_) => FocusScope.of(context).nextFocus(),
),
const SizedBox(height: 16),
// Mot de passe
CustomTextField(
controller: _passwordController,
label: 'Mot de passe',
hintText: 'Minimum 8 caractères',
prefixIcon: Icons.lock_outline,
obscureText: _obscurePassword,
textInputAction: TextInputAction.next,
validator: _validatePassword,
onFieldSubmitted: (_) => FocusScope.of(context).nextFocus(),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
color: AppTheme.textHint,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
const SizedBox(height: 16),
// Confirmer mot de passe
CustomTextField(
controller: _confirmPasswordController,
label: 'Confirmer le mot de passe',
hintText: 'Retapez votre mot de passe',
prefixIcon: Icons.lock_outline,
obscureText: _obscureConfirmPassword,
textInputAction: TextInputAction.done,
validator: _validateConfirmPassword,
onFieldSubmitted: (_) => _handleRegister(),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword ? Icons.visibility_off : Icons.visibility,
color: AppTheme.textHint,
),
onPressed: () {
setState(() {
_obscureConfirmPassword = !_obscureConfirmPassword;
});
},
),
),
const SizedBox(height: 16),
// Indicateur de force du mot de passe
_buildPasswordStrengthIndicator(),
],
),
);
}
Widget _buildPasswordStrengthIndicator() {
final password = _passwordController.text;
final strength = _calculatePasswordStrength(password);
Color strengthColor;
String strengthText;
if (strength < 0.3) {
strengthColor = AppTheme.errorColor;
strengthText = 'Faible';
} else if (strength < 0.7) {
strengthColor = AppTheme.warningColor;
strengthText = 'Moyen';
} else {
strengthColor = AppTheme.successColor;
strengthText = 'Fort';
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Force du mot de passe',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
if (password.isNotEmpty)
Text(
strengthText,
style: TextStyle(
fontSize: 12,
color: strengthColor,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 8),
Container(
height: 4,
decoration: BoxDecoration(
color: AppTheme.borderColor,
borderRadius: BorderRadius.circular(2),
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: password.isEmpty ? 0 : strength,
child: Container(
decoration: BoxDecoration(
color: strengthColor,
borderRadius: BorderRadius.circular(2),
),
),
),
),
],
);
}
double _calculatePasswordStrength(String password) {
if (password.isEmpty) return 0.0;
double strength = 0.0;
// Longueur
if (password.length >= 8) strength += 0.25;
if (password.length >= 12) strength += 0.25;
// Contient des minuscules
if (password.contains(RegExp(r'[a-z]'))) strength += 0.15;
// Contient des majuscules
if (password.contains(RegExp(r'[A-Z]'))) strength += 0.15;
// Contient des chiffres
if (password.contains(RegExp(r'[0-9]'))) strength += 0.1;
// Contient des caractères spéciaux
if (password.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'))) strength += 0.1;
return strength.clamp(0.0, 1.0);
}
Widget _buildTermsAndConditions() {
return Column(
children: [
// Accepter les conditions
Row(
children: [
Checkbox(
value: _acceptTerms,
onChanged: (value) {
setState(() {
_acceptTerms = value ?? false;
});
},
activeColor: AppTheme.primaryColor,
),
Expanded(
child: RichText(
text: TextSpan(
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
children: [
const TextSpan(text: 'J\'accepte les '),
TextSpan(
text: 'Conditions d\'utilisation',
style: const TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
),
const TextSpan(text: ' et la '),
TextSpan(
text: 'Politique de confidentialité',
style: const TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
),
),
],
),
),
),
],
),
// Newsletter (optionnel)
Row(
children: [
Checkbox(
value: _acceptNewsletter,
onChanged: (value) {
setState(() {
_acceptNewsletter = value ?? false;
});
},
activeColor: AppTheme.primaryColor,
),
const Expanded(
child: Text(
'Je souhaite recevoir des actualités et conseils par email (optionnel)',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
),
),
],
),
],
);
}
Widget _buildRegisterButton() {
return LoadingButton(
onPressed: _acceptTerms ? _handleRegister : null,
isLoading: _isLoading,
text: 'Créer mon compte',
width: double.infinity,
height: 56,
enabled: _acceptTerms,
);
}
Widget _buildLoginLink() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Déjà un compte ? ',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
),
TextButton(
onPressed: () => _navigateToLogin(),
child: const Text(
'Se connecter',
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
);
}
String? _validateFirstName(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir votre prénom';
}
if (value.length < 2) {
return 'Le prénom doit contenir au moins 2 caractères';
}
return null;
}
String? _validateLastName(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir votre nom';
}
if (value.length < 2) {
return 'Le nom doit contenir au moins 2 caractères';
}
return null;
}
String? _validateEmail(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir votre adresse email';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return 'Veuillez saisir une adresse email valide';
}
return null;
}
String? _validatePassword(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir un mot de passe';
}
if (value.length < 8) {
return 'Le mot de passe doit contenir au moins 8 caractères';
}
if (!value.contains(RegExp(r'[A-Z]'))) {
return 'Le mot de passe doit contenir au moins une majuscule';
}
if (!value.contains(RegExp(r'[a-z]'))) {
return 'Le mot de passe doit contenir au moins une minuscule';
}
if (!value.contains(RegExp(r'[0-9]'))) {
return 'Le mot de passe doit contenir au moins un chiffre';
}
return null;
}
String? _validateConfirmPassword(String? value) {
if (value == null || value.isEmpty) {
return 'Veuillez confirmer votre mot de passe';
}
if (value != _passwordController.text) {
return 'Les mots de passe ne correspondent pas';
}
return null;
}
Future<void> _handleRegister() async {
if (!_formKey.currentState!.validate()) {
return;
}
if (!_acceptTerms) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez accepter les conditions d\'utilisation'),
backgroundColor: AppTheme.errorColor,
behavior: SnackBarBehavior.floating,
),
);
return;
}
setState(() {
_isLoading = true;
});
try {
// Simulation d'inscription
await Future.delayed(const Duration(seconds: 2));
// Vibration de succès
HapticFeedback.lightImpact();
// Afficher message de succès
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Compte créé avec succès ! Vérifiez votre email.'),
backgroundColor: AppTheme.successColor,
behavior: SnackBarBehavior.floating,
),
);
// Navigation vers l'écran de connexion
_navigateToLogin();
}
} catch (e) {
// Gestion d'erreur
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la création du compte: ${e.toString()}'),
backgroundColor: AppTheme.errorColor,
behavior: SnackBarBehavior.floating,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
void _navigateToLogin() {
Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const LoginScreen(),
transitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(-1.0, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
)),
child: child,
);
},
),
);
}
}

View File

@@ -0,0 +1,400 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import 'login_screen.dart';
import 'register_screen.dart';
class WelcomeScreen extends StatefulWidget {
const WelcomeScreen({super.key});
@override
State<WelcomeScreen> createState() => _WelcomeScreenState();
}
class _WelcomeScreenState extends State<WelcomeScreen>
with TickerProviderStateMixin {
late AnimationController _fadeController;
late AnimationController _slideController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_initializeAnimations();
_startAnimations();
}
void _initializeAnimations() {
_fadeController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
);
_slideController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _fadeController,
curve: Curves.easeInOut,
));
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.5),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController,
curve: Curves.easeOutCubic,
));
}
void _startAnimations() async {
await Future.delayed(const Duration(milliseconds: 200));
_fadeController.forward();
await Future.delayed(const Duration(milliseconds: 300));
_slideController.forward();
}
@override
void dispose() {
_fadeController.dispose();
_slideController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppTheme.primaryColor,
AppTheme.primaryDark,
const Color(0xFF0D47A1),
],
),
),
child: SafeArea(
child: AnimatedBuilder(
animation: _fadeAnimation,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
// Header avec logo
Expanded(
flex: 3,
child: SlideTransition(
position: _slideAnimation,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo principal
Container(
width: 140,
height: 140,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(35),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 25,
offset: const Offset(0, 12),
),
],
),
child: const Icon(
Icons.groups_rounded,
size: 70,
color: AppTheme.primaryColor,
),
),
const SizedBox(height: 32),
// Titre principal
const Text(
'UnionFlow',
style: TextStyle(
fontSize: 42,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: 1.5,
),
),
const SizedBox(height: 16),
// Sous-titre
Text(
'Gestion moderne d\'associations\net de mutuelles',
style: TextStyle(
fontSize: 18,
color: Colors.white.withOpacity(0.9),
fontWeight: FontWeight.w300,
height: 1.4,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Points forts
_buildFeatureHighlights(),
],
),
),
),
// Boutons d'action
Expanded(
flex: 2,
child: SlideTransition(
position: _slideAnimation,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Bouton Connexion
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: () => _navigateToLogin(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppTheme.primaryColor,
elevation: 8,
shadowColor: Colors.black.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.login, size: 20),
SizedBox(width: 8),
Text(
'Se connecter',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
const SizedBox(height: 16),
// Bouton Inscription
SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton(
onPressed: () => _navigateToRegister(),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(
color: Colors.white,
width: 2,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_add, size: 20),
SizedBox(width: 8),
Text(
'Créer un compte',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
const SizedBox(height: 24),
// Lien mode démo
TextButton(
onPressed: () => _navigateToDemo(),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.visibility,
size: 16,
color: Colors.white.withOpacity(0.8),
),
const SizedBox(width: 6),
Text(
'Découvrir en mode démo',
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
),
),
// Footer
Padding(
padding: const EdgeInsets.only(top: 20),
child: Column(
children: [
Text(
'Version 1.0.0 • Sécurisé et confidentiel',
style: TextStyle(
color: Colors.white.withOpacity(0.6),
fontSize: 12,
),
),
const SizedBox(height: 8),
Text(
'© 2024 Lions Club International',
style: TextStyle(
color: Colors.white.withOpacity(0.5),
fontSize: 10,
),
),
],
),
),
],
),
),
);
},
),
),
),
);
}
Widget _buildFeatureHighlights() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withOpacity(0.2),
width: 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildFeatureItem(Icons.security, 'Sécurisé'),
_buildFeatureItem(Icons.analytics, 'Analytique'),
_buildFeatureItem(Icons.cloud_sync, 'Synchronisé'),
],
),
);
}
Widget _buildFeatureItem(IconData icon, String label) {
return Column(
children: [
Icon(
icon,
color: Colors.white,
size: 20,
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
);
}
void _navigateToLogin() {
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const LoginScreen(),
transitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1.0, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
)),
child: child,
);
},
),
);
}
void _navigateToRegister() {
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const RegisterScreen(),
transitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1.0, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
)),
child: child,
);
},
),
);
}
void _navigateToDemo() {
// TODO: Implémenter la navigation vers le mode démo
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Mode démo - En cours de développement'),
backgroundColor: AppTheme.primaryColor,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
);
}
}

View File

@@ -0,0 +1,362 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
/// Pied de page de la connexion avec informations et liens
class LoginFooter extends StatelessWidget {
const LoginFooter({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
// Séparateur
_buildDivider(),
const SizedBox(height: 24),
// Informations sur l'application
_buildAppInfo(),
const SizedBox(height: 20),
// Liens utiles
_buildUsefulLinks(context),
const SizedBox(height: 20),
// Version et copyright
_buildVersionInfo(),
],
);
}
Widget _buildDivider() {
return Row(
children: [
Expanded(
child: Container(
height: 1,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Colors.transparent,
AppTheme.textSecondary.withOpacity(0.3),
Colors.transparent,
],
),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Icon(
Icons.star,
size: 16,
color: AppTheme.textSecondary.withOpacity(0.5),
),
),
Expanded(
child: Container(
height: 1,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Colors.transparent,
AppTheme.textSecondary.withOpacity(0.3),
Colors.transparent,
],
),
),
),
),
],
);
}
Widget _buildAppInfo() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.backgroundLight,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.textSecondary.withOpacity(0.1),
),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.security,
size: 20,
color: AppTheme.successColor,
),
const SizedBox(width: 8),
Text(
'Connexion sécurisée',
style: TextStyle(
fontSize: 14,
color: AppTheme.successColor,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 8),
Text(
'Vos données sont protégées par un cryptage de niveau bancaire',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
);
}
Widget _buildUsefulLinks(BuildContext context) {
return Wrap(
spacing: 20,
runSpacing: 12,
alignment: WrapAlignment.center,
children: [
_buildLinkButton(
icon: Icons.help_outline,
label: 'Aide',
onTap: () => _showHelpDialog(context),
),
_buildLinkButton(
icon: Icons.info_outline,
label: 'À propos',
onTap: () => _showAboutDialog(context),
),
_buildLinkButton(
icon: Icons.privacy_tip_outlined,
label: 'Confidentialité',
onTap: () => _showPrivacyDialog(context),
),
],
);
}
Widget _buildLinkButton({
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppTheme.textSecondary.withOpacity(0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: AppTheme.textSecondary,
),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
Widget _buildVersionInfo() {
return Column(
children: [
Text(
'UnionFlow Mobile v1.0.0',
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary.withOpacity(0.7),
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
'© 2025 Lions Dev Team. Tous droits réservés.',
style: TextStyle(
fontSize: 10,
color: AppTheme.textSecondary.withOpacity(0.5),
),
),
],
);
}
void _showHelpDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Icon(
Icons.help_outline,
color: AppTheme.infoColor,
),
const SizedBox(width: 12),
const Text('Aide'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHelpItem(
'Connexion',
'Utilisez votre email et mot de passe fournis par votre association.',
),
const SizedBox(height: 12),
_buildHelpItem(
'Mot de passe oublié',
'Contactez votre administrateur pour réinitialiser votre mot de passe.',
),
const SizedBox(height: 12),
_buildHelpItem(
'Problèmes techniques',
'Vérifiez votre connexion internet et réessayez.',
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Fermer',
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
void _showAboutDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Icon(
Icons.info_outline,
color: AppTheme.primaryColor,
),
const SizedBox(width: 12),
const Text('À propos'),
],
),
content: const Text(
'UnionFlow est une solution complète de gestion d\'associations développée par Lions Dev Team.\n\n'
'Cette application mobile vous permet de gérer vos membres, cotisations, événements et bien plus encore, où que vous soyez.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Fermer',
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
void _showPrivacyDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Icon(
Icons.privacy_tip_outlined,
color: AppTheme.warningColor,
),
const SizedBox(width: 12),
const Text('Confidentialité'),
],
),
content: const Text(
'Nous respectons votre vie privée. Toutes vos données sont stockées de manière sécurisée et ne sont jamais partagées avec des tiers.\n\n'
'Les données sont chiffrées en transit et au repos selon les standards de sécurité les plus élevés.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Compris',
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
Widget _buildHelpItem(String title, String description) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
description,
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
);
}
}

View File

@@ -0,0 +1,437 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/buttons/buttons.dart';
/// Formulaire de connexion sophistiqué avec validation
class LoginForm extends StatefulWidget {
final GlobalKey<FormState> formKey;
final TextEditingController emailController;
final TextEditingController passwordController;
final bool obscurePassword;
final bool rememberMe;
final bool isLoading;
final VoidCallback onObscureToggle;
final ValueChanged<bool> onRememberMeToggle;
final VoidCallback onSubmit;
const LoginForm({
super.key,
required this.formKey,
required this.emailController,
required this.passwordController,
required this.obscurePassword,
required this.rememberMe,
required this.isLoading,
required this.onObscureToggle,
required this.onRememberMeToggle,
required this.onSubmit,
});
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm>
with TickerProviderStateMixin {
late AnimationController _fieldAnimationController;
late List<Animation<Offset>> _fieldAnimations;
final FocusNode _emailFocusNode = FocusNode();
final FocusNode _passwordFocusNode = FocusNode();
bool _emailHasFocus = false;
bool _passwordHasFocus = false;
@override
void initState() {
super.initState();
_setupAnimations();
_setupFocusListeners();
_startFieldAnimations();
}
void _setupAnimations() {
_fieldAnimationController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_fieldAnimations = List.generate(4, (index) {
return Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _fieldAnimationController,
curve: Interval(
index * 0.2,
(index * 0.2) + 0.6,
curve: Curves.easeOut,
),
));
});
}
void _setupFocusListeners() {
_emailFocusNode.addListener(() {
setState(() {
_emailHasFocus = _emailFocusNode.hasFocus;
});
});
_passwordFocusNode.addListener(() {
setState(() {
_passwordHasFocus = _passwordFocusNode.hasFocus;
});
});
}
void _startFieldAnimations() {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
_fieldAnimationController.forward();
}
});
}
@override
void dispose() {
_fieldAnimationController.dispose();
_emailFocusNode.dispose();
_passwordFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Form(
key: widget.formKey,
child: Column(
children: [
// Champ email
SlideTransition(
position: _fieldAnimations[0],
child: _buildEmailField(),
),
const SizedBox(height: 20),
// Champ mot de passe
SlideTransition(
position: _fieldAnimations[1],
child: _buildPasswordField(),
),
const SizedBox(height: 16),
// Options (Se souvenir de moi, Mot de passe oublié)
SlideTransition(
position: _fieldAnimations[2],
child: _buildOptionsRow(),
),
const SizedBox(height: 32),
// Bouton de connexion
SlideTransition(
position: _fieldAnimations[3],
child: _buildLoginButton(),
),
],
),
);
}
Widget _buildEmailField() {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: _emailHasFocus ? [
BoxShadow(
color: AppTheme.primaryColor.withOpacity(0.2),
blurRadius: 12,
offset: const Offset(0, 4),
),
] : [],
),
child: TextFormField(
controller: widget.emailController,
focusNode: _emailFocusNode,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
enabled: !widget.isLoading,
onFieldSubmitted: (_) {
FocusScope.of(context).requestFocus(_passwordFocusNode);
},
decoration: InputDecoration(
labelText: 'Adresse email',
hintText: 'votre.email@exemple.com',
prefixIcon: AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: Icon(
Icons.email_outlined,
color: _emailHasFocus
? AppTheme.primaryColor
: AppTheme.textSecondary,
),
),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: AppTheme.primaryColor,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: AppTheme.errorColor,
width: 2,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: AppTheme.errorColor,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir votre email';
}
if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
return 'Format d\'email invalide';
}
return null;
},
),
);
}
Widget _buildPasswordField() {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: _passwordHasFocus ? [
BoxShadow(
color: AppTheme.primaryColor.withOpacity(0.2),
blurRadius: 12,
offset: const Offset(0, 4),
),
] : [],
),
child: TextFormField(
controller: widget.passwordController,
focusNode: _passwordFocusNode,
obscureText: widget.obscurePassword,
textInputAction: TextInputAction.done,
enabled: !widget.isLoading,
onFieldSubmitted: (_) => widget.onSubmit(),
decoration: InputDecoration(
labelText: 'Mot de passe',
hintText: 'Saisissez votre mot de passe',
prefixIcon: AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: Icon(
Icons.lock_outlined,
color: _passwordHasFocus
? AppTheme.primaryColor
: AppTheme.textSecondary,
),
),
suffixIcon: IconButton(
onPressed: widget.onObscureToggle,
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Icon(
widget.obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
key: ValueKey(widget.obscurePassword),
color: _passwordHasFocus
? AppTheme.primaryColor
: AppTheme.textSecondary,
),
),
),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: AppTheme.primaryColor,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: AppTheme.errorColor,
width: 2,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: AppTheme.errorColor,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez saisir votre mot de passe';
}
if (value.length < 6) {
return 'Le mot de passe doit contenir au moins 6 caractères';
}
return null;
},
),
);
}
Widget _buildOptionsRow() {
return Row(
children: [
// Se souvenir de moi
Expanded(
child: GestureDetector(
onTap: () => widget.onRememberMeToggle(!widget.rememberMe),
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 20,
height: 20,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: widget.rememberMe
? AppTheme.primaryColor
: AppTheme.textSecondary,
width: 2,
),
color: widget.rememberMe
? AppTheme.primaryColor
: Colors.transparent,
),
child: widget.rememberMe
? Icon(
Icons.check,
size: 14,
color: Colors.white,
)
: null,
),
const SizedBox(width: 8),
Text(
'Se souvenir de moi',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
// Mot de passe oublié
TextButton(
onPressed: widget.isLoading ? null : () {
HapticFeedback.selectionClick();
_showForgotPasswordDialog();
},
child: Text(
'Mot de passe oublié ?',
style: TextStyle(
fontSize: 14,
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
Widget _buildLoginButton() {
return SizedBox(
width: double.infinity,
height: 56,
child: widget.isLoading
? QuickButtons.primary(
text: '',
onPressed: () {},
loading: true,
)
: QuickButtons.primary(
text: 'Se connecter',
icon: Icons.login,
onPressed: widget.onSubmit,
size: ButtonSize.large,
),
);
}
void _showForgotPasswordDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Icon(
Icons.help_outline,
color: AppTheme.primaryColor,
),
const SizedBox(width: 12),
const Text('Mot de passe oublié'),
],
),
content: const Text(
'Pour récupérer votre mot de passe, veuillez contacter votre administrateur ou utiliser la fonction de récupération sur l\'interface web.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'Compris',
style: TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,259 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
/// En-tête de la page de connexion avec logo et animation
class LoginHeader extends StatefulWidget {
final VoidCallback? onAnimationComplete;
const LoginHeader({
super.key,
this.onAnimationComplete,
});
@override
State<LoginHeader> createState() => _LoginHeaderState();
}
class _LoginHeaderState extends State<LoginHeader>
with TickerProviderStateMixin {
late AnimationController _logoController;
late AnimationController _textController;
late Animation<double> _logoScaleAnimation;
late Animation<double> _logoRotationAnimation;
late Animation<double> _textFadeAnimation;
late Animation<Offset> _textSlideAnimation;
@override
void initState() {
super.initState();
_setupAnimations();
_startAnimations();
}
void _setupAnimations() {
_logoController = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_textController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_logoScaleAnimation = Tween<double>(
begin: 0.5,
end: 1.0,
).animate(CurvedAnimation(
parent: _logoController,
curve: Curves.elasticOut,
));
_logoRotationAnimation = Tween<double>(
begin: -0.1,
end: 0.0,
).animate(CurvedAnimation(
parent: _logoController,
curve: Curves.easeOut,
));
_textFadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _textController,
curve: Curves.easeOut,
));
_textSlideAnimation = Tween<Offset>(
begin: const Offset(0, 0.5),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _textController,
curve: Curves.easeOut,
));
}
void _startAnimations() {
_logoController.forward().then((_) {
_textController.forward().then((_) {
widget.onAnimationComplete?.call();
});
});
}
@override
void dispose() {
_logoController.dispose();
_textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Logo animé
AnimatedBuilder(
animation: _logoController,
builder: (context, child) {
return Transform.scale(
scale: _logoScaleAnimation.value,
child: Transform.rotate(
angle: _logoRotationAnimation.value,
child: _buildLogo(),
),
);
},
),
const SizedBox(height: 32),
// Texte animé
AnimatedBuilder(
animation: _textController,
builder: (context, child) {
return FadeTransition(
opacity: _textFadeAnimation,
child: SlideTransition(
position: _textSlideAnimation,
child: _buildWelcomeText(),
),
);
},
),
],
);
}
Widget _buildLogo() {
return Container(
width: 120,
height: 120,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppTheme.primaryColor,
AppTheme.secondaryColor,
],
),
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: AppTheme.primaryColor.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Stack(
alignment: Alignment.center,
children: [
// Effet de brillance
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withOpacity(0.2),
Colors.transparent,
],
),
borderRadius: BorderRadius.circular(25),
),
),
// Icône ou texte du logo
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.group,
size: 48,
color: Colors.white,
),
const SizedBox(height: 4),
Text(
'UF',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: 2,
),
),
],
),
],
),
);
}
Widget _buildWelcomeText() {
return Column(
children: [
// Titre principal
ShaderMask(
shaderCallback: (bounds) => LinearGradient(
colors: [
AppTheme.primaryColor,
AppTheme.secondaryColor,
],
).createShader(bounds),
child: Text(
'UnionFlow',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: 1.2,
),
),
),
const SizedBox(height: 8),
// Sous-titre
Text(
'Gestion d\'associations',
style: TextStyle(
fontSize: 16,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
),
),
const SizedBox(height: 24),
// Message de bienvenue
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppTheme.primaryColor.withOpacity(0.2),
width: 1,
),
),
child: Text(
'Connectez-vous pour accéder à votre espace',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: AppTheme.primaryColor,
fontWeight: FontWeight.w500,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,675 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../../../shared/theme/app_theme.dart';
class DashboardPage extends StatelessWidget {
const DashboardPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
appBar: AppBar(
title: const Text('Tableau de bord'),
backgroundColor: AppTheme.primaryColor,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () {},
),
],
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Message de bienvenue
_buildWelcomeSection(context),
const SizedBox(height: 24),
// Cartes KPI principales
_buildKPICards(context),
const SizedBox(height: 24),
// Graphiques et statistiques
_buildChartsSection(context),
const SizedBox(height: 24),
// Actions rapides
_buildQuickActions(context),
const SizedBox(height: 24),
// Activités récentes
_buildRecentActivities(context),
],
),
),
),
);
}
Widget _buildWelcomeSection(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppTheme.primaryColor, AppTheme.primaryLight],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Bonjour !',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Voici un aperçu de votre association',
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 16,
),
),
],
),
),
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(30),
),
child: const Icon(
Icons.dashboard,
color: Colors.white,
size: 30,
),
),
],
),
);
}
Widget _buildKPICards(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Indicateurs clés',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildKPICard(
context,
'Membres',
'1,247',
'+5.2%',
Icons.people,
AppTheme.primaryColor,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildKPICard(
context,
'Revenus',
'€45,890',
'+12.8%',
Icons.euro,
AppTheme.successColor,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildKPICard(
context,
'Événements',
'23',
'+3',
Icons.event,
AppTheme.accentColor,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildKPICard(
context,
'Cotisations',
'89.5%',
'+2.1%',
Icons.payments,
AppTheme.infoColor,
),
),
],
),
],
);
}
Widget _buildKPICard(
BuildContext context,
String title,
String value,
String change,
IconData icon,
Color color,
) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 20,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.successColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
change,
style: const TextStyle(
color: AppTheme.successColor,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
Text(
value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
title,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
],
),
);
}
Widget _buildChartsSection(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Analyses',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildLineChart(context),
),
const SizedBox(width: 12),
Expanded(
child: _buildPieChart(context),
),
],
),
],
);
}
Widget _buildLineChart(BuildContext context) {
return Container(
height: 200,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Évolution des membres',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
Expanded(
child: LineChart(
LineChartData(
gridData: const FlGridData(show: false),
titlesData: const FlTitlesData(show: false),
borderData: FlBorderData(show: false),
lineBarsData: [
LineChartBarData(
spots: const [
FlSpot(0, 1000),
FlSpot(1, 1050),
FlSpot(2, 1100),
FlSpot(3, 1180),
FlSpot(4, 1247),
],
color: AppTheme.primaryColor,
barWidth: 3,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
color: AppTheme.primaryColor.withOpacity(0.1),
),
),
],
),
),
),
],
),
);
}
Widget _buildPieChart(BuildContext context) {
return Container(
height: 200,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Répartition des membres',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
Expanded(
child: PieChart(
PieChartData(
sectionsSpace: 0,
centerSpaceRadius: 40,
sections: [
PieChartSectionData(
color: AppTheme.primaryColor,
value: 45,
title: '45%',
radius: 50,
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
PieChartSectionData(
color: AppTheme.secondaryColor,
value: 30,
title: '30%',
radius: 50,
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
PieChartSectionData(
color: AppTheme.accentColor,
value: 25,
title: '25%',
radius: 50,
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
),
),
],
),
);
}
Widget _buildQuickActions(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Actions rapides',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildActionCard(
context,
'Nouveau membre',
'Ajouter un membre',
Icons.person_add,
AppTheme.primaryColor,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionCard(
context,
'Créer événement',
'Organiser un événement',
Icons.event_available,
AppTheme.secondaryColor,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildActionCard(
context,
'Suivi cotisations',
'Gérer les cotisations',
Icons.payment,
AppTheme.accentColor,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionCard(
context,
'Rapports',
'Générer des rapports',
Icons.analytics,
AppTheme.infoColor,
),
),
],
),
],
);
}
Widget _buildActionCard(
BuildContext context,
String title,
String subtitle,
IconData icon,
Color color,
) {
return InkWell(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$title - En cours de développement'),
backgroundColor: color,
),
);
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.2)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: color,
size: 24,
),
),
const SizedBox(height: 12),
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
Widget _buildRecentActivities(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Activités récentes',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
TextButton(
onPressed: () {},
child: const Text('Voir tout'),
),
],
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
_buildActivityItem(
'Nouveau membre inscrit',
'Marie Dupont a rejoint l\'association',
Icons.person_add,
AppTheme.successColor,
'Il y a 2h',
),
const Divider(height: 1),
_buildActivityItem(
'Cotisation reçue',
'Jean Martin a payé sa cotisation annuelle',
Icons.payment,
AppTheme.primaryColor,
'Il y a 4h',
),
const Divider(height: 1),
_buildActivityItem(
'Événement créé',
'Assemblée générale 2024 programmée',
Icons.event,
AppTheme.accentColor,
'Hier',
),
],
),
),
],
);
}
Widget _buildActivityItem(
String title,
String description,
IconData icon,
Color color,
String time,
) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: color,
size: 16,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 2),
Text(
description,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
],
),
),
Text(
time,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textHint,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,485 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../widgets/kpi_card.dart';
import '../widgets/clickable_kpi_card.dart';
import '../widgets/chart_card.dart';
import '../widgets/activity_feed.dart';
import '../widgets/quick_actions_grid.dart';
import '../widgets/navigation_cards.dart';
class EnhancedDashboard extends StatefulWidget {
final Function(int)? onNavigateToTab;
const EnhancedDashboard({
super.key,
this.onNavigateToTab,
});
@override
State<EnhancedDashboard> createState() => _EnhancedDashboardState();
}
class _EnhancedDashboardState extends State<EnhancedDashboard> {
final PageController _pageController = PageController();
int _currentPage = 0;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
body: CustomScrollView(
slivers: [
_buildAppBar(),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildWelcomeCard(),
const SizedBox(height: 24),
_buildKPISection(),
const SizedBox(height: 24),
_buildChartsSection(),
const SizedBox(height: 24),
NavigationCards(
onNavigateToTab: widget.onNavigateToTab,
),
const SizedBox(height: 24),
const QuickActionsGrid(),
const SizedBox(height: 24),
const ActivityFeed(),
const SizedBox(height: 24),
],
),
),
),
],
),
);
}
Widget _buildAppBar() {
return SliverAppBar(
expandedHeight: 120,
floating: false,
pinned: true,
backgroundColor: AppTheme.primaryColor,
flexibleSpace: FlexibleSpaceBar(
title: const Text(
'Tableau de bord',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppTheme.primaryColor, AppTheme.primaryDark],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
),
actions: [
IconButton(
icon: const Icon(Icons.notifications_outlined),
onPressed: () => _showNotifications(),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => _refreshData(),
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: _handleMenuSelection,
itemBuilder: (context) => [
const PopupMenuItem(
value: 'settings',
child: Row(
children: [
Icon(Icons.settings),
SizedBox(width: 8),
Text('Paramètres'),
],
),
),
const PopupMenuItem(
value: 'export',
child: Row(
children: [
Icon(Icons.download),
SizedBox(width: 8),
Text('Exporter'),
],
),
),
const PopupMenuItem(
value: 'help',
child: Row(
children: [
Icon(Icons.help),
SizedBox(width: 8),
Text('Aide'),
],
),
),
],
),
],
);
}
Widget _buildWelcomeCard() {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppTheme.primaryColor, AppTheme.primaryLight],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: AppTheme.primaryColor.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Bonjour !',
style: TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Découvrez les dernières statistiques de votre association',
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 16,
),
),
const SizedBox(height: 16),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.trending_up,
color: Colors.white,
size: 16,
),
const SizedBox(width: 4),
Text(
'+12% ce mois',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
],
),
),
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(40),
),
child: const Icon(
Icons.dashboard_rounded,
color: Colors.white,
size: 40,
),
),
],
),
);
}
Widget _buildKPISection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Indicateurs clés',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
TextButton.icon(
onPressed: () {},
icon: const Icon(Icons.analytics, size: 16),
label: const Text('Analyse détaillée'),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 180,
child: PageView(
controller: _pageController,
onPageChanged: (index) {
setState(() {
_currentPage = index;
});
},
children: [
_buildKPIPage1(),
_buildKPIPage2(),
],
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildPageIndicator(0),
const SizedBox(width: 8),
_buildPageIndicator(1),
],
),
],
);
}
Widget _buildKPIPage1() {
return Row(
children: [
Expanded(
child: ClickableKPICard(
title: 'Membres actifs',
value: '1,247',
change: '+5.2%',
icon: Icons.people,
color: AppTheme.secondaryColor,
actionText: 'Gérer',
onTap: () => widget.onNavigateToTab?.call(1),
),
),
const SizedBox(width: 12),
Expanded(
child: ClickableKPICard(
title: 'Revenus mensuel',
value: '€45,890',
change: '+12.8%',
icon: Icons.euro,
color: AppTheme.successColor,
actionText: 'Finances',
onTap: () => _showFinancesMessage(),
),
),
],
);
}
Widget _buildKPIPage2() {
return Row(
children: [
Expanded(
child: ClickableKPICard(
title: 'Événements',
value: '23',
change: '+3',
icon: Icons.event,
color: AppTheme.warningColor,
actionText: 'Planifier',
onTap: () => widget.onNavigateToTab?.call(3),
),
),
const SizedBox(width: 12),
Expanded(
child: ClickableKPICard(
title: 'Taux cotisation',
value: '89.5%',
change: '+2.1%',
icon: Icons.payments,
color: AppTheme.accentColor,
actionText: 'Gérer',
onTap: () => widget.onNavigateToTab?.call(2),
),
),
],
);
}
Widget _buildPageIndicator(int index) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: _currentPage == index ? 20 : 8,
height: 8,
decoration: BoxDecoration(
color: _currentPage == index
? AppTheme.primaryColor
: AppTheme.primaryColor.withOpacity(0.3),
borderRadius: BorderRadius.circular(4),
),
);
}
Widget _buildChartsSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Analyses et tendances',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 16),
ChartCard(
title: 'Évolution des membres',
subtitle: 'Croissance sur 6 mois',
chart: const MembershipChart(),
onTap: () => widget.onNavigateToTab?.call(1),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ChartCard(
title: 'Répartition',
subtitle: 'Par catégorie',
chart: const CategoryChart(),
onTap: () => widget.onNavigateToTab?.call(1),
),
),
const SizedBox(width: 12),
Expanded(
child: ChartCard(
title: 'Revenus',
subtitle: 'Évolution mensuelle',
chart: const RevenueChart(),
onTap: () => _showFinancesMessage(),
),
),
],
),
],
);
}
void _showNotifications() {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Notifications',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.warning, color: AppTheme.warningColor),
title: const Text('3 cotisations en retard'),
subtitle: const Text('Nécessite votre attention'),
onTap: () {},
),
ListTile(
leading: const Icon(Icons.event, color: AppTheme.accentColor),
title: const Text('Assemblée générale'),
subtitle: const Text('Dans 5 jours'),
onTap: () {},
),
ListTile(
leading: const Icon(Icons.check_circle, color: AppTheme.successColor),
title: const Text('Rapport mensuel'),
subtitle: const Text('Prêt à être envoyé'),
onTap: () {},
),
],
),
),
);
}
void _refreshData() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Données actualisées'),
backgroundColor: AppTheme.successColor,
behavior: SnackBarBehavior.floating,
),
);
}
void _handleMenuSelection(String value) {
switch (value) {
case 'settings':
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Paramètres - En développement')),
);
break;
case 'export':
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Export - En développement')),
);
break;
case 'help':
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aide - En développement')),
);
break;
}
}
void _showFinancesMessage() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Module Finances - Prochainement disponible'),
backgroundColor: AppTheme.successColor,
behavior: SnackBarBehavior.floating,
),
);
}
}

View File

@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../shared/theme/app_theme.dart';
class ActivityFeed extends StatelessWidget {
const ActivityFeed({super.key});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Activités récentes',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
TextButton(
onPressed: () {},
child: const Text('Voir tout'),
),
],
),
),
..._getActivities().map((activity) => _buildActivityItem(activity)),
],
),
);
}
Widget _buildActivityItem(ActivityItem activity) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: AppTheme.borderColor, width: 0.5),
),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: activity.color.withOpacity(0.15),
borderRadius: BorderRadius.circular(20),
),
child: Icon(
activity.icon,
color: activity.color,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
activity.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
activity.description,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.access_time,
size: 14,
color: AppTheme.textHint,
),
const SizedBox(width: 4),
Text(
_formatTime(activity.timestamp),
style: const TextStyle(
fontSize: 12,
color: AppTheme.textHint,
),
),
],
),
],
),
),
if (activity.actionRequired)
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppTheme.errorColor,
shape: BoxShape.circle,
),
),
],
),
);
}
List<ActivityItem> _getActivities() {
final now = DateTime.now();
return [
ActivityItem(
title: 'Nouveau membre inscrit',
description: 'Marie Dupont a rejoint l\'association',
icon: Icons.person_add,
color: AppTheme.successColor,
timestamp: now.subtract(const Duration(hours: 2)),
actionRequired: false,
),
ActivityItem(
title: 'Cotisation en retard',
description: 'Pierre Martin - Cotisation échue depuis 5 jours',
icon: Icons.warning,
color: AppTheme.warningColor,
timestamp: now.subtract(const Duration(hours: 4)),
actionRequired: true,
),
ActivityItem(
title: 'Paiement reçu',
description: 'Jean Dubois - Cotisation annuelle 2024',
icon: Icons.payment,
color: AppTheme.primaryColor,
timestamp: now.subtract(const Duration(hours: 6)),
actionRequired: false,
),
ActivityItem(
title: 'Événement créé',
description: 'Assemblée générale 2024 - 15 mars 2024',
icon: Icons.event,
color: AppTheme.accentColor,
timestamp: now.subtract(const Duration(days: 1)),
actionRequired: false,
),
ActivityItem(
title: 'Mise à jour profil',
description: 'Sophie Bernard a modifié ses informations',
icon: Icons.edit,
color: AppTheme.infoColor,
timestamp: now.subtract(const Duration(days: 1, hours: 3)),
actionRequired: false,
),
ActivityItem(
title: 'Nouveau document',
description: 'Procès-verbal ajouté aux archives',
icon: Icons.file_upload,
color: AppTheme.secondaryColor,
timestamp: now.subtract(const Duration(days: 2)),
actionRequired: false,
),
];
}
String _formatTime(DateTime timestamp) {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inMinutes < 60) {
return 'Il y a ${difference.inMinutes} min';
} else if (difference.inHours < 24) {
return 'Il y a ${difference.inHours}h';
} else if (difference.inDays == 1) {
return 'Hier';
} else if (difference.inDays < 7) {
return 'Il y a ${difference.inDays} jours';
} else {
return DateFormat('dd/MM/yyyy').format(timestamp);
}
}
}
class ActivityItem {
final String title;
final String description;
final IconData icon;
final Color color;
final DateTime timestamp;
final bool actionRequired;
ActivityItem({
required this.title,
required this.description,
required this.icon,
required this.color,
required this.timestamp,
this.actionRequired = false,
});
}

View File

@@ -0,0 +1,335 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../../../shared/theme/app_theme.dart';
class ChartCard extends StatelessWidget {
final String title;
final Widget chart;
final String? subtitle;
final VoidCallback? onTap;
const ChartCard({
super.key,
required this.title,
required this.chart,
this.subtitle,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
],
],
),
),
if (onTap != null)
const Icon(
Icons.arrow_forward_ios,
size: 16,
color: AppTheme.textHint,
),
],
),
const SizedBox(height: 20),
SizedBox(
height: 200,
child: chart,
),
],
),
),
);
}
}
class MembershipChart extends StatelessWidget {
const MembershipChart({super.key});
@override
Widget build(BuildContext context) {
return LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 200,
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppTheme.borderColor.withOpacity(0.5),
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 200,
getTitlesWidget: (value, meta) {
return Text(
value.toInt().toString(),
style: const TextStyle(
color: AppTheme.textHint,
fontSize: 12,
),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
const months = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun'];
if (value.toInt() < months.length) {
return Text(
months[value.toInt()],
style: const TextStyle(
color: AppTheme.textHint,
fontSize: 12,
),
);
}
return const Text('');
},
),
),
),
borderData: FlBorderData(show: false),
minX: 0,
maxX: 5,
minY: 800,
maxY: 1400,
lineBarsData: [
LineChartBarData(
spots: const [
FlSpot(0, 1000),
FlSpot(1, 1050),
FlSpot(2, 1100),
FlSpot(3, 1180),
FlSpot(4, 1220),
FlSpot(5, 1247),
],
color: AppTheme.primaryColor,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: AppTheme.primaryColor,
strokeWidth: 2,
strokeColor: Colors.white,
);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
AppTheme.primaryColor.withOpacity(0.3),
AppTheme.primaryColor.withOpacity(0.1),
AppTheme.primaryColor.withOpacity(0.0),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
],
),
);
}
}
class CategoryChart extends StatelessWidget {
const CategoryChart({super.key});
@override
Widget build(BuildContext context) {
return PieChart(
PieChartData(
sectionsSpace: 4,
centerSpaceRadius: 50,
sections: [
PieChartSectionData(
color: AppTheme.primaryColor,
value: 45,
title: 'Actifs\n45%',
radius: 60,
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
PieChartSectionData(
color: AppTheme.secondaryColor,
value: 30,
title: 'Retraités\n30%',
radius: 60,
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
PieChartSectionData(
color: AppTheme.accentColor,
value: 25,
title: 'Étudiants\n25%',
radius: 60,
titleStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
);
}
}
class RevenueChart extends StatelessWidget {
const RevenueChart({super.key});
@override
Widget build(BuildContext context) {
return BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: 15000,
barTouchData: BarTouchData(enabled: false),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 5000,
getTitlesWidget: (value, meta) {
return Text(
'${(value / 1000).toInt()}k€',
style: const TextStyle(
color: AppTheme.textHint,
fontSize: 12,
),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
const months = ['J', 'F', 'M', 'A', 'M', 'J'];
if (value.toInt() < months.length) {
return Text(
months[value.toInt()],
style: const TextStyle(
color: AppTheme.textHint,
fontSize: 12,
),
);
}
return const Text('');
},
),
),
),
borderData: FlBorderData(show: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 5000,
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppTheme.borderColor.withOpacity(0.5),
strokeWidth: 1,
);
},
),
barGroups: [
_buildBarGroup(0, 8000, AppTheme.primaryColor),
_buildBarGroup(1, 9500, AppTheme.primaryColor),
_buildBarGroup(2, 7800, AppTheme.primaryColor),
_buildBarGroup(3, 11200, AppTheme.primaryColor),
_buildBarGroup(4, 13500, AppTheme.primaryColor),
_buildBarGroup(5, 12800, AppTheme.primaryColor),
],
),
);
}
BarChartGroupData _buildBarGroup(int x, double y, Color color) {
return BarChartGroupData(
x: x,
barRods: [
BarChartRodData(
toY: y,
color: color,
width: 16,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(4),
),
),
],
);
}
}

View File

@@ -0,0 +1,252 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../core/utils/responsive_utils.dart';
class ClickableKPICard extends StatefulWidget {
final String title;
final String value;
final String change;
final IconData icon;
final Color color;
final bool isPositiveChange;
final VoidCallback? onTap;
final String? actionText;
const ClickableKPICard({
super.key,
required this.title,
required this.value,
required this.change,
required this.icon,
required this.color,
this.isPositiveChange = true,
this.onTap,
this.actionText,
});
@override
State<ClickableKPICard> createState() => _ClickableKPICardState();
}
class _ClickableKPICardState extends State<ClickableKPICard>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Initialiser ResponsiveUtils
ResponsiveUtils.init(context);
return AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap != null ? _handleTap : null,
onTapDown: widget.onTap != null ? (_) => _animationController.forward() : null,
onTapUp: widget.onTap != null ? (_) => _animationController.reverse() : null,
onTapCancel: widget.onTap != null ? () => _animationController.reverse() : null,
borderRadius: ResponsiveUtils.borderRadius(4),
child: Container(
padding: ResponsiveUtils.paddingAll(5),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: ResponsiveUtils.borderRadius(4),
border: widget.onTap != null
? Border.all(
color: widget.color.withOpacity(0.2),
width: ResponsiveUtils.adaptive(
small: 1,
medium: 1.5,
large: 2,
),
)
: null,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 3.5.sp,
offset: Offset(0, 1.hp),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Icône et indicateur de changement
Flexible(
child: Row(
children: [
Container(
padding: ResponsiveUtils.paddingAll(2.5),
decoration: BoxDecoration(
color: widget.color.withOpacity(0.15),
borderRadius: ResponsiveUtils.borderRadius(2.5),
),
child: Icon(
widget.icon,
color: widget.color,
size: ResponsiveUtils.iconSize(5),
),
),
const Spacer(),
_buildChangeIndicator(),
],
),
),
SizedBox(height: 2.hp),
// Valeur principale
Flexible(
child: Text(
widget.value,
style: TextStyle(
fontSize: ResponsiveUtils.adaptive(
small: 4.5.fs,
medium: 4.2.fs,
large: 4.fs,
),
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
SizedBox(height: 0.5.hp),
// Titre et action
Flexible(
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: TextStyle(
fontSize: ResponsiveUtils.adaptive(
small: 3.fs,
medium: 2.8.fs,
large: 2.6.fs,
),
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (widget.onTap != null) ...[
SizedBox(width: 1.5.wp),
Flexible(
child: Container(
padding: ResponsiveUtils.paddingSymmetric(
horizontal: 1.5,
vertical: 0.3,
),
decoration: BoxDecoration(
color: widget.color.withOpacity(0.1),
borderRadius: ResponsiveUtils.borderRadius(2.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.actionText ?? 'Voir',
style: TextStyle(
color: widget.color,
fontSize: 2.5.fs,
fontWeight: FontWeight.w600,
),
),
SizedBox(width: 0.5.wp),
Icon(
Icons.arrow_forward_ios,
size: ResponsiveUtils.iconSize(2),
color: widget.color,
),
],
),
),
),
],
],
),
),
],
),
),
),
),
);
},
);
}
Widget _buildChangeIndicator() {
final changeColor = widget.isPositiveChange
? AppTheme.successColor
: AppTheme.errorColor;
final changeIcon = widget.isPositiveChange
? Icons.trending_up
: Icons.trending_down;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: changeColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
changeIcon,
size: 16,
color: changeColor,
),
const SizedBox(width: 4),
Text(
widget.change,
style: TextStyle(
color: changeColor,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
void _handleTap() {
HapticFeedback.lightImpact();
widget.onTap?.call();
}
}

View File

@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
class KPICard extends StatelessWidget {
final String title;
final String value;
final String change;
final IconData icon;
final Color color;
final bool isPositiveChange;
const KPICard({
super.key,
required this.title,
required this.value,
required this.change,
required this.icon,
required this.color,
this.isPositiveChange = true,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: color,
size: 24,
),
),
const Spacer(),
_buildChangeIndicator(),
],
),
const SizedBox(height: 20),
Text(
value,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 8),
Text(
title,
style: const TextStyle(
fontSize: 16,
color: AppTheme.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildChangeIndicator() {
final changeColor = isPositiveChange
? AppTheme.successColor
: AppTheme.errorColor;
final changeIcon = isPositiveChange
? Icons.trending_up
: Icons.trending_down;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: changeColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
changeIcon,
size: 16,
color: changeColor,
),
const SizedBox(width: 4),
Text(
change,
style: TextStyle(
color: changeColor,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,281 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../core/utils/responsive_utils.dart';
class NavigationCards extends StatelessWidget {
final Function(int)? onNavigateToTab;
const NavigationCards({
super.key,
this.onNavigateToTab,
});
@override
Widget build(BuildContext context) {
ResponsiveUtils.init(context);
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.all(20),
child: Row(
children: [
Icon(
Icons.dashboard_customize,
color: AppTheme.primaryColor,
size: 20,
),
SizedBox(width: 8),
Text(
'Accès rapide aux modules',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1.1,
children: [
_buildNavigationCard(
context,
title: 'Membres',
subtitle: '1,247 membres',
icon: Icons.people_rounded,
color: AppTheme.secondaryColor,
onTap: () => _navigateToModule(context, 1, 'Membres'),
badge: '+5 cette semaine',
),
_buildNavigationCard(
context,
title: 'Cotisations',
subtitle: '89.5% à jour',
icon: Icons.payment_rounded,
color: AppTheme.accentColor,
onTap: () => _navigateToModule(context, 2, 'Cotisations'),
badge: '15 en retard',
badgeColor: AppTheme.warningColor,
),
_buildNavigationCard(
context,
title: 'Événements',
subtitle: '3 à venir',
icon: Icons.event_rounded,
color: AppTheme.warningColor,
onTap: () => _navigateToModule(context, 3, 'Événements'),
badge: 'AG dans 5 jours',
),
_buildNavigationCard(
context,
title: 'Finances',
subtitle: '€45,890',
icon: Icons.account_balance_rounded,
color: AppTheme.primaryColor,
onTap: () => _navigateToModule(context, 4, 'Finances'),
badge: '+12.8% ce mois',
badgeColor: AppTheme.successColor,
),
],
),
),
],
),
);
}
Widget _buildNavigationCard(
BuildContext context, {
required String title,
required String subtitle,
required IconData icon,
required Color color,
required VoidCallback onTap,
String? badge,
Color? badgeColor,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
onTap();
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(
color: color.withOpacity(0.2),
width: 1,
),
borderRadius: BorderRadius.circular(12),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
color.withOpacity(0.05),
color.withOpacity(0.02),
],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header avec icône et badge
Row(
children: [
Flexible(
child: Container(
width: ResponsiveUtils.iconSize(8),
height: ResponsiveUtils.iconSize(8),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(ResponsiveUtils.iconSize(4)),
),
child: Icon(
icon,
color: color,
size: ResponsiveUtils.iconSize(4.5),
),
),
),
const Spacer(),
if (badge != null)
Flexible(
child: Container(
padding: ResponsiveUtils.paddingSymmetric(
horizontal: 1.5,
vertical: 0.3,
),
decoration: BoxDecoration(
color: (badgeColor ?? AppTheme.successColor).withOpacity(0.1),
borderRadius: ResponsiveUtils.borderRadius(2),
border: Border.all(
color: (badgeColor ?? AppTheme.successColor).withOpacity(0.3),
width: 0.5,
),
),
child: Text(
badge,
style: TextStyle(
color: badgeColor ?? AppTheme.successColor,
fontSize: 2.5.fs,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
const Spacer(),
// Contenu principal
Text(
title,
style: TextStyle(
fontSize: ResponsiveUtils.adaptive(
small: 4.fs,
medium: 3.8.fs,
large: 3.6.fs,
),
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 1.hp),
Text(
subtitle,
style: TextStyle(
fontSize: ResponsiveUtils.adaptive(
small: 3.2.fs,
medium: 3.fs,
large: 2.8.fs,
),
color: AppTheme.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
// Flèche d'action
Row(
children: [
Text(
'Gérer',
style: TextStyle(
fontSize: 12,
color: color,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_forward_ios,
size: 12,
color: color,
),
],
),
],
),
),
),
);
}
void _navigateToModule(BuildContext context, int tabIndex, String moduleName) {
// Si onNavigateToTab est fourni, l'utiliser pour naviguer vers l'onglet
if (onNavigateToTab != null) {
onNavigateToTab!(tabIndex);
} else {
// Sinon, afficher un message temporaire
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Navigation vers $moduleName'),
backgroundColor: AppTheme.primaryColor,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
action: SnackBarAction(
label: 'OK',
textColor: Colors.white,
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
),
);
}
}
}

View File

@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../core/utils/responsive_utils.dart';
class QuickActionsGrid extends StatelessWidget {
const QuickActionsGrid({super.key});
@override
Widget build(BuildContext context) {
ResponsiveUtils.init(context);
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.all(20),
child: Text(
'Actions rapides',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 1.2,
children: _getQuickActions(context),
),
),
],
),
);
}
List<Widget> _getQuickActions(BuildContext context) {
final actions = [
QuickAction(
title: 'Nouveau membre',
description: 'Ajouter un membre',
icon: Icons.person_add,
color: AppTheme.primaryColor,
onTap: () => _showAction(context, 'Nouveau membre'),
),
QuickAction(
title: 'Créer événement',
description: 'Organiser un événement',
icon: Icons.event_available,
color: AppTheme.secondaryColor,
onTap: () => _showAction(context, 'Créer événement'),
),
QuickAction(
title: 'Suivi cotisations',
description: 'Gérer les cotisations',
icon: Icons.payment,
color: AppTheme.accentColor,
onTap: () => _showAction(context, 'Suivi cotisations'),
),
QuickAction(
title: 'Rapports',
description: 'Générer des rapports',
icon: Icons.analytics,
color: AppTheme.infoColor,
onTap: () => _showAction(context, 'Rapports'),
),
QuickAction(
title: 'Messages',
description: 'Envoyer des notifications',
icon: Icons.message,
color: AppTheme.warningColor,
onTap: () => _showAction(context, 'Messages'),
),
QuickAction(
title: 'Documents',
description: 'Gérer les documents',
icon: Icons.folder,
color: Color(0xFF9C27B0),
onTap: () => _showAction(context, 'Documents'),
),
];
return actions.map((action) => _buildActionCard(action)).toList();
}
Widget _buildActionCard(QuickAction action) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: action.onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(
color: action.color.withOpacity(0.2),
width: 1,
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Container(
width: ResponsiveUtils.iconSize(12),
height: ResponsiveUtils.iconSize(12),
decoration: BoxDecoration(
color: action.color.withOpacity(0.15),
borderRadius: BorderRadius.circular(ResponsiveUtils.iconSize(6)),
),
child: Icon(
action.icon,
color: action.color,
size: ResponsiveUtils.iconSize(6),
),
),
),
SizedBox(height: 2.hp),
Flexible(
child: Text(
action.title,
style: TextStyle(
fontSize: ResponsiveUtils.adaptive(
small: 3.5.fs,
medium: 3.2.fs,
large: 3.fs,
),
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
SizedBox(height: 0.5.hp),
Flexible(
child: Text(
action.description,
style: TextStyle(
fontSize: ResponsiveUtils.adaptive(
small: 2.8.fs,
medium: 2.6.fs,
large: 2.4.fs,
),
color: AppTheme.textSecondary,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
}
void _showAction(BuildContext context, String actionName) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$actionName - En cours de développement'),
backgroundColor: AppTheme.primaryColor,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
action: SnackBarAction(
label: 'OK',
textColor: Colors.white,
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
),
);
}
}
class QuickAction {
final String title;
final String description;
final IconData icon;
final Color color;
final VoidCallback onTap;
QuickAction({
required this.title,
required this.description,
required this.icon,
required this.color,
required this.onTap,
});
}

View File

@@ -0,0 +1,627 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../core/utils/responsive_utils.dart';
import '../widgets/sophisticated_member_card.dart';
import '../widgets/members_search_bar.dart';
import '../widgets/members_filter_sheet.dart';
class MembersListPage extends StatefulWidget {
const MembersListPage({super.key});
@override
State<MembersListPage> createState() => _MembersListPageState();
}
class _MembersListPageState extends State<MembersListPage>
with TickerProviderStateMixin {
late TabController _tabController;
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
String _searchQuery = '';
String _selectedFilter = 'Tous';
bool _isSearchActive = false;
final List<Map<String, dynamic>> _members = [
{
'id': '1',
'firstName': 'Jean',
'lastName': 'Dupont',
'email': 'jean.dupont@email.com',
'phone': '+33 6 12 34 56 78',
'role': 'Président',
'status': 'Actif',
'joinDate': '2022-01-15',
'lastActivity': '2024-08-15',
'cotisationStatus': 'À jour',
'avatar': null,
'category': 'Bureau',
},
{
'id': '2',
'firstName': 'Marie',
'lastName': 'Martin',
'email': 'marie.martin@email.com',
'phone': '+33 6 98 76 54 32',
'role': 'Secrétaire',
'status': 'Actif',
'joinDate': '2022-03-20',
'lastActivity': '2024-08-14',
'cotisationStatus': 'À jour',
'avatar': null,
'category': 'Bureau',
},
{
'id': '3',
'firstName': 'Pierre',
'lastName': 'Dubois',
'email': 'pierre.dubois@email.com',
'phone': '+33 6 55 44 33 22',
'role': 'Trésorier',
'status': 'Actif',
'joinDate': '2022-02-10',
'lastActivity': '2024-08-13',
'cotisationStatus': 'En retard',
'avatar': null,
'category': 'Bureau',
},
{
'id': '4',
'firstName': 'Sophie',
'lastName': 'Leroy',
'email': 'sophie.leroy@email.com',
'phone': '+33 6 11 22 33 44',
'role': 'Membre',
'status': 'Actif',
'joinDate': '2023-05-12',
'lastActivity': '2024-08-12',
'cotisationStatus': 'À jour',
'avatar': null,
'category': 'Membres',
},
{
'id': '5',
'firstName': 'Thomas',
'lastName': 'Roux',
'email': 'thomas.roux@email.com',
'phone': '+33 6 77 88 99 00',
'role': 'Membre',
'status': 'Inactif',
'joinDate': '2021-09-08',
'lastActivity': '2024-07-20',
'cotisationStatus': 'En retard',
'avatar': null,
'category': 'Membres',
},
{
'id': '6',
'firstName': 'Emma',
'lastName': 'Moreau',
'email': 'emma.moreau@email.com',
'phone': '+33 6 66 77 88 99',
'role': 'Responsable événements',
'status': 'Actif',
'joinDate': '2023-01-25',
'lastActivity': '2024-08-16',
'cotisationStatus': 'À jour',
'avatar': null,
'category': 'Responsables',
},
];
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
_scrollController.dispose();
super.dispose();
}
List<Map<String, dynamic>> get _filteredMembers {
return _members.where((member) {
final matchesSearch = _searchQuery.isEmpty ||
member['firstName'].toLowerCase().contains(_searchQuery.toLowerCase()) ||
member['lastName'].toLowerCase().contains(_searchQuery.toLowerCase()) ||
member['email'].toLowerCase().contains(_searchQuery.toLowerCase()) ||
member['role'].toLowerCase().contains(_searchQuery.toLowerCase());
final matchesFilter = _selectedFilter == 'Tous' ||
(_selectedFilter == 'Actifs' && member['status'] == 'Actif') ||
(_selectedFilter == 'Inactifs' && member['status'] == 'Inactif') ||
(_selectedFilter == 'Bureau' && member['category'] == 'Bureau') ||
(_selectedFilter == 'En retard' && member['cotisationStatus'] == 'En retard');
return matchesSearch && matchesFilter;
}).toList();
}
@override
Widget build(BuildContext context) {
ResponsiveUtils.init(context);
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
body: NestedScrollView(
controller: _scrollController,
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
_buildAppBar(innerBoxIsScrolled),
_buildTabBar(),
];
},
body: TabBarView(
controller: _tabController,
children: [
_buildMembersList(),
_buildMembersList(filter: 'Bureau'),
_buildMembersList(filter: 'Responsables'),
_buildMembersList(filter: 'Membres'),
],
),
),
);
}
Widget _buildAppBar(bool innerBoxIsScrolled) {
return SliverAppBar(
expandedHeight: _isSearchActive ? 250 : 180,
floating: false,
pinned: true,
backgroundColor: AppTheme.secondaryColor,
flexibleSpace: FlexibleSpaceBar(
title: AnimatedOpacity(
opacity: innerBoxIsScrolled ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: const Text(
'Membres',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppTheme.secondaryColor, AppTheme.secondaryColor.withOpacity(0.8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SafeArea(
child: Column(
children: [
// Titre principal quand l'AppBar est étendu
if (!innerBoxIsScrolled)
Padding(
padding: const EdgeInsets.only(top: 60),
child: Text(
'Membres',
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
),
// Contenu principal
Expanded(
child: Padding(
padding: ResponsiveUtils.paddingOnly(
left: 4,
top: 2,
right: 4,
bottom: 2,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
if (_isSearchActive) ...[
Flexible(
child: MembersSearchBar(
controller: _searchController,
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
onClear: () {
setState(() {
_searchQuery = '';
_searchController.clear();
});
},
),
),
SizedBox(height: 2.hp),
],
Flexible(
child: _buildStatsRow(),
),
],
),
),
),
],
),
),
),
),
actions: [
IconButton(
icon: Icon(_isSearchActive ? Icons.search_off : Icons.search),
onPressed: () {
setState(() {
_isSearchActive = !_isSearchActive;
if (!_isSearchActive) {
_searchController.clear();
_searchQuery = '';
}
});
},
),
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: _showFilterSheet,
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: _handleMenuSelection,
itemBuilder: (context) => [
const PopupMenuItem(
value: 'export',
child: Row(
children: [
Icon(Icons.download),
SizedBox(width: 8),
Text('Exporter'),
],
),
),
const PopupMenuItem(
value: 'import',
child: Row(
children: [
Icon(Icons.upload),
SizedBox(width: 8),
Text('Importer'),
],
),
),
const PopupMenuItem(
value: 'stats',
child: Row(
children: [
Icon(Icons.analytics),
SizedBox(width: 8),
Text('Statistiques'),
],
),
),
],
),
],
);
}
Widget _buildTabBar() {
return SliverPersistentHeader(
delegate: _TabBarDelegate(
TabBar(
controller: _tabController,
labelColor: AppTheme.secondaryColor,
unselectedLabelColor: AppTheme.textSecondary,
indicatorColor: AppTheme.secondaryColor,
indicatorWeight: 3,
labelStyle: const TextStyle(fontWeight: FontWeight.w600),
tabs: const [
Tab(text: 'Tous'),
Tab(text: 'Bureau'),
Tab(text: 'Responsables'),
Tab(text: 'Membres'),
],
),
),
pinned: true,
);
}
Widget _buildStatsRow() {
final activeCount = _members.where((m) => m['status'] == 'Actif').length;
final latePayments = _members.where((m) => m['cotisationStatus'] == 'En retard').length;
return Row(
children: [
_buildStatCard(
title: 'Total',
value: '${_members.length}',
icon: Icons.people,
color: Colors.white,
),
const SizedBox(width: 8),
_buildStatCard(
title: 'Actifs',
value: '$activeCount',
icon: Icons.check_circle,
color: AppTheme.successColor,
),
const SizedBox(width: 8),
_buildStatCard(
title: 'En retard',
value: '$latePayments',
icon: Icons.warning,
color: AppTheme.warningColor,
),
],
);
}
Widget _buildStatCard({
required String title,
required String value,
required IconData icon,
required Color color,
}) {
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.white.withOpacity(0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: color,
size: ResponsiveUtils.iconSize(4),
),
SizedBox(width: 1.5.wp),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: Text(
value,
style: TextStyle(
color: Colors.white,
fontSize: ResponsiveUtils.adaptive(
small: 3.5.fs,
medium: 3.2.fs,
large: 3.fs,
),
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Flexible(
child: Text(
title,
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: ResponsiveUtils.adaptive(
small: 2.8.fs,
medium: 2.6.fs,
large: 2.4.fs,
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
);
}
Widget _buildMembersList({String? filter}) {
List<Map<String, dynamic>> members = _filteredMembers;
if (filter != null) {
members = members.where((member) => member['category'] == filter).toList();
}
if (members.isEmpty) {
return _buildEmptyState();
}
return RefreshIndicator(
onRefresh: _refreshMembers,
color: AppTheme.secondaryColor,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: members.length,
itemBuilder: (context, index) {
final member = members[index];
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SophisticatedMemberCard(
member: member,
onTap: () => _showMemberDetails(member),
onEdit: () => _editMember(member),
compact: false,
),
);
},
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.people_outline,
size: 80,
color: AppTheme.textHint,
),
const SizedBox(height: 16),
const Text(
'Aucun membre trouvé',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
const Text(
'Modifiez vos critères de recherche ou ajoutez de nouveaux membres',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: AppTheme.textHint,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _addMember,
icon: const Icon(Icons.person_add),
label: const Text('Ajouter un membre'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.secondaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
),
);
}
void _showFilterSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => MembersFilterSheet(
selectedFilter: _selectedFilter,
onFilterChanged: (filter) {
setState(() {
_selectedFilter = filter;
});
},
),
);
}
void _handleMenuSelection(String value) {
switch (value) {
case 'export':
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Export des membres - En développement'),
backgroundColor: AppTheme.secondaryColor,
),
);
break;
case 'import':
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Import des membres - En développement'),
backgroundColor: AppTheme.secondaryColor,
),
);
break;
case 'stats':
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Statistiques détaillées - En développement'),
backgroundColor: AppTheme.secondaryColor,
),
);
break;
}
}
Future<void> _refreshMembers() async {
await Future.delayed(const Duration(seconds: 1));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Liste des membres actualisée'),
backgroundColor: AppTheme.successColor,
behavior: SnackBarBehavior.floating,
),
);
}
void _showMemberDetails(Map<String, dynamic> member) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Détails de ${member['firstName']} ${member['lastName']} - En développement'),
backgroundColor: AppTheme.secondaryColor,
behavior: SnackBarBehavior.floating,
),
);
}
void _editMember(Map<String, dynamic> member) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Édition de ${member['firstName']} ${member['lastName']} - En développement'),
backgroundColor: AppTheme.accentColor,
behavior: SnackBarBehavior.floating,
),
);
}
void _addMember() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Ajouter un membre - En développement'),
backgroundColor: AppTheme.secondaryColor,
behavior: SnackBarBehavior.floating,
),
);
}
}
class _TabBarDelegate extends SliverPersistentHeaderDelegate {
final TabBar tabBar;
_TabBarDelegate(this.tabBar);
@override
double get minExtent => tabBar.preferredSize.height;
@override
double get maxExtent => tabBar.preferredSize.height;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(
color: Colors.white,
child: tabBar,
);
}
@override
bool shouldRebuild(_TabBarDelegate oldDelegate) {
return false;
}
}

View File

@@ -0,0 +1,427 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../shared/theme/app_theme.dart';
class MemberCard extends StatefulWidget {
final Map<String, dynamic> member;
final VoidCallback? onTap;
final VoidCallback? onEdit;
const MemberCard({
super.key,
required this.member,
this.onTap,
this.onEdit,
});
@override
State<MemberCard> createState() => _MemberCardState();
}
class _MemberCardState extends State<MemberCard>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.98,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.onTap != null ? _handleTap : null,
onTapDown: widget.onTap != null ? (_) => _animationController.forward() : null,
onTapUp: widget.onTap != null ? (_) => _animationController.reverse() : null,
onTapCancel: widget.onTap != null ? () => _animationController.reverse() : null,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 4),
),
],
border: Border.all(
color: _getStatusColor().withOpacity(0.2),
width: 1,
),
),
child: Column(
children: [
Row(
children: [
_buildAvatar(),
const SizedBox(width: 16),
Expanded(
child: _buildMemberInfo(),
),
_buildStatusBadge(),
],
),
const SizedBox(height: 16),
_buildMemberDetails(),
const SizedBox(height: 12),
_buildActionButtons(),
],
),
),
),
),
);
},
);
}
Widget _buildAvatar() {
return Container(
width: 60,
height: 60,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
_getStatusColor(),
_getStatusColor().withOpacity(0.7),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: _getStatusColor().withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: widget.member['avatar'] != null
? ClipRRect(
borderRadius: BorderRadius.circular(30),
child: Image.network(
widget.member['avatar'],
fit: BoxFit.cover,
),
)
: Center(
child: Text(
_getInitials(),
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
);
}
Widget _buildMemberInfo() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${widget.member['firstName']} ${widget.member['lastName']}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
const SizedBox(height: 4),
Text(
widget.member['role'],
style: TextStyle(
fontSize: 14,
color: _getStatusColor(),
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
widget.member['email'],
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
overflow: TextOverflow.ellipsis,
),
],
);
}
Widget _buildStatusBadge() {
final isActive = widget.member['status'] == 'Actif';
final color = isActive ? AppTheme.successColor : AppTheme.textHint;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: color.withOpacity(0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 6),
Text(
widget.member['status'],
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
Widget _buildMemberDetails() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.backgroundLight,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
_buildDetailRow(
icon: Icons.phone,
label: 'Téléphone',
value: widget.member['phone'],
color: AppTheme.infoColor,
),
const SizedBox(height: 8),
_buildDetailRow(
icon: Icons.calendar_today,
label: 'Adhésion',
value: _formatDate(widget.member['joinDate']),
color: AppTheme.primaryColor,
),
const SizedBox(height: 8),
_buildDetailRow(
icon: Icons.payment,
label: 'Cotisation',
value: widget.member['cotisationStatus'],
color: _getCotisationColor(),
),
],
),
);
}
Widget _buildDetailRow({
required IconData icon,
required String label,
required String value,
required Color color,
}) {
return Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: Icon(
icon,
size: 16,
color: color,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
Text(
value,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: label == 'Cotisation' ? color : AppTheme.textPrimary,
),
),
],
),
),
],
);
}
Widget _buildActionButtons() {
return Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _callMember,
icon: const Icon(Icons.phone, size: 16),
label: const Text('Appeler'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.infoColor,
side: BorderSide(color: AppTheme.infoColor.withOpacity(0.5)),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _emailMember,
icon: const Icon(Icons.email, size: 16),
label: const Text('Email'),
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.primaryColor,
side: BorderSide(color: AppTheme.primaryColor.withOpacity(0.5)),
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
const SizedBox(width: 12),
Container(
decoration: BoxDecoration(
color: AppTheme.secondaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: IconButton(
onPressed: widget.onEdit,
icon: const Icon(Icons.edit, size: 18),
color: AppTheme.secondaryColor,
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(
minWidth: 40,
minHeight: 40,
),
),
),
],
);
}
String _getInitials() {
final firstName = widget.member['firstName'] as String;
final lastName = widget.member['lastName'] as String;
return '${firstName.isNotEmpty ? firstName[0] : ''}${lastName.isNotEmpty ? lastName[0] : ''}'.toUpperCase();
}
Color _getStatusColor() {
switch (widget.member['role']) {
case 'Président':
return AppTheme.primaryColor;
case 'Secrétaire':
return AppTheme.secondaryColor;
case 'Trésorier':
return AppTheme.accentColor;
case 'Responsable événements':
return AppTheme.warningColor;
default:
return AppTheme.infoColor;
}
}
Color _getCotisationColor() {
switch (widget.member['cotisationStatus']) {
case 'À jour':
return AppTheme.successColor;
case 'En retard':
return AppTheme.errorColor;
case 'Exempt':
return AppTheme.infoColor;
default:
return AppTheme.textSecondary;
}
}
String _formatDate(String dateString) {
try {
final date = DateTime.parse(dateString);
final months = [
'Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun',
'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc'
];
return '${date.day} ${months[date.month - 1]} ${date.year}';
} catch (e) {
return dateString;
}
}
void _handleTap() {
HapticFeedback.selectionClick();
widget.onTap?.call();
}
void _callMember() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Appel vers ${widget.member['phone']} - En développement'),
backgroundColor: AppTheme.infoColor,
behavior: SnackBarBehavior.floating,
),
);
}
void _emailMember() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Email vers ${widget.member['email']} - En développement'),
backgroundColor: AppTheme.primaryColor,
behavior: SnackBarBehavior.floating,
),
);
}
}

View File

@@ -0,0 +1,377 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../shared/theme/app_theme.dart';
class MembersFilterSheet extends StatefulWidget {
final String selectedFilter;
final Function(String) onFilterChanged;
const MembersFilterSheet({
super.key,
required this.selectedFilter,
required this.onFilterChanged,
});
@override
State<MembersFilterSheet> createState() => _MembersFilterSheetState();
}
class _MembersFilterSheetState extends State<MembersFilterSheet>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _slideAnimation;
late Animation<double> _fadeAnimation;
String _tempSelectedFilter = '';
final List<Map<String, dynamic>> _filterOptions = [
{
'value': 'Tous',
'label': 'Tous les membres',
'icon': Icons.people,
'color': AppTheme.primaryColor,
'description': 'Afficher tous les membres',
},
{
'value': 'Actifs',
'label': 'Membres actifs',
'icon': Icons.check_circle,
'color': AppTheme.successColor,
'description': 'Membres avec un statut actif',
},
{
'value': 'Inactifs',
'label': 'Membres inactifs',
'icon': Icons.pause_circle,
'color': AppTheme.textHint,
'description': 'Membres avec un statut inactif',
},
{
'value': 'Bureau',
'label': 'Membres du bureau',
'icon': Icons.star,
'color': AppTheme.warningColor,
'description': 'Président, secrétaire, trésorier',
},
{
'value': 'En retard',
'label': 'Cotisations en retard',
'icon': Icons.warning,
'color': AppTheme.errorColor,
'description': 'Membres avec cotisations impayées',
},
];
@override
void initState() {
super.initState();
_tempSelectedFilter = widget.selectedFilter;
_animationController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_slideAnimation = Tween<double>(
begin: 1.0,
end: 0.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
));
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.2, 1.0, curve: Curves.easeOut),
));
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5 * _fadeAnimation.value),
),
child: Align(
alignment: Alignment.bottomCenter,
child: Transform.translate(
offset: Offset(0, MediaQuery.of(context).size.height * _slideAnimation.value),
child: Container(
width: double.infinity,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHandle(),
_buildHeader(),
Flexible(child: _buildFilterOptions()),
_buildActionButtons(),
],
),
),
),
),
);
},
);
}
Widget _buildHandle() {
return Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppTheme.textHint.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
);
}
Widget _buildHeader() {
return Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.secondaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.filter_list,
color: AppTheme.secondaryColor,
size: 20,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Filtrer les membres',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
Text(
'Sélectionnez un critère de filtrage',
style: TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
],
),
),
IconButton(
onPressed: _closeSheet,
icon: Icon(
Icons.close,
color: AppTheme.textHint,
),
),
],
),
);
}
Widget _buildFilterOptions() {
return ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
itemCount: _filterOptions.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final option = _filterOptions[index];
final isSelected = _tempSelectedFilter == option['value'];
return _buildFilterOption(
option: option,
isSelected: isSelected,
onTap: () => _selectFilter(option['value']),
);
},
);
}
Widget _buildFilterOption({
required Map<String, dynamic> option,
required bool isSelected,
required VoidCallback onTap,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
HapticFeedback.selectionClick();
onTap();
},
borderRadius: BorderRadius.circular(16),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected
? option['color'].withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? option['color']
: AppTheme.textHint.withOpacity(0.2),
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: option['color'].withOpacity(isSelected ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(24),
),
child: Icon(
option['icon'],
color: option['color'],
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option['label'],
style: TextStyle(
fontSize: 16,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? option['color'] : AppTheme.textPrimary,
),
),
const SizedBox(height: 2),
Text(
option['description'],
style: const TextStyle(
fontSize: 13,
color: AppTheme.textSecondary,
),
),
],
),
),
AnimatedOpacity(
opacity: isSelected ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: Icon(
Icons.check_circle,
color: option['color'],
size: 24,
),
),
],
),
),
),
);
}
Widget _buildActionButtons() {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppTheme.backgroundLight,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _resetFilter,
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.textSecondary,
side: BorderSide(color: AppTheme.textHint.withOpacity(0.5)),
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text('Réinitialiser'),
),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: ElevatedButton(
onPressed: _applyFilter,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.secondaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
elevation: 0,
),
child: const Text(
'Appliquer',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
),
],
),
);
}
void _selectFilter(String filter) {
setState(() {
_tempSelectedFilter = filter;
});
}
void _resetFilter() {
setState(() {
_tempSelectedFilter = 'Tous';
});
}
void _applyFilter() {
widget.onFilterChanged(_tempSelectedFilter);
_closeSheet();
}
void _closeSheet() {
_animationController.reverse().then((_) {
if (mounted) {
Navigator.of(context).pop();
}
});
}
}

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import '../../../../shared/theme/app_theme.dart';
class MembersSearchBar extends StatefulWidget {
final TextEditingController controller;
final Function(String) onChanged;
final VoidCallback onClear;
const MembersSearchBar({
super.key,
required this.controller,
required this.onChanged,
required this.onClear,
});
@override
State<MembersSearchBar> createState() => _MembersSearchBarState();
}
class _MembersSearchBarState extends State<MembersSearchBar>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
bool _hasText = false;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
widget.controller.addListener(_onTextChanged);
_animationController.forward();
}
@override
void dispose() {
widget.controller.removeListener(_onTextChanged);
_animationController.dispose();
super.dispose();
}
void _onTextChanged() {
final hasText = widget.controller.text.isNotEmpty;
if (hasText != _hasText) {
setState(() {
_hasText = hasText;
});
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _fadeAnimation,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: Transform.translate(
offset: Offset(0, 20 * (1 - _fadeAnimation.value)),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: TextField(
controller: widget.controller,
onChanged: widget.onChanged,
style: const TextStyle(
fontSize: 16,
color: AppTheme.textPrimary,
),
decoration: InputDecoration(
hintText: 'Rechercher un membre...',
hintStyle: TextStyle(
color: AppTheme.textHint,
fontSize: 16,
),
prefixIcon: Icon(
Icons.search,
color: AppTheme.secondaryColor,
size: 24,
),
suffixIcon: _hasText
? AnimatedOpacity(
opacity: _hasText ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: IconButton(
icon: Icon(
Icons.clear,
color: AppTheme.textHint,
size: 20,
),
onPressed: widget.onClear,
),
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.transparent,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
),
),
),
),
);
},
);
}
}

View File

@@ -0,0 +1,544 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/cards/sophisticated_card.dart';
import '../../../../shared/widgets/avatars/sophisticated_avatar.dart';
import '../../../../shared/widgets/badges/status_badge.dart';
import '../../../../shared/widgets/badges/count_badge.dart';
import '../../../../shared/widgets/buttons/buttons.dart';
class SophisticatedMemberCard extends StatefulWidget {
final Map<String, dynamic> member;
final VoidCallback? onTap;
final VoidCallback? onEdit;
final VoidCallback? onMessage;
final VoidCallback? onCall;
final bool showActions;
final bool compact;
const SophisticatedMemberCard({
super.key,
required this.member,
this.onTap,
this.onEdit,
this.onMessage,
this.onCall,
this.showActions = true,
this.compact = false,
});
@override
State<SophisticatedMemberCard> createState() => _SophisticatedMemberCardState();
}
class _SophisticatedMemberCardState extends State<SophisticatedMemberCard>
with TickerProviderStateMixin {
late AnimationController _expandController;
late AnimationController _actionController;
late Animation<double> _expandAnimation;
late Animation<double> _actionAnimation;
bool _isExpanded = false;
@override
void initState() {
super.initState();
_expandController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_actionController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_expandAnimation = CurvedAnimation(
parent: _expandController,
curve: Curves.easeInOut,
);
_actionAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _actionController,
curve: Curves.elasticOut,
));
_actionController.forward();
}
@override
void dispose() {
_expandController.dispose();
_actionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SophisticatedCard(
variant: CardVariant.elevated,
size: widget.compact ? CardSize.compact : CardSize.standard,
onTap: widget.onTap,
margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
child: Column(
children: [
_buildMainContent(),
AnimatedBuilder(
animation: _expandAnimation,
builder: (context, child) {
return ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: _expandAnimation.value,
child: child,
),
);
},
child: _buildExpandedContent(),
),
],
),
);
}
Widget _buildMainContent() {
return Row(
children: [
_buildAvatar(),
const SizedBox(width: 16),
Expanded(child: _buildMemberInfo()),
_buildTrailingActions(),
],
);
}
Widget _buildAvatar() {
final roleColor = _getRoleColor();
final isOnline = widget.member['status'] == 'Actif';
return SophisticatedAvatar(
initials: _getInitials(),
size: widget.compact ? AvatarSize.medium : AvatarSize.large,
variant: AvatarVariant.gradient,
backgroundColor: roleColor,
showOnlineStatus: true,
isOnline: isOnline,
badge: _buildRoleBadge(),
onTap: () => _toggleExpanded(),
);
}
Widget _buildRoleBadge() {
final role = widget.member['role'] as String;
if (role == 'Président' || role == 'Secrétaire' || role == 'Trésorier') {
return CountBadge(
count: 1,
backgroundColor: AppTheme.warningColor,
size: 16,
suffix: '',
);
}
return const SizedBox.shrink();
}
Widget _buildMemberInfo() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'${widget.member['firstName']} ${widget.member['lastName']}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
_buildStatusBadge(),
],
),
const SizedBox(height: 4),
_buildRoleChip(),
if (!widget.compact) ...[
const SizedBox(height: 8),
_buildQuickInfo(),
],
],
);
}
Widget _buildStatusBadge() {
final status = widget.member['status'] as String;
final cotisationStatus = widget.member['cotisationStatus'] as String;
if (cotisationStatus == 'En retard') {
return StatusBadge(
text: 'Retard',
type: BadgeType.error,
size: BadgeSize.small,
variant: BadgeVariant.ghost,
icon: Icons.warning,
);
}
return StatusBadge(
text: status,
type: status == 'Actif' ? BadgeType.success : BadgeType.neutral,
size: BadgeSize.small,
variant: BadgeVariant.ghost,
);
}
Widget _buildRoleChip() {
final role = widget.member['role'] as String;
final roleColor = _getRoleColor();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: roleColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: roleColor.withOpacity(0.3),
width: 1,
),
),
child: Text(
role,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: roleColor,
),
),
);
}
Widget _buildQuickInfo() {
return Row(
children: [
Expanded(
child: _buildInfoItem(
Icons.email_outlined,
widget.member['email'],
AppTheme.infoColor,
),
),
const SizedBox(width: 16),
_buildInfoItem(
Icons.phone_outlined,
_formatPhone(widget.member['phone']),
AppTheme.successColor,
),
],
);
}
Widget _buildInfoItem(IconData icon, String text, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Flexible(
child: Text(
text,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
Widget _buildTrailingActions() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: _actionAnimation,
builder: (context, child) {
return Transform.scale(
scale: _actionAnimation.value,
child: IconButton(
onPressed: _toggleExpanded,
icon: AnimatedRotation(
turns: _isExpanded ? 0.5 : 0.0,
duration: const Duration(milliseconds: 300),
child: const Icon(Icons.expand_more),
),
iconSize: 20,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
style: IconButton.styleFrom(
backgroundColor: AppTheme.backgroundLight,
foregroundColor: AppTheme.textSecondary,
),
),
);
},
),
if (widget.compact) ...[
const SizedBox(height: 4),
_buildQuickActionButton(),
],
],
);
}
Widget _buildQuickActionButton() {
return QuickButtons.iconGhost(
icon: Icons.edit,
onPressed: widget.onEdit ?? _editMember,
size: 32,
color: _getRoleColor(),
tooltip: 'Modifier',
);
}
Widget _buildExpandedContent() {
return Container(
margin: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.backgroundLight,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
_buildDetailedInfo(),
if (widget.showActions) ...[
const SizedBox(height: 16),
_buildActionButtons(),
],
],
),
);
}
Widget _buildDetailedInfo() {
return Column(
children: [
_buildDetailRow(
'Adhésion',
_formatDate(widget.member['joinDate']),
Icons.calendar_today,
AppTheme.primaryColor,
),
const SizedBox(height: 12),
_buildDetailRow(
'Dernière activité',
_formatDate(widget.member['lastActivity']),
Icons.access_time,
AppTheme.infoColor,
),
const SizedBox(height: 12),
_buildDetailRow(
'Cotisation',
widget.member['cotisationStatus'],
Icons.payment,
_getCotisationColor(),
),
],
);
}
Widget _buildDetailRow(String label, String value, IconData icon, Color color) {
return Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: Icon(icon, size: 16, color: color),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
),
),
Text(
value,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: label == 'Cotisation' ? color : AppTheme.textPrimary,
),
),
],
),
),
],
);
}
Widget _buildActionButtons() {
return Row(
children: [
Expanded(
child: QuickButtons.outline(
text: 'Appeler',
icon: Icons.phone,
onPressed: widget.onCall ?? _callMember,
size: ButtonSize.small,
color: AppTheme.successColor,
),
),
const SizedBox(width: 8),
Expanded(
child: QuickButtons.outline(
text: 'Message',
icon: Icons.message,
onPressed: widget.onMessage ?? _messageMember,
size: ButtonSize.small,
color: AppTheme.infoColor,
),
),
const SizedBox(width: 8),
Expanded(
child: QuickButtons.outline(
text: 'Modifier',
icon: Icons.edit,
onPressed: widget.onEdit ?? _editMember,
size: ButtonSize.small,
color: AppTheme.warningColor,
),
),
],
);
}
void _toggleExpanded() {
setState(() {
_isExpanded = !_isExpanded;
if (_isExpanded) {
_expandController.forward();
} else {
_expandController.reverse();
}
});
HapticFeedback.selectionClick();
}
String _getInitials() {
final firstName = widget.member['firstName'] as String;
final lastName = widget.member['lastName'] as String;
return '${firstName.isNotEmpty ? firstName[0] : ''}${lastName.isNotEmpty ? lastName[0] : ''}'.toUpperCase();
}
Color _getRoleColor() {
switch (widget.member['role']) {
case 'Président':
return AppTheme.primaryColor;
case 'Secrétaire':
return AppTheme.secondaryColor;
case 'Trésorier':
return AppTheme.accentColor;
case 'Responsable événements':
return AppTheme.warningColor;
default:
return AppTheme.infoColor;
}
}
Color _getCotisationColor() {
switch (widget.member['cotisationStatus']) {
case 'À jour':
return AppTheme.successColor;
case 'En retard':
return AppTheme.errorColor;
case 'Exempt':
return AppTheme.infoColor;
default:
return AppTheme.textSecondary;
}
}
String _formatDate(String dateString) {
try {
final date = DateTime.parse(dateString);
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays < 1) {
return 'Aujourd\'hui';
} else if (difference.inDays < 7) {
return 'Il y a ${difference.inDays} jour${difference.inDays > 1 ? 's' : ''}';
} else if (difference.inDays < 30) {
final weeks = (difference.inDays / 7).floor();
return 'Il y a $weeks semaine${weeks > 1 ? 's' : ''}';
} else {
final months = [
'Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Jun',
'Jul', 'Aoû', 'Sep', 'Oct', 'Nov', 'Déc'
];
return '${date.day} ${months[date.month - 1]} ${date.year}';
}
} catch (e) {
return dateString;
}
}
String _formatPhone(String phone) {
if (phone.length >= 10) {
return '${phone.substring(0, 3)} ${phone.substring(3, 5)} ${phone.substring(5, 7)} ${phone.substring(7, 9)} ${phone.substring(9)}';
}
return phone;
}
void _callMember() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Appel vers ${widget.member['firstName']} ${widget.member['lastName']}'),
backgroundColor: AppTheme.successColor,
behavior: SnackBarBehavior.floating,
),
);
}
void _messageMember() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Message vers ${widget.member['firstName']} ${widget.member['lastName']}'),
backgroundColor: AppTheme.infoColor,
behavior: SnackBarBehavior.floating,
),
);
}
void _editMember() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Modification de ${widget.member['firstName']} ${widget.member['lastName']}'),
backgroundColor: AppTheme.warningColor,
behavior: SnackBarBehavior.floating,
),
);
}
}

View File

@@ -0,0 +1,406 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../shared/theme/app_theme.dart';
import '../../../../shared/widgets/coming_soon_page.dart';
import '../../../../shared/widgets/buttons/buttons.dart';
import '../../../dashboard/presentation/pages/enhanced_dashboard.dart';
import '../../../members/presentation/pages/members_list_page.dart';
import '../widgets/custom_bottom_nav_bar.dart';
class MainNavigation extends StatefulWidget {
const MainNavigation({super.key});
@override
State<MainNavigation> createState() => _MainNavigationState();
}
class _MainNavigationState extends State<MainNavigation>
with TickerProviderStateMixin {
int _currentIndex = 0;
late PageController _pageController;
late AnimationController _fabAnimationController;
late Animation<double> _fabAnimation;
@override
void initState() {
super.initState();
_pageController = PageController();
_fabAnimationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_fabAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _fabAnimationController,
curve: Curves.easeInOut,
));
_fabAnimationController.forward();
}
@override
void dispose() {
_pageController.dispose();
_fabAnimationController.dispose();
super.dispose();
}
final List<NavigationTab> _tabs = [
NavigationTab(
title: 'Tableau de bord',
icon: Icons.dashboard_outlined,
activeIcon: Icons.dashboard,
color: AppTheme.primaryColor,
),
NavigationTab(
title: 'Membres',
icon: Icons.people_outline,
activeIcon: Icons.people,
color: AppTheme.secondaryColor,
),
NavigationTab(
title: 'Cotisations',
icon: Icons.payment_outlined,
activeIcon: Icons.payment,
color: AppTheme.accentColor,
),
NavigationTab(
title: 'Événements',
icon: Icons.event_outlined,
activeIcon: Icons.event,
color: AppTheme.warningColor,
),
NavigationTab(
title: 'Plus',
icon: Icons.more_horiz_outlined,
activeIcon: Icons.menu,
color: AppTheme.infoColor,
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
children: [
EnhancedDashboard(
onNavigateToTab: _onTabTapped,
),
_buildMembresPage(),
_buildCotisationsPage(),
_buildEventsPage(),
_buildMorePage(),
],
),
bottomNavigationBar: CustomBottomNavBar(
currentIndex: _currentIndex,
tabs: _tabs,
onTap: _onTabTapped,
),
floatingActionButton: _buildFloatingActionButton(),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
);
}
Widget _buildFloatingActionButton() {
// Afficher le FAB seulement sur certains onglets
if (_currentIndex == 1 || _currentIndex == 2 || _currentIndex == 3) {
return ScaleTransition(
scale: _fabAnimation,
child: QuickButtons.fab(
onPressed: _onFabPressed,
icon: _getFabIcon(),
variant: FABVariant.gradient,
size: FABSize.regular,
tooltip: _getFabTooltip(),
),
);
}
return const SizedBox.shrink();
}
IconData _getFabIcon() {
switch (_currentIndex) {
case 1: // Membres
return Icons.person_add;
case 2: // Cotisations
return Icons.add_card;
case 3: // Événements
return Icons.add_circle_outline;
default:
return Icons.add;
}
}
String _getFabTooltip() {
switch (_currentIndex) {
case 1: // Membres
return 'Ajouter un membre';
case 2: // Cotisations
return 'Nouvelle cotisation';
case 3: // Événements
return 'Créer un événement';
default:
return 'Ajouter';
}
}
void _onPageChanged(int index) {
setState(() {
_currentIndex = index;
});
// Animation du FAB
if (index == 1 || index == 2 || index == 3) {
_fabAnimationController.forward();
} else {
_fabAnimationController.reverse();
}
// Vibration légère
HapticFeedback.selectionClick();
}
void _onTabTapped(int index) {
if (_currentIndex != index) {
setState(() {
_currentIndex = index;
});
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
void _onFabPressed() {
HapticFeedback.lightImpact();
String action;
switch (_currentIndex) {
case 1:
action = 'Ajouter un membre';
break;
case 2:
action = 'Nouvelle cotisation';
break;
case 3:
action = 'Créer un événement';
break;
default:
action = 'Action';
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$action - En cours de développement'),
backgroundColor: _tabs[_currentIndex].color,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
action: SnackBarAction(
label: 'OK',
textColor: Colors.white,
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
),
);
}
Widget _buildMembresPage() {
return MembersListPage();
}
Widget _buildCotisationsPage() {
return ComingSoonPage(
title: 'Module Cotisations',
description: 'Suivi et gestion des cotisations avec paiements automatiques',
icon: Icons.payment_rounded,
color: AppTheme.accentColor,
features: [
'Tableau de bord des cotisations',
'Relances automatiques par email/SMS',
'Paiements en ligne sécurisés',
'Génération de reçus automatique',
'Suivi des retards de paiement',
'Rapports financiers détaillés',
],
);
}
Widget _buildEventsPage() {
return ComingSoonPage(
title: 'Module Événements',
description: 'Organisation et gestion d\'événements avec calendrier intégré',
icon: Icons.event_rounded,
color: AppTheme.warningColor,
features: [
'Calendrier interactif des événements',
'Gestion des inscriptions en ligne',
'Envoi d\'invitations automatiques',
'Suivi de la participation',
'Gestion des lieux et ressources',
'Sondages et feedback post-événement',
],
);
}
Widget _buildMorePage() {
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
appBar: AppBar(
title: const Text('Plus'),
backgroundColor: AppTheme.infoColor,
elevation: 0,
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {},
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildMoreSection(
'Gestion',
[
_buildMoreItem(Icons.analytics, 'Rapports', 'Génération de rapports'),
_buildMoreItem(Icons.account_balance, 'Finances', 'Tableau de bord financier'),
_buildMoreItem(Icons.message, 'Communications', 'Messages et notifications'),
_buildMoreItem(Icons.folder, 'Documents', 'Gestion documentaire'),
],
),
const SizedBox(height: 24),
_buildMoreSection(
'Paramètres',
[
_buildMoreItem(Icons.person, 'Mon profil', 'Informations personnelles'),
_buildMoreItem(Icons.notifications, 'Notifications', 'Préférences de notification'),
_buildMoreItem(Icons.security, 'Sécurité', 'Mot de passe et sécurité'),
_buildMoreItem(Icons.language, 'Langue', 'Changer la langue'),
],
),
const SizedBox(height: 24),
_buildMoreSection(
'Support',
[
_buildMoreItem(Icons.help, 'Aide', 'Centre d\'aide et FAQ'),
_buildMoreItem(Icons.contact_support, 'Contact', 'Nous contacter'),
_buildMoreItem(Icons.info, 'À propos', 'Informations sur l\'application'),
_buildMoreItem(Icons.logout, 'Déconnexion', 'Se déconnecter', isDestructive: true),
],
),
],
),
);
}
Widget _buildMoreSection(String title, List<Widget> items) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 12),
child: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: items,
),
),
],
);
}
Widget _buildMoreItem(IconData icon, String title, String subtitle, {bool isDestructive = false}) {
return ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: (isDestructive ? AppTheme.errorColor : AppTheme.primaryColor).withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Icon(
icon,
color: isDestructive ? AppTheme.errorColor : AppTheme.primaryColor,
size: 20,
),
),
title: Text(
title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: isDestructive ? AppTheme.errorColor : AppTheme.textPrimary,
),
),
subtitle: Text(
subtitle,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
trailing: Icon(
Icons.arrow_forward_ios,
size: 16,
color: AppTheme.textHint,
),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$title - En cours de développement'),
backgroundColor: isDestructive ? AppTheme.errorColor : AppTheme.primaryColor,
behavior: SnackBarBehavior.floating,
),
);
},
);
}
}
class NavigationTab {
final String title;
final IconData icon;
final IconData activeIcon;
final Color color;
NavigationTab({
required this.title,
required this.icon,
required this.activeIcon,
required this.color,
});
}

View File

@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../shared/theme/app_theme.dart';
import '../pages/main_navigation.dart';
class CustomBottomNavBar extends StatefulWidget {
final int currentIndex;
final List<NavigationTab> tabs;
final Function(int) onTap;
const CustomBottomNavBar({
super.key,
required this.currentIndex,
required this.tabs,
required this.onTap,
});
@override
State<CustomBottomNavBar> createState() => _CustomBottomNavBarState();
}
class _CustomBottomNavBarState extends State<CustomBottomNavBar>
with TickerProviderStateMixin {
late List<AnimationController> _animationControllers;
late List<Animation<double>> _scaleAnimations;
late List<Animation<Color?>> _colorAnimations;
@override
void initState() {
super.initState();
_initializeAnimations();
}
void _initializeAnimations() {
_animationControllers = List.generate(
widget.tabs.length,
(index) => AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
),
);
_scaleAnimations = _animationControllers
.map((controller) => Tween<double>(
begin: 1.0,
end: 1.2,
).animate(CurvedAnimation(
parent: controller,
curve: Curves.easeInOut,
)))
.toList();
_colorAnimations = _animationControllers
.map((controller) => ColorTween(
begin: AppTheme.textHint,
end: AppTheme.primaryColor,
).animate(CurvedAnimation(
parent: controller,
curve: Curves.easeInOut,
)))
.toList();
// Animation initiale pour l'onglet sélectionné
if (widget.currentIndex < _animationControllers.length) {
_animationControllers[widget.currentIndex].forward();
}
}
@override
void didUpdateWidget(CustomBottomNavBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentIndex != widget.currentIndex) {
// Reverse animation for old tab
if (oldWidget.currentIndex < _animationControllers.length) {
_animationControllers[oldWidget.currentIndex].reverse();
}
// Forward animation for new tab
if (widget.currentIndex < _animationControllers.length) {
_animationControllers[widget.currentIndex].forward();
}
}
}
@override
void dispose() {
for (var controller in _animationControllers) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, -5),
),
],
),
child: SafeArea(
child: Container(
height: 70,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: List.generate(
widget.tabs.length,
(index) => _buildNavItem(index),
),
),
),
),
);
}
Widget _buildNavItem(int index) {
final tab = widget.tabs[index];
final isSelected = index == widget.currentIndex;
return Expanded(
child: GestureDetector(
onTap: () => _handleTap(index),
behavior: HitTestBehavior.opaque,
child: AnimatedBuilder(
animation: _animationControllers[index],
builder: (context, child) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icône avec animation
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: isSelected
? tab.color.withOpacity(0.15)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
),
child: Transform.scale(
scale: _scaleAnimations[index].value,
child: Icon(
isSelected ? tab.activeIcon : tab.icon,
size: 20,
color: isSelected ? tab.color : AppTheme.textHint,
),
),
),
const SizedBox(height: 2),
// Label avec animation
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: TextStyle(
fontSize: 11,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? tab.color : AppTheme.textHint,
),
child: Text(
tab.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Indicateur de sélection
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: isSelected ? 16 : 0,
height: 2,
margin: const EdgeInsets.only(top: 2),
decoration: BoxDecoration(
color: tab.color,
borderRadius: BorderRadius.circular(1),
),
),
],
),
);
},
),
),
);
}
void _handleTap(int index) {
// Vibration tactile
HapticFeedback.selectionClick();
// Animation de pression
_animationControllers[index].forward().then((_) {
if (mounted && index != widget.currentIndex) {
_animationControllers[index].reverse();
}
});
// Callback
widget.onTap(index);
}
}

View File

@@ -0,0 +1,306 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../shared/theme/app_theme.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with TickerProviderStateMixin {
late AnimationController _logoController;
late AnimationController _progressController;
late AnimationController _textController;
late Animation<double> _logoScaleAnimation;
late Animation<double> _logoOpacityAnimation;
late Animation<double> _progressAnimation;
late Animation<double> _textOpacityAnimation;
@override
void initState() {
super.initState();
_initializeAnimations();
_startSplashSequence();
}
void _initializeAnimations() {
// Animation du logo
_logoController = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_logoScaleAnimation = Tween<double>(
begin: 0.5,
end: 1.0,
).animate(CurvedAnimation(
parent: _logoController,
curve: Curves.elasticOut,
));
_logoOpacityAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _logoController,
curve: const Interval(0.0, 0.6, curve: Curves.easeIn),
));
// Animation de la barre de progression
_progressController = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
);
_progressAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _progressController,
curve: Curves.easeInOut,
));
// Animation du texte
_textController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_textOpacityAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _textController,
curve: Curves.easeIn,
));
}
void _startSplashSequence() async {
// Configuration de la barre de statut
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
),
);
// Séquence d'animations
await Future.delayed(const Duration(milliseconds: 300));
_logoController.forward();
await Future.delayed(const Duration(milliseconds: 500));
_textController.forward();
await Future.delayed(const Duration(milliseconds: 300));
_progressController.forward();
// Attendre la fin de toutes les animations + temps de chargement
await Future.delayed(const Duration(milliseconds: 2000));
// Le splash screen sera remplacé automatiquement par l'AppWrapper
// basé sur l'état d'authentification
}
@override
void dispose() {
_logoController.dispose();
_progressController.dispose();
_textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppTheme.primaryColor,
AppTheme.primaryDark,
const Color(0xFF0D47A1),
],
),
),
child: SafeArea(
child: Column(
children: [
Expanded(
flex: 3,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo animé
AnimatedBuilder(
animation: _logoController,
builder: (context, child) {
return Transform.scale(
scale: _logoScaleAnimation.value,
child: Opacity(
opacity: _logoOpacityAnimation.value,
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: const Icon(
Icons.groups_rounded,
size: 60,
color: AppTheme.primaryColor,
),
),
),
);
},
),
const SizedBox(height: 32),
// Titre animé
AnimatedBuilder(
animation: _textController,
builder: (context, child) {
return Opacity(
opacity: _textOpacityAnimation.value,
child: Column(
children: [
const Text(
'UnionFlow',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: 1.2,
),
),
const SizedBox(height: 8),
Text(
'Gestion d\'associations professionnelle',
style: TextStyle(
fontSize: 16,
color: Colors.white.withOpacity(0.9),
fontWeight: FontWeight.w300,
),
textAlign: TextAlign.center,
),
],
),
);
},
),
],
),
),
),
// Section de chargement
Expanded(
flex: 1,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Barre de progression animée
Container(
width: 200,
margin: const EdgeInsets.symmetric(horizontal: 40),
child: Column(
children: [
AnimatedBuilder(
animation: _progressController,
builder: (context, child) {
return LinearProgressIndicator(
value: _progressAnimation.value,
backgroundColor: Colors.white.withOpacity(0.2),
valueColor: const AlwaysStoppedAnimation<Color>(
Colors.white,
),
minHeight: 3,
);
},
),
const SizedBox(height: 16),
AnimatedBuilder(
animation: _progressController,
builder: (context, child) {
return Text(
'${(_progressAnimation.value * 100).toInt()}%',
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 14,
fontWeight: FontWeight.w500,
),
);
},
),
],
),
),
const SizedBox(height: 24),
// Texte de chargement
AnimatedBuilder(
animation: _textController,
builder: (context, child) {
return Opacity(
opacity: _textOpacityAnimation.value,
child: Text(
'Initialisation...',
style: TextStyle(
color: Colors.white.withOpacity(0.7),
fontSize: 14,
),
),
);
},
),
],
),
),
// Footer
Padding(
padding: const EdgeInsets.only(bottom: 40),
child: Column(
children: [
Text(
'Version 1.0.0',
style: TextStyle(
color: Colors.white.withOpacity(0.6),
fontSize: 12,
),
),
const SizedBox(height: 8),
Text(
'© 2024 Lions Club International',
style: TextStyle(
color: Colors.white.withOpacity(0.5),
fontSize: 10,
),
),
],
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/auth/bloc/temp_auth_bloc.dart';
import 'core/auth/bloc/auth_event.dart';
import 'core/auth/services/temp_auth_service.dart';
import 'shared/theme/app_theme.dart';
import 'app_temp.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Configuration du système
await _configureApp();
// Lancement de l'application
runApp(const UnionFlowTempApp());
}
/// Configure les paramètres globaux de l'application
Future<void> _configureApp() async {
// Configuration de l'orientation
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
// Configuration de la barre de statut
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Colors.white,
systemNavigationBarIconBrightness: Brightness.dark,
),
);
}
/// Application principale temporaire
class UnionFlowTempApp extends StatelessWidget {
const UnionFlowTempApp({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<TempAuthBloc>(
create: (context) {
final authService = TempAuthService();
final authBloc = TempAuthBloc(authService);
authBloc.add(const AuthInitializeRequested());
return authBloc;
},
child: MaterialApp(
title: 'UnionFlow',
debugShowCheckedModeBanner: false,
// Configuration du thème
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
// Configuration de la localisation
locale: const Locale('fr', 'FR'),
// Application principale
home: const AppTempWrapper(),
// Builder global pour gérer les erreurs
builder: (context, child) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(1.0),
),
child: child ?? const SizedBox(),
);
},
),
);
}
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/auth/bloc/temp_auth_bloc.dart';
import 'core/auth/bloc/auth_event.dart';
import 'core/auth/services/temp_auth_service.dart';
import 'shared/theme/app_theme.dart';
import 'app_temp.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Configuration du système
await _configureApp();
// Lancement de l'application
runApp(const UnionFlowTempApp());
}
/// Configure les paramètres globaux de l'application
Future<void> _configureApp() async {
// Configuration de l'orientation
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
// Configuration de la barre de statut
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Colors.white,
systemNavigationBarIconBrightness: Brightness.dark,
),
);
}
/// Application principale temporaire
class UnionFlowTempApp extends StatelessWidget {
const UnionFlowTempApp({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<TempAuthBloc>(
create: (context) {
final authService = TempAuthService();
final authBloc = TempAuthBloc(authService);
authBloc.add(const AuthInitializeRequested());
return authBloc;
},
child: MaterialApp(
title: 'UnionFlow',
debugShowCheckedModeBanner: false,
// Configuration du thème
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
// Configuration de la localisation
locale: const Locale('fr', 'FR'),
// Application principale
home: const AppTempWrapper(),
// Builder global pour gérer les erreurs
builder: (context, child) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(1.0),
),
child: child ?? const SizedBox(),
);
},
),
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/auth/bloc/temp_auth_bloc.dart';
import 'core/auth/bloc/auth_event.dart';
import 'core/auth/services/ultra_simple_auth_service.dart';
import 'shared/theme/app_theme.dart';
import 'app_ultra_simple.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Configuration du système
await _configureApp();
// Lancement de l'application
runApp(const UnionFlowUltraSimpleApp());
}
/// Configure les paramètres globaux de l'application
Future<void> _configureApp() async {
// Configuration de l'orientation
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
// Configuration de la barre de statut
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
systemNavigationBarColor: Colors.white,
systemNavigationBarIconBrightness: Brightness.dark,
),
);
}
/// Classe BLoC ultra-simple qui utilise UltraSimpleAuthService
class UltraSimpleAuthBloc extends TempAuthBloc {
UltraSimpleAuthBloc(UltraSimpleAuthService authService) : super(authService);
}
/// Application principale ultra-simple
class UnionFlowUltraSimpleApp extends StatelessWidget {
const UnionFlowUltraSimpleApp({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<UltraSimpleAuthBloc>(
create: (context) {
final authService = UltraSimpleAuthService();
final authBloc = UltraSimpleAuthBloc(authService);
authBloc.add(const AuthInitializeRequested());
return authBloc;
},
child: MaterialApp(
title: 'UnionFlow',
debugShowCheckedModeBanner: false,
// Configuration du thème
theme: AppTheme.lightTheme,
// Configuration de la localisation
locale: const Locale('fr', 'FR'),
// Application principale
home: const UltraSimpleAppWrapper(),
// Builder global pour gérer les erreurs
builder: (context, child) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(1.0),
),
child: child ?? const SizedBox(),
);
},
),
);
}
}

View File

@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class AppTheme {
// Couleurs principales UnionFlow
static const Color primaryColor = Color(0xFF2196F3);
static const Color primaryLight = Color(0xFF64B5F6);
static const Color primaryDark = Color(0xFF1976D2);
static const Color secondaryColor = Color(0xFF4CAF50);
static const Color secondaryLight = Color(0xFF81C784);
static const Color secondaryDark = Color(0xFF388E3C);
static const Color accentColor = Color(0xFFFF9800);
static const Color errorColor = Color(0xFFE53935);
static const Color warningColor = Color(0xFFFFC107);
static const Color successColor = Color(0xFF4CAF50);
static const Color infoColor = Color(0xFF2196F3);
// Couleurs neutres
static const Color backgroundLight = Color(0xFFFAFAFA);
static const Color backgroundDark = Color(0xFF121212);
static const Color surfaceLight = Color(0xFFFFFFFF);
static const Color surfaceDark = Color(0xFF1E1E1E);
static const Color textPrimary = Color(0xFF212121);
static const Color textSecondary = Color(0xFF757575);
static const Color textHint = Color(0xFFBDBDBD);
static const Color textWhite = Color(0xFFFFFFFF);
// Bordures et dividers
static const Color borderColor = Color(0xFFE0E0E0);
static const Color dividerColor = Color(0xFFBDBDBD);
// Thème clair
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
primarySwatch: _createMaterialColor(primaryColor),
colorScheme: const ColorScheme.light(
primary: primaryColor,
onPrimary: textWhite,
secondary: secondaryColor,
onSecondary: textWhite,
error: errorColor,
onError: textWhite,
surface: surfaceLight,
onSurface: textPrimary,
background: backgroundLight,
onBackground: textPrimary,
),
// AppBar
appBarTheme: const AppBarTheme(
elevation: 0,
backgroundColor: primaryColor,
foregroundColor: textWhite,
centerTitle: true,
systemOverlayStyle: SystemUiOverlayStyle.light,
titleTextStyle: TextStyle(
color: textWhite,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
// Cards
cardTheme: CardTheme(
elevation: 2,
shadowColor: Colors.black.withOpacity(0.1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
color: surfaceLight,
),
// Boutons
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: textWhite,
elevation: 2,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: primaryColor,
side: const BorderSide(color: primaryColor, width: 2),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: primaryColor,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
// Champs de saisie
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceLight,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: borderColor),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: borderColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: errorColor),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
hintStyle: const TextStyle(color: textHint),
),
// Navigation bottom
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: surfaceLight,
selectedItemColor: primaryColor,
unselectedItemColor: textSecondary,
type: BottomNavigationBarType.fixed,
elevation: 8,
),
// Chip
chipTheme: ChipThemeData(
backgroundColor: primaryLight.withOpacity(0.1),
selectedColor: primaryColor,
labelStyle: const TextStyle(color: textPrimary),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
// Divider
dividerTheme: const DividerThemeData(
color: dividerColor,
thickness: 1,
),
// Typography
textTheme: _textTheme,
);
}
// Thème sombre (pour plus tard)
static ThemeData get darkTheme {
return lightTheme.copyWith(
brightness: Brightness.dark,
scaffoldBackgroundColor: backgroundDark,
// TODO: Implémenter le thème sombre complet
);
}
// Création d'un MaterialColor à partir d'une Color
static MaterialColor _createMaterialColor(Color color) {
List strengths = <double>[.05];
Map<int, Color> swatch = <int, Color>{};
final int r = color.red, g = color.green, b = color.blue;
for (int i = 1; i < 10; i++) {
strengths.add(0.1 * i);
}
for (var strength in strengths) {
final double ds = 0.5 - strength;
swatch[(strength * 1000).round()] = Color.fromRGBO(
r + ((ds < 0 ? r : (255 - r)) * ds).round(),
g + ((ds < 0 ? g : (255 - g)) * ds).round(),
b + ((ds < 0 ? b : (255 - b)) * ds).round(),
1,
);
}
return MaterialColor(color.value, swatch);
}
// Typographie
static const TextTheme _textTheme = TextTheme(
displayLarge: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: textPrimary,
),
displayMedium: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: textPrimary,
),
displaySmall: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: textPrimary,
),
headlineLarge: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: textPrimary,
),
headlineMedium: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: textPrimary,
),
headlineSmall: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: textPrimary,
),
titleLarge: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: textPrimary,
),
titleMedium: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: textPrimary,
),
titleSmall: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: textPrimary,
),
bodyLarge: TextStyle(
fontSize: 16,
fontWeight: FontWeight.normal,
color: textPrimary,
),
bodyMedium: TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
color: textPrimary,
),
bodySmall: TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
color: textSecondary,
),
labelLarge: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: textPrimary,
),
labelMedium: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: textSecondary,
),
labelSmall: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: textHint,
),
);
}
// Extensions pour faciliter l'utilisation
extension ThemeExtension on BuildContext {
ThemeData get theme => Theme.of(this);
ColorScheme get colors => Theme.of(this).colorScheme;
TextTheme get textTheme => Theme.of(this).textTheme;
}

View File

@@ -0,0 +1,409 @@
import 'package:flutter/material.dart';
import '../../theme/app_theme.dart';
import '../badges/status_badge.dart';
import '../badges/count_badge.dart';
enum AvatarSize {
tiny,
small,
medium,
large,
extraLarge,
}
enum AvatarShape {
circle,
rounded,
square,
}
enum AvatarVariant {
standard,
gradient,
outlined,
glass,
}
class SophisticatedAvatar extends StatefulWidget {
final String? imageUrl;
final String? initials;
final IconData? icon;
final AvatarSize size;
final AvatarShape shape;
final AvatarVariant variant;
final Color? backgroundColor;
final Color? foregroundColor;
final Gradient? gradient;
final VoidCallback? onTap;
final Widget? badge;
final bool showOnlineStatus;
final bool isOnline;
final Widget? overlay;
final bool animated;
final List<BoxShadow>? customShadow;
final Border? border;
const SophisticatedAvatar({
super.key,
this.imageUrl,
this.initials,
this.icon,
this.size = AvatarSize.medium,
this.shape = AvatarShape.circle,
this.variant = AvatarVariant.standard,
this.backgroundColor,
this.foregroundColor,
this.gradient,
this.onTap,
this.badge,
this.showOnlineStatus = false,
this.isOnline = false,
this.overlay,
this.animated = true,
this.customShadow,
this.border,
});
@override
State<SophisticatedAvatar> createState() => _SophisticatedAvatarState();
}
class _SophisticatedAvatarState extends State<SophisticatedAvatar>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
_rotationAnimation = Tween<double>(
begin: 0.0,
end: 0.1,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final size = _getSize();
final borderRadius = _getBorderRadius(size);
Widget avatar = AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: widget.animated ? _scaleAnimation.value : 1.0,
child: Transform.rotate(
angle: widget.animated ? _rotationAnimation.value : 0.0,
child: Container(
width: size,
height: size,
decoration: _getDecoration(size, borderRadius),
child: ClipRRect(
borderRadius: borderRadius,
child: Stack(
fit: StackFit.expand,
children: [
_buildContent(),
if (widget.overlay != null) widget.overlay!,
],
),
),
),
),
);
},
);
// Wrap with gesture detector if onTap is provided
if (widget.onTap != null) {
avatar = GestureDetector(
onTap: widget.onTap,
onTapDown: widget.animated ? (_) => _animationController.forward() : null,
onTapUp: widget.animated ? (_) => _animationController.reverse() : null,
onTapCancel: widget.animated ? () => _animationController.reverse() : null,
child: avatar,
);
}
// Add badges and status indicators
return Stack(
clipBehavior: Clip.none,
children: [
avatar,
// Online status indicator
if (widget.showOnlineStatus)
Positioned(
bottom: size * 0.05,
right: size * 0.05,
child: Container(
width: size * 0.25,
height: size * 0.25,
decoration: BoxDecoration(
color: widget.isOnline ? AppTheme.successColor : AppTheme.textHint,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: size * 0.02,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
),
),
// Custom badge
if (widget.badge != null)
Positioned(
top: -size * 0.1,
right: -size * 0.1,
child: widget.badge!,
),
],
);
}
double _getSize() {
switch (widget.size) {
case AvatarSize.tiny:
return 24;
case AvatarSize.small:
return 32;
case AvatarSize.medium:
return 48;
case AvatarSize.large:
return 64;
case AvatarSize.extraLarge:
return 96;
}
}
BorderRadius _getBorderRadius(double size) {
switch (widget.shape) {
case AvatarShape.circle:
return BorderRadius.circular(size / 2);
case AvatarShape.rounded:
return BorderRadius.circular(size * 0.2);
case AvatarShape.square:
return BorderRadius.zero;
}
}
double _getFontSize() {
switch (widget.size) {
case AvatarSize.tiny:
return 10;
case AvatarSize.small:
return 12;
case AvatarSize.medium:
return 18;
case AvatarSize.large:
return 24;
case AvatarSize.extraLarge:
return 36;
}
}
double _getIconSize() {
switch (widget.size) {
case AvatarSize.tiny:
return 12;
case AvatarSize.small:
return 16;
case AvatarSize.medium:
return 24;
case AvatarSize.large:
return 32;
case AvatarSize.extraLarge:
return 48;
}
}
Decoration _getDecoration(double size, BorderRadius borderRadius) {
switch (widget.variant) {
case AvatarVariant.standard:
return BoxDecoration(
color: widget.backgroundColor ?? AppTheme.primaryColor,
borderRadius: borderRadius,
border: widget.border,
boxShadow: widget.customShadow ?? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
);
case AvatarVariant.gradient:
return BoxDecoration(
gradient: widget.gradient ?? LinearGradient(
colors: [
widget.backgroundColor ?? AppTheme.primaryColor,
(widget.backgroundColor ?? AppTheme.primaryColor).withOpacity(0.7),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: borderRadius,
border: widget.border,
boxShadow: [
BoxShadow(
color: (widget.backgroundColor ?? AppTheme.primaryColor)
.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
);
case AvatarVariant.outlined:
return BoxDecoration(
color: Colors.transparent,
borderRadius: borderRadius,
border: widget.border ?? Border.all(
color: widget.backgroundColor ?? AppTheme.primaryColor,
width: 2,
),
);
case AvatarVariant.glass:
return BoxDecoration(
color: (widget.backgroundColor ?? Colors.white).withOpacity(0.2),
borderRadius: borderRadius,
border: Border.all(
color: Colors.white.withOpacity(0.3),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
);
}
}
Widget _buildContent() {
final foregroundColor = widget.foregroundColor ?? Colors.white;
if (widget.imageUrl != null && widget.imageUrl!.isNotEmpty) {
return Image.network(
widget.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => _buildFallback(foregroundColor),
);
}
return _buildFallback(foregroundColor);
}
Widget _buildFallback(Color foregroundColor) {
if (widget.initials != null && widget.initials!.isNotEmpty) {
return Center(
child: Text(
widget.initials!.toUpperCase(),
style: TextStyle(
color: foregroundColor,
fontSize: _getFontSize(),
fontWeight: FontWeight.bold,
),
),
);
}
if (widget.icon != null) {
return Center(
child: Icon(
widget.icon,
color: foregroundColor,
size: _getIconSize(),
),
);
}
return Center(
child: Icon(
Icons.person,
color: foregroundColor,
size: _getIconSize(),
),
);
}
}
// Predefined avatar variants
class CircleAvatar extends SophisticatedAvatar {
const CircleAvatar({
super.key,
super.imageUrl,
super.initials,
super.icon,
super.size,
super.backgroundColor,
super.foregroundColor,
super.onTap,
super.badge,
super.showOnlineStatus,
super.isOnline,
}) : super(shape: AvatarShape.circle);
}
class RoundedAvatar extends SophisticatedAvatar {
const RoundedAvatar({
super.key,
super.imageUrl,
super.initials,
super.icon,
super.size,
super.backgroundColor,
super.foregroundColor,
super.onTap,
super.badge,
}) : super(shape: AvatarShape.rounded);
}
class GradientAvatar extends SophisticatedAvatar {
const GradientAvatar({
super.key,
super.imageUrl,
super.initials,
super.icon,
super.size,
super.gradient,
super.onTap,
super.badge,
super.showOnlineStatus,
super.isOnline,
}) : super(variant: AvatarVariant.gradient);
}

View File

@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import '../../theme/app_theme.dart';
class CountBadge extends StatefulWidget {
final int count;
final Color? backgroundColor;
final Color? textColor;
final double? size;
final bool showZero;
final bool animated;
final String? suffix;
final int? maxCount;
final VoidCallback? onTap;
const CountBadge({
super.key,
required this.count,
this.backgroundColor,
this.textColor,
this.size,
this.showZero = false,
this.animated = true,
this.suffix,
this.maxCount,
this.onTap,
});
@override
State<CountBadge> createState() => _CountBadgeState();
}
class _CountBadgeState extends State<CountBadge>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _bounceAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.0, 0.5, curve: Curves.elasticOut),
));
_bounceAnimation = Tween<double>(
begin: 1.0,
end: 1.2,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.5, 1.0, curve: Curves.elasticInOut),
));
if (widget.animated) {
_animationController.forward();
}
}
@override
void didUpdateWidget(CountBadge oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.count != oldWidget.count && widget.animated) {
_animationController.reset();
_animationController.forward();
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!widget.showZero && widget.count == 0) {
return const SizedBox.shrink();
}
final displayText = _getDisplayText();
final size = widget.size ?? 20;
final backgroundColor = widget.backgroundColor ?? AppTheme.errorColor;
final textColor = widget.textColor ?? Colors.white;
Widget badge = Container(
constraints: BoxConstraints(
minWidth: size,
minHeight: size,
),
padding: EdgeInsets.symmetric(
horizontal: displayText.length > 1 ? size * 0.2 : 0,
vertical: 2,
),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(size / 2),
boxShadow: [
BoxShadow(
color: backgroundColor.withOpacity(0.4),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
border: Border.all(
color: Colors.white,
width: 1.5,
),
),
child: Center(
child: Text(
displayText,
style: TextStyle(
color: textColor,
fontSize: size * 0.6,
fontWeight: FontWeight.bold,
height: 1.0,
),
textAlign: TextAlign.center,
),
),
);
if (widget.animated) {
badge = AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value * _bounceAnimation.value,
child: child,
);
},
child: badge,
);
}
if (widget.onTap != null) {
badge = GestureDetector(
onTap: widget.onTap,
child: badge,
);
}
return badge;
}
String _getDisplayText() {
if (widget.maxCount != null && widget.count > widget.maxCount!) {
return '${widget.maxCount}+';
}
final countText = widget.count.toString();
return widget.suffix != null ? '$countText${widget.suffix}' : countText;
}
}
class NotificationBadge extends StatelessWidget {
final Widget child;
final int count;
final Color? badgeColor;
final double? size;
final Offset offset;
final bool showZero;
const NotificationBadge({
super.key,
required this.child,
required this.count,
this.badgeColor,
this.size,
this.offset = const Offset(0, 0),
this.showZero = false,
});
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
children: [
child,
if (showZero || count > 0)
Positioned(
top: offset.dy,
right: offset.dx,
child: CountBadge(
count: count,
backgroundColor: badgeColor,
size: size,
showZero: showZero,
),
),
],
);
}
}

View File

@@ -0,0 +1,405 @@
import 'package:flutter/material.dart';
import '../../theme/app_theme.dart';
enum BadgeType {
success,
warning,
error,
info,
neutral,
premium,
new_,
}
enum BadgeSize {
small,
medium,
large,
}
enum BadgeVariant {
filled,
outlined,
ghost,
gradient,
}
class StatusBadge extends StatelessWidget {
final String text;
final BadgeType type;
final BadgeSize size;
final BadgeVariant variant;
final IconData? icon;
final VoidCallback? onTap;
final bool animated;
final String? tooltip;
final Widget? customIcon;
final bool showPulse;
const StatusBadge({
super.key,
required this.text,
this.type = BadgeType.neutral,
this.size = BadgeSize.medium,
this.variant = BadgeVariant.filled,
this.icon,
this.onTap,
this.animated = true,
this.tooltip,
this.customIcon,
this.showPulse = false,
});
@override
Widget build(BuildContext context) {
final config = _getBadgeConfig();
Widget badge = AnimatedContainer(
duration: animated ? const Duration(milliseconds: 200) : Duration.zero,
padding: _getPadding(),
decoration: _getDecoration(config),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null || customIcon != null) ...[
_buildIcon(config),
SizedBox(width: _getIconSpacing()),
],
if (showPulse) ...[
_buildPulseIndicator(config.primaryColor),
SizedBox(width: _getIconSpacing()),
],
Text(
text,
style: _getTextStyle(config),
),
],
),
);
if (onTap != null) {
badge = Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(_getBorderRadius()),
child: badge,
),
);
}
if (tooltip != null) {
badge = Tooltip(
message: tooltip!,
child: badge,
);
}
return badge;
}
_BadgeConfig _getBadgeConfig() {
switch (type) {
case BadgeType.success:
return _BadgeConfig(
primaryColor: AppTheme.successColor,
backgroundColor: AppTheme.successColor.withOpacity(0.1),
borderColor: AppTheme.successColor.withOpacity(0.3),
);
case BadgeType.warning:
return _BadgeConfig(
primaryColor: AppTheme.warningColor,
backgroundColor: AppTheme.warningColor.withOpacity(0.1),
borderColor: AppTheme.warningColor.withOpacity(0.3),
);
case BadgeType.error:
return _BadgeConfig(
primaryColor: AppTheme.errorColor,
backgroundColor: AppTheme.errorColor.withOpacity(0.1),
borderColor: AppTheme.errorColor.withOpacity(0.3),
);
case BadgeType.info:
return _BadgeConfig(
primaryColor: AppTheme.infoColor,
backgroundColor: AppTheme.infoColor.withOpacity(0.1),
borderColor: AppTheme.infoColor.withOpacity(0.3),
);
case BadgeType.premium:
return _BadgeConfig(
primaryColor: const Color(0xFFFFD700),
backgroundColor: const Color(0xFFFFD700).withOpacity(0.1),
borderColor: const Color(0xFFFFD700).withOpacity(0.3),
);
case BadgeType.new_:
return _BadgeConfig(
primaryColor: const Color(0xFFFF6B6B),
backgroundColor: const Color(0xFFFF6B6B).withOpacity(0.1),
borderColor: const Color(0xFFFF6B6B).withOpacity(0.3),
);
default:
return _BadgeConfig(
primaryColor: AppTheme.textSecondary,
backgroundColor: AppTheme.textSecondary.withOpacity(0.1),
borderColor: AppTheme.textSecondary.withOpacity(0.3),
);
}
}
EdgeInsets _getPadding() {
switch (size) {
case BadgeSize.small:
return const EdgeInsets.symmetric(horizontal: 8, vertical: 2);
case BadgeSize.medium:
return const EdgeInsets.symmetric(horizontal: 12, vertical: 4);
case BadgeSize.large:
return const EdgeInsets.symmetric(horizontal: 16, vertical: 8);
}
}
double _getBorderRadius() {
switch (size) {
case BadgeSize.small:
return 12;
case BadgeSize.medium:
return 16;
case BadgeSize.large:
return 20;
}
}
double _getFontSize() {
switch (size) {
case BadgeSize.small:
return 10;
case BadgeSize.medium:
return 12;
case BadgeSize.large:
return 14;
}
}
double _getIconSize() {
switch (size) {
case BadgeSize.small:
return 12;
case BadgeSize.medium:
return 14;
case BadgeSize.large:
return 16;
}
}
double _getIconSpacing() {
switch (size) {
case BadgeSize.small:
return 4;
case BadgeSize.medium:
return 6;
case BadgeSize.large:
return 8;
}
}
Decoration _getDecoration(_BadgeConfig config) {
switch (variant) {
case BadgeVariant.filled:
return BoxDecoration(
color: config.primaryColor,
borderRadius: BorderRadius.circular(_getBorderRadius()),
boxShadow: [
BoxShadow(
color: config.primaryColor.withOpacity(0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
);
case BadgeVariant.outlined:
return BoxDecoration(
color: Colors.transparent,
border: Border.all(color: config.borderColor, width: 1),
borderRadius: BorderRadius.circular(_getBorderRadius()),
);
case BadgeVariant.ghost:
return BoxDecoration(
color: config.backgroundColor,
borderRadius: BorderRadius.circular(_getBorderRadius()),
);
case BadgeVariant.gradient:
return BoxDecoration(
gradient: LinearGradient(
colors: [
config.primaryColor,
config.primaryColor.withOpacity(0.7),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(_getBorderRadius()),
boxShadow: [
BoxShadow(
color: config.primaryColor.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
);
}
}
TextStyle _getTextStyle(_BadgeConfig config) {
Color textColor;
switch (variant) {
case BadgeVariant.filled:
case BadgeVariant.gradient:
textColor = Colors.white;
break;
default:
textColor = config.primaryColor;
}
return TextStyle(
fontSize: _getFontSize(),
fontWeight: FontWeight.w600,
color: textColor,
letterSpacing: 0.2,
);
}
Widget _buildIcon(_BadgeConfig config) {
Color iconColor;
switch (variant) {
case BadgeVariant.filled:
case BadgeVariant.gradient:
iconColor = Colors.white;
break;
default:
iconColor = config.primaryColor;
}
if (customIcon != null) {
return customIcon!;
}
return Icon(
icon,
size: _getIconSize(),
color: iconColor,
);
}
Widget _buildPulseIndicator(Color color) {
if (!showPulse) {
return Container(
width: _getIconSize() * 0.6,
height: _getIconSize() * 0.6,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
);
}
return _PulseWidget(
size: _getIconSize() * 0.6,
color: color,
);
}
}
class _BadgeConfig {
final Color primaryColor;
final Color backgroundColor;
final Color borderColor;
_BadgeConfig({
required this.primaryColor,
required this.backgroundColor,
required this.borderColor,
});
}
// Pulse animation widget
class _PulseWidget extends StatefulWidget {
final double size;
final Color color;
const _PulseWidget({
required this.size,
required this.color,
});
@override
State<_PulseWidget> createState() => _PulseWidgetState();
}
class _PulseWidgetState extends State<_PulseWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_animation = Tween<double>(
begin: 0.0,
end: 1.0,
).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: 0.8 + (_animation.value * 0.4),
child: Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: widget.color.withOpacity(1.0 - _animation.value * 0.5),
shape: BoxShape.circle,
),
),
);
},
);
}
}
// Extension for easy badge creation
extension BadgeBuilder on String {
StatusBadge toBadge({
BadgeType type = BadgeType.neutral,
BadgeSize size = BadgeSize.medium,
BadgeVariant variant = BadgeVariant.filled,
IconData? icon,
VoidCallback? onTap,
}) {
return StatusBadge(
text: this,
type: type,
size: size,
variant: variant,
icon: icon,
onTap: onTap,
);
}
}

View File

@@ -0,0 +1,383 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../theme/app_theme.dart';
enum ButtonGroupVariant {
segmented,
toggle,
tabs,
chips,
}
class ButtonGroupOption {
final String text;
final IconData? icon;
final String value;
final bool disabled;
final Widget? badge;
const ButtonGroupOption({
required this.text,
required this.value,
this.icon,
this.disabled = false,
this.badge,
});
}
class SophisticatedButtonGroup extends StatefulWidget {
final List<ButtonGroupOption> options;
final String? selectedValue;
final List<String>? selectedValues; // For multi-select
final Function(String)? onSelectionChanged;
final Function(List<String>)? onMultiSelectionChanged;
final ButtonGroupVariant variant;
final bool multiSelect;
final Color? backgroundColor;
final Color? selectedColor;
final Color? unselectedColor;
final double? height;
final EdgeInsets? padding;
final bool animated;
final bool fullWidth;
const SophisticatedButtonGroup({
super.key,
required this.options,
this.selectedValue,
this.selectedValues,
this.onSelectionChanged,
this.onMultiSelectionChanged,
this.variant = ButtonGroupVariant.segmented,
this.multiSelect = false,
this.backgroundColor,
this.selectedColor,
this.unselectedColor,
this.height,
this.padding,
this.animated = true,
this.fullWidth = false,
});
@override
State<SophisticatedButtonGroup> createState() => _SophisticatedButtonGroupState();
}
class _SophisticatedButtonGroupState extends State<SophisticatedButtonGroup>
with TickerProviderStateMixin {
late AnimationController _slideController;
late Animation<double> _slideAnimation;
String? _internalSelectedValue;
List<String> _internalSelectedValues = [];
@override
void initState() {
super.initState();
_slideController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_slideAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _slideController,
curve: Curves.easeInOut,
));
_internalSelectedValue = widget.selectedValue;
_internalSelectedValues = widget.selectedValues ?? [];
if (widget.animated) {
_slideController.forward();
}
}
@override
void didUpdateWidget(SophisticatedButtonGroup oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.selectedValue != oldWidget.selectedValue) {
_internalSelectedValue = widget.selectedValue;
}
if (widget.selectedValues != oldWidget.selectedValues) {
_internalSelectedValues = widget.selectedValues ?? [];
}
}
@override
void dispose() {
_slideController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
switch (widget.variant) {
case ButtonGroupVariant.segmented:
return _buildSegmentedGroup();
case ButtonGroupVariant.toggle:
return _buildToggleGroup();
case ButtonGroupVariant.tabs:
return _buildTabsGroup();
case ButtonGroupVariant.chips:
return _buildChipsGroup();
}
}
Widget _buildSegmentedGroup() {
return AnimatedBuilder(
animation: _slideAnimation,
builder: (context, child) {
return Container(
height: widget.height ?? 48,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: widget.backgroundColor ?? AppTheme.backgroundLight,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.textHint.withOpacity(0.2),
width: 1,
),
),
child: Row(
children: widget.options.asMap().entries.map((entry) {
final index = entry.key;
final option = entry.value;
final isSelected = _isSelected(option.value);
return Expanded(
child: _buildSegmentedButton(option, isSelected, index),
);
}).toList(),
),
);
},
);
}
Widget _buildSegmentedButton(ButtonGroupOption option, bool isSelected, int index) {
return AnimatedContainer(
duration: widget.animated ? const Duration(milliseconds: 200) : Duration.zero,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: isSelected
? (widget.selectedColor ?? AppTheme.primaryColor)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
boxShadow: isSelected ? [
BoxShadow(
color: (widget.selectedColor ?? AppTheme.primaryColor).withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
] : null,
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: option.disabled ? null : () => _handleSelection(option.value),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: widget.padding ?? const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: _buildButtonContent(option, isSelected),
),
),
),
);
}
Widget _buildToggleGroup() {
return Wrap(
spacing: 8,
runSpacing: 8,
children: widget.options.map((option) {
final isSelected = _isSelected(option.value);
return _buildToggleButton(option, isSelected);
}).toList(),
);
}
Widget _buildToggleButton(ButtonGroupOption option, bool isSelected) {
return AnimatedContainer(
duration: widget.animated ? const Duration(milliseconds: 200) : Duration.zero,
decoration: BoxDecoration(
color: isSelected
? (widget.selectedColor ?? AppTheme.primaryColor)
: (widget.backgroundColor ?? Colors.transparent),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: isSelected
? (widget.selectedColor ?? AppTheme.primaryColor)
: AppTheme.textHint.withOpacity(0.3),
width: 1.5,
),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: option.disabled ? null : () => _handleSelection(option.value),
borderRadius: BorderRadius.circular(24),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: _buildButtonContent(option, isSelected),
),
),
),
);
}
Widget _buildTabsGroup() {
return Container(
height: widget.height ?? 44,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: AppTheme.textHint.withOpacity(0.2),
width: 1,
),
),
),
child: Row(
children: widget.options.asMap().entries.map((entry) {
final index = entry.key;
final option = entry.value;
final isSelected = _isSelected(option.value);
return widget.fullWidth
? Expanded(child: _buildTabButton(option, isSelected))
: _buildTabButton(option, isSelected);
}).toList(),
),
);
}
Widget _buildTabButton(ButtonGroupOption option, bool isSelected) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: option.disabled ? null : () => _handleSelection(option.value),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: isSelected
? (widget.selectedColor ?? AppTheme.primaryColor)
: Colors.transparent,
width: 2,
),
),
),
child: _buildButtonContent(option, isSelected),
),
),
),
);
}
Widget _buildChipsGroup() {
return Wrap(
spacing: 8,
runSpacing: 8,
children: widget.options.map((option) {
final isSelected = _isSelected(option.value);
return _buildChip(option, isSelected);
}).toList(),
);
}
Widget _buildChip(ButtonGroupOption option, bool isSelected) {
return FilterChip(
label: _buildButtonContent(option, isSelected),
selected: isSelected,
onSelected: option.disabled ? null : (selected) => _handleSelection(option.value),
backgroundColor: widget.backgroundColor,
selectedColor: widget.selectedColor ?? AppTheme.primaryColor,
checkmarkColor: Colors.white,
labelStyle: TextStyle(
color: isSelected ? Colors.white : (widget.unselectedColor ?? AppTheme.textPrimary),
fontWeight: FontWeight.w600,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
);
}
Widget _buildButtonContent(ButtonGroupOption option, bool isSelected) {
final color = isSelected
? Colors.white
: (widget.unselectedColor ?? AppTheme.textSecondary);
if (option.icon != null) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
option.icon,
size: 16,
color: color,
),
const SizedBox(width: 6),
Text(
option.text,
style: TextStyle(
color: color,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
if (option.badge != null) ...[
const SizedBox(width: 6),
option.badge!,
],
],
);
}
return Text(
option.text,
style: TextStyle(
color: color,
fontWeight: FontWeight.w600,
fontSize: 14,
),
textAlign: TextAlign.center,
);
}
bool _isSelected(String value) {
if (widget.multiSelect) {
return _internalSelectedValues.contains(value);
}
return _internalSelectedValue == value;
}
void _handleSelection(String value) {
HapticFeedback.selectionClick();
if (widget.multiSelect) {
setState(() {
if (_internalSelectedValues.contains(value)) {
_internalSelectedValues.remove(value);
} else {
_internalSelectedValues.add(value);
}
});
widget.onMultiSelectionChanged?.call(_internalSelectedValues);
} else {
setState(() {
_internalSelectedValue = value;
});
widget.onSelectionChanged?.call(value);
}
}
}

View File

@@ -0,0 +1,303 @@
// Export all sophisticated button components
export 'sophisticated_button.dart';
export 'floating_action_button.dart';
export 'icon_button.dart';
export 'button_group.dart';
// Predefined button styles for quick usage
import 'package:flutter/material.dart';
import 'sophisticated_button.dart';
import 'floating_action_button.dart';
import 'icon_button.dart';
import '../../theme/app_theme.dart';
// Quick button factory methods
class QuickButtons {
// Primary buttons
static Widget primary({
required String text,
required VoidCallback onPressed,
IconData? icon,
ButtonSize size = ButtonSize.medium,
bool loading = false,
}) {
return SophisticatedButton(
text: text,
icon: icon,
onPressed: onPressed,
variant: ButtonVariant.primary,
size: size,
loading: loading,
);
}
static Widget secondary({
required String text,
required VoidCallback onPressed,
IconData? icon,
ButtonSize size = ButtonSize.medium,
bool loading = false,
}) {
return SophisticatedButton(
text: text,
icon: icon,
onPressed: onPressed,
variant: ButtonVariant.secondary,
size: size,
loading: loading,
);
}
static Widget outline({
required String text,
required VoidCallback onPressed,
IconData? icon,
ButtonSize size = ButtonSize.medium,
Color? color,
}) {
return SophisticatedButton(
text: text,
icon: icon,
onPressed: onPressed,
variant: ButtonVariant.outline,
size: size,
backgroundColor: color,
);
}
static Widget ghost({
required String text,
required VoidCallback onPressed,
IconData? icon,
ButtonSize size = ButtonSize.medium,
Color? color,
}) {
return SophisticatedButton(
text: text,
icon: icon,
onPressed: onPressed,
variant: ButtonVariant.ghost,
size: size,
backgroundColor: color,
);
}
static Widget gradient({
required String text,
required VoidCallback onPressed,
IconData? icon,
ButtonSize size = ButtonSize.medium,
Gradient? gradient,
}) {
return SophisticatedButton(
text: text,
icon: icon,
onPressed: onPressed,
variant: ButtonVariant.gradient,
size: size,
gradient: gradient,
);
}
static Widget glass({
required String text,
required VoidCallback onPressed,
IconData? icon,
ButtonSize size = ButtonSize.medium,
}) {
return SophisticatedButton(
text: text,
icon: icon,
onPressed: onPressed,
variant: ButtonVariant.glass,
size: size,
);
}
static Widget danger({
required String text,
required VoidCallback onPressed,
IconData? icon,
ButtonSize size = ButtonSize.medium,
}) {
return SophisticatedButton(
text: text,
icon: icon,
onPressed: onPressed,
variant: ButtonVariant.danger,
size: size,
);
}
static Widget success({
required String text,
required VoidCallback onPressed,
IconData? icon,
ButtonSize size = ButtonSize.medium,
}) {
return SophisticatedButton(
text: text,
icon: icon,
onPressed: onPressed,
variant: ButtonVariant.success,
size: size,
);
}
// Icon buttons
static Widget iconPrimary({
required IconData icon,
required VoidCallback onPressed,
double? size,
String? tooltip,
int? notificationCount,
}) {
return SophisticatedIconButton(
icon: icon,
onPressed: onPressed,
variant: IconButtonVariant.filled,
backgroundColor: AppTheme.primaryColor,
size: size,
tooltip: tooltip,
notificationCount: notificationCount,
);
}
static Widget iconSecondary({
required IconData icon,
required VoidCallback onPressed,
double? size,
String? tooltip,
int? notificationCount,
}) {
return SophisticatedIconButton(
icon: icon,
onPressed: onPressed,
variant: IconButtonVariant.filled,
backgroundColor: AppTheme.secondaryColor,
size: size,
tooltip: tooltip,
notificationCount: notificationCount,
);
}
static Widget iconOutline({
required IconData icon,
required VoidCallback onPressed,
double? size,
String? tooltip,
Color? color,
}) {
return SophisticatedIconButton(
icon: icon,
onPressed: onPressed,
variant: IconButtonVariant.outlined,
foregroundColor: color ?? AppTheme.primaryColor,
borderColor: color ?? AppTheme.primaryColor,
size: size,
tooltip: tooltip,
);
}
static Widget iconGhost({
required IconData icon,
required VoidCallback onPressed,
double? size,
String? tooltip,
Color? color,
}) {
return SophisticatedIconButton(
icon: icon,
onPressed: onPressed,
variant: IconButtonVariant.ghost,
backgroundColor: color ?? AppTheme.primaryColor,
size: size,
tooltip: tooltip,
);
}
static Widget iconGradient({
required IconData icon,
required VoidCallback onPressed,
double? size,
String? tooltip,
Gradient? gradient,
}) {
return SophisticatedIconButton(
icon: icon,
onPressed: onPressed,
variant: IconButtonVariant.gradient,
gradient: gradient,
size: size,
tooltip: tooltip,
);
}
// FAB buttons
static Widget fab({
required VoidCallback onPressed,
IconData icon = Icons.add,
FABVariant variant = FABVariant.primary,
FABSize size = FABSize.regular,
String? tooltip,
}) {
return SophisticatedFAB(
icon: icon,
onPressed: onPressed,
variant: variant,
size: size,
tooltip: tooltip,
);
}
static Widget fabExtended({
required String label,
required VoidCallback onPressed,
IconData icon = Icons.add,
FABVariant variant = FABVariant.primary,
String? tooltip,
}) {
return SophisticatedFAB(
icon: icon,
label: label,
onPressed: onPressed,
variant: variant,
size: FABSize.extended,
tooltip: tooltip,
);
}
static Widget fabGradient({
required VoidCallback onPressed,
IconData icon = Icons.add,
FABSize size = FABSize.regular,
Gradient? gradient,
String? tooltip,
}) {
return SophisticatedFAB(
icon: icon,
onPressed: onPressed,
variant: FABVariant.gradient,
size: size,
gradient: gradient,
tooltip: tooltip,
);
}
static Widget fabMorphing({
required VoidCallback onPressed,
required List<IconData> icons,
FABSize size = FABSize.regular,
Duration morphingDuration = const Duration(seconds: 2),
String? tooltip,
}) {
return SophisticatedFAB(
onPressed: onPressed,
variant: FABVariant.morphing,
size: size,
morphingIcons: icons,
morphingDuration: morphingDuration,
tooltip: tooltip,
);
}
}

View File

@@ -0,0 +1,400 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../theme/app_theme.dart';
enum FABVariant {
primary,
secondary,
gradient,
glass,
morphing,
}
enum FABSize {
small,
regular,
large,
extended,
}
class SophisticatedFAB extends StatefulWidget {
final IconData? icon;
final String? label;
final VoidCallback? onPressed;
final FABVariant variant;
final FABSize size;
final Color? backgroundColor;
final Color? foregroundColor;
final Gradient? gradient;
final bool animated;
final bool showPulse;
final List<IconData>? morphingIcons;
final Duration morphingDuration;
final String? tooltip;
const SophisticatedFAB({
super.key,
this.icon,
this.label,
this.onPressed,
this.variant = FABVariant.primary,
this.size = FABSize.regular,
this.backgroundColor,
this.foregroundColor,
this.gradient,
this.animated = true,
this.showPulse = false,
this.morphingIcons,
this.morphingDuration = const Duration(seconds: 2),
this.tooltip,
});
@override
State<SophisticatedFAB> createState() => _SophisticatedFABState();
}
class _SophisticatedFABState extends State<SophisticatedFAB>
with TickerProviderStateMixin {
late AnimationController _scaleController;
late AnimationController _rotationController;
late AnimationController _pulseController;
late AnimationController _morphingController;
late Animation<double> _scaleAnimation;
late Animation<double> _rotationAnimation;
late Animation<double> _pulseAnimation;
int _currentMorphingIndex = 0;
@override
void initState() {
super.initState();
_scaleController = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
_rotationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_pulseController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
_morphingController = AnimationController(
duration: widget.morphingDuration,
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.9,
).animate(CurvedAnimation(
parent: _scaleController,
curve: Curves.easeInOut,
));
_rotationAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _rotationController,
curve: Curves.elasticOut,
));
_pulseAnimation = Tween<double>(
begin: 1.0,
end: 1.2,
).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.easeInOut,
));
if (widget.showPulse) {
_pulseController.repeat(reverse: true);
}
if (widget.morphingIcons != null && widget.morphingIcons!.isNotEmpty) {
_startMorphing();
}
}
void _startMorphing() {
_morphingController.addListener(() {
if (_morphingController.isCompleted) {
setState(() {
_currentMorphingIndex =
(_currentMorphingIndex + 1) % widget.morphingIcons!.length;
});
_morphingController.reset();
_morphingController.forward();
}
});
_morphingController.forward();
}
@override
void dispose() {
_scaleController.dispose();
_rotationController.dispose();
_pulseController.dispose();
_morphingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final config = _getFABConfig();
Widget fab = AnimatedBuilder(
animation: Listenable.merge([
_scaleController,
_rotationController,
_pulseController,
]),
builder: (context, child) {
return Transform.scale(
scale: widget.animated
? _scaleAnimation.value * (widget.showPulse ? _pulseAnimation.value : 1.0)
: 1.0,
child: Transform.rotate(
angle: widget.animated ? _rotationAnimation.value * 0.1 : 0.0,
child: Container(
width: _getSize(),
height: _getSize(),
decoration: _getDecoration(config),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: _handleTap,
onTapDown: widget.animated ? (_) => _scaleController.forward() : null,
onTapUp: widget.animated ? (_) => _scaleController.reverse() : null,
onTapCancel: widget.animated ? () => _scaleController.reverse() : null,
customBorder: const CircleBorder(),
child: _buildContent(config),
),
),
),
),
);
},
);
if (widget.tooltip != null) {
fab = Tooltip(
message: widget.tooltip!,
child: fab,
);
}
return fab;
}
Widget _buildContent(_FABConfig config) {
if (widget.size == FABSize.extended && widget.label != null) {
return _buildExtendedContent(config);
}
return Center(
child: _buildIcon(config),
);
}
Widget _buildExtendedContent(_FABConfig config) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildIcon(config),
const SizedBox(width: 8),
Text(
widget.label!,
style: TextStyle(
color: config.foregroundColor,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
Widget _buildIcon(_FABConfig config) {
IconData iconToShow = widget.icon ?? Icons.add;
if (widget.morphingIcons != null && widget.morphingIcons!.isNotEmpty) {
iconToShow = widget.morphingIcons![_currentMorphingIndex];
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return ScaleTransition(
scale: animation,
child: RotationTransition(
turns: animation,
child: child,
),
);
},
child: Icon(
iconToShow,
key: ValueKey(iconToShow),
color: config.foregroundColor,
size: _getIconSize(),
),
);
}
_FABConfig _getFABConfig() {
switch (widget.variant) {
case FABVariant.primary:
return _FABConfig(
backgroundColor: widget.backgroundColor ?? AppTheme.primaryColor,
foregroundColor: widget.foregroundColor ?? Colors.white,
hasElevation: true,
);
case FABVariant.secondary:
return _FABConfig(
backgroundColor: widget.backgroundColor ?? AppTheme.secondaryColor,
foregroundColor: widget.foregroundColor ?? Colors.white,
hasElevation: true,
);
case FABVariant.gradient:
return _FABConfig(
backgroundColor: Colors.transparent,
foregroundColor: widget.foregroundColor ?? Colors.white,
hasElevation: true,
useGradient: true,
);
case FABVariant.glass:
return _FABConfig(
backgroundColor: Colors.white.withOpacity(0.2),
foregroundColor: widget.foregroundColor ?? AppTheme.textPrimary,
borderColor: Colors.white.withOpacity(0.3),
hasElevation: true,
isGlass: true,
);
case FABVariant.morphing:
return _FABConfig(
backgroundColor: widget.backgroundColor ?? AppTheme.accentColor,
foregroundColor: widget.foregroundColor ?? Colors.white,
hasElevation: true,
isMorphing: true,
);
}
}
Decoration _getDecoration(_FABConfig config) {
if (config.useGradient) {
return BoxDecoration(
gradient: widget.gradient ?? LinearGradient(
colors: [
widget.backgroundColor ?? AppTheme.primaryColor,
(widget.backgroundColor ?? AppTheme.primaryColor).withOpacity(0.7),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
boxShadow: config.hasElevation ? _getShadow(config) : null,
);
}
return BoxDecoration(
color: config.backgroundColor,
shape: BoxShape.circle,
border: config.borderColor != null
? Border.all(color: config.borderColor!, width: 1)
: null,
boxShadow: config.hasElevation ? _getShadow(config) : null,
);
}
List<BoxShadow> _getShadow(_FABConfig config) {
final shadowColor = config.useGradient
? (widget.backgroundColor ?? AppTheme.primaryColor)
: config.backgroundColor;
return [
BoxShadow(
color: shadowColor.withOpacity(0.4),
blurRadius: 20,
offset: const Offset(0, 8),
),
BoxShadow(
color: shadowColor.withOpacity(0.2),
blurRadius: 40,
offset: const Offset(0, 16),
),
];
}
double _getSize() {
switch (widget.size) {
case FABSize.small:
return 40;
case FABSize.regular:
return 56;
case FABSize.large:
return 72;
case FABSize.extended:
return 56; // Height for extended FAB
}
}
double _getIconSize() {
switch (widget.size) {
case FABSize.small:
return 20;
case FABSize.regular:
return 24;
case FABSize.large:
return 32;
case FABSize.extended:
return 24;
}
}
void _handleTap() {
HapticFeedback.lightImpact();
if (widget.animated) {
_rotationController.forward().then((_) {
_rotationController.reverse();
});
}
widget.onPressed?.call();
}
}
class _FABConfig {
final Color backgroundColor;
final Color foregroundColor;
final Color? borderColor;
final bool hasElevation;
final bool useGradient;
final bool isGlass;
final bool isMorphing;
_FABConfig({
required this.backgroundColor,
required this.foregroundColor,
this.borderColor,
this.hasElevation = false,
this.useGradient = false,
this.isGlass = false,
this.isMorphing = false,
});
}

View File

@@ -0,0 +1,356 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../theme/app_theme.dart';
import '../badges/count_badge.dart';
enum IconButtonVariant {
standard,
filled,
outlined,
ghost,
gradient,
glass,
}
enum IconButtonShape {
circle,
rounded,
square,
}
class SophisticatedIconButton extends StatefulWidget {
final IconData icon;
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
final IconButtonVariant variant;
final IconButtonShape shape;
final double? size;
final Color? backgroundColor;
final Color? foregroundColor;
final Color? borderColor;
final Gradient? gradient;
final bool animated;
final bool disabled;
final String? tooltip;
final Widget? badge;
final int? notificationCount;
final bool showPulse;
const SophisticatedIconButton({
super.key,
required this.icon,
this.onPressed,
this.onLongPress,
this.variant = IconButtonVariant.standard,
this.shape = IconButtonShape.circle,
this.size,
this.backgroundColor,
this.foregroundColor,
this.borderColor,
this.gradient,
this.animated = true,
this.disabled = false,
this.tooltip,
this.badge,
this.notificationCount,
this.showPulse = false,
});
@override
State<SophisticatedIconButton> createState() => _SophisticatedIconButtonState();
}
class _SophisticatedIconButtonState extends State<SophisticatedIconButton>
with TickerProviderStateMixin {
late AnimationController _pressController;
late AnimationController _pulseController;
late AnimationController _rotationController;
late Animation<double> _scaleAnimation;
late Animation<double> _pulseAnimation;
late Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
_pressController = AnimationController(
duration: const Duration(milliseconds: 100),
vsync: this,
);
_pulseController = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_rotationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.9,
).animate(CurvedAnimation(
parent: _pressController,
curve: Curves.easeInOut,
));
_pulseAnimation = Tween<double>(
begin: 1.0,
end: 1.1,
).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.easeInOut,
));
_rotationAnimation = Tween<double>(
begin: 0.0,
end: 0.25,
).animate(CurvedAnimation(
parent: _rotationController,
curve: Curves.elasticOut,
));
if (widget.showPulse) {
_pulseController.repeat(reverse: true);
}
}
@override
void dispose() {
_pressController.dispose();
_pulseController.dispose();
_rotationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final config = _getButtonConfig();
final buttonSize = widget.size ?? 48.0;
final iconSize = buttonSize * 0.5;
Widget button = AnimatedBuilder(
animation: Listenable.merge([_pressController, _pulseController, _rotationController]),
builder: (context, child) {
return Transform.scale(
scale: widget.animated
? _scaleAnimation.value * (widget.showPulse ? _pulseAnimation.value : 1.0)
: 1.0,
child: Transform.rotate(
angle: widget.animated ? _rotationAnimation.value : 0.0,
child: Container(
width: buttonSize,
height: buttonSize,
decoration: _getDecoration(config, buttonSize),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: widget.disabled ? null : _handleTap,
onLongPress: widget.disabled ? null : widget.onLongPress,
onTapDown: widget.animated && !widget.disabled ? (_) => _pressController.forward() : null,
onTapUp: widget.animated && !widget.disabled ? (_) => _pressController.reverse() : null,
onTapCancel: widget.animated && !widget.disabled ? () => _pressController.reverse() : null,
customBorder: _getInkWellBorder(buttonSize),
child: Center(
child: Icon(
widget.icon,
size: iconSize,
color: widget.disabled
? AppTheme.textHint
: config.foregroundColor,
),
),
),
),
),
),
);
},
);
// Add badge if provided
if (widget.badge != null || widget.notificationCount != null) {
button = Stack(
clipBehavior: Clip.none,
children: [
button,
if (widget.notificationCount != null)
Positioned(
top: -8,
right: -8,
child: CountBadge(
count: widget.notificationCount!,
size: 18,
),
),
if (widget.badge != null)
Positioned(
top: -4,
right: -4,
child: widget.badge!,
),
],
);
}
if (widget.tooltip != null) {
button = Tooltip(
message: widget.tooltip!,
child: button,
);
}
return button;
}
_IconButtonConfig _getButtonConfig() {
switch (widget.variant) {
case IconButtonVariant.standard:
return _IconButtonConfig(
backgroundColor: Colors.transparent,
foregroundColor: widget.foregroundColor ?? AppTheme.textPrimary,
hasElevation: false,
);
case IconButtonVariant.filled:
return _IconButtonConfig(
backgroundColor: widget.backgroundColor ?? AppTheme.primaryColor,
foregroundColor: widget.foregroundColor ?? Colors.white,
hasElevation: true,
);
case IconButtonVariant.outlined:
return _IconButtonConfig(
backgroundColor: Colors.transparent,
foregroundColor: widget.foregroundColor ?? AppTheme.primaryColor,
borderColor: widget.borderColor ?? AppTheme.primaryColor,
hasElevation: false,
);
case IconButtonVariant.ghost:
return _IconButtonConfig(
backgroundColor: (widget.backgroundColor ?? AppTheme.primaryColor).withOpacity(0.1),
foregroundColor: widget.foregroundColor ?? AppTheme.primaryColor,
hasElevation: false,
);
case IconButtonVariant.gradient:
return _IconButtonConfig(
backgroundColor: Colors.transparent,
foregroundColor: widget.foregroundColor ?? Colors.white,
hasElevation: true,
useGradient: true,
);
case IconButtonVariant.glass:
return _IconButtonConfig(
backgroundColor: Colors.white.withOpacity(0.2),
foregroundColor: widget.foregroundColor ?? AppTheme.textPrimary,
borderColor: Colors.white.withOpacity(0.3),
hasElevation: true,
isGlass: true,
);
}
}
Decoration _getDecoration(_IconButtonConfig config, double size) {
final borderRadius = _getBorderRadius(size);
if (config.useGradient) {
return BoxDecoration(
gradient: widget.gradient ?? LinearGradient(
colors: [
widget.backgroundColor ?? AppTheme.primaryColor,
(widget.backgroundColor ?? AppTheme.primaryColor).withOpacity(0.7),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: borderRadius,
boxShadow: config.hasElevation ? _getShadow(config, size) : null,
);
}
return BoxDecoration(
color: config.backgroundColor,
borderRadius: borderRadius,
border: config.borderColor != null
? Border.all(color: config.borderColor!, width: 1.5)
: null,
boxShadow: config.hasElevation && !widget.disabled ? _getShadow(config, size) : null,
);
}
BorderRadius _getBorderRadius(double size) {
switch (widget.shape) {
case IconButtonShape.circle:
return BorderRadius.circular(size / 2);
case IconButtonShape.rounded:
return BorderRadius.circular(size * 0.25);
case IconButtonShape.square:
return BorderRadius.circular(8);
}
}
ShapeBorder _getInkWellBorder(double size) {
switch (widget.shape) {
case IconButtonShape.circle:
return const CircleBorder();
case IconButtonShape.rounded:
return RoundedRectangleBorder(
borderRadius: BorderRadius.circular(size * 0.25),
);
case IconButtonShape.square:
return RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
);
}
}
List<BoxShadow> _getShadow(_IconButtonConfig config, double size) {
final shadowColor = config.useGradient
? (widget.backgroundColor ?? AppTheme.primaryColor)
: config.backgroundColor;
return [
BoxShadow(
color: shadowColor.withOpacity(0.3),
blurRadius: size * 0.3,
offset: Offset(0, size * 0.1),
),
];
}
void _handleTap() {
HapticFeedback.selectionClick();
if (widget.animated) {
_rotationController.forward().then((_) {
_rotationController.reverse();
});
}
widget.onPressed?.call();
}
}
class _IconButtonConfig {
final Color backgroundColor;
final Color foregroundColor;
final Color? borderColor;
final bool hasElevation;
final bool useGradient;
final bool isGlass;
_IconButtonConfig({
required this.backgroundColor,
required this.foregroundColor,
this.borderColor,
this.hasElevation = false,
this.useGradient = false,
this.isGlass = false,
});
}

View File

@@ -0,0 +1,554 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../theme/app_theme.dart';
enum ButtonVariant {
primary,
secondary,
outline,
ghost,
gradient,
glass,
danger,
success,
}
enum ButtonSize {
small,
medium,
large,
extraLarge,
}
enum ButtonShape {
rounded,
circular,
square,
}
class SophisticatedButton extends StatefulWidget {
final String? text;
final Widget? child;
final IconData? icon;
final IconData? suffixIcon;
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
final ButtonVariant variant;
final ButtonSize size;
final ButtonShape shape;
final Color? backgroundColor;
final Color? foregroundColor;
final Gradient? gradient;
final bool loading;
final bool disabled;
final bool animated;
final bool showRipple;
final double? width;
final double? height;
final EdgeInsets? padding;
final List<BoxShadow>? customShadow;
final String? tooltip;
final bool hapticFeedback;
const SophisticatedButton({
super.key,
this.text,
this.child,
this.icon,
this.suffixIcon,
this.onPressed,
this.onLongPress,
this.variant = ButtonVariant.primary,
this.size = ButtonSize.medium,
this.shape = ButtonShape.rounded,
this.backgroundColor,
this.foregroundColor,
this.gradient,
this.loading = false,
this.disabled = false,
this.animated = true,
this.showRipple = true,
this.width,
this.height,
this.padding,
this.customShadow,
this.tooltip,
this.hapticFeedback = true,
});
@override
State<SophisticatedButton> createState() => _SophisticatedButtonState();
}
class _SophisticatedButtonState extends State<SophisticatedButton>
with TickerProviderStateMixin {
late AnimationController _pressController;
late AnimationController _loadingController;
late AnimationController _shimmerController;
late Animation<double> _scaleAnimation;
late Animation<double> _shadowAnimation;
late Animation<double> _loadingAnimation;
late Animation<double> _shimmerAnimation;
bool _isPressed = false;
@override
void initState() {
super.initState();
_pressController = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
_loadingController = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_shimmerController = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _pressController,
curve: Curves.easeInOut,
));
_shadowAnimation = Tween<double>(
begin: 1.0,
end: 0.7,
).animate(CurvedAnimation(
parent: _pressController,
curve: Curves.easeInOut,
));
_loadingAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _loadingController,
curve: Curves.easeInOut,
));
_shimmerAnimation = Tween<double>(
begin: -1.0,
end: 2.0,
).animate(CurvedAnimation(
parent: _shimmerController,
curve: Curves.easeInOut,
));
if (widget.loading) {
_loadingController.repeat();
}
// Shimmer effect for premium buttons
if (widget.variant == ButtonVariant.gradient) {
_shimmerController.repeat();
}
}
@override
void didUpdateWidget(SophisticatedButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.loading != oldWidget.loading) {
if (widget.loading) {
_loadingController.repeat();
} else {
_loadingController.reset();
}
}
}
@override
void dispose() {
_pressController.dispose();
_loadingController.dispose();
_shimmerController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final config = _getButtonConfig();
final isDisabled = widget.disabled || widget.loading;
Widget button = AnimatedBuilder(
animation: Listenable.merge([_pressController, _loadingController, _shimmerController]),
builder: (context, child) {
return Transform.scale(
scale: widget.animated ? _scaleAnimation.value : 1.0,
child: Container(
width: widget.width,
height: widget.height ?? _getHeight(),
padding: widget.padding ?? _getPadding(),
decoration: _getDecoration(config),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: isDisabled ? null : _handleTap,
onLongPress: isDisabled ? null : widget.onLongPress,
onTapDown: widget.animated && !isDisabled ? (_) => _pressController.forward() : null,
onTapUp: widget.animated && !isDisabled ? (_) => _pressController.reverse() : null,
onTapCancel: widget.animated && !isDisabled ? () => _pressController.reverse() : null,
borderRadius: _getBorderRadius(),
splashColor: widget.showRipple ? config.foregroundColor.withOpacity(0.2) : Colors.transparent,
highlightColor: widget.showRipple ? config.foregroundColor.withOpacity(0.1) : Colors.transparent,
child: _buildContent(config),
),
),
),
);
},
);
if (widget.tooltip != null) {
button = Tooltip(
message: widget.tooltip!,
child: button,
);
}
return button;
}
Widget _buildContent(_ButtonConfig config) {
final hasIcon = widget.icon != null;
final hasSuffixIcon = widget.suffixIcon != null;
final hasText = widget.text != null || widget.child != null;
if (widget.loading) {
return _buildLoadingContent(config);
}
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (hasIcon) ...[
_buildIcon(widget.icon!, config),
if (hasText) SizedBox(width: _getIconSpacing()),
],
if (hasText) ...[
Flexible(child: _buildText(config)),
],
if (hasSuffixIcon) ...[
if (hasText || hasIcon) SizedBox(width: _getIconSpacing()),
_buildIcon(widget.suffixIcon!, config),
],
],
);
}
Widget _buildLoadingContent(_ButtonConfig config) {
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: _getIconSize(),
height: _getIconSize(),
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(config.foregroundColor),
),
),
if (widget.text != null) ...[
SizedBox(width: _getIconSpacing()),
Text(
'Chargement...',
style: _getTextStyle(config),
),
],
],
);
}
Widget _buildIcon(IconData icon, _ButtonConfig config) {
return Icon(
icon,
size: _getIconSize(),
color: config.foregroundColor,
);
}
Widget _buildText(_ButtonConfig config) {
if (widget.child != null) {
return DefaultTextStyle(
style: _getTextStyle(config),
child: widget.child!,
);
}
return Text(
widget.text!,
style: _getTextStyle(config),
textAlign: TextAlign.center,
);
}
_ButtonConfig _getButtonConfig() {
final isDisabled = widget.disabled || widget.loading;
switch (widget.variant) {
case ButtonVariant.primary:
return _ButtonConfig(
backgroundColor: isDisabled
? AppTheme.textHint
: (widget.backgroundColor ?? AppTheme.primaryColor),
foregroundColor: isDisabled
? AppTheme.textSecondary
: (widget.foregroundColor ?? Colors.white),
hasElevation: true,
);
case ButtonVariant.secondary:
return _ButtonConfig(
backgroundColor: isDisabled
? AppTheme.backgroundLight
: (widget.backgroundColor ?? AppTheme.secondaryColor),
foregroundColor: isDisabled
? AppTheme.textHint
: (widget.foregroundColor ?? Colors.white),
hasElevation: true,
);
case ButtonVariant.outline:
return _ButtonConfig(
backgroundColor: Colors.transparent,
foregroundColor: isDisabled
? AppTheme.textHint
: (widget.foregroundColor ?? AppTheme.primaryColor),
borderColor: isDisabled
? AppTheme.textHint
: (widget.backgroundColor ?? AppTheme.primaryColor),
hasElevation: false,
);
case ButtonVariant.ghost:
return _ButtonConfig(
backgroundColor: isDisabled
? Colors.transparent
: (widget.backgroundColor ?? AppTheme.primaryColor).withOpacity(0.1),
foregroundColor: isDisabled
? AppTheme.textHint
: (widget.foregroundColor ?? AppTheme.primaryColor),
hasElevation: false,
);
case ButtonVariant.gradient:
return _ButtonConfig(
backgroundColor: Colors.transparent,
foregroundColor: isDisabled
? AppTheme.textHint
: (widget.foregroundColor ?? Colors.white),
hasElevation: true,
useGradient: true,
);
case ButtonVariant.glass:
return _ButtonConfig(
backgroundColor: isDisabled
? Colors.grey.withOpacity(0.1)
: Colors.white.withOpacity(0.2),
foregroundColor: isDisabled
? AppTheme.textHint
: (widget.foregroundColor ?? AppTheme.textPrimary),
borderColor: Colors.white.withOpacity(0.3),
hasElevation: true,
isGlass: true,
);
case ButtonVariant.danger:
return _ButtonConfig(
backgroundColor: isDisabled
? AppTheme.textHint
: AppTheme.errorColor,
foregroundColor: isDisabled
? AppTheme.textSecondary
: Colors.white,
hasElevation: true,
);
case ButtonVariant.success:
return _ButtonConfig(
backgroundColor: isDisabled
? AppTheme.textHint
: AppTheme.successColor,
foregroundColor: isDisabled
? AppTheme.textSecondary
: Colors.white,
hasElevation: true,
);
}
}
Decoration _getDecoration(_ButtonConfig config) {
final borderRadius = _getBorderRadius();
final isDisabled = widget.disabled || widget.loading;
if (config.useGradient && !isDisabled) {
return BoxDecoration(
gradient: widget.gradient ?? LinearGradient(
colors: [
widget.backgroundColor ?? AppTheme.primaryColor,
(widget.backgroundColor ?? AppTheme.primaryColor).withOpacity(0.7),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: borderRadius,
boxShadow: config.hasElevation ? _getShadow(config) : null,
);
}
return BoxDecoration(
color: config.backgroundColor,
borderRadius: borderRadius,
border: config.borderColor != null
? Border.all(color: config.borderColor!, width: 1.5)
: null,
boxShadow: config.hasElevation && !isDisabled ? _getShadow(config) : null,
);
}
List<BoxShadow> _getShadow(_ButtonConfig config) {
if (widget.customShadow != null) {
return widget.customShadow!.map((shadow) => BoxShadow(
color: shadow.color.withOpacity(shadow.color.opacity * _shadowAnimation.value),
blurRadius: shadow.blurRadius * _shadowAnimation.value,
offset: shadow.offset * _shadowAnimation.value,
spreadRadius: shadow.spreadRadius,
)).toList();
}
final shadowColor = config.useGradient
? (widget.backgroundColor ?? AppTheme.primaryColor)
: config.backgroundColor;
return [
BoxShadow(
color: shadowColor.withOpacity(0.3 * _shadowAnimation.value),
blurRadius: 15 * _shadowAnimation.value,
offset: Offset(0, 8 * _shadowAnimation.value),
),
];
}
BorderRadius _getBorderRadius() {
switch (widget.shape) {
case ButtonShape.rounded:
return BorderRadius.circular(_getHeight() / 2);
case ButtonShape.circular:
return BorderRadius.circular(_getHeight());
case ButtonShape.square:
return BorderRadius.circular(8);
}
}
double _getHeight() {
switch (widget.size) {
case ButtonSize.small:
return 32;
case ButtonSize.medium:
return 44;
case ButtonSize.large:
return 56;
case ButtonSize.extraLarge:
return 72;
}
}
EdgeInsets _getPadding() {
switch (widget.size) {
case ButtonSize.small:
return const EdgeInsets.symmetric(horizontal: 16, vertical: 6);
case ButtonSize.medium:
return const EdgeInsets.symmetric(horizontal: 24, vertical: 12);
case ButtonSize.large:
return const EdgeInsets.symmetric(horizontal: 32, vertical: 16);
case ButtonSize.extraLarge:
return const EdgeInsets.symmetric(horizontal: 40, vertical: 20);
}
}
double _getFontSize() {
switch (widget.size) {
case ButtonSize.small:
return 14;
case ButtonSize.medium:
return 16;
case ButtonSize.large:
return 18;
case ButtonSize.extraLarge:
return 20;
}
}
double _getIconSize() {
switch (widget.size) {
case ButtonSize.small:
return 16;
case ButtonSize.medium:
return 20;
case ButtonSize.large:
return 24;
case ButtonSize.extraLarge:
return 28;
}
}
double _getIconSpacing() {
switch (widget.size) {
case ButtonSize.small:
return 6;
case ButtonSize.medium:
return 8;
case ButtonSize.large:
return 10;
case ButtonSize.extraLarge:
return 12;
}
}
TextStyle _getTextStyle(_ButtonConfig config) {
return TextStyle(
fontSize: _getFontSize(),
fontWeight: FontWeight.w600,
color: config.foregroundColor,
letterSpacing: 0.5,
);
}
void _handleTap() {
if (widget.hapticFeedback) {
HapticFeedback.lightImpact();
}
widget.onPressed?.call();
}
}
class _ButtonConfig {
final Color backgroundColor;
final Color foregroundColor;
final Color? borderColor;
final bool hasElevation;
final bool useGradient;
final bool isGlass;
_ButtonConfig({
required this.backgroundColor,
required this.foregroundColor,
this.borderColor,
this.hasElevation = false,
this.useGradient = false,
this.isGlass = false,
});
}

View File

@@ -0,0 +1,322 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../theme/app_theme.dart';
enum CardVariant {
elevated,
outlined,
filled,
glass,
gradient,
}
enum CardSize {
compact,
standard,
expanded,
}
class SophisticatedCard extends StatefulWidget {
final Widget child;
final CardVariant variant;
final CardSize size;
final Color? backgroundColor;
final Color? borderColor;
final Gradient? gradient;
final List<BoxShadow>? customShadow;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final bool animated;
final bool showRipple;
final EdgeInsets? padding;
final EdgeInsets? margin;
final double? elevation;
final BorderRadius? borderRadius;
final Widget? header;
final Widget? footer;
final bool blurBackground;
const SophisticatedCard({
super.key,
required this.child,
this.variant = CardVariant.elevated,
this.size = CardSize.standard,
this.backgroundColor,
this.borderColor,
this.gradient,
this.customShadow,
this.onTap,
this.onLongPress,
this.animated = true,
this.showRipple = true,
this.padding,
this.margin,
this.elevation,
this.borderRadius,
this.header,
this.footer,
this.blurBackground = false,
});
@override
State<SophisticatedCard> createState() => _SophisticatedCardState();
}
class _SophisticatedCardState extends State<SophisticatedCard>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _shadowAnimation;
bool _isPressed = false;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.98,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
_shadowAnimation = Tween<double>(
begin: 1.0,
end: 0.7,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final borderRadius = widget.borderRadius ?? _getDefaultBorderRadius();
final padding = widget.padding ?? _getDefaultPadding();
final margin = widget.margin ?? EdgeInsets.zero;
Widget card = AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: widget.animated ? _scaleAnimation.value : 1.0,
child: Container(
margin: margin,
decoration: _getDecoration(borderRadius),
child: ClipRRect(
borderRadius: borderRadius,
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.header != null) ...[
widget.header!,
const Divider(height: 1),
],
Flexible(
child: Padding(
padding: padding,
child: widget.child,
),
),
if (widget.footer != null) ...[
const Divider(height: 1),
widget.footer!,
],
],
),
),
),
),
);
},
);
if (widget.onTap != null || widget.onLongPress != null) {
card = InkWell(
onTap: widget.onTap != null ? _handleTap : null,
onLongPress: widget.onLongPress,
onTapDown: widget.animated ? (_) => _animationController.forward() : null,
onTapUp: widget.animated ? (_) => _animationController.reverse() : null,
onTapCancel: widget.animated ? () => _animationController.reverse() : null,
borderRadius: borderRadius,
splashColor: widget.showRipple ? null : Colors.transparent,
highlightColor: widget.showRipple ? null : Colors.transparent,
child: card,
);
}
return card;
}
EdgeInsets _getDefaultPadding() {
switch (widget.size) {
case CardSize.compact:
return const EdgeInsets.all(12);
case CardSize.standard:
return const EdgeInsets.all(16);
case CardSize.expanded:
return const EdgeInsets.all(24);
}
}
BorderRadius _getDefaultBorderRadius() {
switch (widget.size) {
case CardSize.compact:
return BorderRadius.circular(12);
case CardSize.standard:
return BorderRadius.circular(16);
case CardSize.expanded:
return BorderRadius.circular(20);
}
}
double _getDefaultElevation() {
switch (widget.variant) {
case CardVariant.elevated:
return widget.elevation ?? 8;
case CardVariant.glass:
return 12;
default:
return 0;
}
}
Decoration _getDecoration(BorderRadius borderRadius) {
final elevation = _getDefaultElevation();
switch (widget.variant) {
case CardVariant.elevated:
return BoxDecoration(
color: widget.backgroundColor ?? Colors.white,
borderRadius: borderRadius,
boxShadow: widget.customShadow ?? [
BoxShadow(
color: Colors.black.withOpacity(0.1 * _shadowAnimation.value),
blurRadius: elevation * _shadowAnimation.value,
offset: Offset(0, elevation * 0.5 * _shadowAnimation.value),
),
],
);
case CardVariant.outlined:
return BoxDecoration(
color: widget.backgroundColor ?? Colors.white,
borderRadius: borderRadius,
border: Border.all(
color: widget.borderColor ?? AppTheme.textHint.withOpacity(0.2),
width: 1,
),
);
case CardVariant.filled:
return BoxDecoration(
color: widget.backgroundColor ?? AppTheme.backgroundLight,
borderRadius: borderRadius,
);
case CardVariant.glass:
return BoxDecoration(
color: (widget.backgroundColor ?? Colors.white).withOpacity(0.9),
borderRadius: borderRadius,
border: Border.all(
color: Colors.white.withOpacity(0.3),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1 * _shadowAnimation.value),
blurRadius: 20 * _shadowAnimation.value,
offset: Offset(0, 8 * _shadowAnimation.value),
),
],
);
case CardVariant.gradient:
return BoxDecoration(
gradient: widget.gradient ?? LinearGradient(
colors: [
widget.backgroundColor ?? AppTheme.primaryColor,
(widget.backgroundColor ?? AppTheme.primaryColor).withOpacity(0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: borderRadius,
boxShadow: [
BoxShadow(
color: (widget.backgroundColor ?? AppTheme.primaryColor)
.withOpacity(0.3 * _shadowAnimation.value),
blurRadius: 15 * _shadowAnimation.value,
offset: Offset(0, 8 * _shadowAnimation.value),
),
],
);
}
}
void _handleTap() {
if (widget.animated) {
HapticFeedback.lightImpact();
}
widget.onTap?.call();
}
}
// Predefined card variants
class ElevatedCard extends SophisticatedCard {
const ElevatedCard({
super.key,
required super.child,
super.onTap,
super.padding,
super.margin,
super.elevation,
}) : super(variant: CardVariant.elevated);
}
class OutlinedCard extends SophisticatedCard {
const OutlinedCard({
super.key,
required super.child,
super.onTap,
super.padding,
super.margin,
super.borderColor,
}) : super(variant: CardVariant.outlined);
}
class GlassCard extends SophisticatedCard {
const GlassCard({
super.key,
required super.child,
super.onTap,
super.padding,
super.margin,
}) : super(variant: CardVariant.glass);
}
class GradientCard extends SophisticatedCard {
const GradientCard({
super.key,
required super.child,
super.onTap,
super.padding,
super.margin,
super.gradient,
super.backgroundColor,
}) : super(variant: CardVariant.gradient);
}

View File

@@ -0,0 +1,213 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class ComingSoonPage extends StatelessWidget {
final String title;
final String description;
final IconData icon;
final Color color;
final List<String>? features;
const ComingSoonPage({
super.key,
required this.title,
required this.description,
required this.icon,
required this.color,
this.features,
});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.backgroundLight,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icône principale
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
color,
color.withOpacity(0.7),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(60),
boxShadow: [
BoxShadow(
color: color.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Icon(
icon,
size: 60,
color: Colors.white,
),
),
const SizedBox(height: 32),
// Titre
Text(
title,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Description
Text(
description,
style: TextStyle(
fontSize: 16,
color: AppTheme.textSecondary,
height: 1.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// Fonctionnalités à venir (si fournies)
if (features != null) ...[
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.upcoming,
color: color,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Fonctionnalités à venir',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 16),
...features!.map((feature) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
feature,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textSecondary,
),
),
),
],
),
)).toList(),
],
),
),
const SizedBox(height: 32),
],
// Badge "En développement"
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppTheme.infoColor,
AppTheme.infoColor.withOpacity(0.8),
],
),
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: AppTheme.infoColor.withOpacity(0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.construction,
color: Colors.white,
size: 16,
),
const SizedBox(width: 8),
const Text(
'En cours de développement',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: 24),
// Message d'encouragement
Text(
'Cette fonctionnalité sera bientôt disponible.\nMerci pour votre patience !',
style: TextStyle(
fontSize: 14,
color: AppTheme.textHint,
height: 1.4,
),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,248 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme/app_theme.dart';
class CustomTextField extends StatefulWidget {
final TextEditingController controller;
final String label;
final String? hintText;
final IconData? prefixIcon;
final Widget? suffixIcon;
final bool obscureText;
final TextInputType keyboardType;
final TextInputAction textInputAction;
final String? Function(String?)? validator;
final void Function(String)? onChanged;
final void Function(String)? onFieldSubmitted;
final bool enabled;
final int maxLines;
final int? maxLength;
final List<TextInputFormatter>? inputFormatters;
final bool autofocus;
const CustomTextField({
super.key,
required this.controller,
required this.label,
this.hintText,
this.prefixIcon,
this.suffixIcon,
this.obscureText = false,
this.keyboardType = TextInputType.text,
this.textInputAction = TextInputAction.done,
this.validator,
this.onChanged,
this.onFieldSubmitted,
this.enabled = true,
this.maxLines = 1,
this.maxLength,
this.inputFormatters,
this.autofocus = false,
});
@override
State<CustomTextField> createState() => _CustomTextFieldState();
}
class _CustomTextFieldState extends State<CustomTextField>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<Color?> _borderColorAnimation;
late Animation<Color?> _labelColorAnimation;
bool _isFocused = false;
String? _errorText;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_borderColorAnimation = ColorTween(
begin: AppTheme.borderColor,
end: AppTheme.primaryColor,
).animate(_animationController);
_labelColorAnimation = ColorTween(
begin: AppTheme.textSecondary,
end: AppTheme.primaryColor,
).animate(_animationController);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Label
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
widget.label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _labelColorAnimation.value,
),
),
),
// Champ de saisie
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: _isFocused
? [
BoxShadow(
color: AppTheme.primaryColor.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: TextFormField(
controller: widget.controller,
obscureText: widget.obscureText,
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
enabled: widget.enabled,
maxLines: widget.maxLines,
maxLength: widget.maxLength,
inputFormatters: widget.inputFormatters,
autofocus: widget.autofocus,
validator: (value) {
final error = widget.validator?.call(value);
setState(() {
_errorText = error;
});
return error;
},
onChanged: widget.onChanged,
onFieldSubmitted: widget.onFieldSubmitted,
onTap: () {
setState(() {
_isFocused = true;
});
_animationController.forward();
},
onTapOutside: (_) {
setState(() {
_isFocused = false;
});
_animationController.reverse();
FocusScope.of(context).unfocus();
},
style: const TextStyle(
fontSize: 16,
color: AppTheme.textPrimary,
),
decoration: InputDecoration(
hintText: widget.hintText,
hintStyle: const TextStyle(
color: AppTheme.textHint,
fontSize: 16,
),
prefixIcon: widget.prefixIcon != null
? Icon(
widget.prefixIcon,
color: _isFocused
? AppTheme.primaryColor
: AppTheme.textHint,
)
: null,
suffixIcon: widget.suffixIcon,
filled: true,
fillColor: widget.enabled
? Colors.white
: AppTheme.backgroundLight,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: _borderColorAnimation.value ?? AppTheme.borderColor,
width: _isFocused ? 2 : 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: _errorText != null
? AppTheme.errorColor
: AppTheme.borderColor,
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: _errorText != null
? AppTheme.errorColor
: AppTheme.primaryColor,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: AppTheme.errorColor,
width: 1,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: AppTheme.errorColor,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
counterText: '',
),
),
),
// Message d'erreur
if (_errorText != null)
Padding(
padding: const EdgeInsets.only(top: 8, left: 4),
child: Row(
children: [
const Icon(
Icons.error_outline,
size: 16,
color: AppTheme.errorColor,
),
const SizedBox(width: 6),
Expanded(
child: Text(
_errorText!,
style: const TextStyle(
color: AppTheme.errorColor,
fontSize: 12,
),
),
),
],
),
),
],
);
},
);
}
}

View File

@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme/app_theme.dart';
class LoadingButton extends StatefulWidget {
final VoidCallback? onPressed;
final String text;
final bool isLoading;
final double? width;
final double height;
final Color? backgroundColor;
final Color? textColor;
final IconData? icon;
final bool enabled;
const LoadingButton({
super.key,
required this.onPressed,
required this.text,
this.isLoading = false,
this.width,
this.height = 48,
this.backgroundColor,
this.textColor,
this.icon,
this.enabled = true,
});
@override
State<LoadingButton> createState() => _LoadingButtonState();
}
class _LoadingButtonState extends State<LoadingButton>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
_opacityAnimation = Tween<double>(
begin: 1.0,
end: 0.8,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
bool get _isEnabled => widget.enabled && !widget.isLoading && widget.onPressed != null;
Color get _backgroundColor {
if (!_isEnabled) {
return AppTheme.textHint.withOpacity(0.3);
}
return widget.backgroundColor ?? AppTheme.primaryColor;
}
Color get _textColor {
if (!_isEnabled) {
return AppTheme.textHint;
}
return widget.textColor ?? Colors.white;
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: _isEnabled
? [
BoxShadow(
color: _backgroundColor.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),
]
: null,
),
child: ElevatedButton(
onPressed: _isEnabled ? _handlePressed : null,
style: ElevatedButton.styleFrom(
backgroundColor: _backgroundColor,
foregroundColor: _textColor,
elevation: 0,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 24),
),
child: _buildButtonContent(),
),
),
),
);
},
);
}
Widget _buildButtonContent() {
if (widget.isLoading) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(_textColor),
strokeWidth: 2,
),
),
const SizedBox(width: 12),
Text(
'Chargement...',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _textColor,
),
),
],
);
}
if (widget.icon != null) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
widget.icon,
size: 20,
color: _textColor,
),
const SizedBox(width: 8),
Text(
widget.text,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _textColor,
),
),
],
);
}
return Text(
widget.text,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _textColor,
),
);
}
void _handlePressed() {
if (!_isEnabled) return;
// Animation de pression
_animationController.forward().then((_) {
_animationController.reverse();
});
// Vibration tactile
HapticFeedback.lightImpact();
// Callback
widget.onPressed?.call();
}
}