initial commit
This commit is contained in:
@@ -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(),
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/// Modèle utilisateur authentifié.
|
||||
class AuthUser {
|
||||
const AuthUser({
|
||||
required this.uuid,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.email,
|
||||
this.phone,
|
||||
this.locale = 'fr',
|
||||
});
|
||||
|
||||
final String uuid;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String email;
|
||||
final String? phone;
|
||||
final String locale;
|
||||
|
||||
String get fullName => '$firstName $lastName'.trim();
|
||||
|
||||
factory AuthUser.fromJson(Map<String, dynamic> json) {
|
||||
return AuthUser(
|
||||
uuid: json['uuid'] as String,
|
||||
firstName: json['first_name'] as String? ?? '',
|
||||
lastName: json['last_name'] as String? ?? '',
|
||||
email: json['email'] as String,
|
||||
phone: json['phone'] as String?,
|
||||
locale: json['locale'] as String? ?? 'fr',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Réponse d'authentification (login / register / refresh).
|
||||
class AuthTokens {
|
||||
const AuthTokens({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
this.user,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final AuthUser? user;
|
||||
|
||||
factory AuthTokens.fromJson(Map<String, dynamic> json) {
|
||||
final userJson = json['user'] as Map<String, dynamic>?;
|
||||
return AuthTokens(
|
||||
accessToken: json['access_token'] as String? ?? json['token'] as String,
|
||||
refreshToken: json['refresh_token'] as String,
|
||||
user: userJson != null ? AuthUser.fromJson(userJson) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/auth/auth_provider.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
|
||||
/// Écran de connexion utilisateur.
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController(text: 'marie.dupont@demo.local');
|
||||
final _passwordController = TextEditingController(text: 'password');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final success = await ref.read(authProvider.notifier).login(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
context.go(AppRoutes.home);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(Icons.local_laundry_service, size: 72, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Laverie Connectée',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Connectez-vous pour réserver et laver',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Email requis';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Mot de passe',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Mot de passe requis';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
if (authState.error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
authState.error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: authState.isLoading ? null : _submit,
|
||||
child: authState.isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Se connecter'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () => context.push(AppRoutes.register),
|
||||
child: const Text('Créer un compte'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/auth/auth_provider.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
|
||||
/// Écran d'inscription utilisateur.
|
||||
class RegisterScreen extends ConsumerStatefulWidget {
|
||||
const RegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
|
||||
}
|
||||
|
||||
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _firstNameController = TextEditingController();
|
||||
final _lastNameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_emailController.dispose();
|
||||
_phoneController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final success = await ref.read(authProvider.notifier).register(
|
||||
firstName: _firstNameController.text.trim(),
|
||||
lastName: _lastNameController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
phone: _phoneController.text.trim().isEmpty
|
||||
? null
|
||||
: _phoneController.text.trim(),
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
context.go(AppRoutes.home);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Inscription')),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _firstNameController,
|
||||
decoration: const InputDecoration(labelText: 'Prénom'),
|
||||
validator: (v) => v == null || v.isEmpty ? 'Prénom requis' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _lastNameController,
|
||||
decoration: const InputDecoration(labelText: 'Nom'),
|
||||
validator: (v) => v == null || v.isEmpty ? 'Nom requis' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
validator: (v) => v == null || v.isEmpty ? 'Email requis' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(labelText: 'Téléphone (optionnel)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(labelText: 'Mot de passe'),
|
||||
validator: (v) {
|
||||
if (v == null || v.length < 8) {
|
||||
return 'Minimum 8 caractères';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
if (authState.error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
authState.error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: authState.isLoading ? null : _submit,
|
||||
child: authState.isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('S\'inscrire'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/// Modèle réservation de créneau machine.
|
||||
class Booking {
|
||||
const Booking({
|
||||
required this.uuid,
|
||||
required this.machineUuid,
|
||||
required this.machineName,
|
||||
required this.slotStart,
|
||||
required this.slotEnd,
|
||||
required this.status,
|
||||
this.bookingFee = 0,
|
||||
});
|
||||
|
||||
final String uuid;
|
||||
final String machineUuid;
|
||||
final String machineName;
|
||||
final DateTime? slotStart;
|
||||
final DateTime? slotEnd;
|
||||
final String status;
|
||||
final double bookingFee;
|
||||
|
||||
factory Booking.fromJson(Map<String, dynamic> json) {
|
||||
final machine = json['machine'] as Map<String, dynamic>?;
|
||||
|
||||
return Booking(
|
||||
uuid: json['uuid'] as String,
|
||||
machineUuid: machine?['uuid'] as String? ?? json['machine_uuid'] as String? ?? '',
|
||||
machineName: machine?['name'] as String? ?? 'Machine',
|
||||
slotStart: json['slot_start'] != null
|
||||
? DateTime.tryParse(json['slot_start'] as String)
|
||||
: null,
|
||||
slotEnd: json['slot_end'] != null
|
||||
? DateTime.tryParse(json['slot_end'] as String)
|
||||
: null,
|
||||
status: json['status'] as String? ?? 'pending',
|
||||
bookingFee: (json['booking_fee'] as num?)?.toDouble() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../../core/api/api_endpoints.dart';
|
||||
import '../domain/booking.dart';
|
||||
|
||||
/// Fournisseur des réservations de l'utilisateur connecté.
|
||||
final bookingsProvider = FutureProvider<List<Booking>>((ref) async {
|
||||
final response = await ref.watch(apiClientProvider).get(ApiEndpoints.bookings);
|
||||
final data = response.data;
|
||||
|
||||
List<dynamic> list;
|
||||
if (data is List<dynamic>) {
|
||||
list = data;
|
||||
} else if (data is Map<String, dynamic> && data['data'] is List<dynamic>) {
|
||||
list = data['data'] as List<dynamic>;
|
||||
} else {
|
||||
list = [];
|
||||
}
|
||||
|
||||
return list
|
||||
.map((json) => Booking.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
/// Écran listant les réservations de l'utilisateur.
|
||||
class BookingsScreen extends ConsumerWidget {
|
||||
const BookingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bookingsAsync = ref.watch(bookingsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Mes réservations')),
|
||||
body: bookingsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Erreur : $error'),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.invalidate(bookingsProvider),
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (bookings) {
|
||||
if (bookings.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Aucune réservation.\nRéservez un créneau depuis une laverie.'),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(bookingsProvider),
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: bookings.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final booking = bookings[index];
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.event),
|
||||
title: Text(booking.machineName),
|
||||
subtitle: Text(
|
||||
booking.slotStart != null
|
||||
? DateFormat('dd/MM/yyyy HH:mm').format(booking.slotStart!)
|
||||
: 'Créneau à confirmer',
|
||||
),
|
||||
trailing: Chip(label: Text(booking.status)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../../core/api/api_endpoints.dart';
|
||||
import '../domain/establishment.dart';
|
||||
|
||||
/// Dépôt de données pour les établissements et machines.
|
||||
class EstablishmentRepository {
|
||||
EstablishmentRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<List<Establishment>> fetchEstablishments() async {
|
||||
final response = await _apiClient.get(ApiEndpoints.establishments);
|
||||
final data = _extractList(response.data);
|
||||
|
||||
return data
|
||||
.map((json) => Establishment.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<Establishment> fetchEstablishment(String uuid) async {
|
||||
final response = await _apiClient.get(ApiEndpoints.establishment(uuid));
|
||||
final json = _extractObject(response.data);
|
||||
return Establishment.fromJson(json);
|
||||
}
|
||||
|
||||
List<dynamic> _extractList(dynamic data) {
|
||||
if (data is List<dynamic>) return data;
|
||||
if (data is Map<String, dynamic> && data['data'] is List<dynamic>) {
|
||||
return data['data'] as List<dynamic>;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Map<String, dynamic> _extractObject(dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
if (data['data'] is Map<String, dynamic>) {
|
||||
return data['data'] as Map<String, dynamic>;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
throw StateError('Réponse API inattendue');
|
||||
}
|
||||
}
|
||||
|
||||
final establishmentRepositoryProvider = Provider<EstablishmentRepository>((ref) {
|
||||
return EstablishmentRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final establishmentsProvider = FutureProvider<List<Establishment>>((ref) async {
|
||||
return ref.watch(establishmentRepositoryProvider).fetchEstablishments();
|
||||
});
|
||||
|
||||
final establishmentDetailProvider =
|
||||
FutureProvider.family<Establishment, String>((ref, uuid) async {
|
||||
return ref.watch(establishmentRepositoryProvider).fetchEstablishment(uuid);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/// Modèle établissement (laverie).
|
||||
class Establishment {
|
||||
const Establishment({
|
||||
required this.uuid,
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.city,
|
||||
this.zipCode,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.isActive = true,
|
||||
this.machines = const [],
|
||||
});
|
||||
|
||||
final String uuid;
|
||||
final String name;
|
||||
final String address;
|
||||
final String? city;
|
||||
final String? zipCode;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final bool isActive;
|
||||
final List<Machine> machines;
|
||||
|
||||
String get fullAddress {
|
||||
final parts = [address, zipCode, city].where((p) => p != null && p.isNotEmpty);
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
factory Establishment.fromJson(Map<String, dynamic> json) {
|
||||
final machinesJson = json['machines'] as List<dynamic>? ?? [];
|
||||
|
||||
return Establishment(
|
||||
uuid: json['uuid'] as String,
|
||||
name: json['name'] as String,
|
||||
address: json['address'] as String? ?? '',
|
||||
city: json['city'] as String?,
|
||||
zipCode: json['zip_code'] as String?,
|
||||
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||
isActive: json['is_active'] as bool? ?? true,
|
||||
machines: machinesJson
|
||||
.map((m) => Machine.fromJson(m as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Modèle machine (lave-linge / sèche-linge).
|
||||
class Machine {
|
||||
const Machine({
|
||||
required this.uuid,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.status,
|
||||
this.qrCode,
|
||||
});
|
||||
|
||||
final String uuid;
|
||||
final String name;
|
||||
final String type;
|
||||
final String status;
|
||||
final String? qrCode;
|
||||
|
||||
bool get isAvailable => status == 'available';
|
||||
|
||||
String get typeLabel {
|
||||
switch (type) {
|
||||
case 'washer_small':
|
||||
return 'Lave-linge petit';
|
||||
case 'washer_large':
|
||||
return 'Lave-linge grand';
|
||||
case 'dryer_small':
|
||||
return 'Sèche-linge petit';
|
||||
case 'dryer_large':
|
||||
return 'Sèche-linge grand';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
String get statusLabel {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return 'Disponible';
|
||||
case 'reserved':
|
||||
return 'Réservée';
|
||||
case 'running':
|
||||
return 'En cours';
|
||||
case 'maintenance':
|
||||
return 'Maintenance';
|
||||
case 'offline':
|
||||
return 'Hors ligne';
|
||||
case 'error':
|
||||
return 'Erreur';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
factory Machine.fromJson(Map<String, dynamic> json) {
|
||||
return Machine(
|
||||
uuid: json['uuid'] as String,
|
||||
name: json['name'] as String,
|
||||
type: json['type'] as String,
|
||||
status: json['status'] as String,
|
||||
qrCode: json['qr_code'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../establishments/data/establishment_repository.dart';
|
||||
import '../../establishments/domain/establishment.dart';
|
||||
|
||||
/// Écran de détail d'un établissement avec liste des machines.
|
||||
class EstablishmentDetailScreen extends ConsumerWidget {
|
||||
const EstablishmentDetailScreen({
|
||||
super.key,
|
||||
required this.establishmentUuid,
|
||||
});
|
||||
|
||||
final String establishmentUuid;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final establishmentAsync =
|
||||
ref.watch(establishmentDetailProvider(establishmentUuid));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Détail laverie')),
|
||||
body: establishmentAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Erreur : $error'),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
ref.invalidate(establishmentDetailProvider(establishmentUuid)),
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (establishment) => _EstablishmentBody(establishment: establishment),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EstablishmentBody extends StatelessWidget {
|
||||
const _EstablishmentBody({required this.establishment});
|
||||
|
||||
final Establishment establishment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
establishment.name,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(establishment.fullAddress),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Machines (${establishment.machines.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (establishment.machines.isEmpty)
|
||||
const Text('Aucune machine disponible')
|
||||
else
|
||||
...establishment.machines.map((machine) => _MachineTile(machine: machine)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MachineTile extends StatelessWidget {
|
||||
const _MachineTile({required this.machine});
|
||||
|
||||
final Machine machine;
|
||||
|
||||
Color _statusColor(BuildContext context) {
|
||||
switch (machine.status) {
|
||||
case 'available':
|
||||
return Colors.green;
|
||||
case 'running':
|
||||
return Colors.blue;
|
||||
case 'reserved':
|
||||
return Colors.orange;
|
||||
case 'maintenance':
|
||||
case 'offline':
|
||||
return Colors.red;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
machine.type.startsWith('washer') ? Icons.water_drop : Icons.air,
|
||||
color: _statusColor(context),
|
||||
),
|
||||
title: Text(machine.name),
|
||||
subtitle: Text('${machine.typeLabel} — ${machine.statusLabel}'),
|
||||
trailing: machine.isAvailable
|
||||
? const Chip(label: Text('Libre'))
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/router/app_router.dart';
|
||||
import '../../establishments/data/establishment_repository.dart';
|
||||
|
||||
/// Écran d'accueil — liste des laveries à proximité.
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final establishmentsAsync = ref.watch(establishmentsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Laveries'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.account_balance_wallet_outlined),
|
||||
onPressed: () => context.push(AppRoutes.wallet),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_outline),
|
||||
onPressed: () => context.push(AppRoutes.profile),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: establishmentsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Impossible de charger les laveries.\nVérifiez que l\'API est démarrée.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.invalidate(establishmentsProvider),
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (establishments) {
|
||||
if (establishments.isEmpty) {
|
||||
return const Center(child: Text('Aucune laverie disponible'));
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(establishmentsProvider),
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: establishments.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final establishment = establishments[index];
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
child: Text(establishment.name.substring(0, 1)),
|
||||
),
|
||||
title: Text(establishment.name),
|
||||
subtitle: Text(establishment.fullAddress),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push('/establishments/${establishment.uuid}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: 0,
|
||||
onDestinationSelected: (index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
context.go(AppRoutes.home);
|
||||
case 1:
|
||||
context.push(AppRoutes.bookings);
|
||||
case 2:
|
||||
context.push(AppRoutes.washes);
|
||||
case 3:
|
||||
context.push(AppRoutes.wallet);
|
||||
}
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.store), label: 'Laveries'),
|
||||
NavigationDestination(icon: Icon(Icons.event), label: 'Réservations'),
|
||||
NavigationDestination(icon: Icon(Icons.local_laundry_service), label: 'Lavages'),
|
||||
NavigationDestination(icon: Icon(Icons.wallet), label: 'Wallet'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/auth/auth_provider.dart';
|
||||
import '../../../core/router/app_router.dart';
|
||||
|
||||
/// Écran profil utilisateur et déconnexion.
|
||||
class ProfileScreen extends ConsumerWidget {
|
||||
const ProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authState = ref.watch(authProvider);
|
||||
final user = authState.user;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Mon profil')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 40,
|
||||
child: Text(
|
||||
user != null && user.firstName.isNotEmpty
|
||||
? user.firstName.substring(0, 1).toUpperCase()
|
||||
: '?',
|
||||
style: const TextStyle(fontSize: 32),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
user?.fullName ?? 'Utilisateur',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
if (user?.email != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(user!.email),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.language),
|
||||
title: const Text('Langue'),
|
||||
subtitle: Text(user?.locale ?? 'fr'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.notifications_outlined),
|
||||
title: const Text('Notifications'),
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Préférences — à implémenter')),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
|
||||
title: Text(
|
||||
'Se déconnecter',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
onTap: () async {
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
if (context.mounted) {
|
||||
context.go(AppRoutes.login);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../../core/api/api_endpoints.dart';
|
||||
import '../domain/wallet.dart';
|
||||
|
||||
/// Dépôt de données pour le portefeuille électronique.
|
||||
class WalletRepository {
|
||||
WalletRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<Wallet> fetchWallet() async {
|
||||
final response = await _apiClient.get(ApiEndpoints.wallet);
|
||||
final json = _extractObject(response.data);
|
||||
return Wallet.fromJson(json);
|
||||
}
|
||||
|
||||
Future<List<WalletTransaction>> fetchTransactions() async {
|
||||
final response = await _apiClient.get(ApiEndpoints.walletTransactions);
|
||||
final list = _extractList(response.data);
|
||||
|
||||
return list
|
||||
.map((json) => WalletTransaction.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<dynamic> _extractList(dynamic data) {
|
||||
if (data is List<dynamic>) return data;
|
||||
if (data is Map<String, dynamic> && data['data'] is List<dynamic>) {
|
||||
return data['data'] as List<dynamic>;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Map<String, dynamic> _extractObject(dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
if (data['data'] is Map<String, dynamic>) {
|
||||
return data['data'] as Map<String, dynamic>;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
throw StateError('Réponse API inattendue');
|
||||
}
|
||||
}
|
||||
|
||||
final walletRepositoryProvider = Provider<WalletRepository>((ref) {
|
||||
return WalletRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final walletProvider = FutureProvider<Wallet>((ref) async {
|
||||
return ref.watch(walletRepositoryProvider).fetchWallet();
|
||||
});
|
||||
|
||||
final walletTransactionsProvider =
|
||||
FutureProvider<List<WalletTransaction>>((ref) async {
|
||||
return ref.watch(walletRepositoryProvider).fetchTransactions();
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/// Modèle portefeuille électronique.
|
||||
class Wallet {
|
||||
const Wallet({
|
||||
required this.currency,
|
||||
required this.currentBalance,
|
||||
this.status = 'active',
|
||||
});
|
||||
|
||||
final String currency;
|
||||
final double currentBalance;
|
||||
final String status;
|
||||
|
||||
factory Wallet.fromJson(Map<String, dynamic> json) {
|
||||
return Wallet(
|
||||
currency: json['currency'] as String? ?? 'EUR',
|
||||
currentBalance: (json['current_balance'] as num?)?.toDouble() ?? 0,
|
||||
status: json['status'] as String? ?? 'active',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mouvement sur le portefeuille.
|
||||
class WalletTransaction {
|
||||
const WalletTransaction({
|
||||
required this.uuid,
|
||||
required this.type,
|
||||
required this.amount,
|
||||
required this.balanceAfter,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String uuid;
|
||||
final String type;
|
||||
final double amount;
|
||||
final double balanceAfter;
|
||||
final DateTime? createdAt;
|
||||
|
||||
factory WalletTransaction.fromJson(Map<String, dynamic> json) {
|
||||
return WalletTransaction(
|
||||
uuid: json['uuid'] as String,
|
||||
type: json['type'] as String,
|
||||
amount: (json['amount'] as num?)?.toDouble() ?? 0,
|
||||
balanceAfter: (json['balance_after'] as num?)?.toDouble() ?? 0,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.tryParse(json['created_at'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../data/wallet_repository.dart';
|
||||
|
||||
/// Écran du portefeuille électronique — solde et historique.
|
||||
class WalletScreen extends ConsumerWidget {
|
||||
const WalletScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final walletAsync = ref.watch(walletProvider);
|
||||
final transactionsAsync = ref.watch(walletTransactionsProvider);
|
||||
final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '€');
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Mon portefeuille')),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(walletProvider);
|
||||
ref.invalidate(walletTransactionsProvider);
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
walletAsync.when(
|
||||
loading: () => const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
error: (error, _) => Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('Erreur solde : $error'),
|
||||
),
|
||||
),
|
||||
data: (wallet) => Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Solde disponible',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
currencyFormat.format(wallet.currentBalance),
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Historique',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
transactionsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Text('Erreur historique : $error'),
|
||||
data: (transactions) {
|
||||
if (transactions.isEmpty) {
|
||||
return const Text('Aucune transaction pour le moment');
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: transactions.map((tx) {
|
||||
final isCredit = tx.type == 'credit' || tx.type == 'refund';
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
isCredit ? Icons.add_circle_outline : Icons.remove_circle_outline,
|
||||
color: isCredit ? Colors.green : Colors.red,
|
||||
),
|
||||
title: Text(tx.type),
|
||||
subtitle: tx.createdAt != null
|
||||
? Text(DateFormat('dd/MM/yyyy HH:mm').format(tx.createdAt!))
|
||||
: null,
|
||||
trailing: Text(
|
||||
'${isCredit ? '+' : '-'}${currencyFormat.format(tx.amount)}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isCredit ? Colors.green : Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Rechargement — à connecter à l\'API')),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Recharger'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/// Modèle lavage (cycle en cours ou terminé).
|
||||
class Wash {
|
||||
const Wash({
|
||||
required this.uuid,
|
||||
required this.machineUuid,
|
||||
required this.machineName,
|
||||
required this.status,
|
||||
required this.cost,
|
||||
this.startedAt,
|
||||
this.endedAt,
|
||||
this.durationMinutes,
|
||||
});
|
||||
|
||||
final String uuid;
|
||||
final String machineUuid;
|
||||
final String machineName;
|
||||
final String status;
|
||||
final double cost;
|
||||
final DateTime? startedAt;
|
||||
final DateTime? endedAt;
|
||||
final int? durationMinutes;
|
||||
|
||||
factory Wash.fromJson(Map<String, dynamic> json) {
|
||||
final machine = json['machine'] as Map<String, dynamic>?;
|
||||
|
||||
return Wash(
|
||||
uuid: json['uuid'] as String,
|
||||
machineUuid: machine?['uuid'] as String? ?? json['machine_uuid'] as String? ?? '',
|
||||
machineName: machine?['name'] as String? ?? 'Machine',
|
||||
status: json['status'] as String? ?? 'pending_start',
|
||||
cost: (json['cost'] as num?)?.toDouble() ?? 0,
|
||||
startedAt: json['started_at'] != null
|
||||
? DateTime.tryParse(json['started_at'] as String)
|
||||
: null,
|
||||
endedAt: json['ended_at'] != null
|
||||
? DateTime.tryParse(json['ended_at'] as String)
|
||||
: null,
|
||||
durationMinutes: json['duration_minutes'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../../core/api/api_endpoints.dart';
|
||||
import '../domain/wash.dart';
|
||||
|
||||
/// Fournisseur de l'historique des lavages.
|
||||
final washesProvider = FutureProvider<List<Wash>>((ref) async {
|
||||
final response = await ref.watch(apiClientProvider).get(ApiEndpoints.washes);
|
||||
final data = response.data;
|
||||
|
||||
List<dynamic> list;
|
||||
if (data is List<dynamic>) {
|
||||
list = data;
|
||||
} else if (data is Map<String, dynamic> && data['data'] is List<dynamic>) {
|
||||
list = data['data'] as List<dynamic>;
|
||||
} else {
|
||||
list = [];
|
||||
}
|
||||
|
||||
return list.map((json) => Wash.fromJson(json as Map<String, dynamic>)).toList();
|
||||
});
|
||||
|
||||
/// Écran historique et démarrage de lavage.
|
||||
class WashScreen extends ConsumerWidget {
|
||||
const WashScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final washesAsync = ref.watch(washesProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Mes lavages')),
|
||||
body: washesAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Erreur : $error'),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.invalidate(washesProvider),
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (washes) {
|
||||
if (washes.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Aucun lavage enregistré.\nScannez un QR code pour démarrer un cycle.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '€');
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(washesProvider),
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: washes.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final wash = washes[index];
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.local_laundry_service),
|
||||
title: Text(wash.machineName),
|
||||
subtitle: wash.startedAt != null
|
||||
? Text(DateFormat('dd/MM/yyyy HH:mm').format(wash.startedAt!))
|
||||
: null,
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(currencyFormat.format(wash.cost)),
|
||||
Text(wash.status, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Scan QR — à connecter à l\'API /washes/start')),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
label: const Text('Scanner'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
|
||||
import 'core/platform/app_platform.dart';
|
||||
import 'core/router/app_router.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
if (!isMobilePlatformSupported) {
|
||||
throw UnsupportedError(
|
||||
'Laverie Mobile cible Android et iOS uniquement (pas de web Flutter).',
|
||||
);
|
||||
}
|
||||
|
||||
runApp(const ProviderScope(child: LaverieApp()));
|
||||
}
|
||||
|
||||
/// Point d'entrée de l'application mobile Laverie Connectée.
|
||||
class LaverieApp extends ConsumerWidget {
|
||||
const LaverieApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(appRouterProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'Laverie Connectée',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light,
|
||||
locale: const Locale('fr', 'FR'),
|
||||
supportedLocales: const [Locale('fr', 'FR')],
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user