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
@@ -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,
),
),
),
],
),
);
}
}