Intégration fonctionnalites V1

This commit is contained in:
bastien
2026-07-04 22:46:14 +02:00
parent bf191d6396
commit 9f352b91d2
25 changed files with 779 additions and 184 deletions
@@ -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) {
+72
View File
@@ -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(),
);
}
}
+73
View File
@@ -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,
),
),
),
],
),
);
}
}