Intégration fonctionnalites V1
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<BookingModifyScreen> {
|
||||
),
|
||||
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<BookingModifyScreen> {
|
||||
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<BookingModifyScreen> {
|
||||
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<BookingModifyScreen> {
|
||||
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,
|
||||
|
||||
@@ -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<void> _cancelBooking(BuildContext context, WidgetRef ref, Booking booking) async {
|
||||
final slotLabel = booking.slotStart != null
|
||||
? DateFormat('EEEE dd MMM à HH:mm', 'fr_FR').format(booking.slotStart!)
|
||||
? ServerTime.format(booking.slotStart, pattern: 'EEEE dd MMM à HH:mm')
|
||||
: 'ce créneau';
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<MachineBookingScreen> {
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
DateTime _selectedDate = ServerTime.startOfToday();
|
||||
TimeSlot? _selectedSlot;
|
||||
bool _isBooking = false;
|
||||
|
||||
@@ -112,14 +112,14 @@ class _MachineBookingScreenState extends ConsumerState<MachineBookingScreen> {
|
||||
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<MachineBookingScreen> {
|
||||
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<MachineBookingScreen> {
|
||||
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,
|
||||
|
||||
@@ -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<String, dynamic>))
|
||||
.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) {
|
||||
|
||||
@@ -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.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<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) {
|
||||
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<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,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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
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))
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user