deposit_screen.dart 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296
  1. import 'dart:io';
  2. import 'dart:ui' as ui;
  3. import 'package:flutter/foundation.dart';
  4. import 'package:flutter/material.dart';
  5. import 'package:flutter/rendering.dart';
  6. import 'package:flutter/services.dart';
  7. import 'package:flutter_riverpod/flutter_riverpod.dart';
  8. import 'package:gal/gal.dart';
  9. import 'package:go_router/go_router.dart';
  10. import 'package:path_provider/path_provider.dart';
  11. import 'package:qr_flutter/qr_flutter.dart';
  12. import 'package:reown_appkit/modal/services/coinbase_service/utils/coinbase_utils.dart';
  13. import 'package:reown_appkit/reown_appkit.dart';
  14. import '../../../core/config/app_config.dart';
  15. import '../../../core/l10n/app_localizations.dart';
  16. import '../../../core/theme/app_colors.dart';
  17. import '../../../core/utils/top_toast.dart';
  18. import '../../../core/wallet/evm_recharge.dart';
  19. import '../../../core/wallet/tron_recharge.dart';
  20. import '../../../core/wallet/wallet_connect_recharge_helper.dart';
  21. import '../../../core/wallet/wallet_connect_tron_recharge.dart';
  22. import '../../../data/models/asset/recharge_order.dart';
  23. import '../../../providers/deposit_provider.dart';
  24. /// AppKit 默认只为 eip155/solana 填 methods;`tron` 会为 `[]`,钱包不会授权 `tron_signTransaction`。
  25. Map<String, RequiredNamespace> _depositAppKitOptionalNamespaces() {
  26. final map = <String, RequiredNamespace>{};
  27. for (final ns in ReownAppKitModalNetworks.getAllSupportedNamespaces()) {
  28. final networks =
  29. ReownAppKitModalNetworks.getAllSupportedNetworks(namespace: ns);
  30. final methods = ns == 'tron'
  31. ? <String>['tron_signTransaction', 'tron_signMessage']
  32. : (NetworkUtils.defaultNetworkMethods[ns] ?? <String>[]);
  33. map[ns] = RequiredNamespace(
  34. chains: networks.map((e) => e.chainId).toList(),
  35. methods: methods,
  36. events: NetworkUtils.defaultNetworkEvents[ns] ?? <String>[],
  37. );
  38. }
  39. return map;
  40. }
  41. /// 充币:与 Web `DepositView.vue` + `useWalletConnectDeposit` 对齐(订单 + WalletConnect / 手动 Hash)。
  42. class DepositScreen extends ConsumerStatefulWidget {
  43. const DepositScreen({super.key});
  44. @override
  45. ConsumerState<DepositScreen> createState() => _DepositScreenState();
  46. }
  47. class _DepositScreenState extends ConsumerState<DepositScreen>
  48. with SingleTickerProviderStateMixin {
  49. static const int _tabManual = 0;
  50. static const int _tabOnChain = 1;
  51. late final TabController _depositModeTab;
  52. final _qrKey = GlobalKey();
  53. final _amountCtrl = TextEditingController();
  54. final _hashCtrl = TextEditingController();
  55. bool _savingQr = false;
  56. ReownAppKitModal? _appKitModal;
  57. bool _appKitInitializing = false;
  58. @override
  59. void initState() {
  60. super.initState();
  61. _depositModeTab = TabController(length: 2, vsync: this);
  62. }
  63. @override
  64. void dispose() {
  65. _depositModeTab.dispose();
  66. _amountCtrl.dispose();
  67. _hashCtrl.dispose();
  68. _appKitModal?.disconnect();
  69. _appKitModal?.dispose();
  70. super.dispose();
  71. }
  72. Future<void> _ensureAppKitModal() async {
  73. final projectId = AppConfig.walletConnectProjectId;
  74. if (projectId.isEmpty) {
  75. return;
  76. }
  77. if (_appKitModal != null) {
  78. return;
  79. }
  80. if (_appKitInitializing) {
  81. return;
  82. }
  83. _appKitInitializing = true;
  84. try {
  85. ReownAppKitModalNetworks.removeSupportedNetworks('solana');
  86. ReownAppKitModalNetworks.addSupportedNetworks('tron', [
  87. ReownAppKitModalNetworkInfo(
  88. name: 'Tron',
  89. chainId: kTronMainnetChainIdHex,
  90. chainIcon:
  91. 'https://pbs.twimg.com/profile_images/1761904730668675072/v98T7vRL_400x400.jpg',
  92. currency: 'TRX',
  93. rpcUrl: AppConfig.tronFullHost,
  94. explorerUrl: 'https://tronscan.org',
  95. ),
  96. ]);
  97. final m = ReownAppKitModal(
  98. context: context,
  99. projectId: projectId,
  100. logLevel: kDebugMode ? LogLevel.error : LogLevel.nothing,
  101. disconnectOnDispose: true,
  102. /// 跳过 Coinbase Wallet 的 Explorer 单钱包拉取,避免弱网环境下访问 api.web3modal.com 被 RST 时阻塞/报错(充币不需 Coinbase)。
  103. excludedWalletIds: {CoinbaseUtils.walletId},
  104. metadata: PairingMetadata(
  105. name: 'iBit',
  106. description:
  107. AppLocalizations.of(context)!.walletConnectPairingDescription,
  108. url: 'https://ibit123.com',
  109. icons: const [
  110. 'https://raw.githubusercontent.com/reown-com/reown_flutter/refs/heads/develop/assets/appkit_logo.png',
  111. ],
  112. redirect: Redirect(
  113. native: 'ibit://',
  114. universal: 'https://ibit123.com',
  115. linkMode: false,
  116. ),
  117. ),
  118. optionalNamespaces: _depositAppKitOptionalNamespaces(),
  119. featuresConfig: FeaturesConfig(socials: const []),
  120. );
  121. await m.init();
  122. if (mounted) {
  123. setState(() {
  124. _appKitModal = m;
  125. });
  126. }
  127. } finally {
  128. _appKitInitializing = false;
  129. }
  130. }
  131. Future<void> _saveQr(String address) async {
  132. if (_savingQr || address.isEmpty) {
  133. return;
  134. }
  135. setState(() => _savingQr = true);
  136. try {
  137. final hasAccess = await Gal.hasAccess(toAlbum: true);
  138. if (!hasAccess) {
  139. final granted = await Gal.requestAccess(toAlbum: true);
  140. if (!granted) {
  141. if (mounted) {
  142. showTopToast(context,
  143. message: AppLocalizations.of(context)!.saveFailed);
  144. }
  145. return;
  146. }
  147. }
  148. await WidgetsBinding.instance.endOfFrame;
  149. final boundary =
  150. _qrKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
  151. final image = await boundary.toImage(pixelRatio: 3.0);
  152. final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
  153. final bytes = byteData!.buffer.asUint8List();
  154. final tempDir = await getTemporaryDirectory();
  155. final file = File(
  156. '${tempDir.path}/deposit_qr_${DateTime.now().millisecondsSinceEpoch}.png');
  157. await file.writeAsBytes(bytes);
  158. await Gal.putImage(file.path);
  159. if (mounted) {
  160. showTopToast(context,
  161. message: AppLocalizations.of(context)!.saveSuccess);
  162. }
  163. } catch (e) {
  164. if (mounted) {
  165. showTopToast(context,
  166. message: AppLocalizations.of(context)!.saveFailed);
  167. }
  168. } finally {
  169. if (mounted) {
  170. setState(() => _savingQr = false);
  171. }
  172. }
  173. }
  174. bool _validateAmount(AppLocalizations l10n) {
  175. final s = _amountCtrl.text.trim();
  176. if (s.isEmpty) {
  177. showTopToast(context, message: l10n.depositEnterAmount);
  178. return false;
  179. }
  180. final n = num.tryParse(s.replaceAll(',', ''));
  181. if (n == null || n <= 0) {
  182. showTopToast(context, message: l10n.depositAmountPositive);
  183. return false;
  184. }
  185. return true;
  186. }
  187. Future<void> _onCreateOrder() async {
  188. final l10n = AppLocalizations.of(context)!;
  189. if (!_validateAmount(l10n)) {
  190. return;
  191. }
  192. await ref.read(depositProvider.notifier).createRechargeOrder(_amountCtrl.text);
  193. final err = ref.read(depositProvider).errorMessage;
  194. if (!mounted) {
  195. return;
  196. }
  197. if (err != null) {
  198. showTopToast(context, message: err);
  199. } else {
  200. _hashCtrl.clear();
  201. showTopToast(context, message: l10n.depositOrderCreated);
  202. }
  203. }
  204. Future<void> _onSubmitHash() async {
  205. final l10n = AppLocalizations.of(context)!;
  206. final h = _hashCtrl.text.trim();
  207. if (h.isEmpty) {
  208. showTopToast(context, message: l10n.depositTxHashPlaceholder);
  209. return;
  210. }
  211. await ref.read(depositProvider.notifier).submitTxHash(h);
  212. final err = ref.read(depositProvider).errorMessage;
  213. if (!mounted) {
  214. return;
  215. }
  216. if (err != null) {
  217. showTopToast(context, message: err);
  218. } else {
  219. showTopToast(context, message: l10n.confirm);
  220. }
  221. }
  222. Future<void> _onWalletPay(
  223. RechargeFlatNetworkOption net,
  224. RechargeOrderDetail order,
  225. ) async {
  226. final l10n = AppLocalizations.of(context)!;
  227. if (AppConfig.walletConnectProjectId.isEmpty) {
  228. showTopToast(context, message: l10n.depositWalletConnectNotConfigured);
  229. return;
  230. }
  231. ref.read(depositProvider.notifier).setWalletPayBusy(true);
  232. try {
  233. await _ensureAppKitModal();
  234. final modal = _appKitModal;
  235. if (modal == null) {
  236. return;
  237. }
  238. final hash = _isTronLike(net)
  239. ? await WalletConnectTronRecharge.connectAndPayTron(
  240. appKitModal: modal,
  241. network: net,
  242. order: order,
  243. )
  244. : await WalletConnectRechargeHelper.connectAndPay(
  245. appKitModal: modal,
  246. network: net,
  247. order: order,
  248. );
  249. if (!mounted) {
  250. return;
  251. }
  252. _hashCtrl.text = hash;
  253. await ref.read(depositProvider.notifier).submitTxHash(hash);
  254. final err = ref.read(depositProvider).errorMessage;
  255. if (!mounted) {
  256. return;
  257. }
  258. if (err != null) {
  259. showTopToast(context, message: err);
  260. } else {
  261. showTopToast(context, message: l10n.confirm);
  262. }
  263. } catch (e) {
  264. if (mounted) {
  265. showTopToast(context, message: e.toString());
  266. }
  267. } finally {
  268. ref.read(depositProvider.notifier).setWalletPayBusy(false);
  269. }
  270. }
  271. String _statusLabel(AppLocalizations l10n, int status) {
  272. switch (status) {
  273. case 0:
  274. return l10n.rechargeStatus0;
  275. case 1:
  276. return l10n.rechargeStatus1;
  277. case 2:
  278. return l10n.rechargeStatus2;
  279. case 3:
  280. return l10n.rechargeStatus3;
  281. case 4:
  282. return l10n.rechargeStatus4;
  283. default:
  284. return '$status';
  285. }
  286. }
  287. bool _canWalletConnect(
  288. RechargeFlatNetworkOption? net,
  289. RechargeOrderDetail? order,
  290. ) {
  291. if (net == null || order == null || !order.isPendingPayment) {
  292. return false;
  293. }
  294. if (resolveEvmChainId(net.protocol, net.networkName) != null) {
  295. return true;
  296. }
  297. return isTronDepositNetwork(net.protocol, net.networkName);
  298. }
  299. bool _isTronLike(RechargeFlatNetworkOption? net) {
  300. if (net == null) {
  301. return false;
  302. }
  303. if (resolveEvmChainId(net.protocol, net.networkName) != null) {
  304. return false;
  305. }
  306. return isTronDepositNetwork(net.protocol, net.networkName);
  307. }
  308. String _parentCoinTabLabel(RechargeParentCoin coin) {
  309. if (coin.nameCn.trim().isNotEmpty) {
  310. return coin.nameCn;
  311. }
  312. if (coin.unit.trim().isNotEmpty) {
  313. return coin.unit;
  314. }
  315. return coin.name;
  316. }
  317. String _rechargeNetworkTabLabel(RechargeNetworkItem net) {
  318. final protocol = net.protocol.trim();
  319. final name = net.name.trim();
  320. if (protocol.isEmpty) {
  321. return name;
  322. }
  323. if (name.isEmpty) {
  324. return protocol;
  325. }
  326. return '$name · $protocol';
  327. }
  328. String _configSubCoinTabLabel(RechargeConfigOption cfg) {
  329. final cn = cfg.coinNameCn.trim();
  330. if (cn.isNotEmpty) {
  331. return cn;
  332. }
  333. return cfg.coinName;
  334. }
  335. Widget _buildSectionLoading(
  336. ColorScheme cs,
  337. AppLocalizations l10n,
  338. bool isDark,
  339. ) {
  340. return Container(
  341. width: double.infinity,
  342. constraints: const BoxConstraints(minHeight: 44),
  343. padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
  344. decoration: _panelDecoration(cs, isDark),
  345. child: Row(
  346. children: [
  347. SizedBox(
  348. width: 18,
  349. height: 18,
  350. child: CircularProgressIndicator(
  351. strokeWidth: 2,
  352. color: AppColors.brand,
  353. ),
  354. ),
  355. const SizedBox(width: 10),
  356. Text(
  357. l10n.loading,
  358. style: TextStyle(
  359. color: cs.onSurface.withAlpha(140),
  360. fontSize: 13,
  361. ),
  362. ),
  363. ],
  364. ),
  365. );
  366. }
  367. Widget _buildSelectableChip({
  368. required BuildContext context,
  369. required ColorScheme cs,
  370. required String label,
  371. required bool selected,
  372. VoidCallback? onTap,
  373. }) {
  374. return Padding(
  375. padding: const EdgeInsets.only(right: 10, bottom: 8),
  376. child: GestureDetector(
  377. onTap: onTap,
  378. child: Container(
  379. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
  380. decoration: BoxDecoration(
  381. color: selected ? AppColors.brand : Colors.transparent,
  382. border: Border.all(
  383. color: selected ? AppColors.brand : cs.outline.withAlpha(80),
  384. ),
  385. borderRadius: BorderRadius.circular(8),
  386. ),
  387. child: Text(
  388. label,
  389. style: TextStyle(
  390. color: selected ? Colors.black : cs.onSurface,
  391. fontSize: 14,
  392. fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
  393. ),
  394. ),
  395. ),
  396. ),
  397. );
  398. }
  399. String _walletPayButtonLabel(
  400. AppLocalizations l10n,
  401. RechargeFlatNetworkOption? net,
  402. ) {
  403. if (net != null && resolveEvmChainId(net.protocol, net.networkName) != null) {
  404. return '${l10n.depositWalletConnectPay}(EVM)';
  405. }
  406. return l10n.depositWalletConnectPay;
  407. }
  408. BoxDecoration _panelDecoration(ColorScheme cs, bool isDark) {
  409. return BoxDecoration(
  410. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  411. borderRadius: BorderRadius.circular(12),
  412. border: Border.all(color: cs.outline.withAlpha(40)),
  413. );
  414. }
  415. Widget _buildModeTabSwitcher(
  416. ColorScheme cs,
  417. AppLocalizations l10n,
  418. bool isDark,
  419. ) {
  420. return Container(
  421. height: 44,
  422. padding: const EdgeInsets.all(4),
  423. decoration: BoxDecoration(
  424. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  425. borderRadius: BorderRadius.circular(10),
  426. border: Border.all(color: cs.outline.withAlpha(50)),
  427. ),
  428. child: TabBar(
  429. controller: _depositModeTab,
  430. indicator: BoxDecoration(
  431. color: AppColors.brand,
  432. borderRadius: BorderRadius.circular(8),
  433. ),
  434. indicatorSize: TabBarIndicatorSize.tab,
  435. dividerColor: Colors.transparent,
  436. labelColor: Colors.black,
  437. unselectedLabelColor: cs.onSurface.withAlpha(180),
  438. labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
  439. unselectedLabelStyle:
  440. const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
  441. tabs: [
  442. Tab(text: l10n.depositTabManual),
  443. Tab(text: l10n.depositTabOnChain),
  444. ],
  445. ),
  446. );
  447. }
  448. void _resetToNewRecharge(DepositNotifier notifier) {
  449. notifier.clearCurrentOrder();
  450. _amountCtrl.clear();
  451. _hashCtrl.clear();
  452. }
  453. @override
  454. Widget build(BuildContext context) {
  455. final cs = Theme.of(context).colorScheme;
  456. final l10n = AppLocalizations.of(context)!;
  457. final state = ref.watch(depositProvider);
  458. final notifier = ref.read(depositProvider.notifier);
  459. final isDark = Theme.of(context).brightness == Brightness.dark;
  460. final wcConfigured = AppConfig.walletConnectProjectId.isNotEmpty;
  461. final hasOrder = state.currentOrder != null;
  462. return ReownAppKitModalTheme(
  463. isDarkMode: isDark,
  464. child: Scaffold(
  465. appBar: AppBar(
  466. leading: IconButton(
  467. icon: const Icon(Icons.chevron_left, size: 28),
  468. onPressed: () => context.pop(),
  469. ),
  470. title: Text(
  471. hasOrder ? l10n.depositOrderInfo : l10n.depositCoin,
  472. style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
  473. ),
  474. centerTitle: true,
  475. actions: [
  476. TextButton(
  477. onPressed: hasOrder
  478. ? () => _resetToNewRecharge(notifier)
  479. : () => context.push('/asset/deposit/history'),
  480. child: Text(
  481. hasOrder ? l10n.depositNewRecharge : l10n.depositRecord,
  482. style: TextStyle(
  483. color: hasOrder ? AppColors.brand : cs.onSurface,
  484. fontSize: 14,
  485. fontWeight: FontWeight.w500,
  486. ),
  487. ),
  488. ),
  489. ],
  490. ),
  491. body: _buildBody(context, state, notifier, cs, l10n, isDark,
  492. wcConfigured: wcConfigured),
  493. ),
  494. );
  495. }
  496. Widget _buildBody(
  497. BuildContext context,
  498. DepositState state,
  499. DepositNotifier notifier,
  500. ColorScheme cs,
  501. AppLocalizations l10n,
  502. bool isDark, {
  503. required bool wcConfigured,
  504. }) {
  505. if (state.loadingParents) {
  506. return const Center(child: CircularProgressIndicator());
  507. }
  508. if (state.errorMessage != null &&
  509. state.parentCoins.isEmpty &&
  510. !state.loadingParents) {
  511. return Center(
  512. child: Padding(
  513. padding: const EdgeInsets.all(24),
  514. child: Column(
  515. mainAxisSize: MainAxisSize.min,
  516. children: [
  517. Text(state.errorMessage!, textAlign: TextAlign.center),
  518. const SizedBox(height: 16),
  519. FilledButton(
  520. onPressed: () => notifier.refresh(),
  521. child: Text(l10n.retry),
  522. ),
  523. ],
  524. ),
  525. ),
  526. );
  527. }
  528. final order = state.currentOrder;
  529. return SingleChildScrollView(
  530. padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
  531. child: order == null
  532. ? _buildOrderForm(context, state, notifier, cs, l10n, isDark)
  533. : _buildOrderDetail(
  534. context,
  535. state,
  536. notifier,
  537. cs,
  538. l10n,
  539. isDark,
  540. order,
  541. wcConfigured: wcConfigured,
  542. ),
  543. );
  544. }
  545. Widget _buildOrderForm(
  546. BuildContext context,
  547. DepositState state,
  548. DepositNotifier notifier,
  549. ColorScheme cs,
  550. AppLocalizations l10n,
  551. bool isDark,
  552. ) {
  553. final net = state.selectedNetwork;
  554. final canCreate = state.selectedNetworkId > 0 && state.selectedConfig != null;
  555. return Column(
  556. crossAxisAlignment: CrossAxisAlignment.start,
  557. children: [
  558. _buildModeTabSwitcher(cs, l10n, isDark),
  559. const SizedBox(height: 20),
  560. if (state.parentCoins.length > 1) ...[
  561. Text(
  562. l10n.depositCurrency,
  563. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13),
  564. ),
  565. const SizedBox(height: 8),
  566. Wrap(
  567. children: [
  568. for (var i = 0; i < state.parentCoins.length; i++)
  569. _buildSelectableChip(
  570. context: context,
  571. cs: cs,
  572. label: _parentCoinTabLabel(state.parentCoins[i]),
  573. selected: state.selectedParentIndex == i,
  574. onTap: state.childrenLoading
  575. ? null
  576. : () => notifier.selectParent(i),
  577. ),
  578. ],
  579. ),
  580. const SizedBox(height: 16),
  581. ],
  582. Text(
  583. l10n.depositMainnetProtocol,
  584. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13),
  585. ),
  586. const SizedBox(height: 8),
  587. if (state.childrenLoading)
  588. _buildSectionLoading(cs, l10n, isDark)
  589. else if (state.networkOptions.isEmpty)
  590. Text(
  591. l10n.depositNoNetwork,
  592. style: TextStyle(color: cs.onSurface.withAlpha(140), fontSize: 13),
  593. )
  594. else
  595. Wrap(
  596. children: [
  597. for (final item in state.networkOptions)
  598. _buildSelectableChip(
  599. context: context,
  600. cs: cs,
  601. label: _rechargeNetworkTabLabel(item),
  602. selected: state.selectedNetworkId == item.id,
  603. onTap: () => notifier.selectNetwork(item.id),
  604. ),
  605. ],
  606. ),
  607. if (state.selectedNetworkId > 0 && !state.childrenLoading) ...[
  608. const SizedBox(height: 16),
  609. Text(
  610. l10n.depositSubCoin,
  611. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13),
  612. ),
  613. const SizedBox(height: 8),
  614. if (state.subCoinsLoading)
  615. _buildSectionLoading(cs, l10n, isDark)
  616. else if (state.subCoinOptions.isEmpty)
  617. Text(
  618. l10n.depositNoSubCoin,
  619. style: TextStyle(color: cs.onSurface.withAlpha(140), fontSize: 13),
  620. )
  621. else
  622. Wrap(
  623. children: [
  624. for (var i = 0; i < state.subCoinOptions.length; i++)
  625. _buildSelectableChip(
  626. context: context,
  627. cs: cs,
  628. label: _configSubCoinTabLabel(state.subCoinOptions[i]),
  629. selected: state.selectedSubCoinIndex == i,
  630. onTap: () => notifier.selectSubCoin(i),
  631. ),
  632. ],
  633. ),
  634. ],
  635. if (net != null && net.hasTokenContract) ...[
  636. const SizedBox(height: 12),
  637. Container(
  638. width: double.infinity,
  639. padding: const EdgeInsets.all(14),
  640. decoration: _panelDecoration(cs, isDark),
  641. child: Column(
  642. crossAxisAlignment: CrossAxisAlignment.start,
  643. children: [
  644. Text(
  645. l10n.depositContractAddress,
  646. style: TextStyle(
  647. color: cs.onSurface.withAlpha(140), fontSize: 12),
  648. ),
  649. const SizedBox(height: 4),
  650. SelectableText(
  651. net.contractAddress,
  652. style: TextStyle(
  653. color: cs.onSurface,
  654. fontSize: 12,
  655. fontFamily: 'monospace',
  656. ),
  657. ),
  658. ],
  659. ),
  660. ),
  661. ],
  662. const SizedBox(height: 20),
  663. Text(
  664. l10n.depositEnterAmount,
  665. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13),
  666. ),
  667. const SizedBox(height: 8),
  668. TextField(
  669. controller: _amountCtrl,
  670. keyboardType: const TextInputType.numberWithOptions(decimal: true),
  671. decoration: InputDecoration(
  672. filled: true,
  673. fillColor:
  674. isDark ? AppColors.darkBgSecondary : AppColors.lightBgSecondary,
  675. border: OutlineInputBorder(
  676. borderRadius: BorderRadius.circular(10),
  677. borderSide: BorderSide(color: cs.outline.withAlpha(50)),
  678. ),
  679. enabledBorder: OutlineInputBorder(
  680. borderRadius: BorderRadius.circular(10),
  681. borderSide: BorderSide(color: cs.outline.withAlpha(50)),
  682. ),
  683. hintText: l10n.depositEnterAmount,
  684. ),
  685. ),
  686. const SizedBox(height: 20),
  687. SizedBox(
  688. width: double.infinity,
  689. height: 48,
  690. child: FilledButton(
  691. style: FilledButton.styleFrom(
  692. backgroundColor: AppColors.brand,
  693. foregroundColor: Colors.black,
  694. shape: RoundedRectangleBorder(
  695. borderRadius: BorderRadius.circular(10),
  696. ),
  697. ),
  698. onPressed: state.orderSubmitting || !canCreate
  699. ? null
  700. : _onCreateOrder,
  701. child: state.orderSubmitting
  702. ? const SizedBox(
  703. height: 20,
  704. width: 20,
  705. child: CircularProgressIndicator(
  706. strokeWidth: 2,
  707. color: Colors.black,
  708. ),
  709. )
  710. : Text(
  711. l10n.depositCreateOrder,
  712. style: const TextStyle(fontWeight: FontWeight.w600),
  713. ),
  714. ),
  715. ),
  716. const SizedBox(height: 20),
  717. _buildRulesCard(context, cs, l10n, isDark, net?.coinName ?? ''),
  718. ],
  719. );
  720. }
  721. Widget _buildRulesCard(
  722. BuildContext context,
  723. ColorScheme cs,
  724. AppLocalizations l10n,
  725. bool isDark,
  726. String currentCoin,
  727. ) {
  728. return Container(
  729. width: double.infinity,
  730. padding: const EdgeInsets.all(14),
  731. decoration: _panelDecoration(cs, isDark),
  732. child: Column(
  733. crossAxisAlignment: CrossAxisAlignment.start,
  734. children: [
  735. Text(
  736. l10n.depositRules,
  737. style: TextStyle(
  738. color: cs.onSurface.withAlpha(153),
  739. fontSize: 13,
  740. fontWeight: FontWeight.w600,
  741. ),
  742. ),
  743. const SizedBox(height: 8),
  744. Text(
  745. l10n.depositRulesBody,
  746. style: TextStyle(
  747. color: cs.onSurface.withAlpha(140),
  748. fontSize: 12,
  749. height: 1.45,
  750. ),
  751. ),
  752. if (currentCoin.isNotEmpty) ...[
  753. const SizedBox(height: 10),
  754. Text(
  755. l10n.depositCurrentCoin(currentCoin),
  756. style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 12),
  757. ),
  758. ],
  759. ],
  760. ),
  761. );
  762. }
  763. Widget _buildOrderDetail(
  764. BuildContext context,
  765. DepositState state,
  766. DepositNotifier notifier,
  767. ColorScheme cs,
  768. AppLocalizations l10n,
  769. bool isDark,
  770. RechargeOrderDetail order, {
  771. required bool wcConfigured,
  772. }) {
  773. final addr = order.rechargeAddress;
  774. final net = state.selectedNetwork;
  775. final canWc = _canWalletConnect(net, order);
  776. return Column(
  777. crossAxisAlignment: CrossAxisAlignment.start,
  778. children: [
  779. Container(
  780. width: double.infinity,
  781. padding: const EdgeInsets.all(14),
  782. decoration: _panelDecoration(cs, isDark),
  783. child: Column(
  784. children: [
  785. _orderMetaRow(
  786. context,
  787. l10n.depositOrderNo,
  788. order.orderNo,
  789. cs,
  790. copyable: true,
  791. ),
  792. _orderMetaRow(
  793. context,
  794. l10n.depositOrderStatus,
  795. _statusLabel(l10n, order.status),
  796. cs,
  797. valueColor: order.isPendingPayment ? AppColors.brand : null,
  798. ),
  799. _orderMetaRow(context, l10n.depositCurrency, order.coinName, cs),
  800. _orderMetaRow(context, l10n.depositNetwork, order.networkName, cs),
  801. _orderMetaRow(
  802. context, l10n.depositOrderAmount, order.amount, cs),
  803. ],
  804. ),
  805. ),
  806. if (order.isPendingPayment) ...[
  807. const SizedBox(height: 16),
  808. _buildModeTabSwitcher(cs, l10n, isDark),
  809. const SizedBox(height: 16),
  810. AnimatedBuilder(
  811. animation: _depositModeTab,
  812. builder: (context, _) {
  813. if (_depositModeTab.index == _tabManual) {
  814. return _buildManualContent(
  815. context,
  816. state,
  817. cs,
  818. l10n,
  819. isDark,
  820. addr,
  821. net,
  822. order: order,
  823. );
  824. } else if (_depositModeTab.index == _tabOnChain) {
  825. return _buildOnChainContent(
  826. context,
  827. state,
  828. cs,
  829. l10n,
  830. isDark,
  831. addr,
  832. net,
  833. canWc,
  834. wcConfigured: wcConfigured,
  835. order: order,
  836. );
  837. } else {
  838. return const SizedBox.shrink();
  839. }
  840. },
  841. ),
  842. ] else ...[
  843. const SizedBox(height: 16),
  844. _buildManualContent(
  845. context,
  846. state,
  847. cs,
  848. l10n,
  849. isDark,
  850. addr,
  851. net,
  852. order: order,
  853. readOnly: true,
  854. ),
  855. ],
  856. ],
  857. );
  858. }
  859. Widget _orderMetaRow(
  860. BuildContext context,
  861. String label,
  862. String value,
  863. ColorScheme cs, {
  864. Color? valueColor,
  865. bool copyable = false,
  866. }) {
  867. final l10n = AppLocalizations.of(context)!;
  868. return Padding(
  869. padding: const EdgeInsets.only(bottom: 10),
  870. child: Row(
  871. crossAxisAlignment: CrossAxisAlignment.start,
  872. children: [
  873. SizedBox(
  874. width: 72,
  875. child: Text(
  876. label,
  877. style: TextStyle(color: cs.onSurface.withAlpha(140), fontSize: 13),
  878. ),
  879. ),
  880. Expanded(
  881. child: Text(
  882. value,
  883. style: TextStyle(
  884. color: valueColor ?? cs.onSurface,
  885. fontSize: 13,
  886. fontWeight:
  887. valueColor != null ? FontWeight.w600 : FontWeight.w400,
  888. ),
  889. ),
  890. ),
  891. if (copyable && value.isNotEmpty)
  892. TextButton(
  893. onPressed: () {
  894. Clipboard.setData(ClipboardData(text: value));
  895. showTopToast(context, message: l10n.copied);
  896. },
  897. style: TextButton.styleFrom(
  898. padding: const EdgeInsets.symmetric(horizontal: 8),
  899. minimumSize: Size.zero,
  900. tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  901. ),
  902. child: Text(
  903. l10n.depositCopy,
  904. style: const TextStyle(
  905. color: AppColors.brand,
  906. fontSize: 12,
  907. fontWeight: FontWeight.w600,
  908. ),
  909. ),
  910. ),
  911. ],
  912. ),
  913. );
  914. }
  915. Widget _buildAddressBlock(
  916. BuildContext context,
  917. ColorScheme cs,
  918. AppLocalizations l10n,
  919. bool isDark,
  920. String addr, {
  921. bool showQr = false,
  922. }) {
  923. return Column(
  924. crossAxisAlignment: CrossAxisAlignment.start,
  925. children: [
  926. Text(
  927. l10n.depositReceivingAddress,
  928. style: TextStyle(
  929. color: cs.onSurface.withAlpha(153),
  930. fontSize: 13,
  931. fontWeight: FontWeight.w600,
  932. ),
  933. ),
  934. const SizedBox(height: 6),
  935. Text(
  936. l10n.depositPayToHint,
  937. style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 12),
  938. ),
  939. const SizedBox(height: 10),
  940. Container(
  941. width: double.infinity,
  942. padding: const EdgeInsets.all(12),
  943. decoration: _panelDecoration(cs, isDark),
  944. child: SelectableText(
  945. addr,
  946. style: TextStyle(color: cs.onSurface, fontSize: 13),
  947. ),
  948. ),
  949. if (showQr && addr.isNotEmpty) ...[
  950. const SizedBox(height: 16),
  951. Text(
  952. l10n.depositQrReceive,
  953. style: TextStyle(
  954. color: cs.onSurface.withAlpha(153),
  955. fontSize: 13,
  956. fontWeight: FontWeight.w600,
  957. ),
  958. ),
  959. const SizedBox(height: 10),
  960. Center(
  961. child: RepaintBoundary(
  962. key: _qrKey,
  963. child: Container(
  964. width: 200,
  965. height: 200,
  966. decoration: BoxDecoration(
  967. color: Colors.white,
  968. borderRadius: BorderRadius.circular(12),
  969. ),
  970. child: QrImageView(
  971. data: addr,
  972. version: QrVersions.auto,
  973. size: 180,
  974. backgroundColor: Colors.white,
  975. ),
  976. ),
  977. ),
  978. ),
  979. const SizedBox(height: 8),
  980. Text(
  981. l10n.depositQrHint,
  982. textAlign: TextAlign.center,
  983. style: TextStyle(color: cs.onSurface.withAlpha(120), fontSize: 12),
  984. ),
  985. const SizedBox(height: 8),
  986. Center(
  987. child: TextButton.icon(
  988. onPressed:
  989. addr.isNotEmpty && !_savingQr ? () => _saveQr(addr) : null,
  990. icon: _savingQr
  991. ? const SizedBox(
  992. width: 16,
  993. height: 16,
  994. child: CircularProgressIndicator(strokeWidth: 2),
  995. )
  996. : Icon(Icons.download_outlined,
  997. size: 16, color: cs.onSurface.withAlpha(153)),
  998. label: Text(
  999. l10n.saveQrCode,
  1000. style: TextStyle(
  1001. color: cs.onSurface.withAlpha(153), fontSize: 13),
  1002. ),
  1003. ),
  1004. ),
  1005. ],
  1006. ],
  1007. );
  1008. }
  1009. Widget _buildContractBlock(
  1010. ColorScheme cs,
  1011. AppLocalizations l10n,
  1012. bool isDark,
  1013. RechargeFlatNetworkOption? net,
  1014. ) {
  1015. if (net == null || !net.hasTokenContract) {
  1016. return const SizedBox.shrink();
  1017. }
  1018. return Padding(
  1019. padding: const EdgeInsets.only(top: 12),
  1020. child: Container(
  1021. width: double.infinity,
  1022. padding: const EdgeInsets.all(12),
  1023. decoration: _panelDecoration(cs, isDark),
  1024. child: Column(
  1025. crossAxisAlignment: CrossAxisAlignment.start,
  1026. children: [
  1027. Text(
  1028. l10n.depositContractAddress,
  1029. style: TextStyle(color: cs.onSurface.withAlpha(140), fontSize: 12),
  1030. ),
  1031. const SizedBox(height: 6),
  1032. SelectableText(
  1033. net.contractAddress,
  1034. style: TextStyle(
  1035. color: cs.onSurface,
  1036. fontSize: 12,
  1037. fontFamily: 'monospace',
  1038. ),
  1039. ),
  1040. ],
  1041. ),
  1042. ),
  1043. );
  1044. }
  1045. Widget _buildTxHashSection(
  1046. BuildContext context,
  1047. DepositState state,
  1048. ColorScheme cs,
  1049. AppLocalizations l10n,
  1050. bool isDark, {
  1051. bool enabled = true,
  1052. }) {
  1053. if (!enabled) {
  1054. return const SizedBox.shrink();
  1055. }
  1056. return Column(
  1057. crossAxisAlignment: CrossAxisAlignment.start,
  1058. children: [
  1059. const SizedBox(height: 20),
  1060. Text(
  1061. l10n.depositSubmitHashHint,
  1062. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 13),
  1063. ),
  1064. const SizedBox(height: 8),
  1065. TextField(
  1066. controller: _hashCtrl,
  1067. decoration: InputDecoration(
  1068. filled: true,
  1069. fillColor: isDark
  1070. ? AppColors.darkBgSecondary
  1071. : AppColors.lightBgSecondary,
  1072. hintText: l10n.depositTxHashPlaceholder,
  1073. border: OutlineInputBorder(
  1074. borderRadius: BorderRadius.circular(10),
  1075. borderSide: BorderSide(color: cs.outline.withAlpha(50)),
  1076. ),
  1077. enabledBorder: OutlineInputBorder(
  1078. borderRadius: BorderRadius.circular(10),
  1079. borderSide: BorderSide(color: cs.outline.withAlpha(50)),
  1080. ),
  1081. ),
  1082. ),
  1083. const SizedBox(height: 12),
  1084. SizedBox(
  1085. width: double.infinity,
  1086. height: 48,
  1087. child: FilledButton(
  1088. style: FilledButton.styleFrom(
  1089. backgroundColor: AppColors.brand,
  1090. foregroundColor: Colors.black,
  1091. shape: RoundedRectangleBorder(
  1092. borderRadius: BorderRadius.circular(10),
  1093. ),
  1094. ),
  1095. onPressed: state.hashSubmitting || state.walletPayBusy
  1096. ? null
  1097. : _onSubmitHash,
  1098. child: state.hashSubmitting
  1099. ? const SizedBox(
  1100. height: 20,
  1101. width: 20,
  1102. child: CircularProgressIndicator(
  1103. strokeWidth: 2,
  1104. color: Colors.black,
  1105. ),
  1106. )
  1107. : Text(
  1108. l10n.depositSubmitHash,
  1109. style: const TextStyle(fontWeight: FontWeight.w600),
  1110. ),
  1111. ),
  1112. ),
  1113. ],
  1114. );
  1115. }
  1116. Widget _buildOnChainContent(
  1117. BuildContext context,
  1118. DepositState state,
  1119. ColorScheme cs,
  1120. AppLocalizations l10n,
  1121. bool isDark,
  1122. String addr,
  1123. RechargeFlatNetworkOption? net,
  1124. bool canWc, {
  1125. required bool wcConfigured,
  1126. required RechargeOrderDetail order,
  1127. }) {
  1128. return Column(
  1129. crossAxisAlignment: CrossAxisAlignment.start,
  1130. children: [
  1131. if (addr.isNotEmpty) ...[
  1132. _buildAddressBlock(context, cs, l10n, isDark, addr),
  1133. ],
  1134. if (order.isPendingPayment) ...[
  1135. const SizedBox(height: 16),
  1136. Container(
  1137. width: double.infinity,
  1138. padding: const EdgeInsets.all(14),
  1139. decoration: _panelDecoration(cs, isDark),
  1140. child: Column(
  1141. crossAxisAlignment: CrossAxisAlignment.start,
  1142. children: [
  1143. if (canWc && wcConfigured)
  1144. Text(
  1145. l10n.depositWalletConnectHint,
  1146. style: TextStyle(
  1147. color: cs.onSurface.withAlpha(153),
  1148. fontSize: 12,
  1149. height: 1.4,
  1150. ),
  1151. )
  1152. else if (canWc && !wcConfigured)
  1153. Text(
  1154. l10n.depositWalletConnectNotConfigured,
  1155. style: TextStyle(
  1156. color: cs.onSurface.withAlpha(120),
  1157. fontSize: 12,
  1158. height: 1.4,
  1159. ),
  1160. )
  1161. else if (_isTronLike(net))
  1162. Text(
  1163. l10n.depositTronHint,
  1164. style: TextStyle(
  1165. color: cs.onSurface.withAlpha(153),
  1166. fontSize: 12,
  1167. height: 1.4,
  1168. ),
  1169. ),
  1170. if (canWc && wcConfigured) ...[
  1171. const SizedBox(height: 12),
  1172. SizedBox(
  1173. width: double.infinity,
  1174. height: 44,
  1175. child: OutlinedButton(
  1176. style: OutlinedButton.styleFrom(
  1177. foregroundColor: AppColors.brand,
  1178. side: const BorderSide(color: AppColors.brand),
  1179. shape: RoundedRectangleBorder(
  1180. borderRadius: BorderRadius.circular(10),
  1181. ),
  1182. ),
  1183. onPressed: (state.hashSubmitting ||
  1184. state.walletPayBusy ||
  1185. net == null)
  1186. ? null
  1187. : () => _onWalletPay(net, order),
  1188. child: state.walletPayBusy
  1189. ? const SizedBox(
  1190. height: 20,
  1191. width: 20,
  1192. child: CircularProgressIndicator(
  1193. strokeWidth: 2,
  1194. color: AppColors.brand,
  1195. ),
  1196. )
  1197. : Text(_walletPayButtonLabel(l10n, net)),
  1198. ),
  1199. ),
  1200. ],
  1201. ],
  1202. ),
  1203. ),
  1204. _buildTxHashSection(context, state, cs, l10n, isDark),
  1205. ] else if (order.txHash != null && order.txHash!.isNotEmpty) ...[
  1206. const SizedBox(height: 12),
  1207. Text(
  1208. '${l10n.depositTxHashPlaceholder}: ${order.txHash}',
  1209. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12),
  1210. ),
  1211. ],
  1212. ],
  1213. );
  1214. }
  1215. Widget _buildManualContent(
  1216. BuildContext context,
  1217. DepositState state,
  1218. ColorScheme cs,
  1219. AppLocalizations l10n,
  1220. bool isDark,
  1221. String addr,
  1222. RechargeFlatNetworkOption? net, {
  1223. required RechargeOrderDetail order,
  1224. bool readOnly = false,
  1225. }) {
  1226. return Column(
  1227. crossAxisAlignment: CrossAxisAlignment.start,
  1228. children: [
  1229. if (addr.isNotEmpty) ...[
  1230. _buildAddressBlock(
  1231. context,
  1232. cs,
  1233. l10n,
  1234. isDark,
  1235. addr,
  1236. showQr: true,
  1237. ),
  1238. ],
  1239. _buildContractBlock(cs, l10n, isDark, net),
  1240. if (order.isPendingPayment && !readOnly)
  1241. _buildTxHashSection(context, state, cs, l10n, isDark)
  1242. else if (order.txHash != null && order.txHash!.isNotEmpty) ...[
  1243. const SizedBox(height: 12),
  1244. Text(
  1245. '${l10n.depositTxHashPlaceholder}: ${order.txHash}',
  1246. style: TextStyle(color: cs.onSurface.withAlpha(153), fontSize: 12),
  1247. ),
  1248. ],
  1249. ],
  1250. );
  1251. }
  1252. }