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