Integration fonctionnalités V1 ( resas + gestion machines
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
kotlin version: 2.3.20
|
||||
error message: Daemon compilation failed
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<application
|
||||
android:label="laverie_mobile"
|
||||
android:name="${applicationName}"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Scanner les QR codes des machines de laverie.</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
|
||||
@@ -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<String?> Function()? getAccessToken;
|
||||
final Future<void> Function()? onUnauthorized;
|
||||
final Future<String?> Function()? onRefreshToken;
|
||||
final Future<void> Function()? onSessionExpired;
|
||||
final Dio _dio;
|
||||
|
||||
Dio get dio => _dio;
|
||||
@@ -94,6 +114,7 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
return ApiClient(
|
||||
baseUrl: kApiBaseUrl,
|
||||
getAccessToken: () async => ref.read(authProvider).accessToken,
|
||||
onUnauthorized: () async => authNotifier.logout(),
|
||||
onRefreshToken: () => authNotifier.refreshAccessToken(),
|
||||
onSessionExpired: () => authNotifier.sessionExpired(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<String, dynamic> payload(dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
if (data['data'] is Map<String, dynamic>) {
|
||||
return data['data'] as Map<String, dynamic>;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
throw StateError('Réponse API inattendue');
|
||||
}
|
||||
|
||||
static List<dynamic> list(dynamic data, String key) {
|
||||
final value = payload(data)[key];
|
||||
if (value is List<dynamic>) {
|
||||
return value;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
static Map<String, dynamic> object(dynamic data, String key) {
|
||||
final value = payload(data)[key];
|
||||
if (value is Map<String, dynamic>) {
|
||||
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<String, dynamic>) {
|
||||
final data = response!.data as Map<String, dynamic>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<AuthState> {
|
||||
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<String?> 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<void> sessionExpired() async {
|
||||
await logout();
|
||||
}
|
||||
|
||||
Future<void> _restoreSession() async {
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
@@ -149,8 +171,16 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
}
|
||||
|
||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||
// Client dédié à l'auth pour éviter une dépendance circulaire avec apiClientProvider.
|
||||
return AuthRepository(apiClient: ApiClient(baseUrl: kApiBaseUrl));
|
||||
final 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<AuthNotifier, AuthState>((ref) {
|
||||
|
||||
@@ -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<String, dynamic>);
|
||||
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<String, dynamic>);
|
||||
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<String, dynamic>);
|
||||
final tokens = AuthTokens.fromJson(ApiResponse.payload(response.data));
|
||||
await _persistTokens(tokens);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
Future<AuthUser?> 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<String, dynamic>?;
|
||||
|
||||
if (data is Map<String, dynamic>) {
|
||||
if (data.containsKey('data')) {
|
||||
return AuthUser.fromJson(data['data'] as Map<String, dynamic>);
|
||||
}
|
||||
return AuthUser.fromJson(data);
|
||||
if (userJson != null) {
|
||||
return AuthUser.fromJson(userJson);
|
||||
}
|
||||
|
||||
return null;
|
||||
return AuthUser.fromJson(payload);
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
|
||||
@@ -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<GoRouter>((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<GoRouter>((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<GoRouter>((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<GoRouter>((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);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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!)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Machine> 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<void> 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<void>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),␍
|
||||
],␍
|
||||
);␍
|
||||
}␍
|
||||
}␍
|
||||
@@ -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<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<LoginScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkHealth() async {
|
||||
setState(() {
|
||||
_isCheckingHealth = true;
|
||||
_healthResult = null;
|
||||
_healthHasError = false;
|
||||
});
|
||||
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final lines = <String>[
|
||||
'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<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
@@ -34,6 +120,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
);
|
||||
|
||||
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<LoginScreen> {
|
||||
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<LoginScreen> {
|
||||
),
|
||||
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<LoginScreen> {
|
||||
? 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<LoginScreen> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RegisterScreen> {
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<List<Booking>> 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<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
Future<Booking> fetchBooking(String uuid) async {
|
||||
final response = await _apiClient.get(ApiEndpoints.booking(uuid));
|
||||
final json = ApiResponse.object(response.data, 'booking');
|
||||
return Booking.fromJson(json);
|
||||
}
|
||||
|
||||
Future<Booking> 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<Booking> 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<Booking> 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<BookingRepository>((ref) {
|
||||
return BookingRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final bookingsProvider = FutureProvider<List<Booking>>((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<Booking, String>((ref, uuid) async {
|
||||
return ref.watch(bookingRepositoryProvider).fetchBooking(uuid);
|
||||
});
|
||||
@@ -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<String, dynamic> json) {
|
||||
final machine = json['machine'] as Map<String, dynamic>?;
|
||||
|
||||
|
||||
@@ -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<BookingModifyScreen> createState() => _BookingModifyScreenState();
|
||||
}
|
||||
|
||||
class _BookingModifyScreenState extends ConsumerState<BookingModifyScreen> {
|
||||
DateTime? _selectedDate;
|
||||
TimeSlot? _selectedSlot;
|
||||
bool _isSaving = false;
|
||||
|
||||
Future<void> _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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<List<Booking>>((ref) async {
|
||||
final response = await ref.watch(apiClientProvider).get(ApiEndpoints.bookings);
|
||||
final data = response.data;
|
||||
|
||||
List<dynamic> list;
|
||||
if (data is List<dynamic>) {
|
||||
list = data;
|
||||
} else if (data is Map<String, dynamic> && data['data'] is List<dynamic>) {
|
||||
list = data['data'] as List<dynamic>;
|
||||
} else {
|
||||
list = [];
|
||||
}
|
||||
|
||||
return list
|
||||
.map((json) => Booking.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
/// Écran listant les réservations de l'utilisateur.
|
||||
/// Écran listant les réservations avec annulation et modification.
|
||||
class BookingsScreen extends ConsumerWidget {
|
||||
const BookingsScreen({super.key});
|
||||
|
||||
Future<void> _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<bool>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<List<Establishment>> 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<String, dynamic>))
|
||||
@@ -21,27 +22,9 @@ class EstablishmentRepository {
|
||||
|
||||
Future<Establishment> 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<dynamic> _extractList(dynamic data) {
|
||||
if (data is List<dynamic>) return data;
|
||||
if (data is Map<String, dynamic> && data['data'] is List<dynamic>) {
|
||||
return data['data'] as List<dynamic>;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Map<String, dynamic> _extractObject(dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
if (data['data'] is Map<String, dynamic>) {
|
||||
return data['data'] as Map<String, dynamic>;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
throw StateError('Réponse API inattendue');
|
||||
}
|
||||
}
|
||||
|
||||
final establishmentRepositoryProvider = Provider<EstablishmentRepository>((ref) {
|
||||
|
||||
@@ -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<EstablishmentDetailScreen> createState() => _EstablishmentDetailScreenState();
|
||||
}
|
||||
|
||||
class _EstablishmentDetailScreenState extends ConsumerState<EstablishmentDetailScreen> {
|
||||
String _filter = 'all';
|
||||
|
||||
List<Machine> _filterMachines(List<Machine> 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<String> onFilterChanged;
|
||||
final Future<void> Function() onRefresh;
|
||||
final List<Machine> machines;
|
||||
final ValueChanged<Machine> 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<MachineDetail> 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<MachineDetail> fetchDetail(String uuid) async {
|
||||
final response = await _apiClient.get(ApiEndpoints.machine(uuid));
|
||||
return MachineDetail.fromJson(ApiResponse.payload(response.data));
|
||||
}
|
||||
|
||||
Future<List<TimeSlot>> 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<dynamic>? ?? [];
|
||||
return slots.map((s) => TimeSlot.fromJson(s as Map<String, dynamic>)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
final machineRepositoryProvider = Provider<MachineRepository>((ref) {
|
||||
return MachineRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final machineDetailProvider = FutureProvider.family<MachineDetail, String>((ref, uuid) async {
|
||||
return ref.watch(machineRepositoryProvider).fetchDetail(uuid);
|
||||
});
|
||||
|
||||
final machineAvailabilityProvider =
|
||||
FutureProvider.family<List<TimeSlot>, ({String uuid, DateTime date})>((ref, params) async {
|
||||
return ref.watch(machineRepositoryProvider).fetchAvailability(params.uuid, params.date);
|
||||
});
|
||||
@@ -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<String, dynamic> json) {
|
||||
final machineJson = json['machine'] as Map<String, dynamic>;
|
||||
final pricing = json['pricing'] as Map<String, dynamic>?;
|
||||
|
||||
String? establishmentName;
|
||||
final establishment = machineJson['establishment'];
|
||||
if (establishment is Map<String, dynamic>) {
|
||||
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<String, dynamic> json) {
|
||||
return TimeSlot(
|
||||
start: DateTime.parse(json['start'] as String),
|
||||
end: DateTime.parse(json['end'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<MachineActionScreen> createState() => _MachineActionScreenState();
|
||||
}
|
||||
|
||||
class _MachineActionScreenState extends ConsumerState<MachineActionScreen> {
|
||||
bool _isStarting = false;
|
||||
|
||||
Future<void> _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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<MachineBookingScreen> createState() => _MachineBookingScreenState();
|
||||
}
|
||||
|
||||
class _MachineBookingScreenState extends ConsumerState<MachineBookingScreen> {
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
TimeSlot? _selectedSlot;
|
||||
bool _isBooking = false;
|
||||
|
||||
Future<void> _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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Wallet> 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<List<WalletTransaction>> 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<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<dynamic> _extractList(dynamic data) {
|
||||
if (data is List<dynamic>) return data;
|
||||
if (data is Map<String, dynamic> && data['data'] is List<dynamic>) {
|
||||
return data['data'] as List<dynamic>;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Map<String, dynamic> _extractObject(dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
if (data['data'] is Map<String, dynamic>) {
|
||||
return data['data'] as Map<String, dynamic>;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
throw StateError('Réponse API inattendue');
|
||||
}
|
||||
}
|
||||
|
||||
final walletRepositoryProvider = Provider<WalletRepository>((ref) {
|
||||
@@ -49,10 +33,20 @@ final walletRepositoryProvider = Provider<WalletRepository>((ref) {
|
||||
});
|
||||
|
||||
final walletProvider = FutureProvider<Wallet>((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<List<WalletTransaction>>((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();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ 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.␍
|
||||
@@ -14,102 +16,114 @@ class WalletScreen extends ConsumerWidget {
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
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: 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');
|
||||
}
|
||||
),␍
|
||||
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';
|
||||
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,
|
||||
),
|
||||
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,␍
|
||||
),␍
|
||||
),␍
|
||||
);
|
||||
}).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'),
|
||||
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(),␍
|
||||
);␍
|
||||
},␍
|
||||
),␍
|
||||
],␍
|
||||
),␍
|
||||
);␍
|
||||
}␍
|
||||
|
||||
@@ -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<String, dynamic>) {
|
||||
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<List<Wash>> 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<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<Wash> 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<Wash> startWashFromQr(String scannedValue) async {
|
||||
final reference = QrMachineReference.parse(scannedValue);
|
||||
if (!reference.isValid) {
|
||||
throw const FormatException('QR code invalide ou vide');
|
||||
}
|
||||
|
||||
final payload = <String, dynamic>{
|
||||
'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<WashRepository>((ref) {
|
||||
return WashRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final washesProvider = FutureProvider<List<Wash>>((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();
|
||||
});
|
||||
@@ -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<String, dynamic> json) {
|
||||
final machine = json['machine'] as Map<String, dynamic>?;
|
||||
final progressJson = json['progress'] as Map<String, dynamic>?;
|
||||
|
||||
return Wash(
|
||||
uuid: json['uuid'] as String,
|
||||
machineUuid: machine?['uuid'] as String? ?? json['machine_uuid'] as String? ?? '',
|
||||
machineName: machine?['name'] as String? ?? 'Machine',
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, dynamic>? 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<QrScannerScreen> createState() => _QrScannerScreenState();
|
||||
}
|
||||
|
||||
class _QrScannerScreenState extends ConsumerState<QrScannerScreen> {
|
||||
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<void> _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<void> _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;
|
||||
}
|
||||
@@ -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<List<Wash>>((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<dynamic> list;
|
||||
if (data is List<dynamic>) {
|
||||
list = data;
|
||||
} else if (data is Map<String, dynamic> && data['data'] is List<dynamic>) {
|
||||
list = data['data'] as List<dynamic>;
|
||||
} else {
|
||||
list = [];
|
||||
}
|
||||
|
||||
return list.map((json) => Wash.fromJson(json as Map<String, dynamic>)).toList();
|
||||
});
|
||||
|
||||
/// Écran historique et démarrage de lavage.
|
||||
class WashScreen extends ConsumerWidget {
|
||||
/// Écran historique et lavages en cours avec progression.
|
||||
class WashScreen extends ConsumerStatefulWidget {
|
||||
const WashScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<WashScreen> createState() => _WashScreenState();
|
||||
}
|
||||
|
||||
class _WashScreenState extends ConsumerState<WashScreen> {
|
||||
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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user