Add saved payout destination UX and service contracts (#54)

This commit is contained in:
Stephan D committed 2026-10-16 12:01:33 +02:00
1 parent a5689876ff
commit 24af794112
49 files changed
+4687 -62

No files matched your search

+1
View File
@@ -48,6 +48,7 @@ String fundsText(
_ => l.wFailedOutgoing,
};
String walletError(AppLocalizations l, WalletError e) => switch (e) {
WalletError.destinationChanged => l.dQuoteChanged,
WalletError.invalidAmount => l.wInvalidAmount,
WalletError.insufficientFunds => l.wInsufficient,
WalletError.limitExceeded => l.wLimitError,
+9
View File
@@ -106,6 +106,15 @@ class SettingsPage extends StatelessWidget {
),
),
const SizedBox(height: 16),
Card(
child: ListTile(
leading: const Icon(Icons.account_balance_outlined),
title: Text(l.dTitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/settings/payout-methods'),
),
),
const SizedBox(height: 16),
const SecuritySettings(),
const SizedBox(height: 20),
TextButton(
+16 -2
View File
@@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
@@ -6,6 +8,8 @@ import 'package:provider/provider.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../destinations/details.dart';
import 'copy.dart';
import '../generated/i18n/app_localizations.dart';
@@ -49,13 +53,23 @@ class TransferSummary extends StatelessWidget {
q.request.kind == TransferKind.payout
? l.wRecipientGets
: l.wCredit,
money(context, b, q.credit),
q.payoutTerms == null
? money(context, b, q.credit)
: '${NumberFormat.decimalPatternDigits(locale: Localizations.localeOf(context).toLanguageTag(), decimalDigits: q.payoutTerms!.precision).format(q.payoutTerms!.amount / pow(10, q.payoutTerms!.precision))} ${q.payoutTerms!.assetLabel}',
large: true,
),
if (q.payoutTerms != null) ...[
const SizedBox(height: 12),
Text(l.dTiming(q.payoutTerms!.expectedSeconds)),
if (p.service.isSimulated) Text(l.dDemoTerms),
],
if (q.request.kind == TransferKind.payout) ...[
const Divider(height: 32),
Text(l.wRecipient),
SelectableText(q.request.recipient),
if (q.payoutSnapshot != null)
DestinationDetailsView(details: q.payoutSnapshot!)
else
SelectableText(q.request.recipient),
],
],
),
+86 -24
View File
@@ -11,6 +11,7 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
import '../security/challenge.dart';
import '../destinations/list.dart';
import 'copy.dart';
import 'summary.dart';
@@ -32,7 +33,7 @@ class TransferPageState extends State<TransferPage> {
source: widget.source,
);
final _amount = TextEditingController();
final _recipient = TextEditingController();
final _focus = FocusNode();
final _content = GlobalKey();
Timer? _timer;
@@ -41,7 +42,13 @@ class TransferPageState extends State<TransferPage> {
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _provider.addListener(_changed);
if (mounted) {
_provider.addListener(_changed);
final destinations = context.read<DestinationsProvider>();
if (destinations.capabilities == null && !destinations.loading) {
unawaited(destinations.refresh());
}
}
});
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted && _provider.quote != null) setState(() {});
@@ -118,7 +125,7 @@ class TransferPageState extends State<TransferPage> {
_provider.removeListener(_changed);
_provider.dispose();
_amount.dispose();
_recipient.dispose();
_focus.dispose();
super.dispose();
}
@@ -134,14 +141,16 @@ class TransferPageState extends State<TransferPage> {
final accounts = wallet.data?.accounts ?? [];
final source = wallet.account(p.source);
final live = !wallet.service.isSimulated;
final destinations = context.watch<DestinationsProvider>();
final legacyPayout =
live &&
!(destinations.capabilities?.types.isNotEmpty ?? false) &&
destinations.methods.isEmpty;
final expiresIn =
p.quote?.expiresAt.difference(DateTime.now()).inSeconds ?? 0;
final unavailableMethod =
live &&
((widget.kind == TransferKind.payout &&
(source?.payoutMethod.isEmpty ?? true)) ||
(widget.kind == TransferKind.topUp &&
!(source?.canFund ?? false)));
((widget.kind == TransferKind.topUp && !(source?.canFund ?? false)));
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
@@ -235,7 +244,11 @@ class TransferPageState extends State<TransferPage> {
FilledButton(
key: const ValueKey('transfer-confirm'),
onPressed: p.busy ? null : _confirm,
child: Text(l.wConfirm),
child: Text(
widget.kind == TransferKind.payout
? '${l.wPayout} ${money(context, source!, p.quote!.debit)}'
: l.wConfirm,
),
),
TextButton(
onPressed: p.busy ? null : p.edit,
@@ -316,23 +329,66 @@ class TransferPageState extends State<TransferPage> {
),
if (widget.kind == TransferKind.payout) ...[
const SizedBox(height: 20),
if (live && source?.payoutMethod.isNotEmpty == true)
InputDecorator(
decoration: InputDecoration(
labelText: l.wRecipient,
if (legacyPayout) ...[
if (source?.payoutMethod.isNotEmpty == true)
OutlinedButton.icon(
key: const ValueKey(
'choose-configured-destination',
),
icon: const Icon(
Icons.account_balance_outlined,
),
label: Text(source!.payoutLabel),
onPressed: p.busy
? null
: () => p.change(
recipient: source.payoutMethod,
payoutLabel: source.payoutLabel,
),
),
child: Text(source!.payoutLabel),
),
if (!live)
TextField(
key: const ValueKey('transfer-recipient'),
controller: _recipient,
enabled: !p.busy,
decoration: InputDecoration(
labelText: l.wRecipient,
hintText: l.wRecipientHint,
if (source?.payoutMethod.isEmpty ?? true)
Text(l.wNoPayoutMethod),
] else
OutlinedButton.icon(
key: const ValueKey(
'choose-payout-destination',
),
onChanged: (v) => p.change(recipient: v),
icon: const Icon(
Icons.account_balance_wallet_outlined,
),
label: Text(
p.payoutDestination == null
? l.dSelect
: p.payoutLabel,
),
onPressed: p.busy
? null
: () async {
final selected =
await Navigator.push<
DestinationInput
>(
context,
MaterialPageRoute(
builder: (_) =>
DestinationsPage(
payoutContext:
PayoutContext(
p.source,
amount: source
?.parse(
p.amount,
),
),
),
),
);
if (!context.mounted ||
selected == null) {
return;
}
await p.selectPayout(selected);
},
),
],
if (unavailableMethod) ...[
@@ -346,7 +402,13 @@ class TransferPageState extends State<TransferPage> {
const SizedBox(height: 28),
FilledButton(
key: const ValueKey('transfer-review'),
onPressed: p.busy || unavailableMethod
onPressed:
p.busy ||
unavailableMethod ||
(widget.kind == TransferKind.payout &&
(legacyPayout
? p.recipient.isEmpty
: p.payoutDestination == null))
? null
: p.review,
child: Text(l.wReview),