From 9f352b91d256b7c1307bd75aa4228cd75e8cfece Mon Sep 17 00:00:00 2001 From: bastien Date: Sat, 4 Jul 2026 22:46:14 +0200 Subject: [PATCH] =?UTF-8?q?Int=C3=A9gration=20fonctionnalites=20V1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 2 + .../fr/laverie/laverie_mobile/MainActivity.kt | 4 +- .../app/src/main/res/values-night/styles.xml | 2 +- android/app/src/main/res/values/styles.xml | 2 +- lib/core/config/app_config.dart | 7 + lib/core/router/app_router.dart | 6 + lib/core/theme/app_theme.dart | 15 +- lib/core/time/server_time.dart | 71 +++++ lib/core/time/server_time_sync.dart | 29 ++ .../booking/data/booking_repository.dart | 9 +- lib/features/booking/domain/booking.dart | 4 +- .../presentation/booking_modify_screen.dart | 16 +- .../booking/presentation/bookings_screen.dart | 6 +- .../presentation/machine_action_screen.dart | 14 - .../presentation/machine_booking_screen.dart | 21 +- .../wallet/data/wallet_repository.dart | 26 ++ lib/features/wallet/domain/payment.dart | 72 +++++ lib/features/wallet/domain/wallet.dart | 73 +++++ .../wallet/presentation/wallet_screen.dart | 268 +++++++++--------- .../presentation/wallet_top_up_screen.dart | 229 +++++++++++++++ lib/features/wash/domain/wash_progress.dart | 4 +- .../wash/presentation/wash_screen.dart | 3 +- lib/main.dart | 14 +- pubspec.lock | 64 +++++ pubspec.yaml | 2 + 25 files changed, 779 insertions(+), 184 deletions(-) create mode 100644 lib/core/time/server_time.dart create mode 100644 lib/core/time/server_time_sync.dart create mode 100644 lib/features/wallet/domain/payment.dart create mode 100644 lib/features/wallet/presentation/wallet_top_up_screen.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1dff5c0..c4b1549 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + - diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index cb1ef88..71d378e 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -12,7 +12,7 @@ running. This Theme is only used starting with V2 of Flutter's Android embedding. --> - diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index c6da9b7..2ff0c6a 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -38,3 +38,10 @@ abstract final class AuthStorageKeys { static const accessToken = 'laverie_access_token'; static const refreshToken = 'laverie_refresh_token'; } + +/// Clé publique Stripe (test) — surcharge via `--dart-define=STRIPE_PUBLISHABLE_KEY=...` +const String _envStripePublishableKey = String.fromEnvironment('STRIPE_PUBLISHABLE_KEY'); + +final String kStripePublishableKey = _envStripePublishableKey.isNotEmpty + ? _envStripePublishableKey + : 'pk_test_51TpANRJRUgjTIwfBR9PoU4Lu201yD5R0JzvOv8Nmyva7ISX3GJPJ3IX4lSqnkg13siYwi3B9Qq0tIpEj6VCzeVFB00PSt9o0OA'; diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index b498e2e..ae29898 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -9,6 +9,7 @@ import '../../features/auth/presentation/splash_screen.dart'; import '../../features/home/presentation/home_screen.dart'; import '../../features/establishments/presentation/establishment_detail_screen.dart'; import '../../features/wallet/presentation/wallet_screen.dart'; +import '../../features/wallet/presentation/wallet_top_up_screen.dart'; import '../../features/booking/presentation/booking_modify_screen.dart'; import '../../features/booking/presentation/bookings_screen.dart'; import '../../features/machines/presentation/machine_action_screen.dart'; @@ -25,6 +26,7 @@ abstract final class AppRoutes { static const register = '/register'; static const home = '/'; static const wallet = '/wallet'; + static const walletTopUp = '/wallet/top-up'; static const bookings = '/bookings'; static const washes = '/washes'; static const washScan = '/washes/scan'; @@ -118,6 +120,10 @@ final appRouterProvider = Provider((ref) { path: AppRoutes.washScan, builder: (context, state) => const QrScannerScreen(), ), + GoRoute( + path: AppRoutes.walletTopUp, + builder: (context, state) => const WalletTopUpScreen(), + ), GoRoute( path: '/machines/:uuid/action', builder: (context, state) { diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 06b8d0f..92b8db6 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -70,8 +70,21 @@ class AppTheme { style: ElevatedButton.styleFrom( minimumSize: const Size.fromHeight(48), elevation: 0, - backgroundColor: AppColors.primary, + backgroundColor: AppColors.success, foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.success.withValues(alpha: 0.4), + disabledForegroundColor: Colors.white70, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + backgroundColor: AppColors.success, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.success.withValues(alpha: 0.5), + disabledForegroundColor: Colors.white.withValues(alpha: 0.8), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), ), diff --git a/lib/core/time/server_time.dart b/lib/core/time/server_time.dart new file mode 100644 index 0000000..f22469d --- /dev/null +++ b/lib/core/time/server_time.dart @@ -0,0 +1,71 @@ +import 'package:intl/intl.dart'; +import 'package:timezone/data/latest.dart' as tz_data; +import 'package:timezone/timezone.dart' as tz; + +/// Heure métier alignée sur le fuseau du serveur API (pas l'UTC ni le téléphone). +abstract final class ServerTime { + static const defaultTimezone = 'Europe/Paris'; + + static bool _initialized = false; + static tz.Location _location = tz.UTC; + + static Future initialize({String timezone = defaultTimezone}) async { + if (!_initialized) { + tz_data.initializeTimeZones(); + _initialized = true; + } + setTimezone(timezone); + } + + static void setTimezone(String timezone) { + if (!_initialized) { + tz_data.initializeTimeZones(); + _initialized = true; + } + _location = tz.getLocation(timezone); + } + + static String get timezone => _location.name; + + static tz.TZDateTime now() => tz.TZDateTime.now(_location); + + static DateTime startOfToday() { + final current = now(); + return DateTime(current.year, current.month, current.day); + } + + static DateTime? parse(String? iso) { + if (iso == null || iso.isEmpty) { + return null; + } + return DateTime.tryParse(iso); + } + + static tz.TZDateTime toServerTime(DateTime dateTime) { + return tz.TZDateTime.from(dateTime.toUtc(), _location); + } + + static String format( + DateTime? dateTime, { + required String pattern, + String locale = 'fr_FR', + }) { + if (dateTime == null) { + return ''; + } + + final server = toServerTime(dateTime); + return DateFormat(pattern, locale).format( + DateTime( + server.year, + server.month, + server.day, + server.hour, + server.minute, + server.second, + server.millisecond, + server.microsecond, + ), + ); + } +} diff --git a/lib/core/time/server_time_sync.dart b/lib/core/time/server_time_sync.dart new file mode 100644 index 0000000..dc1fc2d --- /dev/null +++ b/lib/core/time/server_time_sync.dart @@ -0,0 +1,29 @@ +import 'package:dio/dio.dart'; + +import '../api/api_endpoints.dart'; +import '../api/api_response.dart'; +import '../config/app_config.dart'; +import 'server_time.dart'; + +/// Synchronise le fuseau horaire applicatif avec l'API (`/health`). +Future syncServerTimezone() async { + try { + final client = Dio( + BaseOptions( + baseUrl: kApiBaseUrl, + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 5), + headers: {'Accept': 'application/json'}, + ), + ); + + final response = await client.get(ApiEndpoints.health); + final timezone = ApiResponse.payload(response.data)['timezone']; + + if (timezone is String && timezone.isNotEmpty) { + ServerTime.setTimezone(timezone); + } + } catch (_) { + // Conserve le fuseau par défaut (Europe/Paris). + } +} diff --git a/lib/features/booking/data/booking_repository.dart b/lib/features/booking/data/booking_repository.dart index e6e9d4c..5fd5896 100644 --- a/lib/features/booking/data/booking_repository.dart +++ b/lib/features/booking/data/booking_repository.dart @@ -5,6 +5,7 @@ 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 '../../../core/time/server_time.dart'; import '../domain/booking.dart'; /// Dépôt de données pour les réservations. @@ -35,8 +36,8 @@ class BookingRepository { ApiEndpoints.bookings, data: { 'machine_uuid': machineUuid, - 'slot_start': slotStart.toUtc().toIso8601String(), - 'slot_end': slotEnd.toUtc().toIso8601String(), + 'slot_start': ServerTime.toServerTime(slotStart).toIso8601String(), + 'slot_end': ServerTime.toServerTime(slotEnd).toIso8601String(), }, ); final json = ApiResponse.object(response.data, 'booking'); @@ -65,8 +66,8 @@ class BookingRepository { final response = await _apiClient.patch( ApiEndpoints.bookingMove(uuid), data: { - 'slot_start': slotStart.toUtc().toIso8601String(), - 'slot_end': slotEnd.toUtc().toIso8601String(), + 'slot_start': ServerTime.toServerTime(slotStart).toIso8601String(), + 'slot_end': ServerTime.toServerTime(slotEnd).toIso8601String(), }, ); final json = ApiResponse.object(response.data, 'booking'); diff --git a/lib/features/booking/domain/booking.dart b/lib/features/booking/domain/booking.dart index e5789d9..35da128 100644 --- a/lib/features/booking/domain/booking.dart +++ b/lib/features/booking/domain/booking.dart @@ -1,3 +1,5 @@ +import '../../../core/time/server_time.dart'; + /// Modèle réservation de créneau machine. class Booking { const Booking({ @@ -21,7 +23,7 @@ class Booking { bool get canCancel => (status == 'confirmed' || status == 'pending') && slotStart != null && - slotStart!.isAfter(DateTime.now()); + slotStart!.isAfter(ServerTime.now()); bool get canModify => canCancel; diff --git a/lib/features/booking/presentation/booking_modify_screen.dart b/lib/features/booking/presentation/booking_modify_screen.dart index 1386f09..9a596c7 100644 --- a/lib/features/booking/presentation/booking_modify_screen.dart +++ b/lib/features/booking/presentation/booking_modify_screen.dart @@ -1,10 +1,10 @@ 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/time/server_time.dart'; import '../../machines/data/machine_repository.dart'; import '../../machines/domain/machine_detail.dart'; import '../data/booking_repository.dart'; @@ -118,7 +118,7 @@ class _BookingModifyScreenState extends ConsumerState { ), Text( booking.slotStart != null && booking.slotEnd != null - ? '${DateFormat('EEEE dd MMM · HH:mm', 'fr_FR').format(booking.slotStart!)} – ${DateFormat('HH:mm').format(booking.slotEnd!)}' + ? '${ServerTime.format(booking.slotStart, pattern: 'EEEE dd MMM · HH:mm')} – ${ServerTime.format(booking.slotEnd, pattern: 'HH:mm')}' : '—', style: const TextStyle(fontWeight: FontWeight.w600), ), @@ -136,14 +136,14 @@ class _BookingModifyScreenState extends ConsumerState { itemCount: 6, separatorBuilder: (_, __) => const SizedBox(width: 8), itemBuilder: (context, index) { - final date = DateTime.now().add(Duration(days: index)); + final date = ServerTime.startOfToday().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)), + label: Text(ServerTime.format(normalized, pattern: 'EEE dd/MM')), selected: isSelected, onSelected: (_) => setState(() { _selectedDate = normalized; @@ -172,7 +172,7 @@ class _BookingModifyScreenState extends ConsumerState { runSpacing: 8, children: slots.map((slot) { final label = - '${DateFormat('HH:mm').format(slot.start)} – ${DateFormat('HH:mm').format(slot.end)}'; + '${ServerTime.format(slot.start, pattern: 'HH:mm')} – ${ServerTime.format(slot.end, pattern: 'HH:mm')}'; final isSelected = _selectedSlot?.start == slot.start; return FilterChip( @@ -197,12 +197,6 @@ class _BookingModifyScreenState extends ConsumerState { 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, diff --git a/lib/features/booking/presentation/bookings_screen.dart b/lib/features/booking/presentation/bookings_screen.dart index d4823c6..2a0a3f2 100644 --- a/lib/features/booking/presentation/bookings_screen.dart +++ b/lib/features/booking/presentation/bookings_screen.dart @@ -1,9 +1,9 @@ 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/time/server_time.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../core/widgets/screen_header.dart'; @@ -16,7 +16,7 @@ class BookingsScreen extends ConsumerWidget { Future _cancelBooking(BuildContext context, WidgetRef ref, Booking booking) async { final slotLabel = booking.slotStart != null - ? DateFormat('EEEE dd MMM à HH:mm', 'fr_FR').format(booking.slotStart!) + ? ServerTime.format(booking.slotStart, pattern: 'EEEE dd MMM à HH:mm') : 'ce créneau'; final confirmed = await showDialog( @@ -144,7 +144,7 @@ class _BookingCard extends StatelessWidget { @override Widget build(BuildContext context) { final slotText = booking.slotStart != null - ? DateFormat('EEEE dd MMM · HH:mm', 'fr_FR').format(booking.slotStart!) + ? ServerTime.format(booking.slotStart, pattern: 'EEEE dd MMM · HH:mm') : 'Créneau à confirmer'; return Card( diff --git a/lib/features/machines/presentation/machine_action_screen.dart b/lib/features/machines/presentation/machine_action_screen.dart index bdeb389..4993bf1 100644 --- a/lib/features/machines/presentation/machine_action_screen.dart +++ b/lib/features/machines/presentation/machine_action_screen.dart @@ -215,16 +215,6 @@ class _MachineActionBody extends StatelessWidget { 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, @@ -244,10 +234,6 @@ class _MachineActionBody extends StatelessWidget { 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'), diff --git a/lib/features/machines/presentation/machine_booking_screen.dart b/lib/features/machines/presentation/machine_booking_screen.dart index 2e957b8..5c829ff 100644 --- a/lib/features/machines/presentation/machine_booking_screen.dart +++ b/lib/features/machines/presentation/machine_booking_screen.dart @@ -1,11 +1,11 @@ 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/time/server_time.dart'; import '../../booking/data/booking_repository.dart'; import '../data/machine_repository.dart'; import '../domain/machine_detail.dart'; @@ -21,7 +21,7 @@ class MachineBookingScreen extends ConsumerStatefulWidget { } class _MachineBookingScreenState extends ConsumerState { - DateTime _selectedDate = DateTime.now(); + DateTime _selectedDate = ServerTime.startOfToday(); TimeSlot? _selectedSlot; bool _isBooking = false; @@ -112,14 +112,14 @@ class _MachineBookingScreenState extends ConsumerState { itemCount: 6, separatorBuilder: (_, __) => const SizedBox(width: 8), itemBuilder: (context, index) { - final date = DateTime.now().add(Duration(days: index)); + final date = ServerTime.startOfToday().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)), + label: Text(ServerTime.format(normalized, pattern: 'EEE dd/MM')), selected: isSelected, onSelected: (_) => setState(() { _selectedDate = normalized; @@ -159,7 +159,7 @@ class _MachineBookingScreenState extends ConsumerState { runSpacing: 8, children: slots.map((slot) { final label = - '${DateFormat('HH:mm').format(slot.start)} – ${DateFormat('HH:mm').format(slot.end)}'; + '${ServerTime.format(slot.start, pattern: 'HH:mm')} – ${ServerTime.format(slot.end, pattern: 'HH:mm')}'; final isSelected = _selectedSlot?.start == slot.start; return FilterChip( @@ -183,14 +183,9 @@ class _MachineBookingScreenState extends ConsumerState { 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), - ), + onPressed: (_isBooking || _selectedSlot == null) + ? null + : () => _confirmBooking(detail), icon: _isBooking ? const SizedBox( width: 22, diff --git a/lib/features/wallet/data/wallet_repository.dart b/lib/features/wallet/data/wallet_repository.dart index 06162c3..1bf518c 100644 --- a/lib/features/wallet/data/wallet_repository.dart +++ b/lib/features/wallet/data/wallet_repository.dart @@ -4,6 +4,7 @@ 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/payment.dart'; import '../domain/wallet.dart'; /// Dépôt de données pour le portefeuille électronique. @@ -26,6 +27,31 @@ class WalletRepository { .map((json) => WalletTransaction.fromJson(json as Map)) .toList(); } + + Future initiateTopUp({ + required double amount, + required String idempotencyKey, + }) async { + final response = await _apiClient.post( + ApiEndpoints.walletTopUpInitiate, + data: { + 'amount': amount, + 'idempotency_key': idempotencyKey, + }, + ); + + final json = ApiResponse.object(response.data, 'payment'); + return PaymentTransaction.fromJson(json); + } + + Future confirmTopUp({required String paymentUuid}) async { + final response = await _apiClient.post( + ApiEndpoints.walletTopUpConfirm, + data: {'payment_uuid': paymentUuid}, + ); + + return TopUpResult.fromJson(ApiResponse.payload(response.data)); + } } final walletRepositoryProvider = Provider((ref) { diff --git a/lib/features/wallet/domain/payment.dart b/lib/features/wallet/domain/payment.dart new file mode 100644 index 0000000..e7e5647 --- /dev/null +++ b/lib/features/wallet/domain/payment.dart @@ -0,0 +1,72 @@ +/// Transaction de paiement externe (rechargement wallet). +class PaymentTransaction { + const PaymentTransaction({ + required this.uuid, + required this.provider, + required this.amount, + required this.currency, + required this.status, + this.providerPaymentId, + this.stripe, + }); + + final String uuid; + final String provider; + final String? providerPaymentId; + final double amount; + final String currency; + final String status; + final StripePaymentDetails? stripe; + + factory PaymentTransaction.fromJson(Map json) { + final stripeJson = json['stripe']; + return PaymentTransaction( + uuid: json['uuid'] as String, + provider: json['provider'] as String, + providerPaymentId: json['provider_payment_id'] as String?, + amount: (json['amount'] as num).toDouble(), + currency: json['currency'] as String, + status: json['status'] as String, + stripe: stripeJson is Map + ? StripePaymentDetails.fromJson(stripeJson) + : null, + ); + } +} + +class StripePaymentDetails { + const StripePaymentDetails({ + required this.paymentIntentId, + required this.clientSecret, + required this.publishableKey, + }); + + final String paymentIntentId; + final String clientSecret; + final String publishableKey; + + factory StripePaymentDetails.fromJson(Map json) { + return StripePaymentDetails( + paymentIntentId: json['payment_intent_id'] as String, + clientSecret: json['client_secret'] as String, + publishableKey: json['publishable_key'] as String, + ); + } +} + +class TopUpResult { + const TopUpResult({ + required this.payment, + required this.balance, + }); + + final PaymentTransaction payment; + final double balance; + + factory TopUpResult.fromJson(Map json) { + return TopUpResult( + payment: PaymentTransaction.fromJson(json['payment'] as Map), + balance: (json['balance'] as num).toDouble(), + ); + } +} diff --git a/lib/features/wallet/domain/wallet.dart b/lib/features/wallet/domain/wallet.dart index 1eaa580..d85fc60 100644 --- a/lib/features/wallet/domain/wallet.dart +++ b/lib/features/wallet/domain/wallet.dart @@ -27,6 +27,7 @@ class WalletTransaction { required this.amount, required this.balanceAfter, required this.createdAt, + this.metadata = const {}, }); final String uuid; @@ -34,6 +35,64 @@ class WalletTransaction { final double amount; final double balanceAfter; final DateTime? createdAt; + final Map metadata; + + String get displayLabel { + final label = metadata['label']; + if (label is String && label.isNotEmpty) { + return label; + } + + return switch (type) { + 'credit' => 'Crédit', + 'debit' => 'Débit', + 'refund' => 'Remboursement', + 'hold' => 'Blocage', + 'release' => 'Libération', + 'adjustment' => 'Ajustement', + _ => type, + }; + } + + String? get displaySubtitle { + final stripeMap = _asStringMap(metadata['stripe']); + if (stripeMap != null) { + final brand = stripeMap['card_brand']; + final last4 = stripeMap['card_last4']; + if (brand is String && last4 is String) { + return '${_formatCardBrand(brand)} •••• $last4'; + } + + final paymentIntentId = stripeMap['payment_intent_id']; + if (paymentIntentId is String && paymentIntentId.isNotEmpty) { + return 'Stripe $paymentIntentId'; + } + } + + final providerPaymentId = metadata['provider_payment_id']; + if (providerPaymentId is String && providerPaymentId.isNotEmpty) { + return providerPaymentId; + } + + return null; + } + + static Map? _asStringMap(dynamic value) { + if (value == null) return null; + if (value is Map) return value; + if (value is Map) return Map.from(value); + return null; + } + + static String _formatCardBrand(String brand) { + if (brand.isEmpty) return 'Carte'; + return switch (brand.toLowerCase()) { + 'visa' => 'Visa', + 'mastercard' => 'Mastercard', + 'amex' => 'Amex', + _ => brand[0].toUpperCase() + brand.substring(1), + }; + } factory WalletTransaction.fromJson(Map json) { return WalletTransaction( @@ -44,6 +103,20 @@ class WalletTransaction { createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at'] as String) : null, + metadata: _parseMetadata(json['metadata']), ); } + + static Map _parseMetadata(dynamic value) { + if (value == null) { + return const {}; + } + if (value is Map) { + return value; + } + if (value is Map) { + return Map.from(value); + } + return const {}; + } } diff --git a/lib/features/wallet/presentation/wallet_screen.dart b/lib/features/wallet/presentation/wallet_screen.dart index 572b88d..667b136 100644 --- a/lib/features/wallet/presentation/wallet_screen.dart +++ b/lib/features/wallet/presentation/wallet_screen.dart @@ -1,130 +1,138 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; - -import '../../../core/theme/app_colors.dart'; -import '../../../core/widgets/empty_state.dart'; -import '../data/wallet_repository.dart'; - -/// Écran du portefeuille électronique — solde et historique. -class WalletScreen extends ConsumerWidget { - const WalletScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final walletAsync = ref.watch(walletProvider); - final transactionsAsync = ref.watch(walletTransactionsProvider); - final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '€'); - - return RefreshIndicator( - onRefresh: () async { - ref.invalidate(walletProvider); - ref.invalidate(walletTransactionsProvider); - }, - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - walletAsync.when( - loading: () => const SizedBox( - height: 120, - child: Center(child: CircularProgressIndicator()), - ), - error: (error, _) => Text('Erreur solde : $error'), - data: (wallet) => Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: AppColors.gradientAccent, - borderRadius: BorderRadius.circular(14), - boxShadow: [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.2), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Solde disponible', - style: TextStyle(color: Colors.white.withValues(alpha: 0.9), fontSize: 14), - ), - const SizedBox(height: 8), - Text( - currencyFormat.format(wallet.currentBalance), - style: const TextStyle( - color: Colors.white, - fontSize: 32, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - wallet.status == 'active' ? 'Compte actif' : wallet.status, - style: TextStyle(color: Colors.white.withValues(alpha: 0.85), fontSize: 13), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Rechargement — à connecter à l\'API')), - ); - }, - icon: const Icon(Icons.add), - label: const Text('Recharger'), - ), - const SizedBox(height: 24), - Text('Historique', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - transactionsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => Text('Erreur historique : $error'), - data: (transactions) { - if (transactions.isEmpty) { - return const EmptyState( - icon: Icons.receipt_long_outlined, - title: 'Aucune transaction', - subtitle: 'Vos rechargements et débits apparaîtront ici.', - ); - } - - return Column( - children: transactions.map((tx) { - final isCredit = tx.type == 'credit' || tx.type == 'refund'; - final color = isCredit ? AppColors.success : AppColors.error; - - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - leading: CircleAvatar( - backgroundColor: color.withValues(alpha: 0.1), - child: Icon( - isCredit ? Icons.add : Icons.remove, - color: color, - size: 20, - ), - ), - title: Text(tx.type), - subtitle: tx.createdAt != null - ? Text(DateFormat('dd/MM/yyyy HH:mm').format(tx.createdAt!)) - : null, - trailing: Text( - '${isCredit ? '+' : '-'}${currencyFormat.format(tx.amount)}', - style: TextStyle(fontWeight: FontWeight.w600, color: color), - ), - ), - ); - }).toList(), - ); - }, - ), - ], - ), - ); - } -} +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/time/server_time.dart'; +import '../../../core/widgets/empty_state.dart'; +import '../data/wallet_repository.dart'; + +/// Écran du portefeuille électronique — solde et historique. +class WalletScreen extends ConsumerWidget { + const WalletScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final walletAsync = ref.watch(walletProvider); + final transactionsAsync = ref.watch(walletTransactionsProvider); + final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '€'); + + return RefreshIndicator( + onRefresh: () async { + ref.invalidate(walletProvider); + ref.invalidate(walletTransactionsProvider); + }, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + walletAsync.when( + loading: () => const SizedBox( + height: 120, + child: Center(child: CircularProgressIndicator()), + ), + error: (error, _) => Text('Erreur solde : $error'), + data: (wallet) => Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: AppColors.gradientAccent, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.2), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Solde disponible', + style: TextStyle(color: Colors.white.withValues(alpha: 0.9), fontSize: 14), + ), + const SizedBox(height: 8), + Text( + currencyFormat.format(wallet.currentBalance), + style: const TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + wallet.status == 'active' ? 'Compte actif' : wallet.status, + style: TextStyle(color: Colors.white.withValues(alpha: 0.85), fontSize: 13), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => context.push(AppRoutes.walletTopUp), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + icon: const Icon(Icons.add), + label: const Text('Recharger'), + ), + const SizedBox(height: 24), + Text('Historique', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + transactionsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Text('Erreur historique : $error'), + data: (transactions) { + if (transactions.isEmpty) { + return const EmptyState( + icon: Icons.receipt_long_outlined, + title: 'Aucune transaction', + subtitle: 'Vos rechargements et débits apparaîtront ici.', + ); + } + + return Column( + children: transactions.map((tx) { + final isCredit = tx.type == 'credit' || tx.type == 'refund'; + final color = isCredit ? AppColors.success : AppColors.error; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar( + backgroundColor: color.withValues(alpha: 0.1), + child: Icon( + isCredit ? Icons.add : Icons.remove, + color: color, + size: 20, + ), + ), + title: Text(tx.displayLabel), + subtitle: tx.createdAt != null + ? Text( + [ + ServerTime.format(tx.createdAt, pattern: 'dd/MM/yyyy HH:mm'), + if (tx.displaySubtitle != null) tx.displaySubtitle, + ].join(' · '), + ) + : (tx.displaySubtitle != null ? Text(tx.displaySubtitle!) : null), + trailing: Text( + '${isCredit ? '+' : '-'}${currencyFormat.format(tx.amount)}', + style: TextStyle(fontWeight: FontWeight.w600, color: color), + ), + ), + ); + }).toList(), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/features/wallet/presentation/wallet_top_up_screen.dart b/lib/features/wallet/presentation/wallet_top_up_screen.dart new file mode 100644 index 0000000..0d62f4e --- /dev/null +++ b/lib/features/wallet/presentation/wallet_top_up_screen.dart @@ -0,0 +1,229 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_stripe/flutter_stripe.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../../core/api/api_response.dart'; +import '../../../core/time/server_time.dart'; +import '../../../core/theme/app_colors.dart'; +import '../data/wallet_repository.dart'; + +const _presetAmounts = [10.0, 20.0, 50.0]; +const _minAmount = 5.0; +const _maxAmount = 150.0; + +/// Écran de rechargement du portefeuille via Stripe Payment Sheet. +class WalletTopUpScreen extends ConsumerStatefulWidget { + const WalletTopUpScreen({super.key}); + + @override + ConsumerState createState() => _WalletTopUpScreenState(); +} + +class _WalletTopUpScreenState extends ConsumerState { + final _amountController = TextEditingController(); + double? _selectedPreset; + bool _isProcessing = false; + + @override + void dispose() { + _amountController.dispose(); + super.dispose(); + } + + double? get _amount { + if (_selectedPreset != null) { + return _selectedPreset; + } + + final raw = _amountController.text.trim().replaceAll(',', '.'); + if (raw.isEmpty) { + return null; + } + + return double.tryParse(raw); + } + + String? _validateAmount(double? amount) { + if (amount == null) { + return 'Saisissez un montant'; + } + if (amount < _minAmount || amount > _maxAmount) { + return 'Montant entre $_minAmount € et $_maxAmount €'; + } + return null; + } + + bool get _canPay => !_isProcessing && _validateAmount(_amount) == null; + + Future _pay() async { + final amount = _amount; + final validationError = _validateAmount(amount); + if (validationError != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(validationError)), + ); + return; + } + + setState(() => _isProcessing = true); + + try { + final idempotencyKey = + 'topup-${ServerTime.now().millisecondsSinceEpoch}'; + final payment = await ref.read(walletRepositoryProvider).initiateTopUp( + amount: amount!, + idempotencyKey: idempotencyKey, + ); + + if (payment.provider == 'stripe') { + final stripe = payment.stripe; + if (stripe == null) { + throw StateError('Réponse Stripe incomplète'); + } + + Stripe.publishableKey = stripe.publishableKey; + await Stripe.instance.applySettings(); + + await Stripe.instance.initPaymentSheet( + paymentSheetParameters: SetupPaymentSheetParameters( + paymentIntentClientSecret: stripe.clientSecret, + merchantDisplayName: 'Laverie Connectée', + ), + ); + + await Stripe.instance.presentPaymentSheet(); + } + + final result = await ref.read(walletRepositoryProvider).confirmTopUp( + paymentUuid: payment.uuid, + ); + + ref.invalidate(walletProvider); + ref.invalidate(walletTransactionsProvider); + + if (!mounted) return; + + final currency = NumberFormat.currency(locale: 'fr_FR', symbol: '€'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Rechargement confirmé — nouveau solde : ${currency.format(result.balance)}', + ), + backgroundColor: AppColors.success, + ), + ); + context.pop(); + } on StripeException catch (e) { + if (!mounted) return; + final message = e.error.localizedMessage ?? 'Paiement annulé'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } on DioException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(ApiResponse.errorMessage(e, fallback: 'Erreur de paiement')), + backgroundColor: AppColors.error, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$e'), + backgroundColor: AppColors.error, + ), + ); + } finally { + if (mounted) { + setState(() => _isProcessing = false); + } + } + } + + @override + Widget build(BuildContext context) { + final currency = NumberFormat.currency(locale: 'fr_FR', symbol: '€'); + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar(title: const Text('Recharger')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Choisissez le montant à ajouter à votre portefeuille.', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 20), + Wrap( + spacing: 10, + runSpacing: 10, + children: _presetAmounts.map((amount) { + final selected = _selectedPreset == amount; + return ChoiceChip( + label: Text(currency.format(amount)), + selected: selected, + onSelected: _isProcessing + ? null + : (value) { + setState(() { + _selectedPreset = value ? amount : null; + if (value) { + _amountController.clear(); + } + }); + }, + ); + }).toList(), + ), + const SizedBox(height: 24), + Text('Ou saisissez un montant', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + TextField( + controller: _amountController, + enabled: !_isProcessing, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')), + ], + decoration: InputDecoration( + suffixText: '€', + hintText: 'Ex. 15', + helperText: 'Entre ${currency.format(_minAmount)} et ${currency.format(_maxAmount)}', + border: const OutlineInputBorder(), + ), + onChanged: (_) => setState(() => _selectedPreset = null), + ), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: _canPay ? _pay : null, + icon: _isProcessing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.lock_outline), + label: Text(_isProcessing ? 'Paiement en cours…' : 'Payer'), + ), + const SizedBox(height: 16), + Center( + child: Text( + 'Paiement sécurisé par Stripe', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/wash/domain/wash_progress.dart b/lib/features/wash/domain/wash_progress.dart index d178b5b..daef636 100644 --- a/lib/features/wash/domain/wash_progress.dart +++ b/lib/features/wash/domain/wash_progress.dart @@ -1,3 +1,5 @@ +import '../../../core/time/server_time.dart'; + /// Progression d'un lavage en cours. class WashProgress { const WashProgress({ @@ -50,7 +52,7 @@ class WashProgress { return const WashProgress(percent: 0, phaseLabel: 'En attente de démarrage', phase: 'pending'); } - final now = DateTime.now(); + final now = ServerTime.now(); final end = estimatedEndAt ?? (startedAt != null && durationMinutes != null ? startedAt.add(Duration(minutes: durationMinutes)) diff --git a/lib/features/wash/presentation/wash_screen.dart b/lib/features/wash/presentation/wash_screen.dart index 30a068c..e94108f 100644 --- a/lib/features/wash/presentation/wash_screen.dart +++ b/lib/features/wash/presentation/wash_screen.dart @@ -9,6 +9,7 @@ import 'package:intl/intl.dart'; import '../../../core/api/api_response.dart'; import '../../../core/router/app_router.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/time/server_time.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../core/widgets/screen_header.dart'; import '../data/wash_repository.dart'; @@ -141,7 +142,7 @@ class _HistoryWashTile extends StatelessWidget { ), title: Text(wash.machineName), subtitle: wash.startedAt != null - ? Text(DateFormat('dd/MM/yyyy · HH:mm').format(wash.startedAt!)) + ? Text(ServerTime.format(wash.startedAt, pattern: 'dd/MM/yyyy · HH:mm')) : null, trailing: Text( format.format(wash.cost), diff --git a/lib/main.dart b/lib/main.dart index 2061303..288c7b6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_stripe/flutter_stripe.dart'; +import 'core/config/app_config.dart'; import 'core/platform/app_platform.dart'; +import 'core/time/server_time.dart'; +import 'core/time/server_time_sync.dart'; import 'core/router/app_router.dart'; import 'core/theme/app_theme.dart'; -void main() { +void main() async { WidgetsFlutterBinding.ensureInitialized(); if (!isMobilePlatformSupported) { @@ -15,6 +19,14 @@ void main() { ); } + await ServerTime.initialize(); + await syncServerTimezone(); + + if (kStripePublishableKey.isNotEmpty) { + Stripe.publishableKey = kStripePublishableKey; + await Stripe.instance.applySettings(); + } + runApp(const ProviderScope(child: LaverieApp())); } diff --git a/pubspec.lock b/pubspec.lock index 46ef13a..4ae49e5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -179,6 +179,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + flutter_stripe: + dependency: "direct main" + description: + name: flutter_stripe + sha256: e6984ab5600546df29ef081795b26bb88d2a6978dbb2e5953f2cb76ac7ab62d2 + url: "https://pub.dev" + source: hosted + version: "13.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -189,6 +197,14 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" go_router: dependency: "direct main" description: @@ -205,6 +221,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_parser: dependency: transitive description: @@ -245,6 +269,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" leak_tracker: dependency: transitive description: @@ -482,6 +514,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + stripe_android: + dependency: transitive + description: + name: stripe_android + sha256: "8d14fe209c4a2589786b08cab7cf0620bab23e8604d0285826a49612bc75e305" + url: "https://pub.dev" + source: hosted + version: "13.0.0" + stripe_ios: + dependency: transitive + description: + name: stripe_ios + sha256: "4f270dfa2e82b6653919473c48d4bf8b463efdcf096386b89e0efeb0928e58f4" + url: "https://pub.dev" + source: hosted + version: "13.0.0" + stripe_platform_interface: + dependency: transitive + description: + name: stripe_platform_interface + sha256: a0bac657a075aacccbd97368bfc1c57b742f28e5811734e3df09bff891c2d514 + url: "https://pub.dev" + source: hosted + version: "13.0.0" term_glyph: dependency: transitive description: @@ -498,6 +554,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" + url: "https://pub.dev" + source: hosted + version: "0.11.1" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d2adcfd..544eccf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,8 @@ dependencies: mobile_scanner: ^5.2.3 flutter_secure_storage: ^9.2.4 intl: ^0.20.2 + flutter_stripe: ^13.0.0 + timezone: ^0.11.1 dev_dependencies: flutter_test: