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 createState() => _TransferScreenState(); } class _TransferScreenState extends ConsumerState { final _amountController = TextEditingController(); bool _initialized = false; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap()); } Future _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 _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 otherTypes; final bool readonly; final ValueChanged 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 options; final String Function(String type) labelBuilder; final ValueChanged 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( 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 coins; final String selectedCoin; final bool loading; final ValueChanged 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( 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(), ), ), ); } }