withdraw_screen.dart 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891
  1. import 'package:decimal/decimal.dart';
  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/dialog_utils.dart' show extractErrorMessage;
  9. import '../../../core/utils/top_toast.dart';
  10. import '../../../data/models/asset/withdraw_coin_option.dart';
  11. import '../../../providers/withdraw_provider.dart';
  12. class WithdrawScreen extends ConsumerStatefulWidget {
  13. const WithdrawScreen({super.key});
  14. @override
  15. ConsumerState<WithdrawScreen> createState() => _WithdrawScreenState();
  16. }
  17. class _WithdrawScreenState extends ConsumerState<WithdrawScreen>
  18. with SingleTickerProviderStateMixin {
  19. bool _obscureFundPwd = true;
  20. late TabController _tabController;
  21. late PageController _pageController;
  22. final _addressController = TextEditingController();
  23. final _amountController = TextEditingController();
  24. final _fundPwdController = TextEditingController();
  25. final _emailCodeController = TextEditingController();
  26. final _googleCodeController = TextEditingController();
  27. @override
  28. void initState() {
  29. super.initState();
  30. _tabController = TabController(length: 2, vsync: this);
  31. _pageController = PageController();
  32. _tabController.addListener(() {
  33. if (!mounted) return;
  34. if (_tabController.indexIsChanging) {
  35. _pageController.animateToPage(
  36. _tabController.index,
  37. duration: const Duration(milliseconds: 280),
  38. curve: Curves.easeOut,
  39. );
  40. } else {
  41. _amountController.clear();
  42. ref.read(withdrawProvider.notifier).setTab(_tabController.index);
  43. }
  44. });
  45. _pageController.addListener(() {
  46. if (!mounted) return;
  47. if (!_pageController.hasClients) return;
  48. final page = _pageController.page!;
  49. final offset = page - _tabController.index;
  50. if (offset.abs() <= 1.0 && !_tabController.indexIsChanging) {
  51. _tabController.offset = offset.clamp(-1.0, 1.0);
  52. }
  53. });
  54. // 每次进入页面重置 tab 到链上提币,并刷新数据
  55. WidgetsBinding.instance.addPostFrameCallback((_) {
  56. ref.read(withdrawProvider.notifier).setTab(0);
  57. ref.read(withdrawProvider.notifier).refresh();
  58. });
  59. }
  60. @override
  61. void dispose() {
  62. _tabController.dispose();
  63. _pageController.dispose();
  64. _addressController.dispose();
  65. _amountController.dispose();
  66. _fundPwdController.dispose();
  67. _emailCodeController.dispose();
  68. _googleCodeController.dispose();
  69. super.dispose();
  70. }
  71. @override
  72. Widget build(BuildContext context) {
  73. final cs = Theme.of(context).colorScheme;
  74. final state = ref.watch(withdrawProvider);
  75. final notifier = ref.read(withdrawProvider.notifier);
  76. return Scaffold(
  77. appBar: AppBar(
  78. leading: IconButton(
  79. icon: const Icon(Icons.chevron_left, size: 28),
  80. onPressed: () => context.pop(),
  81. ),
  82. title: Text(AppLocalizations.of(context)!.withdrawCoin, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
  83. centerTitle: true,
  84. actions: [
  85. TextButton(
  86. onPressed: () => context.push('/asset/withdraw/history'),
  87. child: Text(AppLocalizations.of(context)!.withdrawRecord, style: TextStyle(color: cs.onSurface, fontSize: 14)),
  88. ),
  89. ],
  90. ),
  91. body: _buildBody(context, state, notifier),
  92. );
  93. }
  94. Widget _buildBody(BuildContext context, WithdrawState state, WithdrawNotifier notifier) {
  95. if (state.isLoading) {
  96. return const Center(child: CircularProgressIndicator());
  97. }
  98. if (state.errorMessage != null && state.networkList.isEmpty) {
  99. final l10n = AppLocalizations.of(context)!;
  100. return Center(
  101. child: Padding(
  102. padding: const EdgeInsets.all(24),
  103. child: Column(
  104. mainAxisSize: MainAxisSize.min,
  105. children: [
  106. Text(
  107. resolveProviderError(state.errorMessage, l10n) ??
  108. state.errorMessage!,
  109. textAlign: TextAlign.center,
  110. ),
  111. const SizedBox(height: 16),
  112. FilledButton(
  113. onPressed: notifier.refresh,
  114. child: Text(l10n.retry),
  115. ),
  116. ],
  117. ),
  118. ),
  119. );
  120. }
  121. final isDark = Theme.of(context).brightness == Brightness.dark;
  122. return Column(
  123. children: [
  124. // ── 链上提币 / 内部转账 Tab ────────────────────
  125. Padding(
  126. padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
  127. child: Container(
  128. height: 44,
  129. decoration: BoxDecoration(color: isDark ? AppColors.darkBgTertiary : AppColors.lightBgTertiary, borderRadius: BorderRadius.circular(22)),
  130. child: Row(
  131. children: [
  132. _buildTab(context, state, notifier, 0, AppLocalizations.of(context)!.onChainWithdraw),
  133. _buildTab(context, state, notifier, 1, AppLocalizations.of(context)!.internalTransfer),
  134. ],
  135. ),
  136. ),
  137. ),
  138. // ── 内容区 ────────────────────────────────────
  139. Expanded(
  140. child: PageView(
  141. controller: _pageController,
  142. onPageChanged: (index) {
  143. _addressController.clear();
  144. _amountController.clear();
  145. notifier.setTab(index);
  146. },
  147. children: [
  148. SingleChildScrollView(
  149. padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
  150. child: _buildOnChain(context, state, notifier),
  151. ),
  152. SingleChildScrollView(
  153. padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
  154. child: _buildInternal(context, state, notifier),
  155. ),
  156. ],
  157. ),
  158. ),
  159. ],
  160. );
  161. }
  162. Widget _buildTab(BuildContext context, WithdrawState state, WithdrawNotifier notifier, int index, String label) {
  163. final cs = Theme.of(context).colorScheme;
  164. final selected = index == state.tabIndex;
  165. return Expanded(
  166. child: GestureDetector(
  167. onTap: () {
  168. _tabController.animateTo(index);
  169. },
  170. child: Container(
  171. margin: const EdgeInsets.all(3),
  172. decoration: BoxDecoration(
  173. color: selected ? AppColors.brand : Colors.transparent,
  174. borderRadius: BorderRadius.circular(20),
  175. ),
  176. child: Center(
  177. child: Text(label, style: TextStyle(
  178. color: selected ? Colors.black : cs.onSurface.withAlpha(153),
  179. fontSize: 14,
  180. fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
  181. )),
  182. ),
  183. ),
  184. ),
  185. );
  186. }
  187. // ══════════════════════════════════════════════════════════
  188. // 链上提币
  189. // ══════════════════════════════════════════════════════════
  190. void _showNetworkTipDialog(BuildContext context) {
  191. final l10n = AppLocalizations.of(context)!;
  192. final cs = Theme.of(context).colorScheme;
  193. final isDark = Theme.of(context).brightness == Brightness.dark;
  194. showDialog<void>(
  195. context: context,
  196. builder: (ctx) => Dialog(
  197. backgroundColor: isDark ? AppColors.darkBgSecondary : Colors.white,
  198. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
  199. child: Padding(
  200. padding: const EdgeInsets.fromLTRB(20, 16, 16, 20),
  201. child: Column(
  202. mainAxisSize: MainAxisSize.min,
  203. crossAxisAlignment: CrossAxisAlignment.start,
  204. children: [
  205. // 标题行
  206. Row(
  207. children: [
  208. Expanded(
  209. child: Text(
  210. l10n.withdrawNetwork,
  211. style: TextStyle(
  212. color: cs.onSurface,
  213. fontSize: 16,
  214. fontWeight: FontWeight.w600,
  215. ),
  216. ),
  217. ),
  218. GestureDetector(
  219. onTap: () => Navigator.of(ctx).pop(),
  220. child: Icon(Icons.close, size: 20, color: cs.onSurface.withAlpha(153)),
  221. ),
  222. ],
  223. ),
  224. const SizedBox(height: 12),
  225. // 内容
  226. Text(
  227. l10n.withdrawNetworkTip,
  228. style: TextStyle(
  229. color: cs.onSurface.withAlpha(179),
  230. fontSize: 14,
  231. height: 1.6,
  232. ),
  233. ),
  234. const SizedBox(height: 20),
  235. // 确认按钮
  236. SizedBox(
  237. width: double.infinity,
  238. height: 44,
  239. child: ElevatedButton(
  240. onPressed: () => Navigator.of(ctx).pop(),
  241. style: ElevatedButton.styleFrom(
  242. backgroundColor: AppColors.brand,
  243. foregroundColor: Colors.black,
  244. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
  245. elevation: 0,
  246. ),
  247. child: Text(l10n.confirm, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
  248. ),
  249. ),
  250. ],
  251. ),
  252. ),
  253. ),
  254. );
  255. }
  256. String _withdrawNetworkTabLabel(WithdrawNetworkItem net) {
  257. final protocol = net.protocol.trim();
  258. final name = net.name.trim();
  259. if (protocol.isEmpty) {
  260. return name;
  261. }
  262. if (name.isEmpty) {
  263. return protocol;
  264. }
  265. return '$name · $protocol';
  266. }
  267. String _subCoinTabLabel(WithdrawCoinOption coin) {
  268. if (coin.displayName.trim().isNotEmpty) {
  269. return coin.displayName;
  270. }
  271. return coin.unit.toUpperCase();
  272. }
  273. Widget _buildSelectableChip({
  274. required BuildContext context,
  275. required ColorScheme cs,
  276. required String label,
  277. required bool selected,
  278. required VoidCallback? onTap,
  279. }) {
  280. return Padding(
  281. padding: const EdgeInsets.only(right: 10, bottom: 8),
  282. child: GestureDetector(
  283. onTap: onTap,
  284. child: Container(
  285. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
  286. decoration: BoxDecoration(
  287. color: selected ? AppColors.brand : Colors.transparent,
  288. border: Border.all(
  289. color: selected ? AppColors.brand : cs.outline.withAlpha(80),
  290. ),
  291. borderRadius: BorderRadius.circular(8),
  292. ),
  293. child: Text(
  294. label,
  295. style: TextStyle(
  296. color: selected ? Colors.black : cs.onSurface,
  297. fontSize: 14,
  298. fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
  299. ),
  300. ),
  301. ),
  302. ),
  303. );
  304. }
  305. Widget _buildOnChain(BuildContext context, WithdrawState state, WithdrawNotifier notifier) {
  306. final cs = Theme.of(context).colorScheme;
  307. final isDark = Theme.of(context).brightness == Brightness.dark;
  308. final l10n = AppLocalizations.of(context)!;
  309. final fee = state.fee;
  310. final minAmount = state.minWithdrawAmount;
  311. final available = state.withdrawableBalance;
  312. final coinSymbol = state.onchainCoinSymbol;
  313. // 到账金额计算
  314. final inputAmount = Decimal.tryParse(_amountController.text) ?? Decimal.zero;
  315. final receiveAmount = inputAmount - fee;
  316. final receiveDisplay = receiveAmount > Decimal.zero ? receiveAmount.toString() : '0.00';
  317. return Column(
  318. crossAxisAlignment: CrossAxisAlignment.start,
  319. children: [
  320. Row(
  321. children: [
  322. _Label(l10n.withdrawNetwork),
  323. const SizedBox(width: 4),
  324. GestureDetector(
  325. onTap: () => _showNetworkTipDialog(context),
  326. child: Icon(Icons.info_outline, size: 14, color: cs.onSurface.withAlpha(120)),
  327. ),
  328. ],
  329. ),
  330. const SizedBox(height: 8),
  331. if (state.networkList.isEmpty)
  332. Text(
  333. l10n.depositNoNetwork,
  334. style: TextStyle(color: cs.onSurface.withAlpha(140), fontSize: 13),
  335. )
  336. else
  337. Wrap(
  338. children: [
  339. for (final net in state.networkList)
  340. _buildSelectableChip(
  341. context: context,
  342. cs: cs,
  343. label: _withdrawNetworkTabLabel(net),
  344. selected: state.selectedNetworkId == net.id,
  345. onTap: () {
  346. _amountController.clear();
  347. notifier.selectNetwork(net.id);
  348. setState(() {});
  349. },
  350. ),
  351. ],
  352. ),
  353. if (state.selectedNetworkId > 0) ...[
  354. const SizedBox(height: 16),
  355. _Label(l10n.depositSubCoin),
  356. const SizedBox(height: 8),
  357. if (state.onchainSubCoinsLoading)
  358. const LinearProgressIndicator()
  359. else if (state.onchainSubCoins.isEmpty)
  360. Text(
  361. l10n.depositNoSubCoin,
  362. style: TextStyle(color: cs.onSurface.withAlpha(140), fontSize: 13),
  363. )
  364. else
  365. Wrap(
  366. children: [
  367. for (var i = 0; i < state.onchainSubCoins.length; i++)
  368. _buildSelectableChip(
  369. context: context,
  370. cs: cs,
  371. label: _subCoinTabLabel(state.onchainSubCoins[i]),
  372. selected: state.selectedSubCoinIndex == i,
  373. onTap: () {
  374. _amountController.clear();
  375. notifier.selectSubCoin(i);
  376. setState(() {});
  377. },
  378. ),
  379. ],
  380. ),
  381. ],
  382. const SizedBox(height: 16),
  383. // 提币地址
  384. _Label(l10n.withdrawAddress),
  385. const SizedBox(height: 8),
  386. _InputField(
  387. controller: _addressController,
  388. hint: l10n.withdrawAddressForCoin(coinSymbol),
  389. suffixIcon: Icon(Icons.qr_code_scanner, size: 20, color: cs.onSurface.withAlpha(153)),
  390. onSuffixIconTap: _scanQrCode,
  391. ),
  392. const SizedBox(height: 16),
  393. // 提币金额
  394. Row(
  395. children: [
  396. _Label(l10n.withdrawAmountCoin(coinSymbol)),
  397. const SizedBox(width: 4),
  398. Icon(Icons.info_outline, size: 14, color: cs.onSurface.withAlpha(120)),
  399. ],
  400. ),
  401. const SizedBox(height: 8),
  402. _InputField(
  403. controller: _amountController,
  404. hint: l10n.withdrawMinAmountHint(minAmount),
  405. keyboardType: const TextInputType.numberWithOptions(decimal: true),
  406. inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,4}'))],
  407. onChanged: (_) => setState(() {}),
  408. suffix: Row(
  409. mainAxisSize: MainAxisSize.min,
  410. children: [
  411. Text(coinSymbol, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 14)),
  412. const SizedBox(width: 8),
  413. GestureDetector(
  414. onTap: () {
  415. _amountController.text = available.toString();
  416. setState(() {});
  417. },
  418. child: Text(l10n.max, style: const TextStyle(color: AppColors.brand, fontSize: 14, fontWeight: FontWeight.w600)),
  419. ),
  420. ],
  421. ),
  422. ),
  423. const SizedBox(height: 6),
  424. Row(
  425. children: [
  426. Text('${l10n.fundAccountAvailable}:', style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12)),
  427. Flexible(child: Text('$available $coinSymbol ', overflow: TextOverflow.ellipsis, style: TextStyle(color: cs.onSurface, fontSize: 12, fontWeight: FontWeight.w600))),
  428. GestureDetector(
  429. onTap: () async {
  430. await context.push('/asset/transfer');
  431. if (mounted) ref.read(withdrawProvider.notifier).refresh();
  432. },
  433. child: Text(l10n.transfer, style: const TextStyle(color: AppColors.brand, fontSize: 12, fontWeight: FontWeight.w500)),
  434. ),
  435. ],
  436. ),
  437. const SizedBox(height: 20),
  438. // 到账数量
  439. _Label(l10n.receivedAmount),
  440. const SizedBox(height: 8),
  441. Container(
  442. padding: const EdgeInsets.all(12),
  443. decoration: BoxDecoration(color: isDark ? AppColors.darkBgTertiary : AppColors.lightBgTertiary, borderRadius: BorderRadius.circular(8)),
  444. child: Text('$receiveDisplay $coinSymbol', style: TextStyle(color: cs.onSurface, fontSize: 16, fontWeight: FontWeight.w600)),
  445. ),
  446. const SizedBox(height: 16),
  447. // 手续费
  448. Row(
  449. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  450. children: [
  451. _Label(l10n.fee),
  452. Text('$fee $coinSymbol', style: TextStyle(color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w600)),
  453. ],
  454. ),
  455. const SizedBox(height: 20),
  456. // 安全验证
  457. _buildSecurityFields(context, state, notifier),
  458. const SizedBox(height: 24),
  459. // 提币按钮
  460. SizedBox(
  461. width: double.infinity,
  462. height: 50,
  463. child: ElevatedButton(
  464. onPressed: state.isSubmitting || state.onchainSelectedCoin == null
  465. ? null
  466. : () => _submitOnChain(notifier),
  467. style: ElevatedButton.styleFrom(
  468. backgroundColor: AppColors.brand,
  469. disabledBackgroundColor: AppColors.brand.withAlpha(80),
  470. foregroundColor: Colors.black,
  471. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)),
  472. ),
  473. child: state.isSubmitting
  474. ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
  475. : Text(l10n.withdrawCoin, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
  476. ),
  477. ),
  478. const SizedBox(height: 16),
  479. ..._notices(context).map((n) => Padding(
  480. padding: const EdgeInsets.only(bottom: 4),
  481. child: Text('· $n', style: TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 11, height: 1.4)),
  482. )),
  483. ],
  484. );
  485. }
  486. // ══════════════════════════════════════════════════════════
  487. // 内部转账
  488. // ══════════════════════════════════════════════════════════
  489. Widget _buildInternal(BuildContext context, WithdrawState state, WithdrawNotifier notifier) {
  490. final cs = Theme.of(context).colorScheme;
  491. final isDark = Theme.of(context).brightness == Brightness.dark;
  492. final l10n = AppLocalizations.of(context)!;
  493. final available = state.transferableBalance;
  494. final coinSymbol = state.transferCoinSymbol;
  495. return Column(
  496. crossAxisAlignment: CrossAxisAlignment.start,
  497. children: [
  498. if (state.transferMainCoins.isNotEmpty) ...[
  499. _Label(l10n.withdrawMainCoinLabel),
  500. const SizedBox(height: 8),
  501. Container(
  502. padding: const EdgeInsets.symmetric(horizontal: 12),
  503. decoration: BoxDecoration(
  504. color: isDark ? AppColors.darkBgTertiary : AppColors.lightBgTertiary,
  505. borderRadius: BorderRadius.circular(8),
  506. border: Border.all(color: cs.outline.withAlpha(80)),
  507. ),
  508. child: DropdownButtonHideUnderline(
  509. child: DropdownButton<int>(
  510. isExpanded: true,
  511. value: state.selectedTransferCoinIndex.clamp(
  512. 0,
  513. state.transferMainCoins.length - 1,
  514. ),
  515. items: [
  516. for (var i = 0; i < state.transferMainCoins.length; i++)
  517. DropdownMenuItem(
  518. value: i,
  519. child: Text(
  520. _subCoinTabLabel(state.transferMainCoins[i]),
  521. style: TextStyle(color: cs.onSurface, fontSize: 14),
  522. ),
  523. ),
  524. ],
  525. onChanged: (v) {
  526. if (v != null) {
  527. _amountController.clear();
  528. notifier.selectTransferCoin(v);
  529. setState(() {});
  530. }
  531. },
  532. ),
  533. ),
  534. ),
  535. const SizedBox(height: 6),
  536. Text(
  537. l10n.withdrawTransferMainCoinHint,
  538. style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 12),
  539. ),
  540. const SizedBox(height: 16),
  541. ],
  542. _Label(l10n.recipientUidOrAccount),
  543. const SizedBox(height: 8),
  544. _InputField(
  545. controller: _addressController,
  546. hint: l10n.enterRecipientUid,
  547. ),
  548. const SizedBox(height: 16),
  549. _Label(l10n.transferAmountCoin(coinSymbol)),
  550. const SizedBox(height: 8),
  551. _InputField(
  552. controller: _amountController,
  553. hint: l10n.transferMinAmountHint(state.transferMinAmount),
  554. keyboardType: const TextInputType.numberWithOptions(decimal: true),
  555. inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,4}'))],
  556. suffix: Row(
  557. mainAxisSize: MainAxisSize.min,
  558. children: [
  559. Text(coinSymbol, style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 14)),
  560. const SizedBox(width: 8),
  561. GestureDetector(
  562. onTap: () => _amountController.text = available.toString(),
  563. child: Text(l10n.max, style: const TextStyle(color: AppColors.brand, fontSize: 14, fontWeight: FontWeight.w600)),
  564. ),
  565. ],
  566. ),
  567. ),
  568. const SizedBox(height: 6),
  569. Row(
  570. children: [
  571. Text('${l10n.fundAccountAvailable}:', style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12)),
  572. Flexible(child: Text('$available $coinSymbol ', overflow: TextOverflow.ellipsis, style: TextStyle(color: cs.onSurface, fontSize: 12, fontWeight: FontWeight.w600))),
  573. GestureDetector(
  574. onTap: () async {
  575. await context.push('/asset/transfer');
  576. if (mounted) ref.read(withdrawProvider.notifier).refresh();
  577. },
  578. child: Text(l10n.transfer, style: const TextStyle(color: AppColors.brand, fontSize: 12, fontWeight: FontWeight.w500)),
  579. ),
  580. ],
  581. ),
  582. const SizedBox(height: 20),
  583. _buildSecurityFields(context, state, notifier),
  584. const SizedBox(height: 24),
  585. SizedBox(
  586. width: double.infinity,
  587. height: 50,
  588. child: ElevatedButton(
  589. onPressed: state.isSubmitting || state.transferSelectedCoin == null
  590. ? null
  591. : () => _submitTransfer(notifier),
  592. style: ElevatedButton.styleFrom(
  593. backgroundColor: AppColors.brand,
  594. foregroundColor: Colors.black,
  595. shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)),
  596. ),
  597. child: state.isSubmitting
  598. ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black))
  599. : Text(l10n.transfer, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
  600. ),
  601. ),
  602. const SizedBox(height: 16),
  603. ..._notices(context).map((n) => Padding(
  604. padding: const EdgeInsets.only(bottom: 4),
  605. child: Text('· $n', style: TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 11, height: 1.4)),
  606. )),
  607. ],
  608. );
  609. }
  610. // ── 安全验证字段(链上/内部共用)───────────────────────
  611. Widget _buildSecurityFields(BuildContext context, WithdrawState state, WithdrawNotifier notifier) {
  612. final cs = Theme.of(context).colorScheme;
  613. return Column(
  614. crossAxisAlignment: CrossAxisAlignment.start,
  615. children: [
  616. Text(AppLocalizations.of(context)!.securityVerification, style: TextStyle(color: cs.onSurface, fontSize: 15, fontWeight: FontWeight.w600)),
  617. const SizedBox(height: 12),
  618. _InputField(
  619. controller: _fundPwdController,
  620. hint: AppLocalizations.of(context)!.fundPassword,
  621. obscure: _obscureFundPwd,
  622. maxLength: 16,
  623. suffixIcon: GestureDetector(
  624. onTap: () => setState(() => _obscureFundPwd = !_obscureFundPwd),
  625. child: Icon(
  626. _obscureFundPwd ? Icons.visibility_off_outlined : Icons.visibility_outlined,
  627. size: 20, color: cs.onSurface.withAlpha(153),
  628. ),
  629. ),
  630. ),
  631. const SizedBox(height: 10),
  632. _InputField(
  633. controller: _emailCodeController,
  634. hint: AppLocalizations.of(context)!.emailCode,
  635. keyboardType: TextInputType.number,
  636. inputFormatters: [FilteringTextInputFormatter.digitsOnly],
  637. maxLength: 6,
  638. suffix: GestureDetector(
  639. onTap: state.codeCountdown > 0
  640. ? null
  641. : () async {
  642. final error = await notifier.sendEmailCode(
  643. address: _addressController.text,
  644. amount: _amountController.text,
  645. );
  646. if (error != null && mounted) {
  647. final l10n = AppLocalizations.of(context)!;
  648. showTopToast(context, message: resolveProviderError(error, l10n) ?? error, backgroundColor: AppColors.fall);
  649. }
  650. },
  651. child: Text(
  652. state.codeCountdown > 0 ? '${state.codeCountdown}s' : AppLocalizations.of(context)!.sendCode,
  653. style: TextStyle(
  654. color: state.codeCountdown > 0 ? cs.onSurface.withAlpha(100) : AppColors.brand,
  655. fontSize: 13,
  656. ),
  657. ),
  658. ),
  659. ),
  660. const SizedBox(height: 10),
  661. _InputField(
  662. controller: _googleCodeController,
  663. hint: AppLocalizations.of(context)!.googleCode,
  664. keyboardType: TextInputType.number,
  665. inputFormatters: [FilteringTextInputFormatter.digitsOnly],
  666. maxLength: 6,
  667. suffix: GestureDetector(
  668. onTap: () async {
  669. final data = await Clipboard.getData('text/plain');
  670. if (data?.text != null) {
  671. _googleCodeController.text = data!.text!;
  672. }
  673. },
  674. child: Text(AppLocalizations.of(context)!.paste, style: const TextStyle(color: AppColors.brand, fontSize: 13)),
  675. ),
  676. ),
  677. ],
  678. );
  679. }
  680. // ── 提交逻辑 ────────────────────────────────────────
  681. void _submitOnChain(WithdrawNotifier notifier) async {
  682. final error = notifier.validate(
  683. address: _addressController.text,
  684. amount: _amountController.text,
  685. jyPassword: _fundPwdController.text,
  686. vcode: _emailCodeController.text,
  687. vcode2: _googleCodeController.text,
  688. );
  689. if (error != null) {
  690. final l10n = AppLocalizations.of(context)!;
  691. showTopToast(context, message: resolveProviderError(error, l10n) ?? error, backgroundColor: AppColors.fall);
  692. return;
  693. }
  694. try {
  695. final success = await notifier.submitOnChainWithdraw(
  696. address: _addressController.text,
  697. amount: _amountController.text,
  698. jyPassword: _fundPwdController.text,
  699. vcode: _emailCodeController.text,
  700. vcode2: _googleCodeController.text,
  701. );
  702. if (!mounted) return;
  703. if (success) {
  704. showTopToast(context, message: AppLocalizations.of(context)!.withdrawSubmitted, backgroundColor: AppColors.rise);
  705. _clearFields();
  706. notifier.refresh();
  707. } else {
  708. final state = ref.read(withdrawProvider);
  709. if (state.errorMessage != null) {
  710. final l10nE = AppLocalizations.of(context)!;
  711. showTopToast(context, message: resolveProviderError(state.errorMessage!, l10nE) ?? state.errorMessage!, backgroundColor: AppColors.fall);
  712. }
  713. }
  714. } catch (e) {
  715. if (mounted) showTopToast(context, message: extractErrorMessage(e), backgroundColor: AppColors.fall);
  716. }
  717. }
  718. void _submitTransfer(WithdrawNotifier notifier) async {
  719. final error = notifier.validate(
  720. address: _addressController.text,
  721. amount: _amountController.text,
  722. jyPassword: _fundPwdController.text,
  723. vcode: _emailCodeController.text,
  724. vcode2: _googleCodeController.text,
  725. );
  726. if (error != null) {
  727. final l10n = AppLocalizations.of(context)!;
  728. showTopToast(context, message: resolveProviderError(error, l10n) ?? error, backgroundColor: AppColors.fall);
  729. return;
  730. }
  731. try {
  732. final success = await notifier.submitInternalTransfer(
  733. address: _addressController.text,
  734. amount: _amountController.text,
  735. jyPassword: _fundPwdController.text,
  736. vcode: _emailCodeController.text,
  737. vcode2: _googleCodeController.text,
  738. );
  739. if (!mounted) return;
  740. if (success) {
  741. showTopToast(context, message: AppLocalizations.of(context)!.transferSuccess2, backgroundColor: AppColors.rise);
  742. _clearFields();
  743. notifier.refresh();
  744. } else {
  745. final state = ref.read(withdrawProvider);
  746. if (state.errorMessage != null) {
  747. final l10nE = AppLocalizations.of(context)!;
  748. showTopToast(context, message: resolveProviderError(state.errorMessage!, l10nE) ?? state.errorMessage!, backgroundColor: AppColors.fall);
  749. }
  750. }
  751. } catch (e) {
  752. if (mounted) showTopToast(context, message: extractErrorMessage(e), backgroundColor: AppColors.fall);
  753. }
  754. }
  755. void _clearFields() {
  756. _addressController.clear();
  757. _amountController.clear();
  758. _fundPwdController.clear();
  759. _emailCodeController.clear();
  760. _googleCodeController.clear();
  761. }
  762. /// 扫描二维码
  763. Future<void> _scanQrCode() async {
  764. try {
  765. final result = await context.push<String>('/qr-scanner');
  766. if (result != null && result.isNotEmpty) {
  767. _addressController.text = result;
  768. }
  769. } catch (e) {
  770. showTopToast(context, message: AppLocalizations.of(context)!.scannerFailed, backgroundColor: AppColors.fall);
  771. }
  772. }
  773. List<String> _notices(BuildContext context) {
  774. final l10n = AppLocalizations.of(context)!;
  775. return [l10n.withdrawNotice1, l10n.withdrawNotice2];
  776. }
  777. }
  778. // ── 共享组件 ─────────────────────────────────────────────────
  779. class _Label extends StatelessWidget {
  780. const _Label(this.text);
  781. final String text;
  782. @override
  783. Widget build(BuildContext context) {
  784. return Text(text, style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withAlpha(153), fontSize: 13));
  785. }
  786. }
  787. class _InputField extends StatelessWidget {
  788. const _InputField({
  789. required this.controller,
  790. required this.hint,
  791. this.obscure = false,
  792. this.keyboardType,
  793. this.suffix,
  794. this.suffixIcon,
  795. this.onSuffixIconTap,
  796. this.onChanged,
  797. this.inputFormatters,
  798. this.maxLength,
  799. });
  800. final TextEditingController controller;
  801. final String hint;
  802. final bool obscure;
  803. final TextInputType? keyboardType;
  804. final Widget? suffix;
  805. final Widget? suffixIcon;
  806. final VoidCallback? onSuffixIconTap;
  807. final ValueChanged<String>? onChanged;
  808. final List<TextInputFormatter>? inputFormatters;
  809. final int? maxLength;
  810. @override
  811. Widget build(BuildContext context) {
  812. final cs = Theme.of(context).colorScheme;
  813. return TextField(
  814. controller: controller,
  815. obscureText: obscure,
  816. keyboardType: keyboardType,
  817. onChanged: onChanged,
  818. inputFormatters: inputFormatters,
  819. maxLength: maxLength,
  820. style: TextStyle(color: cs.onSurface, fontSize: 14),
  821. decoration: InputDecoration(
  822. hintText: hint,
  823. hintStyle: TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 14),
  824. contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
  825. suffixIcon: suffixIcon != null
  826. ? GestureDetector(
  827. onTap: onSuffixIconTap,
  828. child: Padding(padding: const EdgeInsets.only(right: 12), child: suffixIcon),
  829. )
  830. : null,
  831. suffix: suffix,
  832. suffixIconConstraints: const BoxConstraints(minHeight: 20),
  833. ),
  834. );
  835. }
  836. }