transfer_screen.dart 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640
  1. import 'dart:math' as math;
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter/services.dart';
  4. import 'package:flutter_riverpod/flutter_riverpod.dart';
  5. import 'package:go_router/go_router.dart';
  6. import '../../../core/l10n/app_localizations.dart';
  7. import '../../../core/theme/app_colors.dart';
  8. import '../../../core/utils/number_format.dart';
  9. import '../../../core/utils/top_toast.dart';
  10. import '../../../core/utils/transfer_pair.dart';
  11. import '../../../providers/transfer_provider.dart';
  12. import '../../../providers/futures_provider.dart'
  13. show futuresProvider, futuresActiveSymbolProvider;
  14. class TransferScreen extends ConsumerStatefulWidget {
  15. const TransferScreen({
  16. super.key,
  17. this.initialFrom,
  18. this.initialTo,
  19. this.initialSymbol,
  20. this.preferDefaultSymbol = false,
  21. this.spotTradingBridgeOnly = false,
  22. });
  23. final String? initialFrom;
  24. final String? initialTo;
  25. final String? initialSymbol;
  26. final bool preferDefaultSymbol;
  27. final bool spotTradingBridgeOnly;
  28. @override
  29. ConsumerState<TransferScreen> createState() => _TransferScreenState();
  30. }
  31. class _TransferScreenState extends ConsumerState<TransferScreen> {
  32. final _amountController = TextEditingController();
  33. bool _initialized = false;
  34. @override
  35. void initState() {
  36. super.initState();
  37. WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap());
  38. }
  39. Future<void> _bootstrap() async {
  40. if (_initialized) {
  41. return;
  42. }
  43. _initialized = true;
  44. await ref.read(transferProvider.notifier).init(
  45. TransferInitOptions(
  46. from: widget.initialFrom,
  47. to: widget.initialTo,
  48. defaultSymbol: widget.initialSymbol ?? 'USDT',
  49. preferDefaultSymbol: widget.preferDefaultSymbol,
  50. spotTradingBridgeOnly: widget.spotTradingBridgeOnly,
  51. ),
  52. );
  53. }
  54. @override
  55. void dispose() {
  56. _amountController.dispose();
  57. super.dispose();
  58. }
  59. @override
  60. Widget build(BuildContext context) {
  61. final state = ref.watch(transferProvider);
  62. final notifier = ref.read(transferProvider.notifier);
  63. final l10n = AppLocalizations.of(context)!;
  64. return Scaffold(
  65. appBar: AppBar(
  66. leading: IconButton(
  67. icon: const Icon(Icons.chevron_left, size: 28),
  68. onPressed: () => context.pop(),
  69. ),
  70. title: Text(
  71. l10n.accountTransferTitle,
  72. style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
  73. ),
  74. centerTitle: true,
  75. actions: [
  76. IconButton(
  77. icon: const Icon(Icons.history, size: 22),
  78. onPressed: () => context.push('/asset/transfer/history'),
  79. ),
  80. ],
  81. ),
  82. body: _buildBody(context, state, notifier, l10n),
  83. );
  84. }
  85. Widget _buildBody(
  86. BuildContext context,
  87. TransferState state,
  88. TransferNotifier notifier,
  89. AppLocalizations l10n,
  90. ) {
  91. final cs = Theme.of(context).colorScheme;
  92. final isDark = Theme.of(context).brightness == Brightness.dark;
  93. if (state.isLoading && state.legacyBalances.isEmpty) {
  94. return const Center(child: CircularProgressIndicator());
  95. }
  96. if (state.errorMessage != null && state.legacyBalances.isEmpty) {
  97. return Center(
  98. child: Column(
  99. mainAxisSize: MainAxisSize.min,
  100. children: [
  101. Text(state.errorMessage!,
  102. style: TextStyle(color: cs.onSurface.withAlpha(153))),
  103. const SizedBox(height: 16),
  104. ElevatedButton(
  105. onPressed: notifier.refresh,
  106. child: Text(l10n.retry),
  107. ),
  108. ],
  109. ),
  110. );
  111. }
  112. final fromBalance = state.fromBalance;
  113. return SingleChildScrollView(
  114. padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
  115. child: Column(
  116. crossAxisAlignment: CrossAxisAlignment.start,
  117. children: [
  118. if (state.isSpotCoinTransfer) ...[
  119. _CoinSelector(
  120. coins: state.openCoins,
  121. selectedCoin: state.selectedCoin,
  122. loading: state.isLoadingCoins,
  123. onChanged: (coin) {
  124. notifier.selectCoin(coin);
  125. _amountController.clear();
  126. setState(() {});
  127. },
  128. ),
  129. const SizedBox(height: 16),
  130. ],
  131. Container(
  132. padding: const EdgeInsets.all(16),
  133. decoration: BoxDecoration(
  134. color: isDark
  135. ? AppColors.darkBgSecondary
  136. : AppColors.lightBgSecondary,
  137. borderRadius: BorderRadius.circular(12),
  138. ),
  139. child: Row(
  140. crossAxisAlignment: CrossAxisAlignment.start,
  141. children: [
  142. Expanded(
  143. child: _TransferSide(
  144. label: l10n.fromLabel,
  145. walletType: state.fromType,
  146. otherTypes: state.transferOtherTypes,
  147. readonly: state.fromType == kWalletSpot,
  148. onChanged: (type) {
  149. notifier.setFromType(type);
  150. _amountController.clear();
  151. setState(() {});
  152. },
  153. ),
  154. ),
  155. Padding(
  156. padding: const EdgeInsets.only(top: 28),
  157. child: GestureDetector(
  158. onTap: () {
  159. notifier.swapAccounts();
  160. _amountController.clear();
  161. setState(() {});
  162. },
  163. child: Container(
  164. width: 36,
  165. height: 36,
  166. decoration: BoxDecoration(
  167. border: Border.all(color: cs.outline.withAlpha(60)),
  168. borderRadius: BorderRadius.circular(18),
  169. ),
  170. child: Icon(Icons.swap_horiz,
  171. size: 20, color: cs.onSurface),
  172. ),
  173. ),
  174. ),
  175. Expanded(
  176. child: _TransferSide(
  177. label: l10n.toLabel,
  178. walletType: state.toType,
  179. otherTypes: state.transferOtherTypes,
  180. readonly: state.fromType == kWalletSwap ||
  181. state.fromType == kWalletFollow ||
  182. state.fromType == kWalletSpotTrading,
  183. onChanged: (type) {
  184. notifier.setToType(type);
  185. _amountController.clear();
  186. setState(() {});
  187. },
  188. ),
  189. ),
  190. ],
  191. ),
  192. ),
  193. const SizedBox(height: 8),
  194. Text(
  195. l10n.transferAvailableCoin(
  196. formatAmount(fromBalance.toDouble()),
  197. state.displayCoinUnit,
  198. ),
  199. style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 12),
  200. ),
  201. const SizedBox(height: 20),
  202. Container(
  203. padding: const EdgeInsets.all(16),
  204. decoration: BoxDecoration(
  205. color: isDark
  206. ? AppColors.darkBgSecondary
  207. : AppColors.lightBgSecondary,
  208. borderRadius: BorderRadius.circular(12),
  209. ),
  210. child: Column(
  211. children: [
  212. Row(
  213. children: [
  214. Expanded(
  215. child: Text(
  216. state.isSpotCoinTransfer
  217. ? l10n.transferAmountCoin(state.displayCoinUnit)
  218. : l10n.transferAmount,
  219. style: TextStyle(
  220. color: cs.onSurface.withAlpha(153),
  221. fontSize: 13,
  222. ),
  223. ),
  224. ),
  225. GestureDetector(
  226. onTap: () {
  227. _amountController.text = fromBalance.toString();
  228. setState(() {});
  229. },
  230. child: Text(
  231. l10n.all,
  232. style: const TextStyle(
  233. color: AppColors.brand,
  234. fontSize: 13,
  235. ),
  236. ),
  237. ),
  238. ],
  239. ),
  240. const SizedBox(height: 8),
  241. TextField(
  242. controller: _amountController,
  243. keyboardType:
  244. const TextInputType.numberWithOptions(decimal: true),
  245. inputFormatters: [
  246. FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,8}')),
  247. ],
  248. onChanged: (_) => setState(() {}),
  249. style: TextStyle(color: cs.onSurface, fontSize: 16),
  250. decoration: InputDecoration(
  251. hintText: '0.00',
  252. hintStyle: TextStyle(
  253. color: cs.onSurface.withAlpha(100),
  254. fontSize: 16,
  255. ),
  256. border: InputBorder.none,
  257. enabledBorder: InputBorder.none,
  258. focusedBorder: InputBorder.none,
  259. filled: false,
  260. isDense: true,
  261. contentPadding: EdgeInsets.zero,
  262. ),
  263. ),
  264. ],
  265. ),
  266. ),
  267. const SizedBox(height: 12),
  268. Text(
  269. l10n.transferFreeHint,
  270. style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 12),
  271. ),
  272. const SizedBox(height: 24),
  273. SizedBox(
  274. width: double.infinity,
  275. height: 50,
  276. child: ElevatedButton(
  277. onPressed: _canSubmit(state) ? _submit : null,
  278. style: ElevatedButton.styleFrom(
  279. backgroundColor: AppColors.brand,
  280. disabledBackgroundColor: AppColors.brand.withAlpha(80),
  281. foregroundColor: Colors.black,
  282. disabledForegroundColor: Colors.black.withAlpha(80),
  283. shape: RoundedRectangleBorder(
  284. borderRadius: BorderRadius.circular(25),
  285. ),
  286. ),
  287. child: state.isSubmitting
  288. ? const SizedBox(
  289. width: 20,
  290. height: 20,
  291. child: CircularProgressIndicator(
  292. strokeWidth: 2,
  293. color: Colors.black,
  294. ),
  295. )
  296. : Text(
  297. l10n.confirmTransfer,
  298. style: const TextStyle(
  299. fontSize: 16,
  300. fontWeight: FontWeight.w600,
  301. ),
  302. ),
  303. ),
  304. ),
  305. ],
  306. ),
  307. );
  308. }
  309. bool _canSubmit(TransferState state) {
  310. if (state.isSubmitting || state.isLoadingCoins) {
  311. return false;
  312. }
  313. final amount = double.tryParse(_amountController.text.trim()) ?? 0;
  314. return amount > 0;
  315. }
  316. Future<void> _submit() async {
  317. final l10n = AppLocalizations.of(context)!;
  318. final amount = _amountController.text.trim();
  319. if (amount.isEmpty) {
  320. showTopToast(context, message: l10n.enterTransferAmount);
  321. return;
  322. }
  323. final notifier = ref.read(transferProvider.notifier);
  324. final success = await notifier.submit(amount);
  325. if (!mounted) {
  326. return;
  327. }
  328. if (success) {
  329. showTopToast(
  330. context,
  331. message: l10n.transferSuccess,
  332. backgroundColor: AppColors.success,
  333. );
  334. _amountController.clear();
  335. final activeSymbol = ref.read(futuresActiveSymbolProvider);
  336. if (activeSymbol.isNotEmpty) {
  337. ref.read(futuresProvider(activeSymbol).notifier).refreshWallet();
  338. }
  339. setState(() {});
  340. return;
  341. }
  342. final err = ref.read(transferProvider).errorMessage;
  343. if (err != null) {
  344. showTopToast(context, message: _mapError(err, l10n));
  345. }
  346. }
  347. String _mapError(String err, AppLocalizations l10n) {
  348. if (err == 'errSameAccount') {
  349. return l10n.transferSameAccountError;
  350. }
  351. if (err == 'errEnterAmount') {
  352. return l10n.enterTransferAmount;
  353. }
  354. if (err == 'errExceedBalance') {
  355. return l10n.errExceedBalance;
  356. }
  357. return err;
  358. }
  359. }
  360. class _TransferSide extends StatelessWidget {
  361. const _TransferSide({
  362. required this.label,
  363. required this.walletType,
  364. required this.otherTypes,
  365. required this.readonly,
  366. required this.onChanged,
  367. });
  368. final String label;
  369. final String walletType;
  370. final List<String> otherTypes;
  371. final bool readonly;
  372. final ValueChanged<String> onChanged;
  373. String _name(AppLocalizations l10n, String type) {
  374. switch (type) {
  375. case kWalletSpot:
  376. return l10n.fundAccount;
  377. case kWalletSwap:
  378. return l10n.futuresAccount;
  379. case kWalletFollow:
  380. return l10n.copyAccount;
  381. case kWalletSpotTrading:
  382. return l10n.spotTradingAccount;
  383. default:
  384. return type;
  385. }
  386. }
  387. @override
  388. Widget build(BuildContext context) {
  389. final cs = Theme.of(context).colorScheme;
  390. final l10n = AppLocalizations.of(context)!;
  391. final displayType =
  392. readonly && walletType != kWalletSpot ? kWalletSpot : walletType;
  393. return Column(
  394. crossAxisAlignment: CrossAxisAlignment.start,
  395. children: [
  396. Text(label,
  397. style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 12)),
  398. const SizedBox(height: 8),
  399. if (readonly || otherTypes.length <= 1)
  400. Container(
  401. width: double.infinity,
  402. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
  403. decoration: BoxDecoration(
  404. color: cs.surface.withAlpha(20),
  405. borderRadius: BorderRadius.circular(8),
  406. border: Border.all(color: cs.outline.withAlpha(40)),
  407. ),
  408. child: Text(
  409. _name(l10n, displayType),
  410. style: TextStyle(color: cs.onSurface.withAlpha(180), fontSize: 14),
  411. ),
  412. )
  413. else
  414. _WalletPicker(
  415. value: walletType,
  416. options: otherTypes,
  417. labelBuilder: (type) => _name(l10n, type),
  418. onChanged: onChanged,
  419. ),
  420. ],
  421. );
  422. }
  423. }
  424. class _WalletPicker extends StatelessWidget {
  425. const _WalletPicker({
  426. required this.value,
  427. required this.options,
  428. required this.labelBuilder,
  429. required this.onChanged,
  430. });
  431. final String value;
  432. final List<String> options;
  433. final String Function(String type) labelBuilder;
  434. final ValueChanged<String> onChanged;
  435. @override
  436. Widget build(BuildContext context) {
  437. final cs = Theme.of(context).colorScheme;
  438. return GestureDetector(
  439. onTap: () => _showPicker(context),
  440. child: Container(
  441. width: double.infinity,
  442. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
  443. decoration: BoxDecoration(
  444. borderRadius: BorderRadius.circular(8),
  445. border: Border.all(color: cs.outline.withAlpha(60)),
  446. ),
  447. child: Row(
  448. children: [
  449. Expanded(
  450. child: Text(
  451. labelBuilder(value),
  452. style: TextStyle(color: cs.onSurface, fontSize: 14),
  453. ),
  454. ),
  455. Icon(Icons.keyboard_arrow_down,
  456. size: 18, color: cs.onSurface.withAlpha(120)),
  457. ],
  458. ),
  459. ),
  460. );
  461. }
  462. void _showPicker(BuildContext context) {
  463. final cs = Theme.of(context).colorScheme;
  464. showModalBottomSheet<void>(
  465. context: context,
  466. useRootNavigator: true,
  467. backgroundColor: cs.surface,
  468. shape: const RoundedRectangleBorder(
  469. borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
  470. ),
  471. builder: (sheetCtx) => SafeArea(
  472. child: LayoutBuilder(
  473. builder: (ctx, c) {
  474. const overhead = 8.0 + 4.0 + 16.0 + 8.0;
  475. final listMax = c.maxHeight.isFinite
  476. ? math.max(80.0, c.maxHeight - overhead)
  477. : 320.0;
  478. return Column(
  479. mainAxisSize: MainAxisSize.min,
  480. children: [
  481. const SizedBox(height: 8),
  482. Container(
  483. width: 36,
  484. height: 4,
  485. decoration: BoxDecoration(
  486. color: cs.outline.withAlpha(60),
  487. borderRadius: BorderRadius.circular(2),
  488. ),
  489. ),
  490. const SizedBox(height: 16),
  491. ConstrainedBox(
  492. constraints: BoxConstraints(maxHeight: listMax),
  493. child: ListView(
  494. shrinkWrap: true,
  495. children: options.map((type) {
  496. final selected = type == value;
  497. return ListTile(
  498. title: Text(labelBuilder(type)),
  499. trailing: selected
  500. ? Icon(Icons.check, color: cs.onSurface)
  501. : null,
  502. onTap: () {
  503. onChanged(type);
  504. Navigator.pop(sheetCtx);
  505. },
  506. );
  507. }).toList(),
  508. ),
  509. ),
  510. const SizedBox(height: 8),
  511. ],
  512. );
  513. },
  514. ),
  515. ),
  516. );
  517. }
  518. }
  519. class _CoinSelector extends StatelessWidget {
  520. const _CoinSelector({
  521. required this.coins,
  522. required this.selectedCoin,
  523. required this.loading,
  524. required this.onChanged,
  525. });
  526. final List<String> coins;
  527. final String selectedCoin;
  528. final bool loading;
  529. final ValueChanged<String> onChanged;
  530. @override
  531. Widget build(BuildContext context) {
  532. final cs = Theme.of(context).colorScheme;
  533. final isDark = Theme.of(context).brightness == Brightness.dark;
  534. final canSelect = coins.length > 1 && !loading;
  535. return GestureDetector(
  536. onTap: canSelect ? () => _showPicker(context) : null,
  537. child: Container(
  538. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  539. decoration: BoxDecoration(
  540. color:
  541. isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  542. borderRadius: BorderRadius.circular(12),
  543. ),
  544. child: Column(
  545. crossAxisAlignment: CrossAxisAlignment.start,
  546. children: [
  547. Text(
  548. AppLocalizations.of(context)!.selectTransferCoin,
  549. style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 12),
  550. ),
  551. const SizedBox(height: 8),
  552. Row(
  553. children: [
  554. Text(
  555. selectedCoin,
  556. style: TextStyle(
  557. color: cs.onSurface,
  558. fontSize: 16,
  559. fontWeight: FontWeight.w600,
  560. ),
  561. ),
  562. const Spacer(),
  563. if (loading)
  564. const SizedBox(
  565. width: 16,
  566. height: 16,
  567. child: CircularProgressIndicator(strokeWidth: 2),
  568. )
  569. else if (canSelect)
  570. Icon(Icons.keyboard_arrow_down,
  571. size: 20, color: cs.onSurface.withAlpha(120)),
  572. ],
  573. ),
  574. ],
  575. ),
  576. ),
  577. );
  578. }
  579. void _showPicker(BuildContext context) {
  580. final cs = Theme.of(context).colorScheme;
  581. showModalBottomSheet<void>(
  582. context: context,
  583. useRootNavigator: true,
  584. backgroundColor: cs.surface,
  585. shape: const RoundedRectangleBorder(
  586. borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
  587. ),
  588. builder: (sheetCtx) => SafeArea(
  589. child: ListView(
  590. shrinkWrap: true,
  591. children: coins.map((coin) {
  592. final isSelected = coin == selectedCoin;
  593. return ListTile(
  594. title: Text(coin),
  595. trailing:
  596. isSelected ? Icon(Icons.check, color: cs.onSurface) : null,
  597. onTap: () {
  598. onChanged(coin);
  599. Navigator.pop(sheetCtx);
  600. },
  601. );
  602. }).toList(),
  603. ),
  604. ),
  605. );
  606. }
  607. }