From bf191d6396ba3c99cdc34480b5a9afbf617a8ac9 Mon Sep 17 00:00:00 2001 From: bastien Date: Fri, 3 Jul 2026 19:02:48 +0200 Subject: [PATCH] =?UTF-8?q?Integration=20fonctionnalit=C3=A9s=20V1=20(=20r?= =?UTF-8?q?esas=20+=20gestion=20machines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.kotlin/errors/errors-1783091381042.log | 3 + android/app/src/main/AndroidManifest.xml | 2 + .../reports/problems/problems-report.html | 663 ++++++++++++++++++ android/gradle.properties | 5 +- ios/Runner/Info.plist | 2 + lib/core/api/api_client.dart | 31 +- lib/core/api/api_endpoints.dart | 6 + lib/core/api/api_response.dart | 58 ++ lib/core/auth/auth_provider.dart | 36 +- lib/core/auth/auth_repository.dart | 19 +- lib/core/router/app_router.dart | 85 ++- lib/core/theme/app_colors.dart | 47 ++ lib/core/theme/app_theme.dart | 108 ++- lib/core/theme/machine_status_theme.dart | 30 + lib/core/widgets/empty_state.dart | 56 ++ lib/core/widgets/machine_grid_layout.dart | 31 + lib/core/widgets/machine_widgets.dart | 450 ++++++++++++ lib/core/widgets/main_shell.dart | 104 +++ lib/core/widgets/screen_header.dart | 92 +++ lib/core/widgets/wallet_chip.dart | 74 ++ .../auth/presentation/auth_widgets.dart | 102 +++ .../auth/presentation/login_screen.dart | 189 ++++- .../auth/presentation/register_screen.dart | 138 ++-- .../auth/presentation/splash_screen.dart | 41 ++ .../booking/data/booking_repository.dart | 101 +++ lib/features/booking/domain/booking.dart | 7 + .../presentation/booking_modify_screen.dart | 224 ++++++ .../booking/presentation/bookings_screen.dart | 268 +++++-- .../data/establishment_repository.dart | 23 +- .../establishment_detail_screen.dart | 234 +++++-- .../home/presentation/home_screen.dart | 236 ++++--- .../machines/data/machine_repository.dart | 51 ++ .../machines/domain/machine_detail.dart | 52 ++ .../presentation/machine_action_screen.dart | 293 ++++++++ .../presentation/machine_booking_screen.dart | 212 ++++++ .../profile/presentation/profile_screen.dart | 117 ++-- .../wallet/data/wallet_repository.dart | 34 +- .../wallet/presentation/wallet_screen.dart | 246 ++++--- lib/features/wash/data/wash_repository.dart | 131 ++++ lib/features/wash/domain/wash.dart | 22 + lib/features/wash/domain/wash_progress.dart | 114 +++ .../wash/presentation/qr_scanner_screen.dart | 219 ++++++ .../wash/presentation/wash_screen.dart | 217 +++--- .../widgets/active_wash_progress_card.dart | 163 +++++ pubspec.lock | 8 + pubspec.yaml | 1 + 46 files changed, 4691 insertions(+), 654 deletions(-) create mode 100644 android/.kotlin/errors/errors-1783091381042.log create mode 100644 android/build/reports/problems/problems-report.html create mode 100644 lib/core/api/api_response.dart create mode 100644 lib/core/theme/app_colors.dart create mode 100644 lib/core/theme/machine_status_theme.dart create mode 100644 lib/core/widgets/empty_state.dart create mode 100644 lib/core/widgets/machine_grid_layout.dart create mode 100644 lib/core/widgets/machine_widgets.dart create mode 100644 lib/core/widgets/main_shell.dart create mode 100644 lib/core/widgets/screen_header.dart create mode 100644 lib/core/widgets/wallet_chip.dart create mode 100644 lib/features/auth/presentation/auth_widgets.dart create mode 100644 lib/features/auth/presentation/splash_screen.dart create mode 100644 lib/features/booking/data/booking_repository.dart create mode 100644 lib/features/booking/presentation/booking_modify_screen.dart create mode 100644 lib/features/machines/data/machine_repository.dart create mode 100644 lib/features/machines/domain/machine_detail.dart create mode 100644 lib/features/machines/presentation/machine_action_screen.dart create mode 100644 lib/features/machines/presentation/machine_booking_screen.dart create mode 100644 lib/features/wash/data/wash_repository.dart create mode 100644 lib/features/wash/domain/wash_progress.dart create mode 100644 lib/features/wash/presentation/qr_scanner_screen.dart create mode 100644 lib/features/wash/presentation/widgets/active_wash_progress_card.dart diff --git a/android/.kotlin/errors/errors-1783091381042.log b/android/.kotlin/errors/errors-1783091381042.log new file mode 100644 index 0000000..88185a8 --- /dev/null +++ b/android/.kotlin/errors/errors-1783091381042.log @@ -0,0 +1,3 @@ +kotlin version: 2.3.20 +error message: Daemon compilation failed + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e47ed2c..1dff5c0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/android/gradle.properties b/android/gradle.properties index e96108c..d489590 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -2,5 +2,8 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m android.useAndroidX=true # This newDsl flag was added by the Flutter template android.newDsl=false -# This builtInKotlin flag was added by the Flutter template +# Compatibilité plugins Flutter pas encore migrés vers le Kotlin intégré AGP 9 android.builtInKotlin=false +# Évite les erreurs de cache Kotlin cross-disques (C: pub cache / D: projet) sous Windows +kotlin.incremental=false +kotlin.compiler.execution.strategy=in-process diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index b6c147e..cce2aa4 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -47,6 +47,8 @@ + NSCameraUsageDescription + Scanner les QR codes des machines de laverie. UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName diff --git a/lib/core/api/api_client.dart b/lib/core/api/api_client.dart index 6e5fd6e..08bbd88 100644 --- a/lib/core/api/api_client.dart +++ b/lib/core/api/api_client.dart @@ -9,7 +9,8 @@ class ApiClient { ApiClient({ required this.baseUrl, this.getAccessToken, - this.onUnauthorized, + this.onRefreshToken, + this.onSessionExpired, }) : _dio = Dio( BaseOptions( baseUrl: baseUrl, @@ -31,9 +32,27 @@ class ApiClient { handler.next(options); }, onError: (error, handler) async { - if (error.response?.statusCode == 401) { - await onUnauthorized?.call(); + final response = error.response; + final alreadyRetried = error.requestOptions.extra['auth_retried'] == true; + + if (response?.statusCode == 401 && !alreadyRetried && onRefreshToken != null) { + final newToken = await onRefreshToken!(); + if (newToken != null && newToken.isNotEmpty) { + final request = error.requestOptions; + request.extra['auth_retried'] = true; + request.headers['Authorization'] = 'Bearer $newToken'; + + try { + final retryResponse = await _dio.fetch(request); + return handler.resolve(retryResponse); + } on DioException catch (retryError) { + return handler.next(retryError); + } + } + + await onSessionExpired?.call(); } + handler.next(error); }, ), @@ -42,7 +61,8 @@ class ApiClient { final String baseUrl; final Future Function()? getAccessToken; - final Future Function()? onUnauthorized; + final Future Function()? onRefreshToken; + final Future Function()? onSessionExpired; final Dio _dio; Dio get dio => _dio; @@ -94,6 +114,7 @@ final apiClientProvider = Provider((ref) { return ApiClient( baseUrl: kApiBaseUrl, getAccessToken: () async => ref.read(authProvider).accessToken, - onUnauthorized: () async => authNotifier.logout(), + onRefreshToken: () => authNotifier.refreshAccessToken(), + onSessionExpired: () => authNotifier.sessionExpired(), ); }); diff --git a/lib/core/api/api_endpoints.dart b/lib/core/api/api_endpoints.dart index a8d9655..d91df54 100644 --- a/lib/core/api/api_endpoints.dart +++ b/lib/core/api/api_endpoints.dart @@ -1,5 +1,9 @@ /// Chemins des endpoints REST de l'API Laverie v1. abstract final class ApiEndpoints { + // Santé / monitoring + static const health = '/health'; + static const healthDb = '/health/db'; + // Authentification utilisateur static const authRegister = '/auth/register'; static const authLogin = '/auth/login'; @@ -17,6 +21,7 @@ abstract final class ApiEndpoints { static const establishments = '/establishments'; static String establishment(String uuid) => '/establishments/$uuid'; static String machine(String uuid) => '/machines/$uuid'; + static const machineLookup = '/machines/lookup'; static String machineAvailability(String uuid) => '/machines/$uuid/availability'; static String machinePricing(String uuid) => '/machines/$uuid/pricing'; @@ -24,6 +29,7 @@ abstract final class ApiEndpoints { static const bookings = '/bookings'; static String booking(String uuid) => '/bookings/$uuid'; static String bookingCancel(String uuid) => '/bookings/$uuid/cancel'; + static String bookingMove(String uuid) => '/bookings/$uuid/move'; // Lavages static const washes = '/washes'; diff --git a/lib/core/api/api_response.dart b/lib/core/api/api_response.dart new file mode 100644 index 0000000..3b4d361 --- /dev/null +++ b/lib/core/api/api_response.dart @@ -0,0 +1,58 @@ +import 'package:dio/dio.dart'; + +/// Helpers pour parser les réponses JSON de l'API Laverie (`success` + `data`). +abstract final class ApiResponse { static Map payload(dynamic data) { + if (data is Map) { + if (data['data'] is Map) { + return data['data'] as Map; + } + return data; + } + throw StateError('Réponse API inattendue'); + } + + static List list(dynamic data, String key) { + final value = payload(data)[key]; + if (value is List) { + return value; + } + return []; + } + + static Map object(dynamic data, String key) { + final value = payload(data)[key]; + if (value is Map) { + return value; + } + throw StateError('Champ API "$key" introuvable ou invalide'); + } + + static String errorMessage(DioException error, {String fallback = 'Erreur réseau'}) { + final response = error.response; + if (response?.data is Map) { + final data = response!.data as Map; + final message = data['message']; + if (message is String && message.isNotEmpty) { + return _sanitize(message); + } + } + + return switch (error.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => + 'Délai d\'attente dépassé', + DioExceptionType.connectionError => + 'Connexion impossible', + _ => fallback, + }; + } + + /// Masque les détails SQL techniques pour l'utilisateur. + static String _sanitize(String message) { + if (message.contains('SQLSTATE') || message.contains('SQL:') || message.contains('must be of type')) { + return 'Erreur serveur — réessayez dans un instant'; + } + return message; + } +} \ No newline at end of file diff --git a/lib/core/auth/auth_provider.dart b/lib/core/auth/auth_provider.dart index c8b53dc..4d3546a 100644 --- a/lib/core/auth/auth_provider.dart +++ b/lib/core/auth/auth_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/api_client.dart'; import '../config/app_config.dart'; +import '../config/secure_storage_config.dart'; import '../../features/auth/domain/auth_user.dart'; import 'auth_repository.dart'; @@ -44,12 +45,33 @@ class AuthState { /// Gestionnaire d'état Riverpod pour l'authentification. class AuthNotifier extends StateNotifier { - AuthNotifier(this._repository) : super(const AuthState()) { + AuthNotifier(this._repository) : super(const AuthState(isLoading: true)) { _restoreSession(); } final AuthRepository _repository; + /// Tente de renouveler le token d'accès (appelé par le client API sur 401). + Future refreshAccessToken() async { + final refreshed = await _repository.refresh(); + if (refreshed == null) { + return null; + } + + state = state.copyWith( + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + user: refreshed.user ?? state.user, + ); + + return refreshed.accessToken; + } + + /// Session expirée après échec du refresh. + Future sessionExpired() async { + await logout(); + } + Future _restoreSession() async { state = state.copyWith(isLoading: true, clearError: true); @@ -149,8 +171,16 @@ class AuthNotifier extends StateNotifier { } final authRepositoryProvider = Provider((ref) { - // Client dédié à l'auth pour éviter une dépendance circulaire avec apiClientProvider. - return AuthRepository(apiClient: ApiClient(baseUrl: kApiBaseUrl)); + final storage = laverieSecureStorage; + + // Token lu depuis le stockage sécurisé (évite la dépendance circulaire avec authProvider). + return AuthRepository( + apiClient: ApiClient( + baseUrl: kApiBaseUrl, + getAccessToken: () => storage.read(key: AuthStorageKeys.accessToken), + ), + secureStorage: storage, + ); }); final authProvider = StateNotifierProvider((ref) { diff --git a/lib/core/auth/auth_repository.dart b/lib/core/auth/auth_repository.dart index 74f332c..1edf2b7 100644 --- a/lib/core/auth/auth_repository.dart +++ b/lib/core/auth/auth_repository.dart @@ -2,6 +2,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../api/api_client.dart'; import '../api/api_endpoints.dart'; +import '../api/api_response.dart'; import '../config/app_config.dart'; import '../config/secure_storage_config.dart'; import '../../features/auth/domain/auth_user.dart'; @@ -29,7 +30,7 @@ class AuthRepository { }, ); - final tokens = AuthTokens.fromJson(response.data as Map); + final tokens = AuthTokens.fromJson(ApiResponse.payload(response.data)); await _persistTokens(tokens); return tokens; } @@ -52,7 +53,7 @@ class AuthRepository { }, ); - final tokens = AuthTokens.fromJson(response.data as Map); + final tokens = AuthTokens.fromJson(ApiResponse.payload(response.data)); await _persistTokens(tokens); return tokens; } @@ -68,23 +69,21 @@ class AuthRepository { data: {'refresh_token': refreshToken}, ); - final tokens = AuthTokens.fromJson(response.data as Map); + final tokens = AuthTokens.fromJson(ApiResponse.payload(response.data)); await _persistTokens(tokens); return tokens; } Future fetchCurrentUser() async { final response = await _apiClient.get(ApiEndpoints.authMe); - final data = response.data; + final payload = ApiResponse.payload(response.data); + final userJson = payload['user'] as Map?; - if (data is Map) { - if (data.containsKey('data')) { - return AuthUser.fromJson(data['data'] as Map); - } - return AuthUser.fromJson(data); + if (userJson != null) { + return AuthUser.fromJson(userJson); } - return null; + return AuthUser.fromJson(payload); } Future logout() async { diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 0f62cd7..b498e2e 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -5,23 +5,34 @@ 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/auth/presentation/splash_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/booking_modify_screen.dart'; import '../../features/booking/presentation/bookings_screen.dart'; +import '../../features/machines/presentation/machine_action_screen.dart'; +import '../../features/machines/presentation/machine_booking_screen.dart'; +import '../../features/wash/presentation/qr_scanner_screen.dart'; import '../../features/wash/presentation/wash_screen.dart'; import '../../features/profile/presentation/profile_screen.dart'; +import '../widgets/main_shell.dart'; /// Routes nommées de l'application. abstract final class AppRoutes { + static const splash = '/splash'; 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 washScan = '/washes/scan'; static const profile = '/profile'; static const establishment = '/establishments/:uuid'; + static String machineAction(String uuid) => '/machines/$uuid/action'; + static String machineBooking(String uuid) => '/machines/$uuid/book'; + static String bookingModify(String uuid) => '/bookings/$uuid/edit'; } /// Configuration GoRouter avec redirection selon l'état d'authentification. @@ -29,12 +40,24 @@ final appRouterProvider = Provider((ref) { final authState = ref.watch(authProvider); return GoRouter( - initialLocation: AppRoutes.home, + initialLocation: AppRoutes.splash, refreshListenable: GoRouterRefreshStream(ref), redirect: (context, state) { + final location = state.matchedLocation; + final onSplash = location == AppRoutes.splash; + final isAuthRoute = location == AppRoutes.login || location == AppRoutes.register; + + // Restauration de session en cours → écran de chargement. + if (authState.isLoading) { + return onSplash ? null : AppRoutes.splash; + } + + // Session restaurée → quitter le splash. + if (onSplash) { + return authState.isAuthenticated ? AppRoutes.home : AppRoutes.login; + } + final isAuthenticated = authState.isAuthenticated; - final isAuthRoute = state.matchedLocation == AppRoutes.login || - state.matchedLocation == AppRoutes.register; if (!isAuthenticated && !isAuthRoute) { return AppRoutes.login; @@ -47,6 +70,10 @@ final appRouterProvider = Provider((ref) { return null; }, routes: [ + GoRoute( + path: AppRoutes.splash, + builder: (context, state) => const SplashScreen(), + ), GoRoute( path: AppRoutes.login, builder: (context, state) => const LoginScreen(), @@ -55,9 +82,30 @@ final appRouterProvider = Provider((ref) { path: AppRoutes.register, builder: (context, state) => const RegisterScreen(), ), - GoRoute( - path: AppRoutes.home, - builder: (context, state) => const HomeScreen(), + ShellRoute( + builder: (context, state, child) => MainShell(child: child), + routes: [ + GoRoute( + path: AppRoutes.home, + builder: (context, state) => const HomeScreen(), + ), + GoRoute( + path: AppRoutes.bookings, + builder: (context, state) => const BookingsScreen(), + ), + GoRoute( + path: AppRoutes.washes, + builder: (context, state) => const WashScreen(), + ), + GoRoute( + path: AppRoutes.wallet, + builder: (context, state) => const WalletScreen(), + ), + GoRoute( + path: AppRoutes.profile, + builder: (context, state) => const ProfileScreen(), + ), + ], ), GoRoute( path: AppRoutes.establishment, @@ -67,20 +115,29 @@ final appRouterProvider = Provider((ref) { }, ), GoRoute( - path: AppRoutes.wallet, - builder: (context, state) => const WalletScreen(), + path: AppRoutes.washScan, + builder: (context, state) => const QrScannerScreen(), ), GoRoute( - path: AppRoutes.bookings, - builder: (context, state) => const BookingsScreen(), + path: '/machines/:uuid/action', + builder: (context, state) { + final uuid = state.pathParameters['uuid']!; + return MachineActionScreen(machineUuid: uuid); + }, ), GoRoute( - path: AppRoutes.washes, - builder: (context, state) => const WashScreen(), + path: '/machines/:uuid/book', + builder: (context, state) { + final uuid = state.pathParameters['uuid']!; + return MachineBookingScreen(machineUuid: uuid); + }, ), GoRoute( - path: AppRoutes.profile, - builder: (context, state) => const ProfileScreen(), + path: '/bookings/:uuid/edit', + builder: (context, state) { + final uuid = state.pathParameters['uuid']!; + return BookingModifyScreen(bookingUuid: uuid); + }, ), ], ); diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..19bf990 --- /dev/null +++ b/lib/core/theme/app_colors.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +/// Palette équilibrée — moderne sans excès « web app ». +abstract final class AppColors { + static const primary = Color(0xFF2563EB); + static const primaryDark = Color(0xFF1D4ED8); + static const secondary = Color(0xFF0D9488); + + static const background = Color(0xFFF1F5F9); + static const surface = Color(0xFFFFFFFF); + static const surfaceVariant = Color(0xFFE2E8F0); + static const divider = Color(0xFFE2E8F0); + + static const textPrimary = Color(0xFF0F172A); + static const textSecondary = Color(0xFF64748B); + + static const success = Color(0xFF059669); + static const warning = Color(0xFFD97706); + static const error = Color(0xFFDC2626); + + /// Statuts machines — code couleur type WashOnline. + static const machineAvailable = Color(0xFF16A34A); + static const machineRunning = Color(0xFF2563EB); + static const machineReserved = Color(0xFFEA580C); + static const machineOffline = Color(0xFF94A3B8); + + static const gradientPrimary = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF2563EB), Color(0xFF1D4ED8)], + ); + + static const gradientAccent = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF2563EB), Color(0xFF0D9488)], + ); + + static const gradientSoft = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFEFF6FF), Color(0xFFF1F5F9)], + ); + + /// Deux accents alternés pour les cartes (pas d'arc-en-ciel). + static Color cardAccent(int index) => index.isEven ? primary : secondary; +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 58ff41a..06b8d0f 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -1,45 +1,121 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'app_colors.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, + const colorScheme = ColorScheme.light( + primary: AppColors.primary, + onPrimary: Colors.white, + primaryContainer: Color(0xFFDBEAFE), + onPrimaryContainer: AppColors.primaryDark, + secondary: AppColors.secondary, + onSecondary: Colors.white, + error: AppColors.error, + onError: Colors.white, + surface: AppColors.surface, + onSurface: AppColors.textPrimary, + onSurfaceVariant: AppColors.textSecondary, ); return ThemeData( useMaterial3: true, colorScheme: colorScheme, - appBarTheme: AppBarTheme( - centerTitle: true, - backgroundColor: colorScheme.primary, - foregroundColor: colorScheme.onPrimary, + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: false, elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: AppColors.surface, + foregroundColor: AppColors.textPrimary, + surfaceTintColor: Colors.transparent, + systemOverlayStyle: SystemUiOverlayStyle.dark, + titleTextStyle: TextStyle( + color: AppColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), ), cardTheme: CardThemeData( elevation: 1, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shadowColor: Colors.black.withValues(alpha: 0.06), + color: AppColors.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + margin: EdgeInsets.zero, ), + dividerTheme: const DividerThemeData(color: AppColors.divider, thickness: 1), inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), filled: true, + fillColor: AppColors.surface, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.divider), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + prefixIconColor: AppColors.textSecondary, ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( minimumSize: const Size.fromHeight(48), + elevation: 0, + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.divider), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ), - floatingActionButtonTheme: FloatingActionButtonThemeData( - backgroundColor: colorScheme.secondary, - foregroundColor: colorScheme.onSecondary, + floatingActionButtonTheme: const FloatingActionButtonThemeData( + elevation: 3, + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + navigationBarTheme: NavigationBarThemeData( + elevation: 0, + height: 64, + backgroundColor: AppColors.surface, + indicatorColor: AppColors.primary.withValues(alpha: 0.12), + surfaceTintColor: Colors.transparent, + labelTextStyle: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.primary); + } + return const TextStyle(fontSize: 12, color: AppColors.textSecondary); + }), + iconTheme: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return const IconThemeData(color: AppColors.primary, size: 24); + } + return const IconThemeData(color: AppColors.textSecondary, size: 24); + }), + ), + listTileTheme: const ListTileThemeData( + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + ), + textTheme: const TextTheme( + headlineMedium: TextStyle(fontSize: 22, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + titleMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + bodyLarge: TextStyle(fontSize: 16, color: AppColors.textPrimary), + bodyMedium: TextStyle(fontSize: 14, color: AppColors.textSecondary), ), ); } diff --git a/lib/core/theme/machine_status_theme.dart b/lib/core/theme/machine_status_theme.dart new file mode 100644 index 0000000..0fa8b3a --- /dev/null +++ b/lib/core/theme/machine_status_theme.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +import 'app_colors.dart'; + +/// Couleurs et libellés des statuts machine (inspiré WashOnline, plus lisible). +abstract final class MachineStatusTheme { + static Color color(String status) => switch (status) { + 'available' => AppColors.machineAvailable, + 'running' => AppColors.machineRunning, + 'reserved' => AppColors.machineReserved, + 'maintenance' || 'offline' || 'error' => AppColors.machineOffline, + _ => AppColors.textSecondary, + }; + + static String label(String status) => switch (status) { + 'available' => 'Libre', + 'running' => 'En cours', + 'reserved' => 'Réservée', + 'maintenance' => 'Maintenance', + 'offline' => 'Hors ligne', + 'error' => 'Erreur', + _ => status, + }; + + static IconData iconForType(String type) => type.startsWith('dryer') + ? Icons.air_outlined + : Icons.water_drop_outlined; + + static bool canStart(String status) => status == 'available'; +} diff --git a/lib/core/widgets/empty_state.dart b/lib/core/widgets/empty_state.dart new file mode 100644 index 0000000..e4ce681 --- /dev/null +++ b/lib/core/widgets/empty_state.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; + +/// État vide avec icône dans un cercle coloré. +class EmptyState extends StatelessWidget { + const EmptyState({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + this.iconColor, + this.actionLabel, + this.onAction, + }); + + final IconData icon; + final String title; + final String subtitle; + final Color? iconColor; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final color = iconColor ?? AppColors.primary; + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 32, color: color), + ), + const SizedBox(height: 16), + Text(title, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 6), + Text(subtitle, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium), + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 20), + OutlinedButton(onPressed: onAction, child: Text(actionLabel!)), + ], + ], + ), + ), + ); + } +} diff --git a/lib/core/widgets/machine_grid_layout.dart b/lib/core/widgets/machine_grid_layout.dart new file mode 100644 index 0000000..c04e915 --- /dev/null +++ b/lib/core/widgets/machine_grid_layout.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +/// Grille responsive pour les cartes machines (téléphone → tablette). +abstract final class MachineGridLayout { + static const _minTileWidth = 155.0; + static const _maxTileWidth = 190.0; + + static SliverGridDelegate delegate(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + const padding = 32.0; + final available = width - padding; + + if (available > _maxTileWidth * 3) { + return const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _maxTileWidth, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 0.88, + ); + } + + final count = (available / _minTileWidth).floor().clamp(2, 3); + + return SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: count, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: count >= 3 ? 0.85 : 0.92, + ); + } +} diff --git a/lib/core/widgets/machine_widgets.dart b/lib/core/widgets/machine_widgets.dart new file mode 100644 index 0000000..2a73fd8 --- /dev/null +++ b/lib/core/widgets/machine_widgets.dart @@ -0,0 +1,450 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../router/app_router.dart'; +import '../theme/app_colors.dart'; +import '../theme/machine_status_theme.dart'; +import '../../features/establishments/domain/establishment.dart'; + +/// Légende des statuts machines. +class MachineStatusLegend extends StatelessWidget { + const MachineStatusLegend({super.key}); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 12, + runSpacing: 6, + children: [ + _LegendDot(color: AppColors.machineAvailable, label: 'Libre'), + _LegendDot(color: AppColors.machineRunning, label: 'En cours'), + _LegendDot(color: AppColors.machineReserved, label: 'Réservée'), + _LegendDot(color: AppColors.machineOffline, label: 'Indisponible'), + ], + ); + } +} + +class _LegendDot extends StatelessWidget { + const _LegendDot({required this.color, required this.label}); + + final Color color; + final String label; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 5), + Text(label, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 12)), + ], + ); + } +} + +/// Carte machine en grille — vue d'ensemble type WashOnline. +class MachineGridCard extends StatelessWidget { + const MachineGridCard({ + super.key, + required this.machine, + required this.onTap, + }); + + final Machine machine; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final statusColor = MachineStatusTheme.color(machine.status); + final canStart = MachineStatusTheme.canStart(machine.status); + + return Material( + color: AppColors.surface, + elevation: canStart ? 2 : 0, + shadowColor: statusColor.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: canStart ? statusColor.withValues(alpha: 0.35) : AppColors.divider, + width: canStart ? 1.5 : 1, + ), + ), + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + MachineStatusTheme.iconForType(machine.type), + color: statusColor, + size: 20, + ), + ), + const Spacer(), + _StatusPill(status: machine.status), + ], + ), + const SizedBox(height: 10), + Text( + machine.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontSize: 14), + ), + const SizedBox(height: 2), + Text( + machine.typeLabel, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 11), + ), + if (canStart) ...[ + const Spacer(), + Text( + 'Démarrer', + style: TextStyle( + color: statusColor, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _StatusPill extends StatelessWidget { + const _StatusPill({required this.status}); + + final String status; + + @override + Widget build(BuildContext context) { + final color = MachineStatusTheme.color(status); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + MachineStatusTheme.label(status), + style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w700), + ), + ); + } +} + +/// Statistiques rapides du parc machines. +class MachineStatsRow extends StatelessWidget { + const MachineStatsRow({super.key, required this.machines}); + + final List machines; + + @override + Widget build(BuildContext context) { + final available = machines.where((m) => m.status == 'available').length; + final running = machines.where((m) => m.status == 'running').length; + final reserved = machines.where((m) => m.status == 'reserved').length; + + return Row( + children: [ + Expanded(child: _StatBox(value: '$available', label: 'Libres', color: AppColors.machineAvailable)), + const SizedBox(width: 8), + Expanded(child: _StatBox(value: '$running', label: 'En cours', color: AppColors.machineRunning)), + const SizedBox(width: 8), + Expanded(child: _StatBox(value: '$reserved', label: 'Réservées', color: AppColors.machineReserved)), + ], + ); + } +} + +class _StatBox extends StatelessWidget { + const _StatBox({required this.value, required this.label, required this.color}); + + final String value; + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + children: [ + Text(value, style: TextStyle(color: color, fontSize: 20, fontWeight: FontWeight.w700)), + Text(label, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 11)), + ], + ), + ); + } +} + +/// Actions rapides — démarrage en quelques clics. +class QuickActionsRow extends StatelessWidget { + const QuickActionsRow({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _QuickAction( + icon: Icons.qr_code_scanner_rounded, + label: 'Scanner', + color: AppColors.primary, + onTap: () => context.push(AppRoutes.washScan), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _QuickAction( + icon: Icons.event_available_outlined, + label: 'Réserver', + color: AppColors.machineReserved, + onTap: () => context.go(AppRoutes.bookings), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _QuickAction( + icon: Icons.add_card_outlined, + label: 'Recharger', + color: AppColors.secondary, + onTap: () => context.go(AppRoutes.wallet), + ), + ), + ], + ); + } +} + +class _QuickAction extends StatelessWidget { + const _QuickAction({ + required this.icon, + required this.label, + required this.color, + required this.onTap, + }); + + final IconData icon; + final String label; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.surface, + elevation: 1, + shadowColor: Colors.black.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Column( + children: [ + Icon(icon, color: color, size: 26), + const SizedBox(height: 6), + Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)), + ], + ), + ), + ), + ); + } +} + +/// Bannière lavage en cours. +class ActiveWashBanner extends StatelessWidget { + const ActiveWashBanner({ + super.key, + required this.machineName, + required this.onTap, + }); + + final String machineName; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.primary, + borderRadius: BorderRadius.circular(14), + elevation: 2, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.local_laundry_service, color: Colors.white), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Lavage en cours', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + Text( + machineName, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: Colors.white), + ], + ), + ), + ), + ); + } +} + +/// Bottom sheet détail machine + actions. +class MachineActionSheet { + static Future show( + BuildContext context, { + required Machine machine, + required VoidCallback onStart, + VoidCallback? onReserve, + }) { + final statusColor = MachineStatusTheme.color(machine.status); + final canStart = MachineStatusTheme.canStart(machine.status); + + return showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => Padding( + padding: EdgeInsets.fromLTRB(20, 16, 20, 20 + MediaQuery.of(context).padding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.divider, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + MachineStatusTheme.iconForType(machine.type), + color: statusColor, + size: 28, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(machine.name, style: Theme.of(context).textTheme.titleMedium), + Text(machine.typeLabel, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ), + _StatusPill(status: machine.status), + ], + ), + const SizedBox(height: 20), + if (canStart) ...[ + ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); + onStart(); + }, + icon: const Icon(Icons.play_arrow_rounded), + label: const Text('Démarrer maintenant'), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: () { + Navigator.pop(context); + onReserve?.call(); + }, + icon: const Icon(Icons.event_outlined), + label: const Text('Réserver un créneau'), + ), + ] else + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + machine.status == 'running' + ? 'Cette machine est en cours d\'utilisation.' + : machine.status == 'reserved' + ? 'Machine réservée — elle sera disponible sur votre créneau.' + : 'Machine indisponible pour le moment.', + textAlign: TextAlign.center, + style: TextStyle(color: statusColor, fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/core/widgets/main_shell.dart b/lib/core/widgets/main_shell.dart new file mode 100644 index 0000000..705f111 --- /dev/null +++ b/lib/core/widgets/main_shell.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../router/app_router.dart'; +import '../theme/app_colors.dart'; +import 'wallet_chip.dart'; + +/// Coque principale — navigation + solde visible (style WashOnline amélioré). +class MainShell extends ConsumerWidget { + const MainShell({super.key, required this.child}); + + final Widget child; + + static int _indexFromLocation(String location) { + if (location.startsWith(AppRoutes.bookings)) return 1; + if (location.startsWith(AppRoutes.washes)) return 2; + if (location.startsWith(AppRoutes.wallet)) return 3; + if (location.startsWith(AppRoutes.profile)) return 4; + return 0; + } + + static String _titleFromLocation(String location) { + if (location.startsWith(AppRoutes.bookings)) return 'Réservations'; + if (location.startsWith(AppRoutes.washes)) return 'Mes lavages'; + if (location.startsWith(AppRoutes.wallet)) return 'Portefeuille'; + if (location.startsWith(AppRoutes.profile)) return 'Mon profil'; + return 'Accueil'; + } + + static bool _showScanFab(String location) { + return location.startsWith(AppRoutes.bookings) || + location.startsWith(AppRoutes.washes); + } + + void _onTabTap(BuildContext context, int index) { + switch (index) { + case 0: + context.go(AppRoutes.home); + case 1: + context.go(AppRoutes.bookings); + case 2: + context.go(AppRoutes.washes); + case 3: + context.go(AppRoutes.wallet); + case 4: + context.go(AppRoutes.profile); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final location = GoRouterState.of(context).uri.toString(); + final selectedIndex = _indexFromLocation(location); + final showScanFab = _showScanFab(location); + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + title: Text(_titleFromLocation(location)), + actions: const [WalletChip()], + ), + body: child, + floatingActionButton: showScanFab + ? FloatingActionButton.extended( + onPressed: () => context.push(AppRoutes.washScan), + icon: const Icon(Icons.qr_code_scanner_rounded), + label: const Text('Scanner'), + ) + : null, + bottomNavigationBar: NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: (index) => _onTabTap(context, index), + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home_rounded), + label: 'Accueil', + ), + NavigationDestination( + icon: Icon(Icons.event_outlined), + selectedIcon: Icon(Icons.event), + label: 'Résa', + ), + NavigationDestination( + icon: Icon(Icons.local_laundry_service_outlined), + selectedIcon: Icon(Icons.local_laundry_service), + label: 'Lavages', + ), + NavigationDestination( + icon: Icon(Icons.account_balance_wallet_outlined), + selectedIcon: Icon(Icons.account_balance_wallet), + label: 'Wallet', + ), + NavigationDestination( + icon: Icon(Icons.person_outline), + selectedIcon: Icon(Icons.person), + label: 'Profil', + ), + ], + ), + ); + } +} diff --git a/lib/core/widgets/screen_header.dart b/lib/core/widgets/screen_header.dart new file mode 100644 index 0000000..5adffcd --- /dev/null +++ b/lib/core/widgets/screen_header.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; + +/// Badge de statut discret. +class StatusBadge extends StatelessWidget { + const StatusBadge({super.key, required this.label, required this.color}); + + final String label; + final Color color; + + factory StatusBadge.fromStatus(String status) { + final (label, color) = switch (status) { + 'available' || 'confirmed' || 'completed' => ('Disponible', AppColors.machineAvailable), + 'running' || 'active' || 'pending_start' => ('En cours', AppColors.machineRunning), + 'reserved' || 'pending' => ('Réservé', AppColors.machineReserved), + 'cancelled' => ('Annulé', AppColors.textSecondary), + _ => (status, AppColors.textSecondary), + }; + return StatusBadge(label: label, color: color); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + label, + style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w600), + ), + ); + } +} + +/// Bandeau d'en-tête discret pour les écrans principaux. +class ScreenHeader extends StatelessWidget { + const ScreenHeader({ + super.key, + required this.title, + this.subtitle, + }); + + final String title; + final String? subtitle; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(16, 8, 16, 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: AppColors.gradientAccent, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.15), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 13, + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/core/widgets/wallet_chip.dart b/lib/core/widgets/wallet_chip.dart new file mode 100644 index 0000000..114623c --- /dev/null +++ b/lib/core/widgets/wallet_chip.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../router/app_router.dart'; +import '../theme/app_colors.dart'; +import '../../features/wallet/data/wallet_repository.dart'; + +/// Solde portefeuille compact — toujours visible (comme WashOnline). +class WalletChip extends ConsumerWidget { + const WalletChip({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final walletAsync = ref.watch(walletProvider); + final format = NumberFormat.currency(locale: 'fr_FR', symbol: '€', decimalDigits: 2); + + return walletAsync.when( + loading: () => const Padding( + padding: EdgeInsets.only(right: 12), + child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)), + ), + error: (_, __) => _ChipButton( + label: '— €', + onTap: () => context.go(AppRoutes.wallet), + ), + data: (wallet) => _ChipButton( + label: format.format(wallet.currentBalance), + onTap: () => context.go(AppRoutes.wallet), + ), + ); + } +} + +class _ChipButton extends StatelessWidget { + const _ChipButton({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: Material( + color: AppColors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.account_balance_wallet_outlined, size: 16, color: AppColors.primary), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w700, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/auth_widgets.dart b/lib/features/auth/presentation/auth_widgets.dart new file mode 100644 index 0000000..2a5962f --- /dev/null +++ b/lib/features/auth/presentation/auth_widgets.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; + +/// Fond et carte pour les écrans d'authentification. +class AuthScaffold extends StatelessWidget { + const AuthScaffold({ + super.key, + required this.child, + this.showBackButton = false, + }); + + final Widget child; + final bool showBackButton; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration(gradient: AppColors.gradientSoft), + child: SafeArea( + child: Column( + children: [ + if (showBackButton) + Align( + alignment: Alignment.centerLeft, + child: IconButton( + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.arrow_back), + ), + ), + Expanded( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: child, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Carte blanche pour formulaires auth. +class AuthFormCard extends StatelessWidget { + const AuthFormCard({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Card( + elevation: 2, + shadowColor: Colors.black.withValues(alpha: 0.08), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(24), + child: child, + ), + ), + ); + } +} + +/// Logo et titre auth. +class AuthHeader extends StatelessWidget { + const AuthHeader({super.key, required this.subtitle}); + + final String subtitle; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + gradient: AppColors.gradientPrimary, + borderRadius: BorderRadius.circular(16), + ), + child: const Icon(Icons.local_laundry_service_outlined, size: 32, color: Colors.white), + ), + const SizedBox(height: 16), + Text( + 'Laverie Connectée', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + Text(subtitle, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: 24), + ], + ); + } +} diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index c5f0805..df810f1 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -1,9 +1,19 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../core/api/api_client.dart'; +import '../../../core/api/api_endpoints.dart'; import '../../../core/auth/auth_provider.dart'; import '../../../core/router/app_router.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../booking/data/booking_repository.dart'; +import '../../wallet/data/wallet_repository.dart'; +import '../../wash/data/wash_repository.dart'; +import 'auth_widgets.dart'; /// Écran de connexion utilisateur. class LoginScreen extends ConsumerStatefulWidget { @@ -17,6 +27,9 @@ class _LoginScreenState extends ConsumerState { final _formKey = GlobalKey(); final _emailController = TextEditingController(text: 'marie.dupont@demo.local'); final _passwordController = TextEditingController(text: 'password'); + bool _isCheckingHealth = false; + String? _healthResult; + bool _healthHasError = false; @override void dispose() { @@ -25,6 +38,79 @@ class _LoginScreenState extends ConsumerState { super.dispose(); } + Future _checkHealth() async { + setState(() { + _isCheckingHealth = true; + _healthResult = null; + _healthHasError = false; + }); + + final apiClient = ref.read(apiClientProvider); + final lines = [ + 'URL de base : ${apiClient.baseUrl}', + '', + ]; + var hasError = false; + + for (final entry in [ + ('API', ApiEndpoints.health), + ('Base de données', ApiEndpoints.healthDb), + ]) { + final label = entry.$1; + final path = entry.$2; + final url = '${apiClient.baseUrl}$path'; + + try { + final response = await apiClient.get(path); + lines.add('$label : OK (${response.statusCode})'); + lines.add('URL : $url'); + lines.add(_formatResponse(response.data)); + } on DioException catch (error) { + hasError = true; + lines.add('$label : Erreur'); + lines.add('URL : $url'); + lines.add(_formatDioError(error)); + } + lines.add(''); + } + + if (mounted) { + setState(() { + _isCheckingHealth = false; + _healthHasError = hasError; + _healthResult = lines.join('\n').trim(); + }); + } + } + + String _formatResponse(dynamic data) { + if (data is Map || data is List) { + return const JsonEncoder.withIndent(' ').convert(data); + } + return data?.toString() ?? ''; + } + + String _formatDioError(DioException error) { + final response = error.response; + if (response != null) { + final body = response.data; + if (body is Map || body is List) { + return 'HTTP ${response.statusCode}\n${_formatResponse(body)}'; + } + return 'HTTP ${response.statusCode}: $body'; + } + + return switch (error.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => + 'Délai d\'attente dépassé', + DioExceptionType.connectionError => + 'Connexion impossible (${error.message ?? 'réseau injoignable'})', + _ => error.message ?? 'Erreur réseau', + }; + } + Future _submit() async { if (!_formKey.currentState!.validate()) return; @@ -34,6 +120,10 @@ class _LoginScreenState extends ConsumerState { ); if (success && mounted) { + ref.invalidate(washesProvider); + ref.invalidate(bookingsProvider); + ref.invalidate(walletProvider); + ref.invalidate(walletTransactionsProvider); context.go(AppRoutes.home); } } @@ -42,32 +132,15 @@ class _LoginScreenState extends ConsumerState { 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( + return AuthScaffold( + child: AuthFormCard( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const AuthHeader(subtitle: 'Connectez-vous pour réserver et laver'), + TextFormField( controller: _emailController, keyboardType: TextInputType.emailAddress, decoration: const InputDecoration( @@ -98,10 +171,25 @@ class _LoginScreenState extends ConsumerState { ), if (authState.error != null) ...[ const SizedBox(height: 12), - Text( - authState.error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - textAlign: TextAlign.center, + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.error.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.error.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.error_outline_rounded, color: AppColors.error, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + authState.error!, + style: const TextStyle(color: AppColors.error), + ), + ), + ], + ), ), ], const SizedBox(height: 24), @@ -111,7 +199,7 @@ class _LoginScreenState extends ConsumerState { ? const SizedBox( height: 20, width: 20, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), ) : const Text('Se connecter'), ), @@ -120,12 +208,45 @@ class _LoginScreenState extends ConsumerState { onPressed: () => context.push(AppRoutes.register), child: const Text('Créer un compte'), ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _isCheckingHealth ? null : _checkHealth, + icon: _isCheckingHealth + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.monitor_heart_outlined, size: 18), + label: const Text('Vérifier l\'API'), + ), + if (_healthResult != null) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: (_healthHasError ? AppColors.error : AppColors.primary) + .withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: (_healthHasError ? AppColors.error : AppColors.primary) + .withValues(alpha: 0.3), + ), + ), + child: SelectableText( + _healthResult!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: _healthHasError ? AppColors.error : AppColors.primaryDark, + ), + ), + ), + ], ], ), ), ), - ), - ), - ); + ); } } diff --git a/lib/features/auth/presentation/register_screen.dart b/lib/features/auth/presentation/register_screen.dart index c711f17..7d86cb0 100644 --- a/lib/features/auth/presentation/register_screen.dart +++ b/lib/features/auth/presentation/register_screen.dart @@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart'; import '../../../core/auth/auth_provider.dart'; import '../../../core/router/app_router.dart'; +import '../../../core/theme/app_colors.dart'; +import 'auth_widgets.dart'; /// Écran d'inscription utilisateur. class RegisterScreen extends ConsumerStatefulWidget { @@ -53,72 +55,82 @@ class _RegisterScreenState extends ConsumerState { 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, + return AuthScaffold( + showBackButton: true, + child: AuthFormCard( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const AuthHeader(subtitle: 'Créez votre compte en quelques secondes'), + TextFormField( + controller: _firstNameController, + decoration: const InputDecoration( + labelText: 'Prénom', + prefixIcon: Icon(Icons.person_outline_rounded), ), + validator: (v) => v == null || v.isEmpty ? 'Prénom requis' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _lastNameController, + decoration: const InputDecoration( + labelText: 'Nom', + prefixIcon: Icon(Icons.badge_outlined), + ), + validator: (v) => v == null || v.isEmpty ? 'Nom requis' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email_outlined), + ), + 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)', + prefixIcon: Icon(Icons.phone_outlined), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Mot de passe', + prefixIcon: Icon(Icons.lock_outline), + ), + validator: (v) { + if (v == null || v.length < 8) { + return 'Minimum 8 caractères'; + } + return null; + }, + ), + if (authState.error != 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'), - ), + Text(authState.error!, style: const TextStyle(color: AppColors.error)), ], - ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: authState.isLoading ? null : _submit, + child: authState.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Text('S\'inscrire'), + ), + ], ), ), ), diff --git a/lib/features/auth/presentation/splash_screen.dart b/lib/features/auth/presentation/splash_screen.dart new file mode 100644 index 0000000..e6f7c4d --- /dev/null +++ b/lib/features/auth/presentation/splash_screen.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; + +/// Écran affiché pendant la restauration de session au démarrage. +class SplashScreen extends StatelessWidget { + const SplashScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + decoration: const BoxDecoration(gradient: AppColors.gradientSoft), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + gradient: AppColors.gradientPrimary, + borderRadius: BorderRadius.circular(18), + ), + child: const Icon(Icons.local_laundry_service_outlined, size: 36, color: Colors.white), + ), + const SizedBox(height: 20), + Text( + 'Laverie Connectée', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 32), + const CircularProgressIndicator(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/booking/data/booking_repository.dart b/lib/features/booking/data/booking_repository.dart new file mode 100644 index 0000000..e6e9d4c --- /dev/null +++ b/lib/features/booking/data/booking_repository.dart @@ -0,0 +1,101 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_client.dart'; +import '../../../core/api/api_endpoints.dart'; +import '../../../core/api/api_response.dart'; +import '../../../core/auth/auth_provider.dart'; +import '../domain/booking.dart'; + +/// Dépôt de données pour les réservations. +class BookingRepository { + BookingRepository(this._apiClient); + + final ApiClient _apiClient; + + Future> fetchBookings() async { + final response = await _apiClient.get(ApiEndpoints.bookings); + final list = ApiResponse.list(response.data, 'bookings'); + return list.map((json) => Booking.fromJson(json as Map)).toList(); + } + + Future fetchBooking(String uuid) async { + final response = await _apiClient.get(ApiEndpoints.booking(uuid)); + final json = ApiResponse.object(response.data, 'booking'); + return Booking.fromJson(json); + } + + Future createBooking({ + required String machineUuid, + required DateTime slotStart, + required DateTime slotEnd, + }) async { + try { + final response = await _apiClient.post( + ApiEndpoints.bookings, + data: { + 'machine_uuid': machineUuid, + 'slot_start': slotStart.toUtc().toIso8601String(), + 'slot_end': slotEnd.toUtc().toIso8601String(), + }, + ); + final json = ApiResponse.object(response.data, 'booking'); + return Booking.fromJson(json); + } on DioException catch (error) { + throw BookingException(ApiResponse.errorMessage(error, fallback: 'Impossible de réserver')); + } + } + + Future cancelBooking(String uuid) async { + try { + final response = await _apiClient.patch(ApiEndpoints.bookingCancel(uuid)); + final json = ApiResponse.object(response.data, 'booking'); + return Booking.fromJson(json); + } on DioException catch (error) { + throw BookingException(ApiResponse.errorMessage(error, fallback: 'Impossible d\'annuler')); + } + } + + Future moveBooking({ + required String uuid, + required DateTime slotStart, + required DateTime slotEnd, + }) async { + try { + final response = await _apiClient.patch( + ApiEndpoints.bookingMove(uuid), + data: { + 'slot_start': slotStart.toUtc().toIso8601String(), + 'slot_end': slotEnd.toUtc().toIso8601String(), + }, + ); + final json = ApiResponse.object(response.data, 'booking'); + return Booking.fromJson(json); + } on DioException catch (error) { + throw BookingException(ApiResponse.errorMessage(error, fallback: 'Impossible de modifier')); + } + } +} + +class BookingException implements Exception { + BookingException(this.message); + final String message; + @override + String toString() => message; +} + +final bookingRepositoryProvider = Provider((ref) { + return BookingRepository(ref.watch(apiClientProvider)); +}); + +final bookingsProvider = FutureProvider>((ref) async { + final token = ref.watch(authProvider.select((state) => state.accessToken)); + if (token == null || token.isEmpty) { + throw StateError('Non authentifié'); + } + return ref.watch(bookingRepositoryProvider).fetchBookings(); +}); + +final bookingDetailProvider = FutureProvider.family((ref, uuid) async { + return ref.watch(bookingRepositoryProvider).fetchBooking(uuid); +}); diff --git a/lib/features/booking/domain/booking.dart b/lib/features/booking/domain/booking.dart index ce2b7cd..e5789d9 100644 --- a/lib/features/booking/domain/booking.dart +++ b/lib/features/booking/domain/booking.dart @@ -18,6 +18,13 @@ class Booking { final String status; final double bookingFee; + bool get canCancel => + (status == 'confirmed' || status == 'pending') && + slotStart != null && + slotStart!.isAfter(DateTime.now()); + + bool get canModify => canCancel; + factory Booking.fromJson(Map json) { final machine = json['machine'] as Map?; diff --git a/lib/features/booking/presentation/booking_modify_screen.dart b/lib/features/booking/presentation/booking_modify_screen.dart new file mode 100644 index 0000000..1386f09 --- /dev/null +++ b/lib/features/booking/presentation/booking_modify_screen.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../../core/router/app_router.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../machines/data/machine_repository.dart'; +import '../../machines/domain/machine_detail.dart'; +import '../data/booking_repository.dart'; +import '../domain/booking.dart'; + +/// Modification du créneau d'une réservation existante. +class BookingModifyScreen extends ConsumerStatefulWidget { + const BookingModifyScreen({super.key, required this.bookingUuid}); + + final String bookingUuid; + + @override + ConsumerState createState() => _BookingModifyScreenState(); +} + +class _BookingModifyScreenState extends ConsumerState { + DateTime? _selectedDate; + TimeSlot? _selectedSlot; + bool _isSaving = false; + + Future _confirmMove(Booking booking) async { + final slot = _selectedSlot; + if (slot == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Sélectionnez un nouveau créneau')), + ); + return; + } + + setState(() => _isSaving = true); + try { + await ref.read(bookingRepositoryProvider).moveBooking( + uuid: booking.uuid, + slotStart: slot.start, + slotEnd: slot.end, + ); + ref.invalidate(bookingsProvider); + ref.invalidate(bookingDetailProvider(booking.uuid)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Créneau modifié avec succès'), + backgroundColor: AppColors.success, + ), + ); + context.go(AppRoutes.bookings); + } + } on BookingException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message), backgroundColor: AppColors.error), + ); + } + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + @override + Widget build(BuildContext context) { + final bookingAsync = ref.watch(bookingDetailProvider(widget.bookingUuid)); + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar(title: const Text('Modifier le créneau')), + body: bookingAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => const Center(child: Text('Réservation introuvable')), + data: (booking) { + if (!booking.canModify) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'Cette réservation ne peut plus être modifiée.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ); + } + + final selectedDate = _selectedDate ?? + DateTime( + booking.slotStart!.year, + booking.slotStart!.month, + booking.slotStart!.day, + ); + + final availabilityAsync = ref.watch( + machineAvailabilityProvider((uuid: booking.machineUuid, date: selectedDate)), + ); + + return Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(booking.machineName, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text( + 'Créneau actuel', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 12), + ), + Text( + booking.slotStart != null && booking.slotEnd != null + ? '${DateFormat('EEEE dd MMM · HH:mm', 'fr_FR').format(booking.slotStart!)} – ${DateFormat('HH:mm').format(booking.slotEnd!)}' + : '—', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + Text('Nouveau jour', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 10), + SizedBox( + height: 44, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: 6, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final date = DateTime.now().add(Duration(days: index)); + final normalized = DateTime(date.year, date.month, date.day); + final isSelected = normalized.year == selectedDate.year && + normalized.month == selectedDate.month && + normalized.day == selectedDate.day; + + return ChoiceChip( + label: Text(DateFormat('EEE dd/MM', 'fr_FR').format(normalized)), + selected: isSelected, + onSelected: (_) => setState(() { + _selectedDate = normalized; + _selectedSlot = null; + }), + selectedColor: AppColors.primary.withValues(alpha: 0.15), + ); + }, + ), + ), + const SizedBox(height: 20), + Text('Nouveau créneau', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + availabilityAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => const Text('Impossible de charger les créneaux.'), + data: (slots) { + if (slots.isEmpty) { + return Text( + 'Aucun créneau disponible ce jour.', + style: Theme.of(context).textTheme.bodyMedium, + ); + } + return Wrap( + spacing: 8, + runSpacing: 8, + children: slots.map((slot) { + final label = + '${DateFormat('HH:mm').format(slot.start)} – ${DateFormat('HH:mm').format(slot.end)}'; + final isSelected = _selectedSlot?.start == slot.start; + + return FilterChip( + label: Text(label), + selected: isSelected, + onSelected: (_) => setState(() => _selectedSlot = slot), + selectedColor: AppColors.primary.withValues(alpha: 0.15), + checkmarkColor: AppColors.primary, + ); + }).toList(), + ); + }, + ), + ], + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: _isSaving ? null : () => _confirmMove(booking), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + textStyle: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + icon: _isSaving + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.check_rounded, size: 24), + label: Text(_isSaving ? 'Enregistrement…' : 'Confirmer le changement'), + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/features/booking/presentation/bookings_screen.dart b/lib/features/booking/presentation/bookings_screen.dart index 3c796c0..d4823c6 100644 --- a/lib/features/booking/presentation/bookings_screen.dart +++ b/lib/features/booking/presentation/bookings_screen.dart @@ -1,86 +1,228 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; -import '../../../core/api/api_client.dart'; -import '../../../core/api/api_endpoints.dart'; +import '../../../core/router/app_router.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../core/widgets/screen_header.dart'; +import '../data/booking_repository.dart'; import '../domain/booking.dart'; -/// Fournisseur des réservations de l'utilisateur connecté. -final bookingsProvider = FutureProvider>((ref) async { - final response = await ref.watch(apiClientProvider).get(ApiEndpoints.bookings); - final data = response.data; - - List list; - if (data is List) { - list = data; - } else if (data is Map && data['data'] is List) { - list = data['data'] as List; - } else { - list = []; - } - - return list - .map((json) => Booking.fromJson(json as Map)) - .toList(); -}); - -/// Écran listant les réservations de l'utilisateur. +/// Écran listant les réservations avec annulation et modification. class BookingsScreen extends ConsumerWidget { const BookingsScreen({super.key}); + Future _cancelBooking(BuildContext context, WidgetRef ref, Booking booking) async { + final slotLabel = booking.slotStart != null + ? DateFormat('EEEE dd MMM à HH:mm', 'fr_FR').format(booking.slotStart!) + : 'ce créneau'; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Annuler la réservation ?'), + content: Text( + 'Le créneau du $slotLabel sera libéré.\n\n' + 'Annulation gratuite plus de 2 h avant le créneau, sinon des frais peuvent s\'appliquer.', + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Non')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('Oui, annuler'), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + try { + await ref.read(bookingRepositoryProvider).cancelBooking(booking.uuid); + ref.invalidate(bookingsProvider); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Réservation annulée'), + backgroundColor: AppColors.success, + ), + ); + } + } on BookingException catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message), backgroundColor: AppColors.error), + ); + } + } + } + @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, + return bookingsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => EmptyState( + icon: Icons.event_busy_outlined, + title: 'Erreur', + subtitle: 'Impossible de charger vos réservations.', + actionLabel: 'Réessayer', + onAction: () => ref.invalidate(bookingsProvider), + ), + data: (bookings) { + if (bookings.isEmpty) { + return EmptyState( + icon: Icons.event_outlined, + title: 'Aucune réservation', + subtitle: 'Réservez un créneau jusqu\'à 6 jours\nà l\'avance depuis une laverie.', + actionLabel: 'Voir les laveries', + onAction: () => context.go(AppRoutes.home), + ); + } + + final upcoming = bookings + .where((b) => b.status != 'cancelled' && b.status != 'completed' && b.status != 'no_show') + .toList(); + final past = bookings + .where((b) => b.status == 'cancelled' || b.status == 'completed' || b.status == 'no_show') + .toList(); + + return RefreshIndicator( + onRefresh: () async => ref.invalidate(bookingsProvider), + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 88), children: [ - Text('Erreur : $error'), - const SizedBox(height: 12), - ElevatedButton( - onPressed: () => ref.invalidate(bookingsProvider), - child: const Text('Réessayer'), + const ScreenHeader( + title: 'Mes créneaux', + subtitle: '30 min pour démarrer après notification', ), + if (upcoming.isNotEmpty) ...[ + Text('À venir', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ...upcoming.map( + (b) => _BookingCard( + booking: b, + highlight: true, + onModify: b.canModify + ? () => context.push(AppRoutes.bookingModify(b.uuid)) + : null, + onCancel: b.canCancel ? () => _cancelBooking(context, ref, b) : null, + ), + ), + const SizedBox(height: 16), + ], + if (past.isNotEmpty) ...[ + Text('Passées', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ...past.map((b) => _BookingCard(booking: b, highlight: false)), + ], ], ), - ), - 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)), +class _BookingCard extends StatelessWidget { + const _BookingCard({ + required this.booking, + required this.highlight, + this.onModify, + this.onCancel, + }); + + final Booking booking; + final bool highlight; + final VoidCallback? onModify; + final VoidCallback? onCancel; + + @override + Widget build(BuildContext context) { + final slotText = booking.slotStart != null + ? DateFormat('EEEE dd MMM · HH:mm', 'fr_FR').format(booking.slotStart!) + : 'Créneau à confirmer'; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: highlight + ? BorderSide(color: AppColors.machineReserved.withValues(alpha: 0.4)) + : BorderSide.none, + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: (highlight ? AppColors.machineReserved : AppColors.textSecondary) + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), ), - ); - }, + child: Icon( + Icons.event, + color: highlight ? AppColors.machineReserved : AppColors.textSecondary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(booking.machineName, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 2), + Text(slotText, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ), + StatusBadge.fromStatus(booking.status), + ], ), - ); - }, + if (onModify != null || onCancel != null) ...[ + const SizedBox(height: 12), + Row( + children: [ + if (onModify != null) + Expanded( + child: OutlinedButton.icon( + onPressed: onModify, + icon: const Icon(Icons.edit_calendar_outlined, size: 18), + label: const Text('Modifier'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + if (onModify != null && onCancel != null) const SizedBox(width: 8), + if (onCancel != null) + Expanded( + child: OutlinedButton.icon( + onPressed: onCancel, + icon: const Icon(Icons.cancel_outlined, size: 18), + label: const Text('Annuler'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.error, + padding: const EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + ], + ), + ], + ], + ), ), ); } diff --git a/lib/features/establishments/data/establishment_repository.dart b/lib/features/establishments/data/establishment_repository.dart index 682c14f..01b92db 100644 --- a/lib/features/establishments/data/establishment_repository.dart +++ b/lib/features/establishments/data/establishment_repository.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/api/api_client.dart'; import '../../../core/api/api_endpoints.dart'; +import '../../../core/api/api_response.dart'; import '../domain/establishment.dart'; /// Dépôt de données pour les établissements et machines. @@ -12,7 +13,7 @@ class EstablishmentRepository { Future> fetchEstablishments() async { final response = await _apiClient.get(ApiEndpoints.establishments); - final data = _extractList(response.data); + final data = ApiResponse.list(response.data, 'establishments'); return data .map((json) => Establishment.fromJson(json as Map)) @@ -21,27 +22,9 @@ class EstablishmentRepository { Future fetchEstablishment(String uuid) async { final response = await _apiClient.get(ApiEndpoints.establishment(uuid)); - final json = _extractObject(response.data); + final json = ApiResponse.object(response.data, 'establishment'); return Establishment.fromJson(json); } - - List _extractList(dynamic data) { - if (data is List) return data; - if (data is Map && data['data'] is List) { - return data['data'] as List; - } - return []; - } - - Map _extractObject(dynamic data) { - if (data is Map) { - if (data['data'] is Map) { - return data['data'] as Map; - } - return data; - } - throw StateError('Réponse API inattendue'); - } } final establishmentRepositoryProvider = Provider((ref) { diff --git a/lib/features/establishments/presentation/establishment_detail_screen.dart b/lib/features/establishments/presentation/establishment_detail_screen.dart index 2cbcd83..f5ca814 100644 --- a/lib/features/establishments/presentation/establishment_detail_screen.dart +++ b/lib/features/establishments/presentation/establishment_detail_screen.dart @@ -1,11 +1,17 @@ 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 '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../core/widgets/machine_grid_layout.dart'; +import '../../../core/widgets/machine_widgets.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 { +/// Écran laverie — grille machines. +class EstablishmentDetailScreen extends ConsumerStatefulWidget { const EstablishmentDetailScreen({ super.key, required this.establishmentUuid, @@ -14,99 +20,187 @@ class EstablishmentDetailScreen extends ConsumerWidget { final String establishmentUuid; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _EstablishmentDetailScreenState(); +} + +class _EstablishmentDetailScreenState extends ConsumerState { + String _filter = 'all'; + + List _filterMachines(List machines) => switch (_filter) { + 'washer' => machines.where((m) => m.type.startsWith('washer')).toList(), + 'dryer' => machines.where((m) => m.type.startsWith('dryer')).toList(), + _ => machines, + }; + + @override + Widget build(BuildContext context) { final establishmentAsync = - ref.watch(establishmentDetailProvider(establishmentUuid)); + ref.watch(establishmentDetailProvider(widget.establishmentUuid)); return Scaffold( - appBar: AppBar(title: const Text('Détail laverie')), + backgroundColor: AppColors.background, + appBar: AppBar( + title: establishmentAsync.maybeWhen( + data: (e) => Text(e.name, overflow: TextOverflow.ellipsis), + orElse: () => const Text('Ma 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'), - ), - ], - ), + error: (error, _) => EmptyState( + icon: Icons.error_outline, + title: 'Erreur', + subtitle: '$error', + actionLabel: 'Réessayer', + onAction: () => ref.invalidate(establishmentDetailProvider(widget.establishmentUuid)), + ), + data: (establishment) => _EstablishmentBody( + establishment: establishment, + filter: _filter, + onFilterChanged: (value) => setState(() => _filter = value), + onRefresh: () async { + ref.invalidate(establishmentDetailProvider(widget.establishmentUuid)); + }, + machines: _filterMachines(establishment.machines), + onMachineTap: (machine) => context.push(AppRoutes.machineAction(machine.uuid)), ), - data: (establishment) => _EstablishmentBody(establishment: establishment), ), ); } } class _EstablishmentBody extends StatelessWidget { - const _EstablishmentBody({required this.establishment}); + const _EstablishmentBody({ + required this.establishment, + required this.filter, + required this.onFilterChanged, + required this.onRefresh, + required this.machines, + required this.onMachineTap, + }); final Establishment establishment; + final String filter; + final ValueChanged onFilterChanged; + final Future Function() onRefresh; + final List machines; + final ValueChanged onMachineTap; @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)), - ], + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: AppColors.gradientAccent, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + establishment.name, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + establishment.fullAddress, + style: TextStyle(color: Colors.white.withValues(alpha: 0.9), fontSize: 13), + ), + ], + ), + ), + const SizedBox(height: 16), + if (establishment.machines.isNotEmpty) ...[ + MachineStatsRow(machines: establishment.machines), + const SizedBox(height: 12), + const MachineStatusLegend(), + const SizedBox(height: 16), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _FilterChip( + label: 'Toutes', + selected: filter == 'all', + onTap: () => onFilterChanged('all'), + ), + const SizedBox(width: 8), + _FilterChip( + label: 'Lave-linge', + selected: filter == 'washer', + onTap: () => onFilterChanged('washer'), + ), + const SizedBox(width: 8), + _FilterChip( + label: 'Sèche-linge', + selected: filter == 'dryer', + onTap: () => onFilterChanged('dryer'), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + if (establishment.machines.isEmpty) + const EmptyState( + icon: Icons.local_laundry_service_outlined, + title: 'Aucune machine', + subtitle: 'Cette laverie n\'a pas encore de machines.', + ) + else if (machines.isEmpty) + const EmptyState( + icon: Icons.filter_alt_outlined, + title: 'Aucun résultat', + subtitle: 'Aucune machine dans cette catégorie.', + ) + else + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: MachineGridLayout.delegate(context), + itemCount: machines.length, + itemBuilder: (context, index) => MachineGridCard( + machine: machines[index], + onTap: () => onMachineTap(machines[index]), + ), + ), + ], + ), ); } } -class _MachineTile extends StatelessWidget { - const _MachineTile({required this.machine}); +class _FilterChip extends StatelessWidget { + const _FilterChip({ + required this.label, + required this.selected, + required this.onTap, + }); - final Machine machine; + final String label; + final bool selected; + final VoidCallback onTap; - 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, + return FilterChip( + label: Text(label), + selected: selected, + onSelected: (_) => onTap(), + selectedColor: AppColors.primary.withValues(alpha: 0.15), + checkmarkColor: AppColors.primary, + labelStyle: TextStyle( + color: selected ? AppColors.primary : AppColors.textSecondary, + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, ), ); } diff --git a/lib/features/home/presentation/home_screen.dart b/lib/features/home/presentation/home_screen.dart index 2b6c9d4..e2aec53 100644 --- a/lib/features/home/presentation/home_screen.dart +++ b/lib/features/home/presentation/home_screen.dart @@ -2,104 +2,176 @@ 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'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../core/widgets/machine_widgets.dart'; import '../../establishments/data/establishment_repository.dart'; +import '../../establishments/domain/establishment.dart'; +import '../../wash/data/wash_repository.dart'; -/// Écran d'accueil — liste des laveries à proximité. +/// Tableau de bord — vue d'ensemble inspirée WashOnline, plus moderne. class HomeScreen extends ConsumerWidget { const HomeScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final establishmentsAsync = ref.watch(establishmentsProvider); + final washesAsync = ref.watch(washesProvider); + final user = ref.watch(authProvider).user; - 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), - ), - ], + return establishmentsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => EmptyState( + icon: Icons.cloud_off_outlined, + title: 'Connexion impossible', + subtitle: 'Vérifiez que l\'API est démarrée\net que vous êtes sur le même réseau.', + actionLabel: 'Réessayer', + onAction: () => ref.invalidate(establishmentsProvider), ), - 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, - ), + data: (establishments) { + if (establishments.isEmpty) { + return const EmptyState( + icon: Icons.storefront_outlined, + title: 'Aucune laverie', + subtitle: 'Aucun établissement disponible pour le moment.', + ); + } + + final activeWash = washesAsync.maybeWhen( + data: (washes) { + for (final wash in washes) { + if (wash.status == 'running' || wash.status == 'pending_start' || wash.status == 'active') { + return wash; + } + } + return null; + }, + orElse: () => null, + ); + + return RefreshIndicator( + onRefresh: () async { + ref.invalidate(establishmentsProvider); + ref.invalidate(washesProvider); + }, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + Text( + user?.firstName != null ? 'Bonjour ${user!.firstName} 👋' : 'Bienvenue', + style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontSize: 20), + ), + const SizedBox(height: 4), + Text( + 'Votre laverie, dans votre poche.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + const QuickActionsRow(), + if (activeWash != null) ...[ const SizedBox(height: 16), - ElevatedButton( - onPressed: () => ref.invalidate(establishmentsProvider), - child: const Text('Réessayer'), + ActiveWashBanner( + machineName: activeWash.machineName, + onTap: () => context.go(AppRoutes.washes), ), ], - ), - ), - ), - 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), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Mes laveries', style: Theme.of(context).textTheme.titleMedium), + Text( + '${establishments.length}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColors.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 10), + ...establishments.map( + (establishment) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _EstablishmentCard( + establishment: establishment, 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'), - ], + ), + ), + ], + ), + ); + }, + ); + } +} + +class _EstablishmentCard extends StatelessWidget { + const _EstablishmentCard({ + required this.establishment, + required this.onTap, + }); + + final Establishment establishment; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + gradient: AppColors.gradientPrimary, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.storefront_rounded, color: Colors.white), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(establishment.name, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 2), + Text( + establishment.fullAddress, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.grid_view_rounded, size: 14, color: AppColors.primary.withValues(alpha: 0.8)), + const SizedBox(width: 4), + Text( + 'Voir les machines', + style: TextStyle( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: AppColors.textSecondary), + ], + ), + ), ), ); } diff --git a/lib/features/machines/data/machine_repository.dart b/lib/features/machines/data/machine_repository.dart new file mode 100644 index 0000000..fb02df2 --- /dev/null +++ b/lib/features/machines/data/machine_repository.dart @@ -0,0 +1,51 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_client.dart'; +import '../../../core/api/api_endpoints.dart'; +import '../../../core/api/api_response.dart'; +import '../domain/machine_detail.dart'; + +/// Dépôt de données machines (lookup, détail, créneaux). +class MachineRepository { + MachineRepository(this._apiClient); + + final ApiClient _apiClient; + + Future lookup({String? qrCode, String? machineUuid}) async { + final response = await _apiClient.get( + ApiEndpoints.machineLookup, + queryParameters: { + if (qrCode != null) 'qr_code': qrCode, + if (machineUuid != null) 'machine_uuid': machineUuid, + }, + ); + return MachineDetail.fromJson(ApiResponse.payload(response.data)); + } + + Future fetchDetail(String uuid) async { + final response = await _apiClient.get(ApiEndpoints.machine(uuid)); + return MachineDetail.fromJson(ApiResponse.payload(response.data)); + } + + Future> fetchAvailability(String uuid, DateTime date) async { + final response = await _apiClient.get( + ApiEndpoints.machineAvailability(uuid), + queryParameters: {'date': date.toIso8601String().split('T').first}, + ); + final slots = ApiResponse.payload(response.data)['slots'] as List? ?? []; + return slots.map((s) => TimeSlot.fromJson(s as Map)).toList(); + } +} + +final machineRepositoryProvider = Provider((ref) { + return MachineRepository(ref.watch(apiClientProvider)); +}); + +final machineDetailProvider = FutureProvider.family((ref, uuid) async { + return ref.watch(machineRepositoryProvider).fetchDetail(uuid); +}); + +final machineAvailabilityProvider = + FutureProvider.family, ({String uuid, DateTime date})>((ref, params) async { + return ref.watch(machineRepositoryProvider).fetchAvailability(params.uuid, params.date); +}); diff --git a/lib/features/machines/domain/machine_detail.dart b/lib/features/machines/domain/machine_detail.dart new file mode 100644 index 0000000..9946753 --- /dev/null +++ b/lib/features/machines/domain/machine_detail.dart @@ -0,0 +1,52 @@ +import '../../establishments/domain/establishment.dart'; + +/// Détails machine avec tarif et durée estimée. +class MachineDetail { + const MachineDetail({ + required this.machine, + required this.price, + required this.estimatedDurationMinutes, + this.establishmentName, + this.currency = 'EUR', + }); + + final Machine machine; + final double price; + final int estimatedDurationMinutes; + final String? establishmentName; + final String currency; + + factory MachineDetail.fromJson(Map json) { + final machineJson = json['machine'] as Map; + final pricing = json['pricing'] as Map?; + + String? establishmentName; + final establishment = machineJson['establishment']; + if (establishment is Map) { + establishmentName = establishment['name'] as String?; + } + + return MachineDetail( + machine: Machine.fromJson(machineJson), + price: (pricing?['price'] as num?)?.toDouble() ?? 0, + currency: pricing?['currency'] as String? ?? 'EUR', + estimatedDurationMinutes: json['estimated_duration_minutes'] as int? ?? 40, + establishmentName: establishmentName, + ); + } +} + +/// Créneau disponible pour réservation. +class TimeSlot { + const TimeSlot({required this.start, required this.end}); + + final DateTime start; + final DateTime end; + + factory TimeSlot.fromJson(Map json) { + return TimeSlot( + start: DateTime.parse(json['start'] as String), + end: DateTime.parse(json['end'] as String), + ); + } +} diff --git a/lib/features/machines/presentation/machine_action_screen.dart b/lib/features/machines/presentation/machine_action_screen.dart new file mode 100644 index 0000000..bdeb389 --- /dev/null +++ b/lib/features/machines/presentation/machine_action_screen.dart @@ -0,0 +1,293 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../../core/router/app_router.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/machine_status_theme.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../wash/data/wash_repository.dart'; +import '../data/machine_repository.dart'; +import '../domain/machine_detail.dart'; + +/// Écran machine — infos + actions principales. +class MachineActionScreen extends ConsumerStatefulWidget { + const MachineActionScreen({super.key, required this.machineUuid}); + + final String machineUuid; + + @override + ConsumerState createState() => _MachineActionScreenState(); +} + +class _MachineActionScreenState extends ConsumerState { + bool _isStarting = false; + + Future _startWash(MachineDetail detail) async { + setState(() => _isStarting = true); + try { + await ref.read(washRepositoryProvider).startWashFromMachine(detail.machine.uuid); + ref.invalidate(washesProvider); + ref.invalidate(machineDetailProvider(widget.machineUuid)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${detail.machine.name} — cycle démarré !'), + backgroundColor: AppColors.success, + ), + ); + context.go(AppRoutes.washes); + } + } on WashStartException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message), backgroundColor: AppColors.error), + ); + } + } finally { + if (mounted) setState(() => _isStarting = false); + } + } + + @override + Widget build(BuildContext context) { + final detailAsync = ref.watch(machineDetailProvider(widget.machineUuid)); + final currency = NumberFormat.currency(locale: 'fr_FR', symbol: '€'); + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar(title: const Text('Machine')), + body: detailAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => EmptyState( + icon: Icons.error_outline, + title: 'Machine introuvable', + subtitle: error is DioException + ? 'Impossible de charger les informations.' + : '$error', + actionLabel: 'Retour', + onAction: () => context.pop(), + ), + data: (detail) => _MachineActionBody( + detail: detail, + currency: currency, + isStarting: _isStarting, + onStart: () => _startWash(detail), + onReserve: () => context.push(AppRoutes.machineBooking(widget.machineUuid)), + ), + ), + ); + } +} + +class _MachineActionBody extends StatelessWidget { + const _MachineActionBody({ + required this.detail, + required this.currency, + required this.isStarting, + required this.onStart, + required this.onReserve, + }); + + final MachineDetail detail; + final NumberFormat currency; + final bool isStarting; + final VoidCallback onStart; + final VoidCallback onReserve; + + @override + Widget build(BuildContext context) { + final machine = detail.machine; + final statusColor = MachineStatusTheme.color(machine.status); + final canStart = MachineStatusTheme.canStart(machine.status); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: AppColors.gradientAccent, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + MachineStatusTheme.iconForType(machine.type), + color: Colors.white, + size: 28, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + machine.name, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + Text( + machine.typeLabel, + style: TextStyle(color: Colors.white.withValues(alpha: 0.9)), + ), + if (detail.establishmentName != null) + Text( + detail.establishmentName!, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.75), + fontSize: 12, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + MachineStatusTheme.label(machine.status), + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 12), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _InfoTile( + icon: Icons.euro, + label: 'Prix du cycle', + value: currency.format(detail.price), + color: AppColors.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _InfoTile( + icon: Icons.schedule, + label: 'Durée estimée', + value: '~${detail.estimatedDurationMinutes} min', + color: AppColors.secondary, + ), + ), + ], + ), + if (!canStart) ...[ + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + machine.status == 'running' + ? 'Machine en cours d\'utilisation — vous pouvez réserver un créneau ultérieur.' + : 'Machine indisponible pour le moment — réservez un créneau.', + textAlign: TextAlign.center, + style: TextStyle(color: statusColor, fontWeight: FontWeight.w500), + ), + ), + ], + const SizedBox(height: 28), + SizedBox( + width: double.infinity, + height: 60, + child: ElevatedButton.icon( + onPressed: canStart && !isStarting ? onStart : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.machineAvailable, + disabledBackgroundColor: AppColors.machineAvailable.withValues(alpha: 0.4), + foregroundColor: Colors.white, + disabledForegroundColor: Colors.white70, + elevation: canStart ? 2 : 0, + shadowColor: AppColors.machineAvailable.withValues(alpha: 0.4), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + icon: isStarting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white), + ) + : const Icon(Icons.play_arrow_rounded, size: 28), + label: Text(isStarting ? 'Démarrage…' : 'Commencer un lavage'), + ), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: onReserve, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 2, + shadowColor: AppColors.primary.withValues(alpha: 0.35), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + textStyle: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + icon: const Icon(Icons.event_available_outlined, size: 24), + label: const Text('Réserver un créneau'), + ), + ), + const SizedBox(height: 24), + ], + ); + } +} + +class _InfoTile extends StatelessWidget { + const _InfoTile({ + required this.icon, + required this.label, + required this.value, + required this.color, + }); + + final IconData icon; + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color, size: 22), + const SizedBox(height: 8), + Text(label, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 12)), + const SizedBox(height: 2), + Text(value, style: Theme.of(context).textTheme.titleMedium), + ], + ), + ), + ); + } +} diff --git a/lib/features/machines/presentation/machine_booking_screen.dart b/lib/features/machines/presentation/machine_booking_screen.dart new file mode 100644 index 0000000..2e957b8 --- /dev/null +++ b/lib/features/machines/presentation/machine_booking_screen.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../../core/router/app_router.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/machine_status_theme.dart'; +import '../../booking/data/booking_repository.dart'; +import '../data/machine_repository.dart'; +import '../domain/machine_detail.dart'; + +/// Réservation de créneau pour une machine. +class MachineBookingScreen extends ConsumerStatefulWidget { + const MachineBookingScreen({super.key, required this.machineUuid}); + + final String machineUuid; + + @override + ConsumerState createState() => _MachineBookingScreenState(); +} + +class _MachineBookingScreenState extends ConsumerState { + DateTime _selectedDate = DateTime.now(); + TimeSlot? _selectedSlot; + bool _isBooking = false; + + Future _confirmBooking(MachineDetail detail) async { + final slot = _selectedSlot; + if (slot == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Sélectionnez un créneau horaire')), + ); + return; + } + + setState(() => _isBooking = true); + try { + await ref.read(bookingRepositoryProvider).createBooking( + machineUuid: detail.machine.uuid, + slotStart: slot.start, + slotEnd: slot.end, + ); + ref.invalidate(bookingsProvider); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Créneau réservé avec succès'), + backgroundColor: AppColors.success, + ), + ); + context.go(AppRoutes.bookings); + } + } on BookingException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message), backgroundColor: AppColors.error), + ); + } + } finally { + if (mounted) setState(() => _isBooking = false); + } + } + + @override + Widget build(BuildContext context) { + final detailAsync = ref.watch(machineDetailProvider(widget.machineUuid)); + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar(title: const Text('Réserver un créneau')), + body: detailAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => Center(child: Text('Machine introuvable', style: Theme.of(context).textTheme.titleMedium)), + data: (detail) { + final machine = detail.machine; + final availabilityAsync = ref.watch( + machineAvailabilityProvider((uuid: machine.uuid, date: _selectedDate)), + ); + + return Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.primary.withValues(alpha: 0.1), + child: Icon( + MachineStatusTheme.iconForType(machine.type), + color: AppColors.primary, + ), + ), + title: Text(machine.name), + subtitle: Text( + [ + machine.typeLabel, + if (detail.establishmentName != null) detail.establishmentName, + ].join(' · '), + ), + ), + ), + const SizedBox(height: 20), + Text('Choisir un jour', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 10), + SizedBox( + height: 44, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: 6, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final date = DateTime.now().add(Duration(days: index)); + final normalized = DateTime(date.year, date.month, date.day); + final isSelected = normalized.year == _selectedDate.year && + normalized.month == _selectedDate.month && + normalized.day == _selectedDate.day; + + return ChoiceChip( + label: Text(DateFormat('EEE dd/MM', 'fr_FR').format(normalized)), + selected: isSelected, + onSelected: (_) => setState(() { + _selectedDate = normalized; + _selectedSlot = null; + }), + selectedColor: AppColors.primary.withValues(alpha: 0.15), + ); + }, + ), + ), + const SizedBox(height: 20), + Text('Créneaux disponibles', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 4), + Text( + 'Vous aurez 30 min pour démarrer après le début du créneau.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + availabilityAsync.when( + loading: () => const Center(child: Padding( + padding: EdgeInsets.all(24), + child: CircularProgressIndicator(), + )), + error: (_, __) => const Text('Impossible de charger les créneaux.'), + data: (slots) { + if (slots.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + 'Aucun créneau disponible ce jour.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + return Wrap( + spacing: 8, + runSpacing: 8, + children: slots.map((slot) { + final label = + '${DateFormat('HH:mm').format(slot.start)} – ${DateFormat('HH:mm').format(slot.end)}'; + final isSelected = _selectedSlot?.start == slot.start; + + return FilterChip( + label: Text(label), + selected: isSelected, + onSelected: (_) => setState(() => _selectedSlot = slot), + selectedColor: AppColors.primary.withValues(alpha: 0.15), + checkmarkColor: AppColors.primary, + ); + }).toList(), + ); + }, + ), + ], + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: _isBooking ? null : () => _confirmBooking(detail), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + textStyle: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + ), + icon: _isBooking + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.event_available_rounded, size: 24), + label: Text(_isBooking ? 'Réservation…' : 'Confirmer la réservation'), + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 9b8f41e..1a46dfe 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../../core/auth/auth_provider.dart'; import '../../../core/router/app_router.dart'; +import '../../../core/theme/app_colors.dart'; /// Écran profil utilisateur et déconnexion. class ProfileScreen extends ConsumerWidget { @@ -11,63 +12,73 @@ class ProfileScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final authState = ref.watch(authProvider); - final user = authState.user; + final user = ref.watch(authProvider).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), + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: AppColors.primary.withValues(alpha: 0.1), + child: Text( + user != null && user.firstName.isNotEmpty + ? user.firstName.substring(0, 1).toUpperCase() + : '?', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w600, + color: AppColors.primary, ), ), - const SizedBox(height: 16), - Text( - user?.fullName ?? 'Utilisateur', - style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(user?.fullName ?? 'Utilisateur', style: Theme.of(context).textTheme.titleMedium), + if (user?.email != null) + Text(user!.email, style: Theme.of(context).textTheme.bodyMedium), + ], ), - if (user?.email != null) ...[ - const SizedBox(height: 4), - Text(user!.email), - ], - ], + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Card( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.language_outlined, color: AppColors.textSecondary), + title: const Text('Langue'), + trailing: Text(user?.locale ?? 'fr'), ), - ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.notifications_outlined, color: AppColors.textSecondary), + title: const Text('Notifications'), + trailing: const Icon(Icons.chevron_right, color: AppColors.textSecondary), + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Préférences — à implémenter')), + ); + }, + ), + ], ), - 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), - ), + ), + const SizedBox(height: 8), + Card( + child: ListTile( + leading: const Icon(Icons.logout, color: AppColors.error), + title: const Text('Se déconnecter', style: TextStyle(color: AppColors.error)), onTap: () async { await ref.read(authProvider.notifier).logout(); if (context.mounted) { @@ -75,8 +86,8 @@ class ProfileScreen extends ConsumerWidget { } }, ), - ], - ), + ), + ], ); } } diff --git a/lib/features/wallet/data/wallet_repository.dart b/lib/features/wallet/data/wallet_repository.dart index 58d29d8..06162c3 100644 --- a/lib/features/wallet/data/wallet_repository.dart +++ b/lib/features/wallet/data/wallet_repository.dart @@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/api/api_client.dart'; import '../../../core/api/api_endpoints.dart'; +import '../../../core/api/api_response.dart'; +import '../../../core/auth/auth_provider.dart'; import '../domain/wallet.dart'; /// Dépôt de données pour le portefeuille électronique. @@ -12,36 +14,18 @@ class WalletRepository { Future fetchWallet() async { final response = await _apiClient.get(ApiEndpoints.wallet); - final json = _extractObject(response.data); + final json = ApiResponse.object(response.data, 'wallet'); return Wallet.fromJson(json); } Future> fetchTransactions() async { final response = await _apiClient.get(ApiEndpoints.walletTransactions); - final list = _extractList(response.data); + final list = ApiResponse.list(response.data, 'transactions'); return list .map((json) => WalletTransaction.fromJson(json as Map)) .toList(); } - - List _extractList(dynamic data) { - if (data is List) return data; - if (data is Map && data['data'] is List) { - return data['data'] as List; - } - return []; - } - - Map _extractObject(dynamic data) { - if (data is Map) { - if (data['data'] is Map) { - return data['data'] as Map; - } - return data; - } - throw StateError('Réponse API inattendue'); - } } final walletRepositoryProvider = Provider((ref) { @@ -49,10 +33,20 @@ final walletRepositoryProvider = Provider((ref) { }); final walletProvider = FutureProvider((ref) async { + final token = ref.watch(authProvider.select((state) => state.accessToken)); + if (token == null || token.isEmpty) { + throw StateError('Non authentifié'); + } + return ref.watch(walletRepositoryProvider).fetchWallet(); }); final walletTransactionsProvider = FutureProvider>((ref) async { + final token = ref.watch(authProvider.select((state) => state.accessToken)); + if (token == null || token.isEmpty) { + throw StateError('Non authentifié'); + } + return ref.watch(walletRepositoryProvider).fetchTransactions(); }); diff --git a/lib/features/wallet/presentation/wallet_screen.dart b/lib/features/wallet/presentation/wallet_screen.dart index ed8c754..572b88d 100644 --- a/lib/features/wallet/presentation/wallet_screen.dart +++ b/lib/features/wallet/presentation/wallet_screen.dart @@ -1,116 +1,130 @@ -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'), - ), - ); - } -} +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/empty_state.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 RefreshIndicator( + onRefresh: () async { + ref.invalidate(walletProvider); + ref.invalidate(walletTransactionsProvider); + }, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + walletAsync.when( + loading: () => const SizedBox( + height: 120, + child: Center(child: CircularProgressIndicator()), + ), + error: (error, _) => Text('Erreur solde : $error'), + data: (wallet) => Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: AppColors.gradientAccent, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.2), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Solde disponible', + style: TextStyle(color: Colors.white.withValues(alpha: 0.9), fontSize: 14), + ), + const SizedBox(height: 8), + Text( + currencyFormat.format(wallet.currentBalance), + style: const TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + wallet.status == 'active' ? 'Compte actif' : wallet.status, + style: TextStyle(color: Colors.white.withValues(alpha: 0.85), fontSize: 13), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Rechargement — à connecter à l\'API')), + ); + }, + icon: const Icon(Icons.add), + label: const Text('Recharger'), + ), + 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 EmptyState( + icon: Icons.receipt_long_outlined, + title: 'Aucune transaction', + subtitle: 'Vos rechargements et débits apparaîtront ici.', + ); + } + + return Column( + children: transactions.map((tx) { + final isCredit = tx.type == 'credit' || tx.type == 'refund'; + final color = isCredit ? AppColors.success : AppColors.error; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar( + backgroundColor: color.withValues(alpha: 0.1), + child: Icon( + isCredit ? Icons.add : Icons.remove, + color: color, + size: 20, + ), + ), + 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.w600, color: color), + ), + ), + ); + }).toList(), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/features/wash/data/wash_repository.dart b/lib/features/wash/data/wash_repository.dart new file mode 100644 index 0000000..f33cce2 --- /dev/null +++ b/lib/features/wash/data/wash_repository.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/auth/auth_provider.dart'; +import '../../../core/api/api_client.dart'; +import '../../../core/api/api_endpoints.dart'; +import '../../../core/api/api_response.dart'; +import '../domain/wash.dart'; + +/// Référence machine extraite d'un QR code scanné. +class QrMachineReference { + const QrMachineReference({ + this.machineUuid, + this.qrCode, + }); + + final String? machineUuid; + final String? qrCode; + + bool get isValid => + (machineUuid != null && machineUuid!.isNotEmpty) || + (qrCode != null && qrCode!.isNotEmpty); + + factory QrMachineReference.parse(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return const QrMachineReference(); + } + + try { + final decoded = jsonDecode(trimmed); + if (decoded is Map) { + return QrMachineReference( + machineUuid: decoded['machine_uuid'] as String?, + qrCode: decoded['qr_code'] as String?, + ); + } + } catch (_) { + // Contenu texte brut (ex. LAVERIE-DEMO-001). + } + + final uuidPattern = RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', + ); + if (uuidPattern.hasMatch(trimmed)) { + return QrMachineReference(machineUuid: trimmed); + } + + return QrMachineReference(qrCode: trimmed); + } +} + +/// Dépôt de données pour les lavages. +class WashRepository { + WashRepository(this._apiClient); + + final ApiClient _apiClient; + + Future> fetchWashes() async { + final response = await _apiClient.get(ApiEndpoints.washes); + final list = ApiResponse.list(response.data, 'washes'); + + return list + .map((json) => Wash.fromJson(json as Map)) + .toList(); + } + + Future startWashFromMachine(String machineUuid) async { + try { + final response = await _apiClient.post( + ApiEndpoints.washesStart, + data: { + 'machine_uuid': machineUuid, + 'trigger_method': 'qr_code', + }, + ); + final washJson = ApiResponse.object(response.data, 'wash'); + return Wash.fromJson(washJson); + } on DioException catch (error) { + throw WashStartException(ApiResponse.errorMessage(error, fallback: 'Impossible de démarrer le lavage')); + } + } + + Future startWashFromQr(String scannedValue) async { + final reference = QrMachineReference.parse(scannedValue); + if (!reference.isValid) { + throw const FormatException('QR code invalide ou vide'); + } + + final payload = { + 'trigger_method': 'qr_code', + if (reference.machineUuid != null) 'machine_uuid': reference.machineUuid, + if (reference.qrCode != null) 'qr_code': reference.qrCode, + }; + + try { + final response = await _apiClient.post( + ApiEndpoints.washesStart, + data: payload, + ); + final washJson = ApiResponse.object(response.data, 'wash'); + return Wash.fromJson(washJson); + } on DioException catch (error) { + throw WashStartException(ApiResponse.errorMessage(error, fallback: 'Impossible de démarrer le lavage')); + } + } +} + +class WashStartException implements Exception { + WashStartException(this.message); + + final String message; + + @override + String toString() => message; +} + +final washRepositoryProvider = Provider((ref) { + return WashRepository(ref.watch(apiClientProvider)); +}); + +final washesProvider = FutureProvider>((ref) async { + final token = ref.watch(authProvider.select((state) => state.accessToken)); + if (token == null || token.isEmpty) { + throw StateError('Non authentifié'); + } + + return ref.watch(washRepositoryProvider).fetchWashes(); +}); diff --git a/lib/features/wash/domain/wash.dart b/lib/features/wash/domain/wash.dart index 46e4de4..cf0eee5 100644 --- a/lib/features/wash/domain/wash.dart +++ b/lib/features/wash/domain/wash.dart @@ -1,3 +1,5 @@ +import 'wash_progress.dart'; + /// Modèle lavage (cycle en cours ou terminé). class Wash { const Wash({ @@ -9,6 +11,9 @@ class Wash { this.startedAt, this.endedAt, this.durationMinutes, + this.machineType, + this.cycleEndsAt, + this.progress, }); final String uuid; @@ -19,14 +24,27 @@ class Wash { final DateTime? startedAt; final DateTime? endedAt; final int? durationMinutes; + final String? machineType; + final DateTime? cycleEndsAt; + final WashProgress? progress; + + WashProgress get liveProgress => WashProgress.compute( + startedAt: startedAt, + estimatedEndAt: progress?.estimatedEndAt ?? cycleEndsAt, + durationMinutes: durationMinutes, + machineType: machineType, + status: status, + ); factory Wash.fromJson(Map json) { final machine = json['machine'] as Map?; + final progressJson = json['progress'] as Map?; return Wash( uuid: json['uuid'] as String, machineUuid: machine?['uuid'] as String? ?? json['machine_uuid'] as String? ?? '', machineName: machine?['name'] as String? ?? 'Machine', + machineType: machine?['type'] as String?, status: json['status'] as String? ?? 'pending_start', cost: (json['cost'] as num?)?.toDouble() ?? 0, startedAt: json['started_at'] != null @@ -36,6 +54,10 @@ class Wash { ? DateTime.tryParse(json['ended_at'] as String) : null, durationMinutes: json['duration_minutes'] as int?, + cycleEndsAt: machine?['cycle_ends_at'] != null + ? DateTime.tryParse(machine!['cycle_ends_at'] as String) + : null, + progress: progressJson != null ? WashProgress.fromJson(progressJson) : null, ); } } diff --git a/lib/features/wash/domain/wash_progress.dart b/lib/features/wash/domain/wash_progress.dart new file mode 100644 index 0000000..d178b5b --- /dev/null +++ b/lib/features/wash/domain/wash_progress.dart @@ -0,0 +1,114 @@ +/// Progression d'un lavage en cours. +class WashProgress { + const WashProgress({ + required this.percent, + required this.phaseLabel, + this.phase, + this.estimatedEndAt, + this.remainingSeconds, + }); + + final int percent; + final String phaseLabel; + final String? phase; + final DateTime? estimatedEndAt; + final int? remainingSeconds; + + String get remainingLabel { + if (remainingSeconds == null) return ''; + final s = remainingSeconds!; + if (s <= 0) return 'Bientôt terminé'; + if (s < 60) return '$s s restantes'; + final min = (s / 60).ceil(); + return '$min min restantes'; + } + + factory WashProgress.fromJson(Map? json) { + if (json == null) { + return const WashProgress(percent: 0, phaseLabel: 'En cours'); + } + return WashProgress( + percent: json['percent'] as int? ?? 0, + phase: json['phase'] as String?, + phaseLabel: json['phase_label'] as String? ?? 'En cours', + estimatedEndAt: json['estimated_end_at'] != null + ? DateTime.tryParse(json['estimated_end_at'] as String) + : null, + remainingSeconds: json['remaining_seconds'] as int?, + ); + } + + /// Calcul local si pas de données API (rafraîchissement chaque seconde). + factory WashProgress.compute({ + required DateTime? startedAt, + required DateTime? estimatedEndAt, + required int? durationMinutes, + required String? machineType, + required String status, + }) { + if (status == 'pending_start') { + return const WashProgress(percent: 0, phaseLabel: 'En attente de démarrage', phase: 'pending'); + } + + final now = DateTime.now(); + final end = estimatedEndAt ?? + (startedAt != null && durationMinutes != null + ? startedAt.add(Duration(minutes: durationMinutes)) + : null); + + if (startedAt == null || end == null) { + return const WashProgress(percent: 5, phaseLabel: 'Démarrage…', phase: 'lock'); + } + + final total = end.difference(startedAt).inSeconds.clamp(1, 999999); + final elapsed = now.difference(startedAt).inSeconds.clamp(0, total); + final percent = ((elapsed / total) * 100).round().clamp(0, 99); + final remaining = end.difference(now).inSeconds.clamp(0, total); + + return WashProgress( + percent: percent, + phaseLabel: _phaseLabel(percent, machineType), + phase: _phase(percent, machineType), + estimatedEndAt: end, + remainingSeconds: remaining, + ); + } + + static String _phase(int percent, String? type) { + final isDryer = type?.startsWith('dryer') ?? false; + if (percent < 5) return 'lock'; + if (isDryer) { + if (percent < 15) return 'heat'; + if (percent < 85) return 'dry'; + return 'finish'; + } + if (percent < 15) return 'fill'; + if (percent < 55) return 'wash'; + if (percent < 75) return 'rinse'; + if (percent < 90) return 'spin'; + return 'finish'; + } + + static String _phaseLabel(int percent, String? type) { + switch (_phase(percent, type)) { + case 'lock': + return 'Verrouillage'; + case 'heat': + return 'Préchauffage'; + case 'dry': + return 'Séchage'; + case 'fill': + return 'Remplissage'; + case 'wash': + return 'Lavage'; + case 'rinse': + return 'Rinçage'; + case 'spin': + return 'Essorage'; + case 'finish': + return 'Finition'; + default: + return 'En cours'; + } + } +} diff --git a/lib/features/wash/presentation/qr_scanner_screen.dart b/lib/features/wash/presentation/qr_scanner_screen.dart new file mode 100644 index 0000000..7f6a47a --- /dev/null +++ b/lib/features/wash/presentation/qr_scanner_screen.dart @@ -0,0 +1,219 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +import '../../../core/api/api_response.dart'; +import '../../../core/router/app_router.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../machines/data/machine_repository.dart'; +import '../../wash/data/wash_repository.dart'; + +/// Écran de scan QR → page dédiée machine. +class QrScannerScreen extends ConsumerStatefulWidget { + const QrScannerScreen({super.key}); + + @override + ConsumerState createState() => _QrScannerScreenState(); +} + +class _QrScannerScreenState extends ConsumerState { + final MobileScannerController _controller = MobileScannerController( + detectionSpeed: DetectionSpeed.noDuplicates, + facing: CameraFacing.back, + ); + + bool _isProcessing = false; + String? _lastScannedValue; + + static const _frameSize = 260.0; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _onDetect(BarcodeCapture capture) async { + if (_isProcessing) return; + + final value = capture.barcodes.firstOrNull?.rawValue?.trim(); + if (value == null || value.isEmpty) return; + if (value == _lastScannedValue) return; + + setState(() { + _isProcessing = true; + _lastScannedValue = value; + }); + + await _controller.stop(); + + try { + final reference = QrMachineReference.parse(value); + if (!reference.isValid) { + throw const FormatException('QR code invalide'); + } + + final detail = await ref.read(machineRepositoryProvider).lookup( + qrCode: reference.qrCode, + machineUuid: reference.machineUuid, + ); + + if (!mounted) return; + + context.pop(); + context.push(AppRoutes.machineAction(detail.machine.uuid)); + } on DioException catch (error) { + if (!mounted) return; + _showErrorAndResume(ApiResponse.errorMessage(error, fallback: 'Machine introuvable')); + } on FormatException catch (error) { + if (!mounted) return; + _showErrorAndResume(error.message); + } catch (_) { + if (!mounted) return; + _showErrorAndResume('Impossible de lire ce QR code'); + } + } + + Future _showErrorAndResume(String message) async { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: AppColors.error), + ); + + setState(() { + _isProcessing = false; + _lastScannedValue = null; + }); + + await _controller.start(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + title: const Text('Scanner une machine'), + backgroundColor: Colors.black, + foregroundColor: Colors.white, + actions: [ + IconButton( + tooltip: 'Lampe', + onPressed: () => _controller.toggleTorch(), + icon: const Icon(Icons.flash_on_rounded), + ), + ], + ), + body: Stack( + fit: StackFit.expand, + children: [ + MobileScanner( + controller: _controller, + onDetect: _onDetect, + ), + CustomPaint( + painter: _ScannerOverlayPainter(frameSize: _frameSize), + child: const SizedBox.expand(), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.9)], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Placez le QR code dans le cadre', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + const SizedBox(height: 6), + Text( + 'Ex. démo : LAVERIE-DEMO-001', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 13), + ), + if (_isProcessing) ...[ + const SizedBox(height: 16), + const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white), + ), + const SizedBox(height: 8), + const Text( + 'Identification de la machine…', + style: TextStyle(color: Colors.white70, fontSize: 13), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } +} + +class _ScannerOverlayPainter extends CustomPainter { + _ScannerOverlayPainter({required this.frameSize}); + + final double frameSize; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final rect = Rect.fromCenter(center: center, width: frameSize, height: frameSize); + final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(20)); + + canvas.drawPath( + Path.combine( + PathOperation.difference, + Path()..addRect(Rect.fromLTWH(0, 0, size.width, size.height)), + Path()..addRRect(rrect), + ), + Paint()..color = Colors.black.withValues(alpha: 0.55), + ); + + canvas.drawRRect( + rrect, + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5, + ); + + const cornerLen = 28.0; + final corner = Paint() + ..color = AppColors.primary + ..style = PaintingStyle.stroke + ..strokeWidth = 4 + ..strokeCap = StrokeCap.round; + + canvas.drawLine(rect.topLeft, rect.topLeft + const Offset(cornerLen, 0), corner); + canvas.drawLine(rect.topLeft, rect.topLeft + const Offset(0, cornerLen), corner); + canvas.drawLine(rect.topRight, rect.topRight + const Offset(-cornerLen, 0), corner); + canvas.drawLine(rect.topRight, rect.topRight + const Offset(0, cornerLen), corner); + canvas.drawLine(rect.bottomLeft, rect.bottomLeft + const Offset(cornerLen, 0), corner); + canvas.drawLine(rect.bottomLeft, rect.bottomLeft + const Offset(0, -cornerLen), corner); + canvas.drawLine(rect.bottomRight, rect.bottomRight + const Offset(-cornerLen, 0), corner); + canvas.drawLine(rect.bottomRight, rect.bottomRight + const Offset(0, -cornerLen), corner); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/features/wash/presentation/wash_screen.dart b/lib/features/wash/presentation/wash_screen.dart index 7a6c645..30a068c 100644 --- a/lib/features/wash/presentation/wash_screen.dart +++ b/lib/features/wash/presentation/wash_screen.dart @@ -1,103 +1,152 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; -import '../../../core/api/api_client.dart'; -import '../../../core/api/api_endpoints.dart'; +import '../../../core/api/api_response.dart'; +import '../../../core/router/app_router.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../../../core/widgets/screen_header.dart'; +import '../data/wash_repository.dart'; import '../domain/wash.dart'; +import '../domain/wash_progress.dart'; +import 'widgets/active_wash_progress_card.dart'; -/// Fournisseur de l'historique des lavages. -final washesProvider = FutureProvider>((ref) async { - final response = await ref.watch(apiClientProvider).get(ApiEndpoints.washes); - final data = response.data; +bool _isActiveWash(String status) => + status == 'running' || status == 'pending_start' || status == 'active'; - List list; - if (data is List) { - list = data; - } else if (data is Map && data['data'] is List) { - list = data['data'] as List; - } else { - list = []; - } - - return list.map((json) => Wash.fromJson(json as Map)).toList(); -}); - -/// Écran historique et démarrage de lavage. -class WashScreen extends ConsumerWidget { +/// Écran historique et lavages en cours avec progression. +class WashScreen extends ConsumerStatefulWidget { const WashScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _WashScreenState(); +} + +class _WashScreenState extends ConsumerState { + Timer? _ticker; + + @override + void initState() { + super.initState(); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { 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, + return washesAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => EmptyState( + icon: Icons.error_outline, + title: 'Erreur de chargement', + subtitle: error is DioException + ? ApiResponse.errorMessage(error, fallback: 'Impossible de charger les lavages.') + : 'Impossible de charger les lavages.', + actionLabel: 'Réessayer', + onAction: () => ref.invalidate(washesProvider), + ), + data: (washes) { + if (washes.isEmpty) { + return EmptyState( + icon: Icons.qr_code_scanner_outlined, + title: 'Aucun lavage', + subtitle: 'Scannez une machine pour voir\nses infos et démarrer un cycle.', + actionLabel: 'Scanner', + onAction: () => context.push(AppRoutes.washScan), + ); + } + + final active = washes.where((w) => _isActiveWash(w.status)).toList(); + final history = washes.where((w) => !_isActiveWash(w.status)).toList(); + final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '€'); + + return RefreshIndicator( + onRefresh: () async => ref.invalidate(washesProvider), + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 88), children: [ - Text('Erreur : $error'), - const SizedBox(height: 12), - ElevatedButton( - onPressed: () => ref.invalidate(washesProvider), - child: const Text('Réessayer'), - ), + if (active.isNotEmpty) ...[ + const ScreenHeader( + title: 'En cours', + subtitle: 'Progression en temps réel', + ), + ...active.map((wash) { + final progress = _liveProgress(wash); + return ActiveWashProgressCard( + wash: wash, + format: currencyFormat, + progress: progress, + ); + }), + const SizedBox(height: 16), + ], + if (history.isNotEmpty) ...[ + Text('Historique', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 10), + ...history.map( + (wash) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _HistoryWashTile(wash: wash, format: currencyFormat), + ), + ), + ], ], ), + ); + }, + ); + } + + WashProgress _liveProgress(Wash wash) { + if (wash.progress != null && wash.progress!.percent > 0) { + return WashProgress.compute( + startedAt: wash.startedAt, + estimatedEndAt: wash.progress!.estimatedEndAt ?? wash.cycleEndsAt, + durationMinutes: wash.durationMinutes, + machineType: wash.machineType, + status: wash.status, + ); + } + return wash.liveProgress; + } +} + +class _HistoryWashTile extends StatelessWidget { + const _HistoryWashTile({required this.wash, required this.format}); + + final Wash wash; + final NumberFormat format; + + @override + Widget build(BuildContext context) { + return Card( + child: ListTile( + leading: const CircleAvatar( + backgroundColor: Color(0xFFE2E8F0), + child: Icon(Icons.check_circle_outline, color: AppColors.success, size: 20), + ), + title: Text(wash.machineName), + subtitle: wash.startedAt != null + ? Text(DateFormat('dd/MM/yyyy · HH:mm').format(wash.startedAt!)) + : null, + trailing: Text( + format.format(wash.cost), + style: const TextStyle(fontWeight: FontWeight.w600), ), - 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'), ), ); } diff --git a/lib/features/wash/presentation/widgets/active_wash_progress_card.dart b/lib/features/wash/presentation/widgets/active_wash_progress_card.dart new file mode 100644 index 0000000..df5dd34 --- /dev/null +++ b/lib/features/wash/presentation/widgets/active_wash_progress_card.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../../core/theme/app_colors.dart'; +import '../../domain/wash.dart'; +import '../../domain/wash_progress.dart'; + +/// Carte lavage en cours avec pourcentage et étape du cycle. +class ActiveWashProgressCard extends StatelessWidget { + const ActiveWashProgressCard({ + super.key, + required this.wash, + required this.format, + this.progress, + }); + + final Wash wash; + final NumberFormat format; + final WashProgress? progress; + + @override + Widget build(BuildContext context) { + final p = progress ?? wash.liveProgress; + final steps = _stepsForType(wash.machineType); + final currentIndex = steps.indexWhere((s) => s.key == p.phase); + final activeStep = currentIndex >= 0 ? currentIndex : 0; + + return Card( + margin: const EdgeInsets.only(bottom: 10), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppColors.machineRunning.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.local_laundry_service, color: AppColors.machineRunning), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(wash.machineName, style: Theme.of(context).textTheme.titleMedium), + Text( + p.phaseLabel, + style: TextStyle( + color: AppColors.machineRunning, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + ), + Text( + '${p.percent}%', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.machineRunning, + ), + ), + ], + ), + const SizedBox(height: 14), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: p.percent / 100, + minHeight: 8, + backgroundColor: AppColors.machineRunning.withValues(alpha: 0.12), + color: AppColors.machineRunning, + ), + ), + const SizedBox(height: 14), + Row( + children: List.generate(steps.length, (index) { + final step = steps[index]; + final isDone = index < activeStep; + final isActive = index == activeStep; + final color = isDone || isActive + ? AppColors.machineRunning + : AppColors.textSecondary.withValues(alpha: 0.35); + + return Expanded( + child: Column( + children: [ + Icon( + isDone ? Icons.check_circle : step.icon, + size: 20, + color: color, + ), + const SizedBox(height: 4), + Text( + step.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 9, + fontWeight: isActive ? FontWeight.w700 : FontWeight.normal, + color: isActive ? AppColors.machineRunning : AppColors.textSecondary, + ), + ), + ], + ), + ); + }), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + p.remainingLabel.isNotEmpty ? p.remainingLabel : 'Cycle en cours…', + style: Theme.of(context).textTheme.bodyMedium, + ), + Text( + format.format(wash.cost), + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ], + ), + ], + ), + ), + ); + } + + static List<_WashStep> _stepsForType(String? type) { + if (type?.startsWith('dryer') ?? false) { + return const [ + _WashStep('lock', 'Verrou', Icons.lock_outline), + _WashStep('heat', 'Chauffe', Icons.whatshot_outlined), + _WashStep('dry', 'Sèche', Icons.air), + _WashStep('finish', 'Fin', Icons.check), + ]; + } + return const [ + _WashStep('lock', 'Verrou', Icons.lock_outline), + _WashStep('fill', 'Eau', Icons.water_drop_outlined), + _WashStep('wash', 'Lave', Icons.local_laundry_service_outlined), + _WashStep('rinse', 'Rince', Icons.waves_outlined), + _WashStep('spin', 'Essore', Icons.rotate_right), + _WashStep('finish', 'Fin', Icons.check), + ]; + } +} + +class _WashStep { + const _WashStep(this.key, this.label, this.icon); + final String key; + final String label; + final IconData icon; +} diff --git a/pubspec.lock b/pubspec.lock index 9acbb37..46ef13a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -317,6 +317,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: d234581c090526676fd8fab4ada92f35c6746e3fb4f05a399665d75a399fb760 + url: "https://pub.dev" + source: hosted + version: "5.2.3" objective_c: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9146b3c..d2adcfd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: flutter_riverpod: ^2.6.1 go_router: ^14.8.1 dio: ^5.8.0+1 + mobile_scanner: ^5.2.3 flutter_secure_storage: ^9.2.4 intl: ^0.20.2