| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640 |
- import 'dart:math' as math;
- import 'package:flutter/material.dart';
- import 'package:flutter/services.dart';
- import 'package:flutter_riverpod/flutter_riverpod.dart';
- import 'package:go_router/go_router.dart';
- import '../../../core/l10n/app_localizations.dart';
- import '../../../core/theme/app_colors.dart';
- import '../../../core/utils/number_format.dart';
- import '../../../core/utils/top_toast.dart';
- import '../../../core/utils/transfer_pair.dart';
- import '../../../providers/transfer_provider.dart';
- import '../../../providers/futures_provider.dart'
- show futuresProvider, futuresActiveSymbolProvider;
- class TransferScreen extends ConsumerStatefulWidget {
- const TransferScreen({
- super.key,
- this.initialFrom,
- this.initialTo,
- this.initialSymbol,
- this.preferDefaultSymbol = false,
- this.spotTradingBridgeOnly = false,
- });
- final String? initialFrom;
- final String? initialTo;
- final String? initialSymbol;
- final bool preferDefaultSymbol;
- final bool spotTradingBridgeOnly;
- @override
- ConsumerState<TransferScreen> createState() => _TransferScreenState();
- }
- class _TransferScreenState extends ConsumerState<TransferScreen> {
- final _amountController = TextEditingController();
- bool _initialized = false;
- @override
- void initState() {
- super.initState();
- WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap());
- }
- Future<void> _bootstrap() async {
- if (_initialized) {
- return;
- }
- _initialized = true;
- await ref.read(transferProvider.notifier).init(
- TransferInitOptions(
- from: widget.initialFrom,
- to: widget.initialTo,
- defaultSymbol: widget.initialSymbol ?? 'USDT',
- preferDefaultSymbol: widget.preferDefaultSymbol,
- spotTradingBridgeOnly: widget.spotTradingBridgeOnly,
- ),
- );
- }
- @override
- void dispose() {
- _amountController.dispose();
- super.dispose();
- }
- @override
- Widget build(BuildContext context) {
- final state = ref.watch(transferProvider);
- final notifier = ref.read(transferProvider.notifier);
- final l10n = AppLocalizations.of(context)!;
- return Scaffold(
- appBar: AppBar(
- leading: IconButton(
- icon: const Icon(Icons.chevron_left, size: 28),
- onPressed: () => context.pop(),
- ),
- title: Text(
- l10n.accountTransferTitle,
- style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
- ),
- centerTitle: true,
- actions: [
- IconButton(
- icon: const Icon(Icons.history, size: 22),
- onPressed: () => context.push('/asset/transfer/history'),
- ),
- ],
- ),
- body: _buildBody(context, state, notifier, l10n),
- );
- }
- Widget _buildBody(
- BuildContext context,
- TransferState state,
- TransferNotifier notifier,
- AppLocalizations l10n,
- ) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- if (state.isLoading && state.legacyBalances.isEmpty) {
- return const Center(child: CircularProgressIndicator());
- }
- if (state.errorMessage != null && state.legacyBalances.isEmpty) {
- return Center(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(state.errorMessage!,
- style: TextStyle(color: cs.onSurface.withAlpha(153))),
- const SizedBox(height: 16),
- ElevatedButton(
- onPressed: notifier.refresh,
- child: Text(l10n.retry),
- ),
- ],
- ),
- );
- }
- final fromBalance = state.fromBalance;
- return SingleChildScrollView(
- padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- if (state.isSpotCoinTransfer) ...[
- _CoinSelector(
- coins: state.openCoins,
- selectedCoin: state.selectedCoin,
- loading: state.isLoadingCoins,
- onChanged: (coin) {
- notifier.selectCoin(coin);
- _amountController.clear();
- setState(() {});
- },
- ),
- const SizedBox(height: 16),
- ],
- Container(
- padding: const EdgeInsets.all(16),
- decoration: BoxDecoration(
- color: isDark
- ? AppColors.darkBgSecondary
- : AppColors.lightBgSecondary,
- borderRadius: BorderRadius.circular(12),
- ),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Expanded(
- child: _TransferSide(
- label: l10n.fromLabel,
- walletType: state.fromType,
- otherTypes: state.transferOtherTypes,
- readonly: state.fromType == kWalletSpot,
- onChanged: (type) {
- notifier.setFromType(type);
- _amountController.clear();
- setState(() {});
- },
- ),
- ),
- Padding(
- padding: const EdgeInsets.only(top: 28),
- child: GestureDetector(
- onTap: () {
- notifier.swapAccounts();
- _amountController.clear();
- setState(() {});
- },
- child: Container(
- width: 36,
- height: 36,
- decoration: BoxDecoration(
- border: Border.all(color: cs.outline.withAlpha(60)),
- borderRadius: BorderRadius.circular(18),
- ),
- child: Icon(Icons.swap_horiz,
- size: 20, color: cs.onSurface),
- ),
- ),
- ),
- Expanded(
- child: _TransferSide(
- label: l10n.toLabel,
- walletType: state.toType,
- otherTypes: state.transferOtherTypes,
- readonly: state.fromType == kWalletSwap ||
- state.fromType == kWalletFollow ||
- state.fromType == kWalletSpotTrading,
- onChanged: (type) {
- notifier.setToType(type);
- _amountController.clear();
- setState(() {});
- },
- ),
- ),
- ],
- ),
- ),
- const SizedBox(height: 8),
- Text(
- l10n.transferAvailableCoin(
- formatAmount(fromBalance.toDouble()),
- state.displayCoinUnit,
- ),
- style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 12),
- ),
- const SizedBox(height: 20),
- Container(
- padding: const EdgeInsets.all(16),
- decoration: BoxDecoration(
- color: isDark
- ? AppColors.darkBgSecondary
- : AppColors.lightBgSecondary,
- borderRadius: BorderRadius.circular(12),
- ),
- child: Column(
- children: [
- Row(
- children: [
- Expanded(
- child: Text(
- state.isSpotCoinTransfer
- ? l10n.transferAmountCoin(state.displayCoinUnit)
- : l10n.transferAmount,
- style: TextStyle(
- color: cs.onSurface.withAlpha(153),
- fontSize: 13,
- ),
- ),
- ),
- GestureDetector(
- onTap: () {
- _amountController.text = fromBalance.toString();
- setState(() {});
- },
- child: Text(
- l10n.all,
- style: const TextStyle(
- color: AppColors.brand,
- fontSize: 13,
- ),
- ),
- ),
- ],
- ),
- const SizedBox(height: 8),
- TextField(
- controller: _amountController,
- keyboardType:
- const TextInputType.numberWithOptions(decimal: true),
- inputFormatters: [
- FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,8}')),
- ],
- onChanged: (_) => setState(() {}),
- style: TextStyle(color: cs.onSurface, fontSize: 16),
- decoration: InputDecoration(
- hintText: '0.00',
- hintStyle: TextStyle(
- color: cs.onSurface.withAlpha(100),
- fontSize: 16,
- ),
- border: InputBorder.none,
- enabledBorder: InputBorder.none,
- focusedBorder: InputBorder.none,
- filled: false,
- isDense: true,
- contentPadding: EdgeInsets.zero,
- ),
- ),
- ],
- ),
- ),
- const SizedBox(height: 12),
- Text(
- l10n.transferFreeHint,
- style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 12),
- ),
- const SizedBox(height: 24),
- SizedBox(
- width: double.infinity,
- height: 50,
- child: ElevatedButton(
- onPressed: _canSubmit(state) ? _submit : null,
- style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.brand,
- disabledBackgroundColor: AppColors.brand.withAlpha(80),
- foregroundColor: Colors.black,
- disabledForegroundColor: Colors.black.withAlpha(80),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(25),
- ),
- ),
- child: state.isSubmitting
- ? const SizedBox(
- width: 20,
- height: 20,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: Colors.black,
- ),
- )
- : Text(
- l10n.confirmTransfer,
- style: const TextStyle(
- fontSize: 16,
- fontWeight: FontWeight.w600,
- ),
- ),
- ),
- ),
- ],
- ),
- );
- }
- bool _canSubmit(TransferState state) {
- if (state.isSubmitting || state.isLoadingCoins) {
- return false;
- }
- final amount = double.tryParse(_amountController.text.trim()) ?? 0;
- return amount > 0;
- }
- Future<void> _submit() async {
- final l10n = AppLocalizations.of(context)!;
- final amount = _amountController.text.trim();
- if (amount.isEmpty) {
- showTopToast(context, message: l10n.enterTransferAmount);
- return;
- }
- final notifier = ref.read(transferProvider.notifier);
- final success = await notifier.submit(amount);
- if (!mounted) {
- return;
- }
- if (success) {
- showTopToast(
- context,
- message: l10n.transferSuccess,
- backgroundColor: AppColors.success,
- );
- _amountController.clear();
- final activeSymbol = ref.read(futuresActiveSymbolProvider);
- if (activeSymbol.isNotEmpty) {
- ref.read(futuresProvider(activeSymbol).notifier).refreshWallet();
- }
- setState(() {});
- return;
- }
- final err = ref.read(transferProvider).errorMessage;
- if (err != null) {
- showTopToast(context, message: _mapError(err, l10n));
- }
- }
- String _mapError(String err, AppLocalizations l10n) {
- if (err == 'errSameAccount') {
- return l10n.transferSameAccountError;
- }
- if (err == 'errEnterAmount') {
- return l10n.enterTransferAmount;
- }
- if (err == 'errExceedBalance') {
- return l10n.errExceedBalance;
- }
- return err;
- }
- }
- class _TransferSide extends StatelessWidget {
- const _TransferSide({
- required this.label,
- required this.walletType,
- required this.otherTypes,
- required this.readonly,
- required this.onChanged,
- });
- final String label;
- final String walletType;
- final List<String> otherTypes;
- final bool readonly;
- final ValueChanged<String> onChanged;
- String _name(AppLocalizations l10n, String type) {
- switch (type) {
- case kWalletSpot:
- return l10n.fundAccount;
- case kWalletSwap:
- return l10n.futuresAccount;
- case kWalletFollow:
- return l10n.copyAccount;
- case kWalletSpotTrading:
- return l10n.spotTradingAccount;
- default:
- return type;
- }
- }
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final l10n = AppLocalizations.of(context)!;
- final displayType =
- readonly && walletType != kWalletSpot ? kWalletSpot : walletType;
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(label,
- style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 12)),
- const SizedBox(height: 8),
- if (readonly || otherTypes.length <= 1)
- Container(
- width: double.infinity,
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
- decoration: BoxDecoration(
- color: cs.surface.withAlpha(20),
- borderRadius: BorderRadius.circular(8),
- border: Border.all(color: cs.outline.withAlpha(40)),
- ),
- child: Text(
- _name(l10n, displayType),
- style: TextStyle(color: cs.onSurface.withAlpha(180), fontSize: 14),
- ),
- )
- else
- _WalletPicker(
- value: walletType,
- options: otherTypes,
- labelBuilder: (type) => _name(l10n, type),
- onChanged: onChanged,
- ),
- ],
- );
- }
- }
- class _WalletPicker extends StatelessWidget {
- const _WalletPicker({
- required this.value,
- required this.options,
- required this.labelBuilder,
- required this.onChanged,
- });
- final String value;
- final List<String> options;
- final String Function(String type) labelBuilder;
- final ValueChanged<String> onChanged;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- return GestureDetector(
- onTap: () => _showPicker(context),
- child: Container(
- width: double.infinity,
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(8),
- border: Border.all(color: cs.outline.withAlpha(60)),
- ),
- child: Row(
- children: [
- Expanded(
- child: Text(
- labelBuilder(value),
- style: TextStyle(color: cs.onSurface, fontSize: 14),
- ),
- ),
- Icon(Icons.keyboard_arrow_down,
- size: 18, color: cs.onSurface.withAlpha(120)),
- ],
- ),
- ),
- );
- }
- void _showPicker(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- showModalBottomSheet<void>(
- context: context,
- useRootNavigator: true,
- backgroundColor: cs.surface,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
- ),
- builder: (sheetCtx) => SafeArea(
- child: LayoutBuilder(
- builder: (ctx, c) {
- const overhead = 8.0 + 4.0 + 16.0 + 8.0;
- final listMax = c.maxHeight.isFinite
- ? math.max(80.0, c.maxHeight - overhead)
- : 320.0;
- return Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- const SizedBox(height: 8),
- Container(
- width: 36,
- height: 4,
- decoration: BoxDecoration(
- color: cs.outline.withAlpha(60),
- borderRadius: BorderRadius.circular(2),
- ),
- ),
- const SizedBox(height: 16),
- ConstrainedBox(
- constraints: BoxConstraints(maxHeight: listMax),
- child: ListView(
- shrinkWrap: true,
- children: options.map((type) {
- final selected = type == value;
- return ListTile(
- title: Text(labelBuilder(type)),
- trailing: selected
- ? Icon(Icons.check, color: cs.onSurface)
- : null,
- onTap: () {
- onChanged(type);
- Navigator.pop(sheetCtx);
- },
- );
- }).toList(),
- ),
- ),
- const SizedBox(height: 8),
- ],
- );
- },
- ),
- ),
- );
- }
- }
- class _CoinSelector extends StatelessWidget {
- const _CoinSelector({
- required this.coins,
- required this.selectedCoin,
- required this.loading,
- required this.onChanged,
- });
- final List<String> coins;
- final String selectedCoin;
- final bool loading;
- final ValueChanged<String> onChanged;
- @override
- Widget build(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- final isDark = Theme.of(context).brightness == Brightness.dark;
- final canSelect = coins.length > 1 && !loading;
- return GestureDetector(
- onTap: canSelect ? () => _showPicker(context) : null,
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
- decoration: BoxDecoration(
- color:
- isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
- borderRadius: BorderRadius.circular(12),
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- AppLocalizations.of(context)!.selectTransferCoin,
- style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 12),
- ),
- const SizedBox(height: 8),
- Row(
- children: [
- Text(
- selectedCoin,
- style: TextStyle(
- color: cs.onSurface,
- fontSize: 16,
- fontWeight: FontWeight.w600,
- ),
- ),
- const Spacer(),
- if (loading)
- const SizedBox(
- width: 16,
- height: 16,
- child: CircularProgressIndicator(strokeWidth: 2),
- )
- else if (canSelect)
- Icon(Icons.keyboard_arrow_down,
- size: 20, color: cs.onSurface.withAlpha(120)),
- ],
- ),
- ],
- ),
- ),
- );
- }
- void _showPicker(BuildContext context) {
- final cs = Theme.of(context).colorScheme;
- showModalBottomSheet<void>(
- context: context,
- useRootNavigator: true,
- backgroundColor: cs.surface,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
- ),
- builder: (sheetCtx) => SafeArea(
- child: ListView(
- shrinkWrap: true,
- children: coins.map((coin) {
- final isSelected = coin == selectedCoin;
- return ListTile(
- title: Text(coin),
- trailing:
- isSelected ? Icon(Icons.check, color: cs.onSurface) : null,
- onTap: () {
- onChanged(coin);
- Navigator.pop(sheetCtx);
- },
- );
- }).toList(),
- ),
- ),
- );
- }
- }
|