initial commit

This commit is contained in:
bastien
2026-06-28 12:29:06 +02:00
parent 79d5b012a9
commit 20f383dd62
93 changed files with 4269 additions and 1 deletions
+99
View File
@@ -0,0 +1,99 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/app_config.dart';
import '../auth/auth_provider.dart';
/// Client HTTP Dio configuré pour l'API Laverie.
class ApiClient {
ApiClient({
required this.baseUrl,
this.getAccessToken,
this.onUnauthorized,
}) : _dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
),
) {
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await getAccessToken?.call();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
if (error.response?.statusCode == 401) {
await onUnauthorized?.call();
}
handler.next(error);
},
),
);
}
final String baseUrl;
final Future<String?> Function()? getAccessToken;
final Future<void> Function()? onUnauthorized;
final Dio _dio;
Dio get dio => _dio;
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
}) {
return _dio.get<T>(path, queryParameters: queryParameters);
}
Future<Response<T>> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
}) {
return _dio.post<T>(path, data: data, queryParameters: queryParameters);
}
Future<Response<T>> patch<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
}) {
return _dio.patch<T>(path, data: data, queryParameters: queryParameters);
}
Future<Response<T>> put<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
}) {
return _dio.put<T>(path, data: data, queryParameters: queryParameters);
}
Future<Response<T>> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
}) {
return _dio.delete<T>(path, data: data, queryParameters: queryParameters);
}
}
/// Fournisseur Riverpod du client API (injecte le token depuis l'état auth).
final apiClientProvider = Provider<ApiClient>((ref) {
final authNotifier = ref.read(authProvider.notifier);
return ApiClient(
baseUrl: kApiBaseUrl,
getAccessToken: () async => ref.read(authProvider).accessToken,
onUnauthorized: () async => authNotifier.logout(),
);
});
+32
View File
@@ -0,0 +1,32 @@
/// Chemins des endpoints REST de l'API Laverie v1.
abstract final class ApiEndpoints {
// Authentification utilisateur
static const authRegister = '/auth/register';
static const authLogin = '/auth/login';
static const authRefresh = '/auth/refresh';
static const authLogout = '/auth/logout';
static const authMe = '/auth/me';
// Portefeuille
static const wallet = '/wallet';
static const walletTransactions = '/wallet/transactions';
static const walletTopUpInitiate = '/wallet/top-up/initiate';
static const walletTopUpConfirm = '/wallet/top-up/confirm';
// Établissements & machines
static const establishments = '/establishments';
static String establishment(String uuid) => '/establishments/$uuid';
static String machine(String uuid) => '/machines/$uuid';
static String machineAvailability(String uuid) => '/machines/$uuid/availability';
static String machinePricing(String uuid) => '/machines/$uuid/pricing';
// Réservations
static const bookings = '/bookings';
static String booking(String uuid) => '/bookings/$uuid';
static String bookingCancel(String uuid) => '/bookings/$uuid/cancel';
// Lavages
static const washes = '/washes';
static const washesStart = '/washes/start';
static String wash(String uuid) => '/washes/$uuid';
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/api_client.dart';
import '../config/app_config.dart';
import '../../features/auth/domain/auth_user.dart';
import 'auth_repository.dart';
/// État d'authentification de l'application.
class AuthState {
const AuthState({
this.user,
this.accessToken,
this.refreshToken,
this.isLoading = false,
this.error,
});
final AuthUser? user;
final String? accessToken;
final String? refreshToken;
final bool isLoading;
final String? error;
bool get isAuthenticated => accessToken != null && accessToken!.isNotEmpty;
AuthState copyWith({
AuthUser? user,
String? accessToken,
String? refreshToken,
bool? isLoading,
String? error,
bool clearError = false,
bool clearUser = false,
}) {
return AuthState(
user: clearUser ? null : (user ?? this.user),
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
isLoading: isLoading ?? this.isLoading,
error: clearError ? null : (error ?? this.error),
);
}
}
/// Gestionnaire d'état Riverpod pour l'authentification.
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier(this._repository) : super(const AuthState()) {
_restoreSession();
}
final AuthRepository _repository;
Future<void> _restoreSession() async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final stored = await _repository.loadStoredTokens();
if (stored == null) {
state = state.copyWith(isLoading: false);
return;
}
state = state.copyWith(
accessToken: stored.accessToken,
refreshToken: stored.refreshToken,
);
final user = await _repository.fetchCurrentUser();
state = state.copyWith(user: user, isLoading: false);
} catch (_) {
final refreshed = await _repository.refresh();
if (refreshed != null) {
final user = await _repository.fetchCurrentUser();
state = state.copyWith(
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
user: user,
isLoading: false,
);
} else {
await _repository.clearStoredTokens();
state = const AuthState(isLoading: false);
}
}
}
Future<bool> login(String email, String password) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final tokens = await _repository.login(email: email, password: password);
final user = tokens.user ?? await _repository.fetchCurrentUser();
state = state.copyWith(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
user: user,
isLoading: false,
);
return true;
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Connexion impossible. Vérifiez vos identifiants.',
);
return false;
}
}
Future<bool> register({
required String firstName,
required String lastName,
required String email,
required String password,
String? phone,
}) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final tokens = await _repository.register(
firstName: firstName,
lastName: lastName,
email: email,
password: password,
phone: phone,
);
final user = tokens.user ?? await _repository.fetchCurrentUser();
state = state.copyWith(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
user: user,
isLoading: false,
);
return true;
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Inscription impossible. L\'email est peut-être déjà utilisé.',
);
return false;
}
}
Future<void> logout() async {
await _repository.logout();
state = const AuthState();
}
}
final authRepositoryProvider = Provider<AuthRepository>((ref) {
// Client dédié à l'auth pour éviter une dépendance circulaire avec apiClientProvider.
return AuthRepository(apiClient: ApiClient(baseUrl: kApiBaseUrl));
});
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(ref.watch(authRepositoryProvider));
});
+125
View File
@@ -0,0 +1,125 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../api/api_client.dart';
import '../api/api_endpoints.dart';
import '../config/app_config.dart';
import '../config/secure_storage_config.dart';
import '../../features/auth/domain/auth_user.dart';
/// Dépôt d'authentification — login, inscription, refresh et logout.
class AuthRepository {
AuthRepository({
required ApiClient apiClient,
FlutterSecureStorage? secureStorage,
}) : _apiClient = apiClient,
_secureStorage = secureStorage ?? laverieSecureStorage;
final ApiClient _apiClient;
final FlutterSecureStorage _secureStorage;
Future<AuthTokens> login({
required String email,
required String password,
}) async {
final response = await _apiClient.post(
ApiEndpoints.authLogin,
data: {
'email': email,
'password': password,
},
);
final tokens = AuthTokens.fromJson(response.data as Map<String, dynamic>);
await _persistTokens(tokens);
return tokens;
}
Future<AuthTokens> register({
required String firstName,
required String lastName,
required String email,
required String password,
String? phone,
}) async {
final response = await _apiClient.post(
ApiEndpoints.authRegister,
data: {
'first_name': firstName,
'last_name': lastName,
'email': email,
'password': password,
if (phone != null) 'phone': phone,
},
);
final tokens = AuthTokens.fromJson(response.data as Map<String, dynamic>);
await _persistTokens(tokens);
return tokens;
}
Future<AuthTokens?> refresh() async {
final refreshToken = await _secureStorage.read(key: AuthStorageKeys.refreshToken);
if (refreshToken == null || refreshToken.isEmpty) {
return null;
}
final response = await _apiClient.post(
ApiEndpoints.authRefresh,
data: {'refresh_token': refreshToken},
);
final tokens = AuthTokens.fromJson(response.data as Map<String, dynamic>);
await _persistTokens(tokens);
return tokens;
}
Future<AuthUser?> fetchCurrentUser() async {
final response = await _apiClient.get(ApiEndpoints.authMe);
final data = response.data;
if (data is Map<String, dynamic>) {
if (data.containsKey('data')) {
return AuthUser.fromJson(data['data'] as Map<String, dynamic>);
}
return AuthUser.fromJson(data);
}
return null;
}
Future<void> logout() async {
try {
await _apiClient.post(ApiEndpoints.authLogout);
} catch (_) {
// Déconnexion locale même si l'API est injoignable.
}
await clearStoredTokens();
}
Future<AuthTokens?> loadStoredTokens() async {
final accessToken = await _secureStorage.read(key: AuthStorageKeys.accessToken);
final refreshToken = await _secureStorage.read(key: AuthStorageKeys.refreshToken);
if (accessToken == null || refreshToken == null) {
return null;
}
return AuthTokens(accessToken: accessToken, refreshToken: refreshToken);
}
Future<void> clearStoredTokens() async {
await _secureStorage.delete(key: AuthStorageKeys.accessToken);
await _secureStorage.delete(key: AuthStorageKeys.refreshToken);
}
Future<void> _persistTokens(AuthTokens tokens) async {
await _secureStorage.write(
key: AuthStorageKeys.accessToken,
value: tokens.accessToken,
);
await _secureStorage.write(
key: AuthStorageKeys.refreshToken,
value: tokens.refreshToken,
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
/// Surcharge explicite via `--dart-define=API_BASE_URL=...`
const String _envApiBaseUrl = String.fromEnvironment('API_BASE_URL');
/// URL de base de l'API Laverie.
///
/// Priorité :
/// 1. `API_BASE_URL` passé en `--dart-define`
/// 2. Valeur par défaut selon la plateforme :
/// - Android émulateur : `10.0.2.2` (localhost de l'hôte)
/// - iOS simulateur : `127.0.0.1`
/// 3. Appareil physique : toujours passer `--dart-define=API_BASE_URL=http://IP:8000/api/v1`
String get kApiBaseUrl {
if (_envApiBaseUrl.isNotEmpty) {
return _envApiBaseUrl;
}
if (kIsWeb) {
throw UnsupportedError('Le web Flutter n\'est pas supporté.');
}
if (Platform.isAndroid) {
return 'http://10.0.2.2:8000/api/v1';
}
if (Platform.isIOS) {
return 'http://127.0.0.1:8000/api/v1';
}
throw UnsupportedError('Plateforme non supportée.');
}
/// Clés de stockage sécurisé pour les jetons d'authentification.
abstract final class AuthStorageKeys {
static const accessToken = 'laverie_access_token';
static const refreshToken = 'laverie_refresh_token';
}
@@ -0,0 +1,14 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Configuration du stockage sécurisé adaptée Android / iOS.
///
/// Android : SharedPreferences chiffrées (Keystore).
/// iOS : Keychain avec accessibilité au premier déverrouillage.
const FlutterSecureStorage laverieSecureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock,
),
);
+39
View File
@@ -0,0 +1,39 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
/// Plateformes supportées par l'application (mobile natif uniquement, pas de web).
enum AppPlatform {
android,
ios,
}
/// Indique si la plateforme courante est supportée (Android ou iOS).
bool get isMobilePlatformSupported {
if (kIsWeb) {
return false;
}
return Platform.isAndroid || Platform.isIOS;
}
/// Plateforme courante. Lance une exception si web ou desktop.
AppPlatform get currentAppPlatform {
if (kIsWeb) {
throw UnsupportedError(
'Laverie Mobile ne cible pas le web. Utilisez Android ou iOS.',
);
}
if (Platform.isAndroid) {
return AppPlatform.android;
}
if (Platform.isIOS) {
return AppPlatform.ios;
}
throw UnsupportedError(
'Plateforme non supportée. Cible : Android (prioritaire) et iOS.',
);
}
bool get isAndroid => !kIsWeb && Platform.isAndroid;
bool get isIos => !kIsWeb && Platform.isIOS;
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_provider.dart';
import '../../features/auth/presentation/login_screen.dart';
import '../../features/auth/presentation/register_screen.dart';
import '../../features/home/presentation/home_screen.dart';
import '../../features/establishments/presentation/establishment_detail_screen.dart';
import '../../features/wallet/presentation/wallet_screen.dart';
import '../../features/booking/presentation/bookings_screen.dart';
import '../../features/wash/presentation/wash_screen.dart';
import '../../features/profile/presentation/profile_screen.dart';
/// Routes nommées de l'application.
abstract final class AppRoutes {
static const login = '/login';
static const register = '/register';
static const home = '/';
static const wallet = '/wallet';
static const bookings = '/bookings';
static const washes = '/washes';
static const profile = '/profile';
static const establishment = '/establishments/:uuid';
}
/// Configuration GoRouter avec redirection selon l'état d'authentification.
final appRouterProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authProvider);
return GoRouter(
initialLocation: AppRoutes.home,
refreshListenable: GoRouterRefreshStream(ref),
redirect: (context, state) {
final isAuthenticated = authState.isAuthenticated;
final isAuthRoute = state.matchedLocation == AppRoutes.login ||
state.matchedLocation == AppRoutes.register;
if (!isAuthenticated && !isAuthRoute) {
return AppRoutes.login;
}
if (isAuthenticated && isAuthRoute) {
return AppRoutes.home;
}
return null;
},
routes: [
GoRoute(
path: AppRoutes.login,
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: AppRoutes.register,
builder: (context, state) => const RegisterScreen(),
),
GoRoute(
path: AppRoutes.home,
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: AppRoutes.establishment,
builder: (context, state) {
final uuid = state.pathParameters['uuid']!;
return EstablishmentDetailScreen(establishmentUuid: uuid);
},
),
GoRoute(
path: AppRoutes.wallet,
builder: (context, state) => const WalletScreen(),
),
GoRoute(
path: AppRoutes.bookings,
builder: (context, state) => const BookingsScreen(),
),
GoRoute(
path: AppRoutes.washes,
builder: (context, state) => const WashScreen(),
),
GoRoute(
path: AppRoutes.profile,
builder: (context, state) => const ProfileScreen(),
),
],
);
});
/// Écoute les changements Riverpod pour rafraîchir les redirections GoRouter.
class GoRouterRefreshStream extends ChangeNotifier {
GoRouterRefreshStream(this._ref) {
_ref.listen<AuthState>(authProvider, (_, __) => notifyListeners());
}
final Ref _ref;
}
+46
View File
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
/// Thème visuel de l'application Laverie.
class AppTheme {
AppTheme._();
static const Color _primary = Color(0xFF1565C0);
static const Color _secondary = Color(0xFF26A69A);
static ThemeData get light {
final colorScheme = ColorScheme.fromSeed(
seedColor: _primary,
secondary: _secondary,
brightness: Brightness.light,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
appBarTheme: AppBarTheme(
centerTitle: true,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
elevation: 0,
),
cardTheme: CardThemeData(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: colorScheme.secondary,
foregroundColor: colorScheme.onSecondary,
),
);
}
}