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">
|
<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
|
<application
|
||||||
android:label="laverie_mobile"
|
android:label="laverie_mobile"
|
||||||
android:name="${applicationName}"
|
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
|
android.useAndroidX=true
|
||||||
# This newDsl flag was added by the Flutter template
|
# This newDsl flag was added by the Flutter template
|
||||||
android.newDsl=false
|
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
|
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>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Scanner les QR codes des machines de laverie.</string>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ class ApiClient {
|
|||||||
ApiClient({
|
ApiClient({
|
||||||
required this.baseUrl,
|
required this.baseUrl,
|
||||||
this.getAccessToken,
|
this.getAccessToken,
|
||||||
this.onUnauthorized,
|
this.onRefreshToken,
|
||||||
|
this.onSessionExpired,
|
||||||
}) : _dio = Dio(
|
}) : _dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
@@ -31,9 +32,27 @@ class ApiClient {
|
|||||||
handler.next(options);
|
handler.next(options);
|
||||||
},
|
},
|
||||||
onError: (error, handler) async {
|
onError: (error, handler) async {
|
||||||
if (error.response?.statusCode == 401) {
|
final response = error.response;
|
||||||
await onUnauthorized?.call();
|
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);
|
handler.next(error);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -42,7 +61,8 @@ class ApiClient {
|
|||||||
|
|
||||||
final String baseUrl;
|
final String baseUrl;
|
||||||
final Future<String?> Function()? getAccessToken;
|
final Future<String?> Function()? getAccessToken;
|
||||||
final Future<void> Function()? onUnauthorized;
|
final Future<String?> Function()? onRefreshToken;
|
||||||
|
final Future<void> Function()? onSessionExpired;
|
||||||
final Dio _dio;
|
final Dio _dio;
|
||||||
|
|
||||||
Dio get dio => _dio;
|
Dio get dio => _dio;
|
||||||
@@ -94,6 +114,7 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
|||||||
return ApiClient(
|
return ApiClient(
|
||||||
baseUrl: kApiBaseUrl,
|
baseUrl: kApiBaseUrl,
|
||||||
getAccessToken: () async => ref.read(authProvider).accessToken,
|
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.
|
/// Chemins des endpoints REST de l'API Laverie v1.
|
||||||
abstract final class ApiEndpoints {
|
abstract final class ApiEndpoints {
|
||||||
|
// Santé / monitoring
|
||||||
|
static const health = '/health';
|
||||||
|
static const healthDb = '/health/db';
|
||||||
|
|
||||||
// Authentification utilisateur
|
// Authentification utilisateur
|
||||||
static const authRegister = '/auth/register';
|
static const authRegister = '/auth/register';
|
||||||
static const authLogin = '/auth/login';
|
static const authLogin = '/auth/login';
|
||||||
@@ -17,6 +21,7 @@ abstract final class ApiEndpoints {
|
|||||||
static const establishments = '/establishments';
|
static const establishments = '/establishments';
|
||||||
static String establishment(String uuid) => '/establishments/$uuid';
|
static String establishment(String uuid) => '/establishments/$uuid';
|
||||||
static String machine(String uuid) => '/machines/$uuid';
|
static String machine(String uuid) => '/machines/$uuid';
|
||||||
|
static const machineLookup = '/machines/lookup';
|
||||||
static String machineAvailability(String uuid) => '/machines/$uuid/availability';
|
static String machineAvailability(String uuid) => '/machines/$uuid/availability';
|
||||||
static String machinePricing(String uuid) => '/machines/$uuid/pricing';
|
static String machinePricing(String uuid) => '/machines/$uuid/pricing';
|
||||||
|
|
||||||
@@ -24,6 +29,7 @@ abstract final class ApiEndpoints {
|
|||||||
static const bookings = '/bookings';
|
static const bookings = '/bookings';
|
||||||
static String booking(String uuid) => '/bookings/$uuid';
|
static String booking(String uuid) => '/bookings/$uuid';
|
||||||
static String bookingCancel(String uuid) => '/bookings/$uuid/cancel';
|
static String bookingCancel(String uuid) => '/bookings/$uuid/cancel';
|
||||||
|
static String bookingMove(String uuid) => '/bookings/$uuid/move';
|
||||||
|
|
||||||
// Lavages
|
// Lavages
|
||||||
static const washes = '/washes';
|
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 '../api/api_client.dart';
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
|
import '../config/secure_storage_config.dart';
|
||||||
import '../../features/auth/domain/auth_user.dart';
|
import '../../features/auth/domain/auth_user.dart';
|
||||||
import 'auth_repository.dart';
|
import 'auth_repository.dart';
|
||||||
|
|
||||||
@@ -44,12 +45,33 @@ class AuthState {
|
|||||||
|
|
||||||
/// Gestionnaire d'état Riverpod pour l'authentification.
|
/// Gestionnaire d'état Riverpod pour l'authentification.
|
||||||
class AuthNotifier extends StateNotifier<AuthState> {
|
class AuthNotifier extends StateNotifier<AuthState> {
|
||||||
AuthNotifier(this._repository) : super(const AuthState()) {
|
AuthNotifier(this._repository) : super(const AuthState(isLoading: true)) {
|
||||||
_restoreSession();
|
_restoreSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
final AuthRepository _repository;
|
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 {
|
Future<void> _restoreSession() async {
|
||||||
state = state.copyWith(isLoading: true, clearError: true);
|
state = state.copyWith(isLoading: true, clearError: true);
|
||||||
|
|
||||||
@@ -149,8 +171,16 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||||
// Client dédié à l'auth pour éviter une dépendance circulaire avec apiClientProvider.
|
final storage = laverieSecureStorage;
|
||||||
return AuthRepository(apiClient: ApiClient(baseUrl: kApiBaseUrl));
|
|
||||||
|
// 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) {
|
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_client.dart';
|
||||||
import '../api/api_endpoints.dart';
|
import '../api/api_endpoints.dart';
|
||||||
|
import '../api/api_response.dart';
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import '../config/secure_storage_config.dart';
|
import '../config/secure_storage_config.dart';
|
||||||
import '../../features/auth/domain/auth_user.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);
|
await _persistTokens(tokens);
|
||||||
return 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);
|
await _persistTokens(tokens);
|
||||||
return tokens;
|
return tokens;
|
||||||
}
|
}
|
||||||
@@ -68,23 +69,21 @@ class AuthRepository {
|
|||||||
data: {'refresh_token': refreshToken},
|
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);
|
await _persistTokens(tokens);
|
||||||
return tokens;
|
return tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<AuthUser?> fetchCurrentUser() async {
|
Future<AuthUser?> fetchCurrentUser() async {
|
||||||
final response = await _apiClient.get(ApiEndpoints.authMe);
|
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 (userJson != null) {
|
||||||
if (data.containsKey('data')) {
|
return AuthUser.fromJson(userJson);
|
||||||
return AuthUser.fromJson(data['data'] as Map<String, dynamic>);
|
|
||||||
}
|
|
||||||
return AuthUser.fromJson(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return AuthUser.fromJson(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
|
|||||||
@@ -5,23 +5,34 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../auth/auth_provider.dart';
|
import '../auth/auth_provider.dart';
|
||||||
import '../../features/auth/presentation/login_screen.dart';
|
import '../../features/auth/presentation/login_screen.dart';
|
||||||
import '../../features/auth/presentation/register_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/home/presentation/home_screen.dart';
|
||||||
import '../../features/establishments/presentation/establishment_detail_screen.dart';
|
import '../../features/establishments/presentation/establishment_detail_screen.dart';
|
||||||
import '../../features/wallet/presentation/wallet_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/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/wash/presentation/wash_screen.dart';
|
||||||
import '../../features/profile/presentation/profile_screen.dart';
|
import '../../features/profile/presentation/profile_screen.dart';
|
||||||
|
import '../widgets/main_shell.dart';
|
||||||
|
|
||||||
/// Routes nommées de l'application.
|
/// Routes nommées de l'application.
|
||||||
abstract final class AppRoutes {
|
abstract final class AppRoutes {
|
||||||
|
static const splash = '/splash';
|
||||||
static const login = '/login';
|
static const login = '/login';
|
||||||
static const register = '/register';
|
static const register = '/register';
|
||||||
static const home = '/';
|
static const home = '/';
|
||||||
static const wallet = '/wallet';
|
static const wallet = '/wallet';
|
||||||
static const bookings = '/bookings';
|
static const bookings = '/bookings';
|
||||||
static const washes = '/washes';
|
static const washes = '/washes';
|
||||||
|
static const washScan = '/washes/scan';
|
||||||
static const profile = '/profile';
|
static const profile = '/profile';
|
||||||
static const establishment = '/establishments/:uuid';
|
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.
|
/// Configuration GoRouter avec redirection selon l'état d'authentification.
|
||||||
@@ -29,12 +40,24 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: AppRoutes.home,
|
initialLocation: AppRoutes.splash,
|
||||||
refreshListenable: GoRouterRefreshStream(ref),
|
refreshListenable: GoRouterRefreshStream(ref),
|
||||||
redirect: (context, state) {
|
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 isAuthenticated = authState.isAuthenticated;
|
||||||
final isAuthRoute = state.matchedLocation == AppRoutes.login ||
|
|
||||||
state.matchedLocation == AppRoutes.register;
|
|
||||||
|
|
||||||
if (!isAuthenticated && !isAuthRoute) {
|
if (!isAuthenticated && !isAuthRoute) {
|
||||||
return AppRoutes.login;
|
return AppRoutes.login;
|
||||||
@@ -47,6 +70,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.splash,
|
||||||
|
builder: (context, state) => const SplashScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.login,
|
path: AppRoutes.login,
|
||||||
builder: (context, state) => const LoginScreen(),
|
builder: (context, state) => const LoginScreen(),
|
||||||
@@ -55,9 +82,30 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
path: AppRoutes.register,
|
path: AppRoutes.register,
|
||||||
builder: (context, state) => const RegisterScreen(),
|
builder: (context, state) => const RegisterScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
ShellRoute(
|
||||||
path: AppRoutes.home,
|
builder: (context, state, child) => MainShell(child: child),
|
||||||
builder: (context, state) => const HomeScreen(),
|
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(
|
GoRoute(
|
||||||
path: AppRoutes.establishment,
|
path: AppRoutes.establishment,
|
||||||
@@ -67,20 +115,29 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.wallet,
|
path: AppRoutes.washScan,
|
||||||
builder: (context, state) => const WalletScreen(),
|
builder: (context, state) => const QrScannerScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.bookings,
|
path: '/machines/:uuid/action',
|
||||||
builder: (context, state) => const BookingsScreen(),
|
builder: (context, state) {
|
||||||
|
final uuid = state.pathParameters['uuid']!;
|
||||||
|
return MachineActionScreen(machineUuid: uuid);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.washes,
|
path: '/machines/:uuid/book',
|
||||||
builder: (context, state) => const WashScreen(),
|
builder: (context, state) {
|
||||||
|
final uuid = state.pathParameters['uuid']!;
|
||||||
|
return MachineBookingScreen(machineUuid: uuid);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.profile,
|
path: '/bookings/:uuid/edit',
|
||||||
builder: (context, state) => const ProfileScreen(),
|
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/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'app_colors.dart';
|
||||||
|
|
||||||
/// Thème visuel de l'application Laverie.
|
/// Thème visuel de l'application Laverie.
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
AppTheme._();
|
AppTheme._();
|
||||||
|
|
||||||
static const Color _primary = Color(0xFF1565C0);
|
|
||||||
static const Color _secondary = Color(0xFF26A69A);
|
|
||||||
|
|
||||||
static ThemeData get light {
|
static ThemeData get light {
|
||||||
final colorScheme = ColorScheme.fromSeed(
|
const colorScheme = ColorScheme.light(
|
||||||
seedColor: _primary,
|
primary: AppColors.primary,
|
||||||
secondary: _secondary,
|
onPrimary: Colors.white,
|
||||||
brightness: Brightness.light,
|
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(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
appBarTheme: AppBarTheme(
|
scaffoldBackgroundColor: AppColors.background,
|
||||||
centerTitle: true,
|
appBarTheme: const AppBarTheme(
|
||||||
backgroundColor: colorScheme.primary,
|
centerTitle: false,
|
||||||
foregroundColor: colorScheme.onPrimary,
|
|
||||||
elevation: 0,
|
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(
|
cardTheme: CardThemeData(
|
||||||
elevation: 1,
|
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(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
filled: true,
|
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(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(48),
|
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)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||||
backgroundColor: colorScheme.secondary,
|
elevation: 3,
|
||||||
foregroundColor: colorScheme.onSecondary,
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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/auth/auth_provider.dart';
|
||||||
import '../../../core/router/app_router.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.
|
/// Écran de connexion utilisateur.
|
||||||
class LoginScreen extends ConsumerStatefulWidget {
|
class LoginScreen extends ConsumerStatefulWidget {
|
||||||
@@ -17,6 +27,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _emailController = TextEditingController(text: 'marie.dupont@demo.local');
|
final _emailController = TextEditingController(text: 'marie.dupont@demo.local');
|
||||||
final _passwordController = TextEditingController(text: 'password');
|
final _passwordController = TextEditingController(text: 'password');
|
||||||
|
bool _isCheckingHealth = false;
|
||||||
|
String? _healthResult;
|
||||||
|
bool _healthHasError = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -25,6 +38,79 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
super.dispose();
|
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 {
|
Future<void> _submit() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
@@ -34,6 +120,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
|
ref.invalidate(washesProvider);
|
||||||
|
ref.invalidate(bookingsProvider);
|
||||||
|
ref.invalidate(walletProvider);
|
||||||
|
ref.invalidate(walletTransactionsProvider);
|
||||||
context.go(AppRoutes.home);
|
context.go(AppRoutes.home);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,32 +132,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return AuthScaffold(
|
||||||
body: SafeArea(
|
child: AuthFormCard(
|
||||||
child: Center(
|
child: Form(
|
||||||
child: SingleChildScrollView(
|
key: _formKey,
|
||||||
padding: const EdgeInsets.all(24),
|
child: Column(
|
||||||
child: Form(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
key: _formKey,
|
children: [
|
||||||
child: Column(
|
const AuthHeader(subtitle: 'Connectez-vous pour réserver et laver'),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
TextFormField(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.local_laundry_service, size: 72, color: Theme.of(context).colorScheme.primary),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Laverie Connectée',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Connectez-vous pour réserver et laver',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
TextFormField(
|
|
||||||
controller: _emailController,
|
controller: _emailController,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
@@ -98,10 +171,25 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
),
|
),
|
||||||
if (authState.error != null) ...[
|
if (authState.error != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Container(
|
||||||
authState.error!,
|
padding: const EdgeInsets.all(12),
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
decoration: BoxDecoration(
|
||||||
textAlign: TextAlign.center,
|
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),
|
const SizedBox(height: 24),
|
||||||
@@ -111,7 +199,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
width: 20,
|
width: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
)
|
)
|
||||||
: const Text('Se connecter'),
|
: const Text('Se connecter'),
|
||||||
),
|
),
|
||||||
@@ -120,12 +208,45 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
onPressed: () => context.push(AppRoutes.register),
|
onPressed: () => context.push(AppRoutes.register),
|
||||||
child: const Text('Créer un compte'),
|
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/auth/auth_provider.dart';
|
||||||
import '../../../core/router/app_router.dart';
|
import '../../../core/router/app_router.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import 'auth_widgets.dart';
|
||||||
|
|
||||||
/// Écran d'inscription utilisateur.
|
/// Écran d'inscription utilisateur.
|
||||||
class RegisterScreen extends ConsumerStatefulWidget {
|
class RegisterScreen extends ConsumerStatefulWidget {
|
||||||
@@ -53,72 +55,82 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return AuthScaffold(
|
||||||
appBar: AppBar(title: const Text('Inscription')),
|
showBackButton: true,
|
||||||
body: SafeArea(
|
child: AuthFormCard(
|
||||||
child: SingleChildScrollView(
|
child: Form(
|
||||||
padding: const EdgeInsets.all(24),
|
key: _formKey,
|
||||||
child: Form(
|
child: Column(
|
||||||
key: _formKey,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
const AuthHeader(subtitle: 'Créez votre compte en quelques secondes'),
|
||||||
children: [
|
TextFormField(
|
||||||
TextFormField(
|
controller: _firstNameController,
|
||||||
controller: _firstNameController,
|
decoration: const InputDecoration(
|
||||||
decoration: const InputDecoration(labelText: 'Prénom'),
|
labelText: 'Prénom',
|
||||||
validator: (v) => v == null || v.isEmpty ? 'Prénom requis' : null,
|
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),
|
const SizedBox(height: 12),
|
||||||
TextFormField(
|
Text(authState.error!, style: const TextStyle(color: AppColors.error)),
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
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 String status;
|
||||||
final double bookingFee;
|
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) {
|
factory Booking.fromJson(Map<String, dynamic> json) {
|
||||||
final machine = json['machine'] as Map<String, dynamic>?;
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../../../core/api/api_client.dart';
|
import '../../../core/router/app_router.dart';
|
||||||
import '../../../core/api/api_endpoints.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';
|
import '../domain/booking.dart';
|
||||||
|
|
||||||
/// Fournisseur des réservations de l'utilisateur connecté.
|
/// Écran listant les réservations avec annulation et modification.
|
||||||
final bookingsProvider = FutureProvider<List<Booking>>((ref) async {
|
|
||||||
final response = await ref.watch(apiClientProvider).get(ApiEndpoints.bookings);
|
|
||||||
final data = response.data;
|
|
||||||
|
|
||||||
List<dynamic> list;
|
|
||||||
if (data is List<dynamic>) {
|
|
||||||
list = data;
|
|
||||||
} else if (data is Map<String, dynamic> && data['data'] is List<dynamic>) {
|
|
||||||
list = data['data'] as List<dynamic>;
|
|
||||||
} else {
|
|
||||||
list = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return list
|
|
||||||
.map((json) => Booking.fromJson(json as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Écran listant les réservations de l'utilisateur.
|
|
||||||
class BookingsScreen extends ConsumerWidget {
|
class BookingsScreen extends ConsumerWidget {
|
||||||
const BookingsScreen({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final bookingsAsync = ref.watch(bookingsProvider);
|
final bookingsAsync = ref.watch(bookingsProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return bookingsAsync.when(
|
||||||
appBar: AppBar(title: const Text('Mes réservations')),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
body: bookingsAsync.when(
|
error: (error, _) => EmptyState(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
icon: Icons.event_busy_outlined,
|
||||||
error: (error, _) => Center(
|
title: 'Erreur',
|
||||||
child: Column(
|
subtitle: 'Impossible de charger vos réservations.',
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
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: [
|
children: [
|
||||||
Text('Erreur : $error'),
|
const ScreenHeader(
|
||||||
const SizedBox(height: 12),
|
title: 'Mes créneaux',
|
||||||
ElevatedButton(
|
subtitle: '30 min pour démarrer après notification',
|
||||||
onPressed: () => ref.invalidate(bookingsProvider),
|
|
||||||
child: const Text('Réessayer'),
|
|
||||||
),
|
),
|
||||||
|
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(
|
class _BookingCard extends StatelessWidget {
|
||||||
onRefresh: () async => ref.invalidate(bookingsProvider),
|
const _BookingCard({
|
||||||
child: ListView.separated(
|
required this.booking,
|
||||||
padding: const EdgeInsets.all(16),
|
required this.highlight,
|
||||||
itemCount: bookings.length,
|
this.onModify,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
this.onCancel,
|
||||||
itemBuilder: (context, index) {
|
});
|
||||||
final booking = bookings[index];
|
|
||||||
return Card(
|
final Booking booking;
|
||||||
child: ListTile(
|
final bool highlight;
|
||||||
leading: const Icon(Icons.event),
|
final VoidCallback? onModify;
|
||||||
title: Text(booking.machineName),
|
final VoidCallback? onCancel;
|
||||||
subtitle: Text(
|
|
||||||
booking.slotStart != null
|
@override
|
||||||
? DateFormat('dd/MM/yyyy HH:mm').format(booking.slotStart!)
|
Widget build(BuildContext context) {
|
||||||
: 'Créneau à confirmer',
|
final slotText = booking.slotStart != null
|
||||||
),
|
? DateFormat('EEEE dd MMM · HH:mm', 'fr_FR').format(booking.slotStart!)
|
||||||
trailing: Chip(label: Text(booking.status)),
|
: '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_client.dart';
|
||||||
import '../../../core/api/api_endpoints.dart';
|
import '../../../core/api/api_endpoints.dart';
|
||||||
|
import '../../../core/api/api_response.dart';
|
||||||
import '../domain/establishment.dart';
|
import '../domain/establishment.dart';
|
||||||
|
|
||||||
/// Dépôt de données pour les établissements et machines.
|
/// Dépôt de données pour les établissements et machines.
|
||||||
@@ -12,7 +13,7 @@ class EstablishmentRepository {
|
|||||||
|
|
||||||
Future<List<Establishment>> fetchEstablishments() async {
|
Future<List<Establishment>> fetchEstablishments() async {
|
||||||
final response = await _apiClient.get(ApiEndpoints.establishments);
|
final response = await _apiClient.get(ApiEndpoints.establishments);
|
||||||
final data = _extractList(response.data);
|
final data = ApiResponse.list(response.data, 'establishments');
|
||||||
|
|
||||||
return data
|
return data
|
||||||
.map((json) => Establishment.fromJson(json as Map<String, dynamic>))
|
.map((json) => Establishment.fromJson(json as Map<String, dynamic>))
|
||||||
@@ -21,27 +22,9 @@ class EstablishmentRepository {
|
|||||||
|
|
||||||
Future<Establishment> fetchEstablishment(String uuid) async {
|
Future<Establishment> fetchEstablishment(String uuid) async {
|
||||||
final response = await _apiClient.get(ApiEndpoints.establishment(uuid));
|
final response = await _apiClient.get(ApiEndpoints.establishment(uuid));
|
||||||
final json = _extractObject(response.data);
|
final json = ApiResponse.object(response.data, 'establishment');
|
||||||
return Establishment.fromJson(json);
|
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) {
|
final establishmentRepositoryProvider = Provider<EstablishmentRepository>((ref) {
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/data/establishment_repository.dart';
|
||||||
import '../../establishments/domain/establishment.dart';
|
import '../../establishments/domain/establishment.dart';
|
||||||
|
|
||||||
/// Écran de détail d'un établissement avec liste des machines.
|
/// Écran laverie — grille machines.
|
||||||
class EstablishmentDetailScreen extends ConsumerWidget {
|
class EstablishmentDetailScreen extends ConsumerStatefulWidget {
|
||||||
const EstablishmentDetailScreen({
|
const EstablishmentDetailScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.establishmentUuid,
|
required this.establishmentUuid,
|
||||||
@@ -14,99 +20,187 @@ class EstablishmentDetailScreen extends ConsumerWidget {
|
|||||||
final String establishmentUuid;
|
final String establishmentUuid;
|
||||||
|
|
||||||
@override
|
@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 =
|
final establishmentAsync =
|
||||||
ref.watch(establishmentDetailProvider(establishmentUuid));
|
ref.watch(establishmentDetailProvider(widget.establishmentUuid));
|
||||||
|
|
||||||
return Scaffold(
|
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(
|
body: establishmentAsync.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) => Center(
|
error: (error, _) => EmptyState(
|
||||||
child: Column(
|
icon: Icons.error_outline,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
title: 'Erreur',
|
||||||
children: [
|
subtitle: '$error',
|
||||||
Text('Erreur : $error'),
|
actionLabel: 'Réessayer',
|
||||||
const SizedBox(height: 12),
|
onAction: () => ref.invalidate(establishmentDetailProvider(widget.establishmentUuid)),
|
||||||
ElevatedButton(
|
),
|
||||||
onPressed: () =>
|
data: (establishment) => _EstablishmentBody(
|
||||||
ref.invalidate(establishmentDetailProvider(establishmentUuid)),
|
establishment: establishment,
|
||||||
child: const Text('Réessayer'),
|
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 {
|
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 Establishment establishment;
|
||||||
|
final String filter;
|
||||||
|
final ValueChanged<String> onFilterChanged;
|
||||||
|
final Future<void> Function() onRefresh;
|
||||||
|
final List<Machine> machines;
|
||||||
|
final ValueChanged<Machine> onMachineTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListView(
|
return RefreshIndicator(
|
||||||
padding: const EdgeInsets.all(16),
|
onRefresh: onRefresh,
|
||||||
children: [
|
child: ListView(
|
||||||
Text(
|
padding: const EdgeInsets.all(16),
|
||||||
establishment.name,
|
children: [
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
Container(
|
||||||
),
|
padding: const EdgeInsets.all(16),
|
||||||
const SizedBox(height: 4),
|
decoration: BoxDecoration(
|
||||||
Text(establishment.fullAddress),
|
gradient: AppColors.gradientAccent,
|
||||||
const SizedBox(height: 24),
|
borderRadius: BorderRadius.circular(14),
|
||||||
Text(
|
),
|
||||||
'Machines (${establishment.machines.length})',
|
child: Column(
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
),
|
children: [
|
||||||
const SizedBox(height: 8),
|
Text(
|
||||||
if (establishment.machines.isEmpty)
|
establishment.name,
|
||||||
const Text('Aucune machine disponible')
|
style: const TextStyle(
|
||||||
else
|
color: Colors.white,
|
||||||
...establishment.machines.map((machine) => _MachineTile(machine: machine)),
|
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 {
|
class _FilterChip extends StatelessWidget {
|
||||||
const _MachineTile({required this.machine});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return FilterChip(
|
||||||
child: ListTile(
|
label: Text(label),
|
||||||
leading: Icon(
|
selected: selected,
|
||||||
machine.type.startsWith('washer') ? Icons.water_drop : Icons.air,
|
onSelected: (_) => onTap(),
|
||||||
color: _statusColor(context),
|
selectedColor: AppColors.primary.withValues(alpha: 0.15),
|
||||||
),
|
checkmarkColor: AppColors.primary,
|
||||||
title: Text(machine.name),
|
labelStyle: TextStyle(
|
||||||
subtitle: Text('${machine.typeLabel} — ${machine.statusLabel}'),
|
color: selected ? AppColors.primary : AppColors.textSecondary,
|
||||||
trailing: machine.isAvailable
|
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
|
||||||
? const Chip(label: Text('Libre'))
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,104 +2,176 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../../core/auth/auth_provider.dart';
|
||||||
import '../../../core/router/app_router.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/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 {
|
class HomeScreen extends ConsumerWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final establishmentsAsync = ref.watch(establishmentsProvider);
|
final establishmentsAsync = ref.watch(establishmentsProvider);
|
||||||
|
final washesAsync = ref.watch(washesProvider);
|
||||||
|
final user = ref.watch(authProvider).user;
|
||||||
|
|
||||||
return Scaffold(
|
return establishmentsAsync.when(
|
||||||
appBar: AppBar(
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
title: const Text('Laveries'),
|
error: (error, _) => EmptyState(
|
||||||
actions: [
|
icon: Icons.cloud_off_outlined,
|
||||||
IconButton(
|
title: 'Connexion impossible',
|
||||||
icon: const Icon(Icons.account_balance_wallet_outlined),
|
subtitle: 'Vérifiez que l\'API est démarrée\net que vous êtes sur le même réseau.',
|
||||||
onPressed: () => context.push(AppRoutes.wallet),
|
actionLabel: 'Réessayer',
|
||||||
),
|
onAction: () => ref.invalidate(establishmentsProvider),
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.person_outline),
|
|
||||||
onPressed: () => context.push(AppRoutes.profile),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
body: establishmentsAsync.when(
|
data: (establishments) {
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
if (establishments.isEmpty) {
|
||||||
error: (error, _) => Center(
|
return const EmptyState(
|
||||||
child: Padding(
|
icon: Icons.storefront_outlined,
|
||||||
padding: const EdgeInsets.all(24),
|
title: 'Aucune laverie',
|
||||||
child: Column(
|
subtitle: 'Aucun établissement disponible pour le moment.',
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
);
|
||||||
children: [
|
}
|
||||||
const Icon(Icons.cloud_off, size: 48),
|
|
||||||
const SizedBox(height: 12),
|
final activeWash = washesAsync.maybeWhen(
|
||||||
Text(
|
data: (washes) {
|
||||||
'Impossible de charger les laveries.\nVérifiez que l\'API est démarrée.',
|
for (final wash in washes) {
|
||||||
textAlign: TextAlign.center,
|
if (wash.status == 'running' || wash.status == 'pending_start' || wash.status == 'active') {
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
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),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ActiveWashBanner(
|
||||||
onPressed: () => ref.invalidate(establishmentsProvider),
|
machineName: activeWash.machineName,
|
||||||
child: const Text('Réessayer'),
|
onTap: () => context.go(AppRoutes.washes),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
),
|
Row(
|
||||||
),
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
data: (establishments) {
|
children: [
|
||||||
if (establishments.isEmpty) {
|
Text('Mes laveries', style: Theme.of(context).textTheme.titleMedium),
|
||||||
return const Center(child: Text('Aucune laverie disponible'));
|
Text(
|
||||||
}
|
'${establishments.length}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
return RefreshIndicator(
|
color: AppColors.primary,
|
||||||
onRefresh: () async => ref.invalidate(establishmentsProvider),
|
fontWeight: FontWeight.w600,
|
||||||
child: ListView.separated(
|
),
|
||||||
padding: const EdgeInsets.all(16),
|
),
|
||||||
itemCount: establishments.length,
|
],
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
),
|
||||||
itemBuilder: (context, index) {
|
const SizedBox(height: 10),
|
||||||
final establishment = establishments[index];
|
...establishments.map(
|
||||||
return Card(
|
(establishment) => Padding(
|
||||||
child: ListTile(
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
leading: CircleAvatar(
|
child: _EstablishmentCard(
|
||||||
child: Text(establishment.name.substring(0, 1)),
|
establishment: establishment,
|
||||||
),
|
|
||||||
title: Text(establishment.name),
|
|
||||||
subtitle: Text(establishment.fullAddress),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () => context.push('/establishments/${establishment.uuid}'),
|
onTap: () => context.push('/establishments/${establishment.uuid}'),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
),
|
],
|
||||||
);
|
),
|
||||||
},
|
);
|
||||||
),
|
},
|
||||||
bottomNavigationBar: NavigationBar(
|
);
|
||||||
selectedIndex: 0,
|
}
|
||||||
onDestinationSelected: (index) {
|
}
|
||||||
switch (index) {
|
|
||||||
case 0:
|
class _EstablishmentCard extends StatelessWidget {
|
||||||
context.go(AppRoutes.home);
|
const _EstablishmentCard({
|
||||||
case 1:
|
required this.establishment,
|
||||||
context.push(AppRoutes.bookings);
|
required this.onTap,
|
||||||
case 2:
|
});
|
||||||
context.push(AppRoutes.washes);
|
|
||||||
case 3:
|
final Establishment establishment;
|
||||||
context.push(AppRoutes.wallet);
|
final VoidCallback onTap;
|
||||||
}
|
|
||||||
},
|
@override
|
||||||
destinations: const [
|
Widget build(BuildContext context) {
|
||||||
NavigationDestination(icon: Icon(Icons.store), label: 'Laveries'),
|
return Card(
|
||||||
NavigationDestination(icon: Icon(Icons.event), label: 'Réservations'),
|
clipBehavior: Clip.antiAlias,
|
||||||
NavigationDestination(icon: Icon(Icons.local_laundry_service), label: 'Lavages'),
|
child: InkWell(
|
||||||
NavigationDestination(icon: Icon(Icons.wallet), label: 'Wallet'),
|
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/auth/auth_provider.dart';
|
||||||
import '../../../core/router/app_router.dart';
|
import '../../../core/router/app_router.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
|
||||||
/// Écran profil utilisateur et déconnexion.
|
/// Écran profil utilisateur et déconnexion.
|
||||||
class ProfileScreen extends ConsumerWidget {
|
class ProfileScreen extends ConsumerWidget {
|
||||||
@@ -11,63 +12,73 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final authState = ref.watch(authProvider);
|
final user = ref.watch(authProvider).user;
|
||||||
final user = authState.user;
|
|
||||||
|
|
||||||
return Scaffold(
|
return ListView(
|
||||||
appBar: AppBar(title: const Text('Mon profil')),
|
padding: const EdgeInsets.all(16),
|
||||||
body: ListView(
|
children: [
|
||||||
padding: const EdgeInsets.all(16),
|
Card(
|
||||||
children: [
|
child: Padding(
|
||||||
Card(
|
padding: const EdgeInsets.all(20),
|
||||||
child: Padding(
|
child: Row(
|
||||||
padding: const EdgeInsets.all(24),
|
children: [
|
||||||
child: Column(
|
CircleAvatar(
|
||||||
children: [
|
radius: 28,
|
||||||
CircleAvatar(
|
backgroundColor: AppColors.primary.withValues(alpha: 0.1),
|
||||||
radius: 40,
|
child: Text(
|
||||||
child: Text(
|
user != null && user.firstName.isNotEmpty
|
||||||
user != null && user.firstName.isNotEmpty
|
? user.firstName.substring(0, 1).toUpperCase()
|
||||||
? user.firstName.substring(0, 1).toUpperCase()
|
: '?',
|
||||||
: '?',
|
style: const TextStyle(
|
||||||
style: const TextStyle(fontSize: 32),
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
Text(
|
const SizedBox(width: 16),
|
||||||
user?.fullName ?? 'Utilisateur',
|
Expanded(
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
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(
|
const SizedBox(height: 8),
|
||||||
leading: const Icon(Icons.language),
|
Card(
|
||||||
title: const Text('Langue'),
|
child: ListTile(
|
||||||
subtitle: Text(user?.locale ?? 'fr'),
|
leading: const Icon(Icons.logout, color: AppColors.error),
|
||||||
),
|
title: const Text('Se déconnecter', style: TextStyle(color: AppColors.error)),
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.notifications_outlined),
|
|
||||||
title: const Text('Notifications'),
|
|
||||||
onTap: () {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Préférences — à implémenter')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
|
|
||||||
title: Text(
|
|
||||||
'Se déconnecter',
|
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
||||||
),
|
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await ref.read(authProvider.notifier).logout();
|
await ref.read(authProvider.notifier).logout();
|
||||||
if (context.mounted) {
|
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_client.dart';
|
||||||
import '../../../core/api/api_endpoints.dart';
|
import '../../../core/api/api_endpoints.dart';
|
||||||
|
import '../../../core/api/api_response.dart';
|
||||||
|
import '../../../core/auth/auth_provider.dart';
|
||||||
import '../domain/wallet.dart';
|
import '../domain/wallet.dart';
|
||||||
|
|
||||||
/// Dépôt de données pour le portefeuille électronique.
|
/// Dépôt de données pour le portefeuille électronique.
|
||||||
@@ -12,36 +14,18 @@ class WalletRepository {
|
|||||||
|
|
||||||
Future<Wallet> fetchWallet() async {
|
Future<Wallet> fetchWallet() async {
|
||||||
final response = await _apiClient.get(ApiEndpoints.wallet);
|
final response = await _apiClient.get(ApiEndpoints.wallet);
|
||||||
final json = _extractObject(response.data);
|
final json = ApiResponse.object(response.data, 'wallet');
|
||||||
return Wallet.fromJson(json);
|
return Wallet.fromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<WalletTransaction>> fetchTransactions() async {
|
Future<List<WalletTransaction>> fetchTransactions() async {
|
||||||
final response = await _apiClient.get(ApiEndpoints.walletTransactions);
|
final response = await _apiClient.get(ApiEndpoints.walletTransactions);
|
||||||
final list = _extractList(response.data);
|
final list = ApiResponse.list(response.data, 'transactions');
|
||||||
|
|
||||||
return list
|
return list
|
||||||
.map((json) => WalletTransaction.fromJson(json as Map<String, dynamic>))
|
.map((json) => WalletTransaction.fromJson(json as Map<String, dynamic>))
|
||||||
.toList();
|
.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) {
|
final walletRepositoryProvider = Provider<WalletRepository>((ref) {
|
||||||
@@ -49,10 +33,20 @@ final walletRepositoryProvider = Provider<WalletRepository>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final walletProvider = FutureProvider<Wallet>((ref) async {
|
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();
|
return ref.watch(walletRepositoryProvider).fetchWallet();
|
||||||
});
|
});
|
||||||
|
|
||||||
final walletTransactionsProvider =
|
final walletTransactionsProvider =
|
||||||
FutureProvider<List<WalletTransaction>>((ref) async {
|
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();
|
return ref.watch(walletRepositoryProvider).fetchTransactions();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,116 +1,130 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';␍
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';␍
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';␍
|
||||||
|
␍
|
||||||
import '../data/wallet_repository.dart';
|
import '../../../core/theme/app_colors.dart';␍
|
||||||
|
import '../../../core/widgets/empty_state.dart';␍
|
||||||
/// Écran du portefeuille électronique — solde et historique.
|
import '../data/wallet_repository.dart';␍
|
||||||
class WalletScreen extends ConsumerWidget {
|
␍
|
||||||
const WalletScreen({super.key});
|
/// Écran du portefeuille électronique — solde et historique.␍
|
||||||
|
class WalletScreen extends ConsumerWidget {␍
|
||||||
@override
|
const WalletScreen({super.key});␍
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
␍
|
||||||
final walletAsync = ref.watch(walletProvider);
|
@override␍
|
||||||
final transactionsAsync = ref.watch(walletTransactionsProvider);
|
Widget build(BuildContext context, WidgetRef ref) {␍
|
||||||
final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '€');
|
final walletAsync = ref.watch(walletProvider);␍
|
||||||
|
final transactionsAsync = ref.watch(walletTransactionsProvider);␍
|
||||||
return Scaffold(
|
final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '€');␍
|
||||||
appBar: AppBar(title: const Text('Mon portefeuille')),
|
␍
|
||||||
body: RefreshIndicator(
|
return RefreshIndicator(␍
|
||||||
onRefresh: () async {
|
onRefresh: () async {␍
|
||||||
ref.invalidate(walletProvider);
|
ref.invalidate(walletProvider);␍
|
||||||
ref.invalidate(walletTransactionsProvider);
|
ref.invalidate(walletTransactionsProvider);␍
|
||||||
},
|
},␍
|
||||||
child: ListView(
|
child: ListView(␍
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),␍
|
||||||
children: [
|
children: [␍
|
||||||
walletAsync.when(
|
walletAsync.when(␍
|
||||||
loading: () => const Card(
|
loading: () => const SizedBox(␍
|
||||||
child: Padding(
|
height: 120,␍
|
||||||
padding: EdgeInsets.all(24),
|
child: Center(child: CircularProgressIndicator()),␍
|
||||||
child: Center(child: CircularProgressIndicator()),
|
),␍
|
||||||
),
|
error: (error, _) => Text('Erreur solde : $error'),␍
|
||||||
),
|
data: (wallet) => Container(␍
|
||||||
error: (error, _) => Card(
|
padding: const EdgeInsets.all(20),␍
|
||||||
child: Padding(
|
decoration: BoxDecoration(␍
|
||||||
padding: const EdgeInsets.all(16),
|
gradient: AppColors.gradientAccent,␍
|
||||||
child: Text('Erreur solde : $error'),
|
borderRadius: BorderRadius.circular(14),␍
|
||||||
),
|
boxShadow: [␍
|
||||||
),
|
BoxShadow(␍
|
||||||
data: (wallet) => Card(
|
color: AppColors.primary.withValues(alpha: 0.2),␍
|
||||||
child: Padding(
|
blurRadius: 10,␍
|
||||||
padding: const EdgeInsets.all(24),
|
offset: const Offset(0, 4),␍
|
||||||
child: Column(
|
),␍
|
||||||
children: [
|
],␍
|
||||||
Text(
|
),␍
|
||||||
'Solde disponible',
|
child: Column(␍
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
crossAxisAlignment: CrossAxisAlignment.start,␍
|
||||||
),
|
children: [␍
|
||||||
const SizedBox(height: 8),
|
Text(␍
|
||||||
Text(
|
'Solde disponible',␍
|
||||||
currencyFormat.format(wallet.currentBalance),
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.9), fontSize: 14),␍
|
||||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
),␍
|
||||||
color: Theme.of(context).colorScheme.primary,
|
const SizedBox(height: 8),␍
|
||||||
fontWeight: FontWeight.bold,
|
Text(␍
|
||||||
),
|
currencyFormat.format(wallet.currentBalance),␍
|
||||||
),
|
style: const TextStyle(␍
|
||||||
],
|
color: Colors.white,␍
|
||||||
),
|
fontSize: 32,␍
|
||||||
),
|
fontWeight: FontWeight.w700,␍
|
||||||
),
|
),␍
|
||||||
),
|
),␍
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 4),␍
|
||||||
Text(
|
Text(␍
|
||||||
'Historique',
|
wallet.status == 'active' ? 'Compte actif' : wallet.status,␍
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.85), fontSize: 13),␍
|
||||||
),
|
),␍
|
||||||
const SizedBox(height: 8),
|
],␍
|
||||||
transactionsAsync.when(
|
),␍
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
),␍
|
||||||
error: (error, _) => Text('Erreur historique : $error'),
|
),␍
|
||||||
data: (transactions) {
|
const SizedBox(height: 16),␍
|
||||||
if (transactions.isEmpty) {
|
ElevatedButton.icon(␍
|
||||||
return const Text('Aucune transaction pour le moment');
|
onPressed: () {␍
|
||||||
}
|
ScaffoldMessenger.of(context).showSnackBar(␍
|
||||||
|
const SnackBar(content: Text('Rechargement — à connecter à l\'API')),␍
|
||||||
return Column(
|
);␍
|
||||||
children: transactions.map((tx) {
|
},␍
|
||||||
final isCredit = tx.type == 'credit' || tx.type == 'refund';
|
icon: const Icon(Icons.add),␍
|
||||||
return Card(
|
label: const Text('Recharger'),␍
|
||||||
child: ListTile(
|
),␍
|
||||||
leading: Icon(
|
const SizedBox(height: 24),␍
|
||||||
isCredit ? Icons.add_circle_outline : Icons.remove_circle_outline,
|
Text('Historique', style: Theme.of(context).textTheme.titleMedium),␍
|
||||||
color: isCredit ? Colors.green : Colors.red,
|
const SizedBox(height: 8),␍
|
||||||
),
|
transactionsAsync.when(␍
|
||||||
title: Text(tx.type),
|
loading: () => const Center(child: CircularProgressIndicator()),␍
|
||||||
subtitle: tx.createdAt != null
|
error: (error, _) => Text('Erreur historique : $error'),␍
|
||||||
? Text(DateFormat('dd/MM/yyyy HH:mm').format(tx.createdAt!))
|
data: (transactions) {␍
|
||||||
: null,
|
if (transactions.isEmpty) {␍
|
||||||
trailing: Text(
|
return const EmptyState(␍
|
||||||
'${isCredit ? '+' : '-'}${currencyFormat.format(tx.amount)}',
|
icon: Icons.receipt_long_outlined,␍
|
||||||
style: TextStyle(
|
title: 'Aucune transaction',␍
|
||||||
fontWeight: FontWeight.bold,
|
subtitle: 'Vos rechargements et débits apparaîtront ici.',␍
|
||||||
color: isCredit ? Colors.green : Colors.red,
|
);␍
|
||||||
),
|
}␍
|
||||||
),
|
␍
|
||||||
),
|
return Column(␍
|
||||||
);
|
children: transactions.map((tx) {␍
|
||||||
}).toList(),
|
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(␍
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
backgroundColor: color.withValues(alpha: 0.1),␍
|
||||||
onPressed: () {
|
child: Icon(␍
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
isCredit ? Icons.add : Icons.remove,␍
|
||||||
const SnackBar(content: Text('Rechargement — à connecter à l\'API')),
|
color: color,␍
|
||||||
);
|
size: 20,␍
|
||||||
},
|
),␍
|
||||||
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é).
|
/// Modèle lavage (cycle en cours ou terminé).
|
||||||
class Wash {
|
class Wash {
|
||||||
const Wash({
|
const Wash({
|
||||||
@@ -9,6 +11,9 @@ class Wash {
|
|||||||
this.startedAt,
|
this.startedAt,
|
||||||
this.endedAt,
|
this.endedAt,
|
||||||
this.durationMinutes,
|
this.durationMinutes,
|
||||||
|
this.machineType,
|
||||||
|
this.cycleEndsAt,
|
||||||
|
this.progress,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String uuid;
|
final String uuid;
|
||||||
@@ -19,14 +24,27 @@ class Wash {
|
|||||||
final DateTime? startedAt;
|
final DateTime? startedAt;
|
||||||
final DateTime? endedAt;
|
final DateTime? endedAt;
|
||||||
final int? durationMinutes;
|
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) {
|
factory Wash.fromJson(Map<String, dynamic> json) {
|
||||||
final machine = json['machine'] as Map<String, dynamic>?;
|
final machine = json['machine'] as Map<String, dynamic>?;
|
||||||
|
final progressJson = json['progress'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
return Wash(
|
return Wash(
|
||||||
uuid: json['uuid'] as String,
|
uuid: json['uuid'] as String,
|
||||||
machineUuid: machine?['uuid'] as String? ?? json['machine_uuid'] as String? ?? '',
|
machineUuid: machine?['uuid'] as String? ?? json['machine_uuid'] as String? ?? '',
|
||||||
machineName: machine?['name'] as String? ?? 'Machine',
|
machineName: machine?['name'] as String? ?? 'Machine',
|
||||||
|
machineType: machine?['type'] as String?,
|
||||||
status: json['status'] as String? ?? 'pending_start',
|
status: json['status'] as String? ?? 'pending_start',
|
||||||
cost: (json['cost'] as num?)?.toDouble() ?? 0,
|
cost: (json['cost'] as num?)?.toDouble() ?? 0,
|
||||||
startedAt: json['started_at'] != null
|
startedAt: json['started_at'] != null
|
||||||
@@ -36,6 +54,10 @@ class Wash {
|
|||||||
? DateTime.tryParse(json['ended_at'] as String)
|
? DateTime.tryParse(json['ended_at'] as String)
|
||||||
: null,
|
: null,
|
||||||
durationMinutes: json['duration_minutes'] as int?,
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../../../core/api/api_client.dart';
|
import '../../../core/api/api_response.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/wash_repository.dart';
|
||||||
import '../domain/wash.dart';
|
import '../domain/wash.dart';
|
||||||
|
import '../domain/wash_progress.dart';
|
||||||
|
import 'widgets/active_wash_progress_card.dart';
|
||||||
|
|
||||||
/// Fournisseur de l'historique des lavages.
|
bool _isActiveWash(String status) =>
|
||||||
final washesProvider = FutureProvider<List<Wash>>((ref) async {
|
status == 'running' || status == 'pending_start' || status == 'active';
|
||||||
final response = await ref.watch(apiClientProvider).get(ApiEndpoints.washes);
|
|
||||||
final data = response.data;
|
|
||||||
|
|
||||||
List<dynamic> list;
|
/// Écran historique et lavages en cours avec progression.
|
||||||
if (data is List<dynamic>) {
|
class WashScreen extends ConsumerStatefulWidget {
|
||||||
list = data;
|
|
||||||
} else if (data is Map<String, dynamic> && data['data'] is List<dynamic>) {
|
|
||||||
list = data['data'] as List<dynamic>;
|
|
||||||
} else {
|
|
||||||
list = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return list.map((json) => Wash.fromJson(json as Map<String, dynamic>)).toList();
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Écran historique et démarrage de lavage.
|
|
||||||
class WashScreen extends ConsumerWidget {
|
|
||||||
const WashScreen({super.key});
|
const WashScreen({super.key});
|
||||||
|
|
||||||
@override
|
@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);
|
final washesAsync = ref.watch(washesProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return washesAsync.when(
|
||||||
appBar: AppBar(title: const Text('Mes lavages')),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
body: washesAsync.when(
|
error: (error, _) => EmptyState(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
icon: Icons.error_outline,
|
||||||
error: (error, _) => Center(
|
title: 'Erreur de chargement',
|
||||||
child: Column(
|
subtitle: error is DioException
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
? 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: [
|
children: [
|
||||||
Text('Erreur : $error'),
|
if (active.isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
const ScreenHeader(
|
||||||
ElevatedButton(
|
title: 'En cours',
|
||||||
onPressed: () => ref.invalidate(washesProvider),
|
subtitle: 'Progression en temps réel',
|
||||||
child: const Text('Réessayer'),
|
),
|
||||||
),
|
...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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ dependencies:
|
|||||||
flutter_riverpod: ^2.6.1
|
flutter_riverpod: ^2.6.1
|
||||||
go_router: ^14.8.1
|
go_router: ^14.8.1
|
||||||
dio: ^5.8.0+1
|
dio: ^5.8.0+1
|
||||||
|
mobile_scanner: ^5.2.3
|
||||||
flutter_secure_storage: ^9.2.4
|
flutter_secure_storage: ^9.2.4
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user