Intégration fonctionnalites V1
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
<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.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
|
<!-- Requis par Google Play Services / Stripe Payment Sheet sur Android -->
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
<application
|
<application
|
||||||
android:label="laverie_mobile"
|
android:label="laverie_mobile"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package fr.laverie.laverie_mobile
|
package fr.laverie.laverie_mobile
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterFragmentActivity()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
running.
|
running.
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="NormalTheme" parent="Theme.AppCompat.NoActionBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
running.
|
running.
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="NormalTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -38,3 +38,10 @@ abstract final class AuthStorageKeys {
|
|||||||
static const accessToken = 'laverie_access_token';
|
static const accessToken = 'laverie_access_token';
|
||||||
static const refreshToken = 'laverie_refresh_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';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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/wallet/presentation/wallet_top_up_screen.dart';
|
||||||
import '../../features/booking/presentation/booking_modify_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_action_screen.dart';
|
||||||
@@ -25,6 +26,7 @@ abstract final class AppRoutes {
|
|||||||
static const register = '/register';
|
static const register = '/register';
|
||||||
static const home = '/';
|
static const home = '/';
|
||||||
static const wallet = '/wallet';
|
static const wallet = '/wallet';
|
||||||
|
static const walletTopUp = '/wallet/top-up';
|
||||||
static const bookings = '/bookings';
|
static const bookings = '/bookings';
|
||||||
static const washes = '/washes';
|
static const washes = '/washes';
|
||||||
static const washScan = '/washes/scan';
|
static const washScan = '/washes/scan';
|
||||||
@@ -118,6 +120,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
path: AppRoutes.washScan,
|
path: AppRoutes.washScan,
|
||||||
builder: (context, state) => const QrScannerScreen(),
|
builder: (context, state) => const QrScannerScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.walletTopUp,
|
||||||
|
builder: (context, state) => const WalletTopUpScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/machines/:uuid/action',
|
path: '/machines/:uuid/action',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
|
|||||||
@@ -70,8 +70,21 @@ class AppTheme {
|
|||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(48),
|
minimumSize: const Size.fromHeight(48),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: AppColors.primary,
|
backgroundColor: AppColors.success,
|
||||||
foregroundColor: Colors.white,
|
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)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
|
textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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<void> 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> 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).
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ 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/api/api_response.dart';
|
||||||
import '../../../core/auth/auth_provider.dart';
|
import '../../../core/auth/auth_provider.dart';
|
||||||
|
import '../../../core/time/server_time.dart';
|
||||||
import '../domain/booking.dart';
|
import '../domain/booking.dart';
|
||||||
|
|
||||||
/// Dépôt de données pour les réservations.
|
/// Dépôt de données pour les réservations.
|
||||||
@@ -35,8 +36,8 @@ class BookingRepository {
|
|||||||
ApiEndpoints.bookings,
|
ApiEndpoints.bookings,
|
||||||
data: {
|
data: {
|
||||||
'machine_uuid': machineUuid,
|
'machine_uuid': machineUuid,
|
||||||
'slot_start': slotStart.toUtc().toIso8601String(),
|
'slot_start': ServerTime.toServerTime(slotStart).toIso8601String(),
|
||||||
'slot_end': slotEnd.toUtc().toIso8601String(),
|
'slot_end': ServerTime.toServerTime(slotEnd).toIso8601String(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final json = ApiResponse.object(response.data, 'booking');
|
final json = ApiResponse.object(response.data, 'booking');
|
||||||
@@ -65,8 +66,8 @@ class BookingRepository {
|
|||||||
final response = await _apiClient.patch(
|
final response = await _apiClient.patch(
|
||||||
ApiEndpoints.bookingMove(uuid),
|
ApiEndpoints.bookingMove(uuid),
|
||||||
data: {
|
data: {
|
||||||
'slot_start': slotStart.toUtc().toIso8601String(),
|
'slot_start': ServerTime.toServerTime(slotStart).toIso8601String(),
|
||||||
'slot_end': slotEnd.toUtc().toIso8601String(),
|
'slot_end': ServerTime.toServerTime(slotEnd).toIso8601String(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final json = ApiResponse.object(response.data, 'booking');
|
final json = ApiResponse.object(response.data, 'booking');
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import '../../../core/time/server_time.dart';
|
||||||
|
|
||||||
/// Modèle réservation de créneau machine.
|
/// Modèle réservation de créneau machine.
|
||||||
class Booking {
|
class Booking {
|
||||||
const Booking({
|
const Booking({
|
||||||
@@ -21,7 +23,7 @@ class Booking {
|
|||||||
bool get canCancel =>
|
bool get canCancel =>
|
||||||
(status == 'confirmed' || status == 'pending') &&
|
(status == 'confirmed' || status == 'pending') &&
|
||||||
slotStart != null &&
|
slotStart != null &&
|
||||||
slotStart!.isAfter(DateTime.now());
|
slotStart!.isAfter(ServerTime.now());
|
||||||
|
|
||||||
bool get canModify => canCancel;
|
bool get canModify => canCancel;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
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 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import '../../../core/router/app_router.dart';
|
import '../../../core/router/app_router.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/time/server_time.dart';
|
||||||
import '../../machines/data/machine_repository.dart';
|
import '../../machines/data/machine_repository.dart';
|
||||||
import '../../machines/domain/machine_detail.dart';
|
import '../../machines/domain/machine_detail.dart';
|
||||||
import '../data/booking_repository.dart';
|
import '../data/booking_repository.dart';
|
||||||
@@ -118,7 +118,7 @@ class _BookingModifyScreenState extends ConsumerState<BookingModifyScreen> {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
booking.slotStart != null && booking.slotEnd != null
|
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),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
@@ -136,14 +136,14 @@ class _BookingModifyScreenState extends ConsumerState<BookingModifyScreen> {
|
|||||||
itemCount: 6,
|
itemCount: 6,
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||||
itemBuilder: (context, index) {
|
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 normalized = DateTime(date.year, date.month, date.day);
|
||||||
final isSelected = normalized.year == selectedDate.year &&
|
final isSelected = normalized.year == selectedDate.year &&
|
||||||
normalized.month == selectedDate.month &&
|
normalized.month == selectedDate.month &&
|
||||||
normalized.day == selectedDate.day;
|
normalized.day == selectedDate.day;
|
||||||
|
|
||||||
return ChoiceChip(
|
return ChoiceChip(
|
||||||
label: Text(DateFormat('EEE dd/MM', 'fr_FR').format(normalized)),
|
label: Text(ServerTime.format(normalized, pattern: 'EEE dd/MM')),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
onSelected: (_) => setState(() {
|
onSelected: (_) => setState(() {
|
||||||
_selectedDate = normalized;
|
_selectedDate = normalized;
|
||||||
@@ -172,7 +172,7 @@ class _BookingModifyScreenState extends ConsumerState<BookingModifyScreen> {
|
|||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: slots.map((slot) {
|
children: slots.map((slot) {
|
||||||
final label =
|
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;
|
final isSelected = _selectedSlot?.start == slot.start;
|
||||||
|
|
||||||
return FilterChip(
|
return FilterChip(
|
||||||
@@ -197,12 +197,6 @@ class _BookingModifyScreenState extends ConsumerState<BookingModifyScreen> {
|
|||||||
height: 56,
|
height: 56,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isSaving ? null : () => _confirmMove(booking),
|
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
|
icon: _isSaving
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 22,
|
width: 22,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
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 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import '../../../core/router/app_router.dart';
|
import '../../../core/router/app_router.dart';
|
||||||
|
import '../../../core/time/server_time.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/widgets/empty_state.dart';
|
import '../../../core/widgets/empty_state.dart';
|
||||||
import '../../../core/widgets/screen_header.dart';
|
import '../../../core/widgets/screen_header.dart';
|
||||||
@@ -16,7 +16,7 @@ class BookingsScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
Future<void> _cancelBooking(BuildContext context, WidgetRef ref, Booking booking) async {
|
Future<void> _cancelBooking(BuildContext context, WidgetRef ref, Booking booking) async {
|
||||||
final slotLabel = booking.slotStart != null
|
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';
|
: 'ce créneau';
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
@@ -144,7 +144,7 @@ class _BookingCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final slotText = booking.slotStart != null
|
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';
|
: 'Créneau à confirmer';
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
|
|||||||
@@ -215,16 +215,6 @@ class _MachineActionBody extends StatelessWidget {
|
|||||||
height: 60,
|
height: 60,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: canStart && !isStarting ? onStart : null,
|
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
|
icon: isStarting
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
@@ -244,10 +234,6 @@ class _MachineActionBody extends StatelessWidget {
|
|||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primary,
|
backgroundColor: AppColors.primary,
|
||||||
foregroundColor: Colors.white,
|
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),
|
icon: const Icon(Icons.event_available_outlined, size: 24),
|
||||||
label: const Text('Réserver un créneau'),
|
label: const Text('Réserver un créneau'),
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
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 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import '../../../core/router/app_router.dart';
|
import '../../../core/router/app_router.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/machine_status_theme.dart';
|
import '../../../core/theme/machine_status_theme.dart';
|
||||||
|
import '../../../core/time/server_time.dart';
|
||||||
import '../../booking/data/booking_repository.dart';
|
import '../../booking/data/booking_repository.dart';
|
||||||
import '../data/machine_repository.dart';
|
import '../data/machine_repository.dart';
|
||||||
import '../domain/machine_detail.dart';
|
import '../domain/machine_detail.dart';
|
||||||
@@ -21,7 +21,7 @@ class MachineBookingScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MachineBookingScreenState extends ConsumerState<MachineBookingScreen> {
|
class _MachineBookingScreenState extends ConsumerState<MachineBookingScreen> {
|
||||||
DateTime _selectedDate = DateTime.now();
|
DateTime _selectedDate = ServerTime.startOfToday();
|
||||||
TimeSlot? _selectedSlot;
|
TimeSlot? _selectedSlot;
|
||||||
bool _isBooking = false;
|
bool _isBooking = false;
|
||||||
|
|
||||||
@@ -112,14 +112,14 @@ class _MachineBookingScreenState extends ConsumerState<MachineBookingScreen> {
|
|||||||
itemCount: 6,
|
itemCount: 6,
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||||
itemBuilder: (context, index) {
|
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 normalized = DateTime(date.year, date.month, date.day);
|
||||||
final isSelected = normalized.year == _selectedDate.year &&
|
final isSelected = normalized.year == _selectedDate.year &&
|
||||||
normalized.month == _selectedDate.month &&
|
normalized.month == _selectedDate.month &&
|
||||||
normalized.day == _selectedDate.day;
|
normalized.day == _selectedDate.day;
|
||||||
|
|
||||||
return ChoiceChip(
|
return ChoiceChip(
|
||||||
label: Text(DateFormat('EEE dd/MM', 'fr_FR').format(normalized)),
|
label: Text(ServerTime.format(normalized, pattern: 'EEE dd/MM')),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
onSelected: (_) => setState(() {
|
onSelected: (_) => setState(() {
|
||||||
_selectedDate = normalized;
|
_selectedDate = normalized;
|
||||||
@@ -159,7 +159,7 @@ class _MachineBookingScreenState extends ConsumerState<MachineBookingScreen> {
|
|||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: slots.map((slot) {
|
children: slots.map((slot) {
|
||||||
final label =
|
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;
|
final isSelected = _selectedSlot?.start == slot.start;
|
||||||
|
|
||||||
return FilterChip(
|
return FilterChip(
|
||||||
@@ -183,14 +183,9 @@ class _MachineBookingScreenState extends ConsumerState<MachineBookingScreen> {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 56,
|
height: 56,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isBooking ? null : () => _confirmBooking(detail),
|
onPressed: (_isBooking || _selectedSlot == null)
|
||||||
style: ElevatedButton.styleFrom(
|
? null
|
||||||
backgroundColor: AppColors.primary,
|
: () => _confirmBooking(detail),
|
||||||
foregroundColor: Colors.white,
|
|
||||||
elevation: 0,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
|
||||||
textStyle: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
icon: _isBooking
|
icon: _isBooking
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 22,
|
width: 22,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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/api/api_response.dart';
|
||||||
import '../../../core/auth/auth_provider.dart';
|
import '../../../core/auth/auth_provider.dart';
|
||||||
|
import '../domain/payment.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.
|
||||||
@@ -26,6 +27,31 @@ class WalletRepository {
|
|||||||
.map((json) => WalletTransaction.fromJson(json as Map<String, dynamic>))
|
.map((json) => WalletTransaction.fromJson(json as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<PaymentTransaction> 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<TopUpResult> 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<WalletRepository>((ref) {
|
final walletRepositoryProvider = Provider<WalletRepository>((ref) {
|
||||||
|
|||||||
@@ -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<String, dynamic> 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<String, dynamic>
|
||||||
|
? 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<String, dynamic> 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<String, dynamic> json) {
|
||||||
|
return TopUpResult(
|
||||||
|
payment: PaymentTransaction.fromJson(json['payment'] as Map<String, dynamic>),
|
||||||
|
balance: (json['balance'] as num).toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ class WalletTransaction {
|
|||||||
required this.amount,
|
required this.amount,
|
||||||
required this.balanceAfter,
|
required this.balanceAfter,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
this.metadata = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
final String uuid;
|
final String uuid;
|
||||||
@@ -34,6 +35,64 @@ class WalletTransaction {
|
|||||||
final double amount;
|
final double amount;
|
||||||
final double balanceAfter;
|
final double balanceAfter;
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
|
final Map<String, dynamic> 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<String, dynamic>? _asStringMap(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is Map<String, dynamic>) return value;
|
||||||
|
if (value is Map) return Map<String, dynamic>.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<String, dynamic> json) {
|
factory WalletTransaction.fromJson(Map<String, dynamic> json) {
|
||||||
return WalletTransaction(
|
return WalletTransaction(
|
||||||
@@ -44,6 +103,20 @@ class WalletTransaction {
|
|||||||
createdAt: json['created_at'] != null
|
createdAt: json['created_at'] != null
|
||||||
? DateTime.tryParse(json['created_at'] as String)
|
? DateTime.tryParse(json['created_at'] as String)
|
||||||
: null,
|
: null,
|
||||||
|
metadata: _parseMetadata(json['metadata']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> _parseMetadata(dynamic value) {
|
||||||
|
if (value == null) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
if (value is Map<String, dynamic>) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is Map) {
|
||||||
|
return Map<String, dynamic>.from(value);
|
||||||
|
}
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
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/router/app_router.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/time/server_time.dart';
|
||||||
import '../../../core/widgets/empty_state.dart';
|
import '../../../core/widgets/empty_state.dart';
|
||||||
import '../data/wallet_repository.dart';
|
import '../data/wallet_repository.dart';
|
||||||
|
|
||||||
@@ -70,11 +73,11 @@ class WalletScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () {␍
|
onPressed: () => context.push(AppRoutes.walletTopUp),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(␍
|
style: ElevatedButton.styleFrom(
|
||||||
const SnackBar(content: Text('Rechargement — à connecter à l\'API')),␍
|
backgroundColor: AppColors.primary,
|
||||||
);␍
|
foregroundColor: Colors.white,
|
||||||
},␍
|
),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('Recharger'),
|
label: const Text('Recharger'),
|
||||||
),
|
),
|
||||||
@@ -109,10 +112,15 @@ class WalletScreen extends ConsumerWidget {
|
|||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(tx.type),␍
|
title: Text(tx.displayLabel),
|
||||||
subtitle: tx.createdAt != null
|
subtitle: tx.createdAt != null
|
||||||
? Text(DateFormat('dd/MM/yyyy HH:mm').format(tx.createdAt!))␍
|
? Text(
|
||||||
: null,␍
|
[
|
||||||
|
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(
|
trailing: Text(
|
||||||
'${isCredit ? '+' : '-'}${currencyFormat.format(tx.amount)}',
|
'${isCredit ? '+' : '-'}${currencyFormat.format(tx.amount)}',
|
||||||
style: TextStyle(fontWeight: FontWeight.w600, color: color),
|
style: TextStyle(fontWeight: FontWeight.w600, color: color),
|
||||||
|
|||||||
@@ -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<WalletTopUpScreen> createState() => _WalletTopUpScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WalletTopUpScreenState extends ConsumerState<WalletTopUpScreen> {
|
||||||
|
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<void> _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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import '../../../core/time/server_time.dart';
|
||||||
|
|
||||||
/// Progression d'un lavage en cours.
|
/// Progression d'un lavage en cours.
|
||||||
class WashProgress {
|
class WashProgress {
|
||||||
const WashProgress({
|
const WashProgress({
|
||||||
@@ -50,7 +52,7 @@ class WashProgress {
|
|||||||
return const WashProgress(percent: 0, phaseLabel: 'En attente de démarrage', phase: 'pending');
|
return const WashProgress(percent: 0, phaseLabel: 'En attente de démarrage', phase: 'pending');
|
||||||
}
|
}
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = ServerTime.now();
|
||||||
final end = estimatedEndAt ??
|
final end = estimatedEndAt ??
|
||||||
(startedAt != null && durationMinutes != null
|
(startedAt != null && durationMinutes != null
|
||||||
? startedAt.add(Duration(minutes: durationMinutes))
|
? startedAt.add(Duration(minutes: durationMinutes))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:intl/intl.dart';
|
|||||||
import '../../../core/api/api_response.dart';
|
import '../../../core/api/api_response.dart';
|
||||||
import '../../../core/router/app_router.dart';
|
import '../../../core/router/app_router.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/time/server_time.dart';
|
||||||
import '../../../core/widgets/empty_state.dart';
|
import '../../../core/widgets/empty_state.dart';
|
||||||
import '../../../core/widgets/screen_header.dart';
|
import '../../../core/widgets/screen_header.dart';
|
||||||
import '../data/wash_repository.dart';
|
import '../data/wash_repository.dart';
|
||||||
@@ -141,7 +142,7 @@ class _HistoryWashTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
title: Text(wash.machineName),
|
title: Text(wash.machineName),
|
||||||
subtitle: wash.startedAt != null
|
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,
|
: null,
|
||||||
trailing: Text(
|
trailing: Text(
|
||||||
format.format(wash.cost),
|
format.format(wash.cost),
|
||||||
|
|||||||
+13
-1
@@ -1,12 +1,16 @@
|
|||||||
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:flutter_localizations/flutter_localizations.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/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/router/app_router.dart';
|
||||||
import 'core/theme/app_theme.dart';
|
import 'core/theme/app_theme.dart';
|
||||||
|
|
||||||
void main() {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
if (!isMobilePlatformSupported) {
|
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()));
|
runApp(const ProviderScope(child: LaverieApp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -189,6 +197,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
go_router:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -205,6 +221,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.2"
|
||||||
|
http:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -245,6 +269,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.7"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -482,6 +514,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
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:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -498,6 +554,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
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:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ dependencies:
|
|||||||
mobile_scanner: ^5.2.3
|
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
|
||||||
|
flutter_stripe: ^13.0.0
|
||||||
|
timezone: ^0.11.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user