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,
),
);
}
}
+53
View File
@@ -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'),
),
],
),
),
),
),
);
}
}
+38
View File
@@ -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();
});
+49
View File
@@ -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'),
),
);
}
}
+41
View File
@@ -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'),
),
);
}
}
+43
View File
@@ -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,
);
}
}