Files
sxchnge/frontend/app/lib/workspace/transfer.dart
T

440 lines
18 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
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';
import '../generated/i18n/app_localizations.dart';
class TransferPage extends StatefulWidget {
const TransferPage({super.key, required this.kind, this.source});
final TransferKind kind;
final String? source;
@override
State<TransferPage> createState() => TransferPageState();
}
class TransferPageState extends State<TransferPage> {
late final _provider = TransferProvider(
context.read<WalletProvider>(),
widget.kind,
source: widget.source,
);
final _amount = TextEditingController();
final _focus = FocusNode();
final _content = GlobalKey();
Timer? _timer;
bool _navigated = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
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(() {});
});
}
void _changed() {
if (_provider.result != null && !_navigated) {
_navigated = true;
context.go('/activity/${_provider.result!.id}');
return;
}
if (!_provider.busy &&
(_provider.quote != null || _provider.error != null)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _content.currentContext != null) {
Scrollable.ensureVisible(
_content.currentContext!,
duration: const Duration(milliseconds: 150),
);
_focus.requestFocus();
}
});
}
}
Future<void> _confirm() async {
final p = _provider;
final challenge = await p.challenge();
if (!mounted || p.factorError != null || p.error != null) return;
FactorGrant? grant;
if (challenge != null) {
grant = await verifyFactor(
context,
challenge: challenge,
service: p.wallet.factors!,
recorder: p.recorder,
journey: p.journey,
summary: TransferSummary(quote: p.quote!),
);
if (!mounted || grant == null) return;
}
await p.submit(approval: grant);
}
Future<bool> allowExit() async {
if (_provider.result != null) return true;
if (_provider.busy || _provider.uncertain) return false;
if (!_provider.hasDraft) return true;
final l = AppLocalizations.of(context)!;
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l.wDiscardTitle),
content: Text(l.wDiscardBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l.wStay),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l.wClose),
),
],
),
) ??
false;
}
@override
void dispose() {
_timer?.cancel();
_provider.removeListener(_changed);
_provider.dispose();
_amount.dispose();
_focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: _provider,
builder: (context, _) {
final p = _provider;
final wallet = context.watch<WalletProvider>();
final l = AppLocalizations.of(context)!;
final t = Theme.of(context);
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.topUp && !(source?.canFund ?? false)));
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
p.quote == null
? kindText(l, widget.kind)
: l.wReviewTitle,
style: t.textTheme.displaySmall,
),
),
IconButton(
tooltip: l.wClose,
onPressed: p.busy || p.uncertain
? null
: () => context.go('/accounts'),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 20),
Focus(
key: _content,
focusNode: _focus,
skipTraversal: true,
includeSemantics: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (p.busy) ...[
const LinearProgressIndicator(),
const SizedBox(height: 12),
Semantics(liveRegion: true, child: Text(l.wWorking)),
],
if (p.error != null)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Semantics(
liveRegion: true,
child: Text(
walletError(l, p.error!),
key: const ValueKey('transfer-error'),
style: TextStyle(color: t.colorScheme.error),
),
),
),
if (p.factorError != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Semantics(
liveRegion: true,
child: Text(
factorErrorText(l, p.factorError!),
style: TextStyle(color: t.colorScheme.error),
),
),
),
if (p.noSubmission) Text(l.wNoSubmission),
if (p.uncertain) ...[
Text(l.wUncertain),
FilledButton(
onPressed: p.busy ? null : p.reconcile,
child: Text(l.wCheckOutcome),
),
] else if (p.quote != null) ...[
TransferSummary(quote: p.quote!),
const SizedBox(height: 16),
Text(
p.expired
? l.wExpired
: live
? l.wLiveQuoteNotice(
expiresIn < 0 ? 0 : expiresIn,
)
: l.wQuoteNotice,
),
const SizedBox(height: 20),
if (p.expired)
FilledButton(
onPressed: p.busy ? null : p.review,
child: Text(l.wNewQuote),
)
else
FilledButton(
key: const ValueKey('transfer-confirm'),
onPressed: p.busy ? null : _confirm,
child: Text(
widget.kind == TransferKind.payout
? '${l.wPayout} ${money(context, source!, p.quote!.debit)}'
: l.wConfirm,
),
),
TextButton(
onPressed: p.busy ? null : p.edit,
child: Text(l.wEdit),
),
] else ...[
if (accounts.isEmpty) ...[
Text(wallet.loading ? l.wWorking : l.wEmptyAccounts),
TextButton(
onPressed: wallet.loading ? null : wallet.refresh,
child: Text(l.wRefresh),
),
] else ...[
DropdownButtonFormField<String>(
key: ValueKey('source-${p.source}'),
initialValue: accounts.any((a) => a.id == p.source)
? p.source
: null,
isExpanded: true,
decoration: InputDecoration(
labelText: widget.kind == TransferKind.topUp
? l.wReceiveAccount
: l.wSource,
),
items: accounts
.map(
(a) => DropdownMenuItem(
value: a.id,
child: Text(a.currency),
),
)
.toList(),
onChanged: p.busy
? null
: (v) => p.change(source: v),
),
if (source != null) ...[
const SizedBox(height: 10),
Text(
'${l.wAvailable}: ${money(context, source, source.available)}',
),
],
if (widget.kind == TransferKind.exchange) ...[
const SizedBox(height: 20),
DropdownButtonFormField<String>(
key: ValueKey('destination-${p.destination}'),
initialValue: p.destination,
isExpanded: true,
decoration: InputDecoration(
labelText: l.wDestination,
),
items: accounts
.map(
(a) => DropdownMenuItem(
value: a.id,
child: Text(a.currency),
),
)
.toList(),
onChanged: p.busy
? null
: (v) => p.change(destination: v),
),
],
const SizedBox(height: 20),
TextField(
key: const ValueKey('transfer-amount'),
controller: _amount,
enabled: !p.busy,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
labelText: l.wAmount,
suffixText: source?.currency,
),
onChanged: (v) => p.change(amount: v),
),
if (widget.kind == TransferKind.payout) ...[
const SizedBox(height: 20),
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,
),
),
if (source?.payoutMethod.isEmpty ?? true)
Text(l.wNoPayoutMethod),
] else
OutlinedButton.icon(
key: const ValueKey(
'choose-payout-destination',
),
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) ...[
const SizedBox(height: 12),
Text(
widget.kind == TransferKind.payout
? l.wNoPayoutMethod
: l.wNoFundingMethod,
),
],
const SizedBox(height: 28),
FilledButton(
key: const ValueKey('transfer-review'),
onPressed:
p.busy ||
unavailableMethod ||
(widget.kind == TransferKind.payout &&
(legacyPayout
? p.recipient.isEmpty
: p.payoutDestination == null))
? null
: p.review,
child: Text(l.wReview),
),
if (p.error == WalletError.limitExceeded)
TextButton(
onPressed: () => context.push('/limits'),
child: Text(l.wLimits),
),
if (p.error == WalletError.insufficientFunds)
TextButton(
onPressed: () =>
context.push('/top-up', extra: p.source),
child: Text(l.wTopUp),
),
],
],
],
),
),
],
),
),
),
);
},
);
}