withdraw_detail_screen.dart 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:go_router/go_router.dart';
  5. import '../../../core/l10n/app_localizations.dart';
  6. import '../../../core/theme/app_colors.dart';
  7. import '../../../core/utils/top_toast.dart';
  8. import '../../../core/utils/number_format.dart';
  9. import '../../../data/models/asset/withdraw_record.dart';
  10. import '../../../providers/withdraw_detail_provider.dart';
  11. class WithdrawDetailScreen extends ConsumerStatefulWidget {
  12. const WithdrawDetailScreen({
  13. super.key,
  14. required this.record,
  15. required this.isTransfer,
  16. });
  17. final WithdrawRecord record;
  18. final bool isTransfer;
  19. @override
  20. ConsumerState<WithdrawDetailScreen> createState() =>
  21. _WithdrawDetailScreenState();
  22. }
  23. class _WithdrawDetailScreenState
  24. extends ConsumerState<WithdrawDetailScreen> {
  25. @override
  26. void initState() {
  27. super.initState();
  28. Future.microtask(() {
  29. ref.read(withdrawDetailProvider.notifier).loadRecord(
  30. widget.record,
  31. isTransfer: widget.isTransfer,
  32. );
  33. });
  34. }
  35. @override
  36. Widget build(BuildContext context) {
  37. final state = ref.watch(withdrawDetailProvider);
  38. final record = state.record ?? widget.record;
  39. final isDark = Theme.of(context).brightness == Brightness.dark;
  40. final dividerColor =
  41. isDark ? AppColors.darkBg : AppColors.lightBgSecondary;
  42. return Scaffold(
  43. backgroundColor: isDark ? AppColors.darkBg : AppColors.lightBg,
  44. appBar: AppBar(
  45. backgroundColor: isDark ? AppColors.darkBg : AppColors.lightBg,
  46. leading: IconButton(
  47. icon: const Icon(Icons.chevron_left, size: 28),
  48. onPressed: () => context.pop(),
  49. ),
  50. title: Text(
  51. widget.isTransfer ? AppLocalizations.of(context)!.transferDetail : AppLocalizations.of(context)!.withdrawDetail,
  52. style: const TextStyle(
  53. fontSize: 17, fontWeight: FontWeight.w600),
  54. ),
  55. centerTitle: true,
  56. ),
  57. body: state.isLoading
  58. ? const Center(child: CircularProgressIndicator())
  59. : SingleChildScrollView(
  60. child: Column(
  61. crossAxisAlignment: CrossAxisAlignment.stretch,
  62. children: [
  63. // ── 数量 ──────────────────────────────────────
  64. _AmountSection(
  65. record: record, isTransfer: widget.isTransfer),
  66. // ── 分隔 ──────────────────────────────────────
  67. Container(height: 8, color: dividerColor),
  68. // ── 提现进度 ──────────────────────────────────
  69. _ProgressSection(
  70. record: record, isTransfer: widget.isTransfer),
  71. // ── 分隔 ──────────────────────────────────────
  72. Container(height: 8, color: dividerColor),
  73. // ── 信息卡片(链路 / 类型 / 状态)──────────────
  74. _InfoCards(
  75. record: record, isTransfer: widget.isTransfer),
  76. // ── 分隔 ──────────────────────────────────────
  77. Container(height: 8, color: dividerColor),
  78. // ── 详细信息 ──────────────────────────────────
  79. _DetailRows(
  80. record: record, isTransfer: widget.isTransfer),
  81. // ── 取消按钮 ──────────────────────────────────
  82. if (record.canCancel && _within3Minutes(record.createTime))
  83. Padding(
  84. padding: const EdgeInsets.fromLTRB(16, 24, 16, 40),
  85. child: _CancelButton(
  86. isCancelling: state.isCancelling,
  87. onCancel: () => _showCancelDialog(context),
  88. ),
  89. )
  90. else
  91. const SizedBox(height: 40),
  92. ],
  93. ),
  94. ),
  95. );
  96. }
  97. bool _within3Minutes(String createTime) {
  98. try {
  99. final dt = DateTime.parse(createTime.replaceFirst(' ', 'T'));
  100. return DateTime.now().difference(dt).inSeconds <= 180;
  101. } catch (_) {
  102. return false;
  103. }
  104. }
  105. void _showCancelDialog(BuildContext ctx) {
  106. showDialog(
  107. context: ctx,
  108. builder: (dialogCtx) => AlertDialog(
  109. title: Text(AppLocalizations.of(ctx)!.cancelWithdraw),
  110. content: Text(AppLocalizations.of(ctx)!.confirmCancelWithdraw),
  111. actions: [
  112. TextButton(
  113. onPressed: () => Navigator.pop(dialogCtx),
  114. child: Text(AppLocalizations.of(ctx)!.cancel),
  115. ),
  116. TextButton(
  117. onPressed: () async {
  118. Navigator.pop(dialogCtx);
  119. final ok = await ref
  120. .read(withdrawDetailProvider.notifier)
  121. .cancel(isTransfer: widget.isTransfer);
  122. if (!mounted) return;
  123. final l10n = AppLocalizations.of(ctx)!;
  124. ScaffoldMessenger.of(ctx).showSnackBar(
  125. SnackBar(content: Text(ok ? l10n.withdrawCancelled : l10n.operationFailed)),
  126. );
  127. },
  128. child: Text(AppLocalizations.of(ctx)!.confirm),
  129. ),
  130. ],
  131. ),
  132. );
  133. }
  134. }
  135. // ── 数量区块(居中,无卡片,与原型一致)──────────────────────────────
  136. class _AmountSection extends StatelessWidget {
  137. const _AmountSection({required this.record, required this.isTransfer});
  138. final WithdrawRecord record;
  139. final bool isTransfer;
  140. @override
  141. Widget build(BuildContext context) {
  142. final cs = Theme.of(context).colorScheme;
  143. final coinUnit = record.coin?.baseCoinDisplay.isNotEmpty == true
  144. ? record.coin!.baseCoinDisplay
  145. : (record.coin?.coinName.isNotEmpty == true
  146. ? record.coin!.coinName
  147. : 'USDT');
  148. final rawAmount = isTransfer ? record.transferAmount : record.amount;
  149. final amountStr =
  150. formatAmount(double.tryParse(rawAmount) ?? 0);
  151. return Padding(
  152. padding: const EdgeInsets.fromLTRB(16, 32, 16, 28),
  153. child: Column(
  154. crossAxisAlignment: CrossAxisAlignment.center,
  155. children: [
  156. Text(
  157. AppLocalizations.of(context)!.amountLabel,
  158. style: TextStyle(
  159. color: cs.onSurface.withAlpha(120), fontSize: 12),
  160. ),
  161. const SizedBox(height: 10),
  162. Text(
  163. '-$amountStr $coinUnit',
  164. style: const TextStyle(
  165. color: AppColors.fall,
  166. fontSize: 30,
  167. fontWeight: FontWeight.w700,
  168. letterSpacing: -0.5,
  169. ),
  170. ),
  171. ],
  172. ),
  173. );
  174. }
  175. }
  176. // ── 提现进度(垂直步骤条,步骤居中对齐原型)────────────────────────────
  177. class _ProgressSection extends StatelessWidget {
  178. const _ProgressSection(
  179. {required this.record, required this.isTransfer});
  180. final WithdrawRecord record;
  181. final bool isTransfer;
  182. @override
  183. Widget build(BuildContext context) {
  184. final cs = Theme.of(context).colorScheme;
  185. final status = record.status;
  186. // 步骤 2(等待提现):有申请记录就已进入等待状态
  187. const step2Done = true;
  188. // 步骤 3:失败/成功/取消 时完成
  189. final step3Done = ['2', '3', '4'].contains(status);
  190. final isFailed = status == '2';
  191. final isCancelled = status == '4';
  192. final l10n = AppLocalizations.of(context)!;
  193. final String step3Title;
  194. if (isFailed) {
  195. step3Title = l10n.withdrawFailed;
  196. } else if (isCancelled) {
  197. step3Title = l10n.cancelWithdraw;
  198. } else {
  199. step3Title = l10n.withdrawSuccess;
  200. }
  201. return Padding(
  202. padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
  203. child: Column(
  204. crossAxisAlignment: CrossAxisAlignment.start,
  205. children: [
  206. Text(
  207. AppLocalizations.of(context)!.withdrawProgress,
  208. style: TextStyle(
  209. color: cs.onSurface.withAlpha(120), fontSize: 12),
  210. ),
  211. const SizedBox(height: 16),
  212. // 步骤容器居中,宽 200
  213. Center(
  214. child: SizedBox(
  215. width: 200,
  216. child: Column(
  217. children: [
  218. _VStep(
  219. done: true,
  220. failed: false,
  221. hasLine: true,
  222. title: l10n.withdrawApplication,
  223. desc: record.createTime,
  224. ),
  225. _VStep(
  226. done: step2Done,
  227. failed: false,
  228. hasLine: true,
  229. title: l10n.waitingWithdraw,
  230. desc: record.canCancel ? AppLocalizations.of(context)!.cancelWithdrawHint : '',
  231. ),
  232. _VStep(
  233. done: step3Done,
  234. failed: isFailed,
  235. hasLine: false,
  236. title: step3Title,
  237. desc: step3Done && record.dealTime.isNotEmpty
  238. ? record.dealTime
  239. : '',
  240. ),
  241. ],
  242. ),
  243. ),
  244. ),
  245. ],
  246. ),
  247. );
  248. }
  249. }
  250. class _VStep extends StatelessWidget {
  251. const _VStep({
  252. required this.done,
  253. required this.failed,
  254. required this.hasLine,
  255. required this.title,
  256. required this.desc,
  257. });
  258. final bool done;
  259. final bool failed;
  260. final bool hasLine;
  261. final String title;
  262. final String desc;
  263. @override
  264. Widget build(BuildContext context) {
  265. final cs = Theme.of(context).colorScheme;
  266. final isDark = Theme.of(context).brightness == Brightness.dark;
  267. final Color borderColor = done
  268. ? (failed ? AppColors.fall : (isDark ? Colors.white : Colors.black))
  269. : (isDark ? const Color(0xFF444444) : const Color(0xFFEEEEEE));
  270. final Color iconColor = done
  271. ? (failed ? AppColors.fall : (isDark ? Colors.white : Colors.black))
  272. : (isDark ? const Color(0xFF555555) : const Color(0xFFCCCCCC));
  273. final Color lineColor = done
  274. ? (isDark ? Colors.white70 : Colors.black)
  275. : (isDark ? const Color(0xFF444444) : const Color(0xFFEEEEEE));
  276. return Row(
  277. crossAxisAlignment: CrossAxisAlignment.start,
  278. children: [
  279. SizedBox(
  280. width: 24,
  281. child: Column(
  282. children: [
  283. Container(
  284. width: 24,
  285. height: 24,
  286. decoration: BoxDecoration(
  287. shape: BoxShape.circle,
  288. color: isDark ? AppColors.darkBg : Colors.white,
  289. border: Border.all(color: borderColor, width: 2),
  290. ),
  291. child: Center(
  292. child: Icon(
  293. failed ? Icons.close : Icons.check,
  294. size: 13,
  295. color: iconColor,
  296. ),
  297. ),
  298. ),
  299. if (hasLine)
  300. Container(
  301. width: 2,
  302. height: 44,
  303. margin: const EdgeInsets.symmetric(vertical: 2),
  304. color: lineColor,
  305. ),
  306. ],
  307. ),
  308. ),
  309. const SizedBox(width: 14),
  310. Expanded(
  311. child: Padding(
  312. padding: EdgeInsets.only(top: 2, bottom: hasLine ? 20 : 0),
  313. child: Column(
  314. crossAxisAlignment: CrossAxisAlignment.start,
  315. children: [
  316. Text(
  317. title,
  318. style: TextStyle(
  319. color: done
  320. ? (isDark ? Colors.white : Colors.black)
  321. : cs.onSurface.withAlpha(100),
  322. fontSize: 14,
  323. fontWeight: FontWeight.w600,
  324. ),
  325. ),
  326. if (desc.isNotEmpty) ...[
  327. const SizedBox(height: 4),
  328. Text(
  329. desc,
  330. style: TextStyle(
  331. color: cs.onSurface.withAlpha(100),
  332. fontSize: 10),
  333. ),
  334. ],
  335. ],
  336. ),
  337. ),
  338. ),
  339. ],
  340. );
  341. }
  342. }
  343. // ── 信息卡片(3 个独立带边框卡片,间距 10,对齐原型)────────────────────
  344. class _InfoCards extends StatelessWidget {
  345. const _InfoCards({required this.record, required this.isTransfer});
  346. final WithdrawRecord record;
  347. final bool isTransfer;
  348. String _localizedStatus(AppLocalizations l10n) {
  349. switch (record.status) {
  350. case '0': return l10n.withdrawStatusReviewing;
  351. case '1': return l10n.withdrawStatusReleasing;
  352. case '2': return l10n.withdrawStatusFailed;
  353. case '3': return l10n.withdrawStatusSuccess;
  354. case '4': return l10n.withdrawStatusCancelled;
  355. default: return l10n.unknown;
  356. }
  357. }
  358. @override
  359. Widget build(BuildContext context) {
  360. final cs = Theme.of(context).colorScheme;
  361. final isDark = Theme.of(context).brightness == Brightness.dark;
  362. final baseCoin = record.coin?.baseCoinDisplay.isNotEmpty == true
  363. ? record.coin!.baseCoinDisplay
  364. : (record.coin?.coinName.isNotEmpty == true
  365. ? record.coin!.coinName
  366. : 'USDT');
  367. final network = record.coin?.networkName ?? '';
  368. final l10n = AppLocalizations.of(context)!;
  369. final chainLabel = isTransfer
  370. ? l10n.internalLabel
  371. : (network.isNotEmpty ? '$baseCoin-$network' : baseCoin);
  372. final typeLabel = isTransfer ? l10n.internalTransfer : l10n.onChainWithdraw;
  373. Color statusColor;
  374. switch (record.status) {
  375. case '3':
  376. statusColor = AppColors.rise;
  377. break;
  378. case '2':
  379. statusColor = AppColors.fall;
  380. break;
  381. case '4':
  382. statusColor = cs.onSurface.withAlpha(153);
  383. break;
  384. default:
  385. statusColor = AppColors.warning;
  386. }
  387. return Padding(
  388. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
  389. child: Row(
  390. children: [
  391. _InfoCard(label: AppLocalizations.of(context)!.withdrawNetwork, value: chainLabel, isDark: isDark),
  392. const SizedBox(width: 10),
  393. _InfoCard(label: AppLocalizations.of(context)!.typeLabel, value: typeLabel, isDark: isDark),
  394. const SizedBox(width: 10),
  395. _InfoCard(
  396. label: AppLocalizations.of(context)!.statusLabel,
  397. value: _localizedStatus(l10n),
  398. valueColor: statusColor,
  399. isDark: isDark,
  400. ),
  401. ],
  402. ),
  403. );
  404. }
  405. }
  406. class _InfoCard extends StatelessWidget {
  407. const _InfoCard({
  408. required this.label,
  409. required this.value,
  410. required this.isDark,
  411. this.valueColor,
  412. });
  413. final String label;
  414. final String value;
  415. final bool isDark;
  416. final Color? valueColor;
  417. @override
  418. Widget build(BuildContext context) {
  419. final cs = Theme.of(context).colorScheme;
  420. final borderColor =
  421. isDark ? AppColors.darkDivider : AppColors.lightBorder;
  422. return Expanded(
  423. child: Container(
  424. decoration: BoxDecoration(
  425. border: Border.all(color: borderColor, width: 1),
  426. borderRadius: BorderRadius.circular(6),
  427. ),
  428. padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
  429. child: Column(
  430. mainAxisAlignment: MainAxisAlignment.center,
  431. children: [
  432. Text(
  433. label,
  434. style: TextStyle(
  435. color: cs.onSurface.withAlpha(120), fontSize: 11),
  436. ),
  437. const SizedBox(height: 6),
  438. Text(
  439. value,
  440. textAlign: TextAlign.center,
  441. style: TextStyle(
  442. color: valueColor ?? cs.onSurface,
  443. fontSize: 12,
  444. fontWeight: FontWeight.w600,
  445. ),
  446. ),
  447. ],
  448. ),
  449. ),
  450. );
  451. }
  452. }
  453. // ── 详细信息行(平铺,行间 1px 分隔,对齐原型)──────────────────────────
  454. class _DetailRows extends StatelessWidget {
  455. const _DetailRows({required this.record, required this.isTransfer});
  456. final WithdrawRecord record;
  457. final bool isTransfer;
  458. @override
  459. Widget build(BuildContext context) {
  460. final cs = Theme.of(context).colorScheme;
  461. final isDark = Theme.of(context).brightness == Brightness.dark;
  462. final coinUnit = record.coin?.baseCoinDisplay.isNotEmpty == true
  463. ? record.coin!.baseCoinDisplay
  464. : (record.coin?.coinName.isNotEmpty == true
  465. ? record.coin!.coinName
  466. : 'USDT');
  467. final feeDouble = double.tryParse(record.coin?.fee ?? '0') ?? 0;
  468. final feeStr = formatAmount(feeDouble);
  469. final address = record.address;
  470. final txHash = record.transactionHashId;
  471. final divColor =
  472. isDark ? AppColors.darkDivider : AppColors.lightBgSecondary;
  473. return Padding(
  474. padding: const EdgeInsets.symmetric(horizontal: 16),
  475. child: Column(
  476. children: [
  477. // 手续费
  478. Padding(
  479. padding: const EdgeInsets.symmetric(vertical: 14),
  480. child: Row(
  481. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  482. children: [
  483. Text(AppLocalizations.of(context)!.fee,
  484. style: TextStyle(
  485. color: cs.onSurface.withAlpha(120), fontSize: 13)),
  486. Text('$feeStr $coinUnit',
  487. style: TextStyle(
  488. color: cs.onSurface,
  489. fontSize: 13,
  490. fontWeight: FontWeight.w500)),
  491. ],
  492. ),
  493. ),
  494. Divider(
  495. height: 0.5,
  496. thickness: 0.5,
  497. color: divColor),
  498. // 提币地址 / 转入账户
  499. Padding(
  500. padding: const EdgeInsets.symmetric(vertical: 14),
  501. child: Row(
  502. crossAxisAlignment: CrossAxisAlignment.start,
  503. children: [
  504. Text(
  505. isTransfer ? AppLocalizations.of(context)!.transferUser : AppLocalizations.of(context)!.withdrawAddress,
  506. style: TextStyle(
  507. color: cs.onSurface.withAlpha(120), fontSize: 13),
  508. ),
  509. const SizedBox(width: 16),
  510. Expanded(
  511. child: Row(
  512. mainAxisAlignment: MainAxisAlignment.end,
  513. crossAxisAlignment: CrossAxisAlignment.start,
  514. children: [
  515. Flexible(
  516. child: Text(
  517. address,
  518. textAlign: TextAlign.right,
  519. style: TextStyle(
  520. color: cs.onSurface,
  521. fontSize: 13,
  522. fontWeight: FontWeight.w500),
  523. ),
  524. ),
  525. const SizedBox(width: 8),
  526. GestureDetector(
  527. onTap: () {
  528. Clipboard.setData(ClipboardData(text: address));
  529. showTopToast(context,
  530. message: AppLocalizations.of(context)!.uidCopied,
  531. backgroundColor: AppColors.rise);
  532. },
  533. child: Icon(Icons.content_copy,
  534. size: 15,
  535. color: cs.onSurface.withAlpha(120)),
  536. ),
  537. ],
  538. ),
  539. ),
  540. ],
  541. ),
  542. ),
  543. // 哈希(仅链上提现且有值时展示)
  544. if (!isTransfer && txHash.isNotEmpty) ...[
  545. Divider(height: 0.5, thickness: 0.5, color: divColor),
  546. Padding(
  547. padding: const EdgeInsets.symmetric(vertical: 14),
  548. child: Row(
  549. crossAxisAlignment: CrossAxisAlignment.start,
  550. children: [
  551. Text(
  552. AppLocalizations.of(context)!.txHash,
  553. style: TextStyle(
  554. color: cs.onSurface.withAlpha(120), fontSize: 13),
  555. ),
  556. const SizedBox(width: 16),
  557. Expanded(
  558. child: Row(
  559. mainAxisAlignment: MainAxisAlignment.end,
  560. crossAxisAlignment: CrossAxisAlignment.start,
  561. children: [
  562. Flexible(
  563. child: Text(
  564. txHash,
  565. textAlign: TextAlign.right,
  566. style: TextStyle(
  567. color: cs.onSurface,
  568. fontSize: 13,
  569. fontWeight: FontWeight.w500),
  570. ),
  571. ),
  572. const SizedBox(width: 8),
  573. GestureDetector(
  574. onTap: () {
  575. Clipboard.setData(ClipboardData(text: txHash));
  576. ScaffoldMessenger.of(context).showSnackBar(
  577. SnackBar(
  578. content: Text(AppLocalizations.of(context)!.copied),
  579. duration: const Duration(seconds: 1)),
  580. );
  581. },
  582. child: Icon(Icons.content_copy,
  583. size: 15,
  584. color: cs.onSurface.withAlpha(120)),
  585. ),
  586. ],
  587. ),
  588. ),
  589. ],
  590. ),
  591. ),
  592. ],
  593. ],
  594. ),
  595. );
  596. }
  597. }
  598. // ── 取消按钮 ─────────────────────────────────────────────────────
  599. class _CancelButton extends StatelessWidget {
  600. const _CancelButton(
  601. {required this.isCancelling, required this.onCancel});
  602. final bool isCancelling;
  603. final VoidCallback onCancel;
  604. @override
  605. Widget build(BuildContext context) {
  606. return SizedBox(
  607. width: double.infinity,
  608. height: 48,
  609. child: OutlinedButton(
  610. onPressed: isCancelling ? null : onCancel,
  611. style: OutlinedButton.styleFrom(
  612. foregroundColor: AppColors.fall,
  613. side: const BorderSide(color: AppColors.fall, width: 1),
  614. shape: RoundedRectangleBorder(
  615. borderRadius: BorderRadius.circular(8)),
  616. ),
  617. child: isCancelling
  618. ? const SizedBox(
  619. width: 20,
  620. height: 20,
  621. child: CircularProgressIndicator(
  622. strokeWidth: 2,
  623. valueColor:
  624. AlwaysStoppedAnimation<Color>(AppColors.fall),
  625. ),
  626. )
  627. : Text(AppLocalizations.of(context)!.cancelWithdraw,
  628. style: const TextStyle(
  629. fontSize: 15, fontWeight: FontWeight.w600)),
  630. ),
  631. );
  632. }
  633. }