230 lines
7.1 KiB
Dart
230 lines
7.1 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|