withdraw_screen.dart 29 KB

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