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 '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 createState() => TransferPageState(); } class TransferPageState extends State { late final _provider = TransferProvider( context.read(), widget.kind, source: widget.source, ); final _amount = TextEditingController(); final _recipient = 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); }); _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 _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 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( 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(); _recipient.dispose(); _focus.dispose(); super.dispose(); } @override Widget build(BuildContext context) => ListenableBuilder( listenable: _provider, builder: (context, _) { final p = _provider; final wallet = context.watch(); 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 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))); 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(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( 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( 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 (live && source?.payoutMethod.isNotEmpty == true) InputDecorator( decoration: InputDecoration( labelText: l.wRecipient, ), 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, ), onChanged: (v) => p.change(recipient: v), ), ], 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 ? 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), ), ], ], ], ), ), ], ), ), ), ); }, ); }