withdraw_history_screen.dart 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import '../../../core/l10n/app_localizations.dart';
  5. import '../../../core/theme/app_colors.dart';
  6. import '../../../core/utils/number_format.dart';
  7. import '../../../data/models/asset/withdraw_record.dart';
  8. import '../../../providers/withdraw_provider.dart';
  9. import '../../widgets/common/app_refresh_indicator.dart';
  10. class WithdrawHistoryScreen extends ConsumerStatefulWidget {
  11. const WithdrawHistoryScreen({super.key});
  12. @override
  13. ConsumerState<WithdrawHistoryScreen> createState() =>
  14. _WithdrawHistoryScreenState();
  15. }
  16. class _WithdrawHistoryScreenState
  17. extends ConsumerState<WithdrawHistoryScreen> {
  18. @override
  19. void initState() {
  20. super.initState();
  21. Future.microtask(() {
  22. ref.read(withdrawHistoryProvider.notifier).refresh();
  23. });
  24. }
  25. @override
  26. Widget build(BuildContext context) {
  27. final state = ref.watch(withdrawHistoryProvider);
  28. final notifier = ref.read(withdrawHistoryProvider.notifier);
  29. final isDark = Theme.of(context).brightness == Brightness.dark;
  30. return Scaffold(
  31. // 原型:灰色页面背景,item 白色卡片浮于上
  32. backgroundColor:
  33. isDark ? AppColors.darkBg : AppColors.lightBgSecondary,
  34. appBar: AppBar(
  35. backgroundColor:
  36. isDark ? AppColors.darkBg : AppColors.lightBg,
  37. leading: IconButton(
  38. icon: const Icon(Icons.chevron_left, size: 28),
  39. onPressed: () => context.pop(),
  40. ),
  41. title: Text(AppLocalizations.of(context)!.withdrawRecord,
  42. style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
  43. centerTitle: true,
  44. ),
  45. body: _buildBody(context, state, notifier),
  46. );
  47. }
  48. Widget _buildBody(BuildContext context, WithdrawHistoryState state,
  49. WithdrawHistoryNotifier notifier) {
  50. final cs = Theme.of(context).colorScheme;
  51. if (state.isLoading && state.records.isEmpty) {
  52. return const Center(child: CircularProgressIndicator());
  53. }
  54. if (state.errorMessage != null && state.records.isEmpty) {
  55. return AppRefreshIndicator(
  56. onRefresh: notifier.refresh,
  57. child: SingleChildScrollView(
  58. physics: const AlwaysScrollableScrollPhysics(),
  59. child: Center(
  60. child: Column(
  61. mainAxisSize: MainAxisSize.min,
  62. children: [
  63. const SizedBox(height: 120),
  64. Text(state.errorMessage!,
  65. style: TextStyle(
  66. color: cs.onSurface.withAlpha(153), fontSize: 14)),
  67. const SizedBox(height: 16),
  68. ElevatedButton(
  69. onPressed: notifier.refresh, child: Text(AppLocalizations.of(context)!.retry)),
  70. ],
  71. ),
  72. ),
  73. ),
  74. );
  75. }
  76. if (state.records.isEmpty) {
  77. return AppRefreshIndicator(
  78. onRefresh: notifier.refresh,
  79. child: SingleChildScrollView(
  80. physics: const AlwaysScrollableScrollPhysics(),
  81. child: Center(
  82. child: Padding(
  83. padding: const EdgeInsets.symmetric(vertical: 120),
  84. child: Text(AppLocalizations.of(context)!.noRecord,
  85. style: TextStyle(
  86. color: cs.onSurface.withAlpha(120), fontSize: 14)),
  87. ),
  88. ),
  89. ),
  90. );
  91. }
  92. return AppRefreshIndicator(
  93. onRefresh: notifier.refresh,
  94. child: NotificationListener<ScrollNotification>(
  95. onNotification: (n) {
  96. if (n is ScrollEndNotification &&
  97. n.metrics.pixels >= n.metrics.maxScrollExtent - 100) {
  98. notifier.loadMore();
  99. }
  100. return false;
  101. },
  102. child: ListView.builder(
  103. padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
  104. itemCount: state.records.length + (state.hasMore ? 1 : 0),
  105. itemBuilder: (_, i) {
  106. if (i >= state.records.length) {
  107. return const Padding(
  108. padding: EdgeInsets.symmetric(vertical: 16),
  109. child: Center(
  110. child: CircularProgressIndicator(strokeWidth: 2)),
  111. );
  112. }
  113. final record = state.records[i];
  114. return _RecordItem(
  115. record: record,
  116. onCancel: record.canCancel
  117. ? () => notifier.cancelWithdraw(record.id)
  118. : null,
  119. );
  120. },
  121. ),
  122. ),
  123. );
  124. }
  125. }
  126. // ── 提币记录条目(对齐原型 record-item)────────────────────────────
  127. class _RecordItem extends StatelessWidget {
  128. const _RecordItem({
  129. required this.record,
  130. this.onCancel,
  131. });
  132. final WithdrawRecord record;
  133. final VoidCallback? onCancel;
  134. String _localizedStatus(AppLocalizations l10n) {
  135. switch (record.status) {
  136. case '0': return l10n.withdrawStatusReviewing;
  137. case '1': return l10n.withdrawStatusReleasing;
  138. case '2': return l10n.withdrawStatusFailed;
  139. case '3': return l10n.withdrawStatusSuccess;
  140. case '4': return l10n.withdrawStatusCancelled;
  141. default: return l10n.unknown;
  142. }
  143. }
  144. @override
  145. Widget build(BuildContext context) {
  146. final cs = Theme.of(context).colorScheme;
  147. final isDark = Theme.of(context).brightness == Brightness.dark;
  148. final isTransfer = record.isTransfer;
  149. final baseCoin = record.coin?.baseCoinDisplay.isNotEmpty == true
  150. ? record.coin!.baseCoinDisplay
  151. : (record.coin?.coinName.isNotEmpty == true
  152. ? record.coin!.coinName
  153. : 'USDT');
  154. final coinUnit = baseCoin;
  155. final l10n = AppLocalizations.of(context)!;
  156. final direction = isTransfer ? '$baseCoin ${l10n.internalTransfer}' : '$baseCoin ${l10n.onChainWithdraw}';
  157. final network = isTransfer ? l10n.internalLabel : (record.coin?.networkName ?? '');
  158. // 内部转账用 transferAmount(JSON: amount),链上提币用 amount(JSON: totalAmount)
  159. final rawAmount = isTransfer ? record.transferAmount : record.amount;
  160. final amountDouble = double.tryParse(rawAmount) ?? 0;
  161. final amountStr = formatAmount(amountDouble);
  162. Color statusColor;
  163. switch (record.status) {
  164. case '3':
  165. statusColor = AppColors.rise;
  166. break;
  167. case '2':
  168. statusColor = AppColors.fall;
  169. break;
  170. case '4':
  171. statusColor = cs.onSurface.withAlpha(120);
  172. break;
  173. default:
  174. statusColor = AppColors.warning;
  175. }
  176. return GestureDetector(
  177. behavior: HitTestBehavior.opaque,
  178. onTap: () => context.push(
  179. '/asset/withdraw/detail?isTransfer=$isTransfer',
  180. extra: record,
  181. ),
  182. child: Container(
  183. margin: const EdgeInsets.only(bottom: 10),
  184. decoration: BoxDecoration(
  185. color: isDark ? AppColors.darkBgSecondary : AppColors.lightBg,
  186. borderRadius: BorderRadius.circular(12),
  187. boxShadow: isDark
  188. ? null
  189. : [
  190. BoxShadow(
  191. color: Colors.black.withAlpha(13),
  192. blurRadius: 2,
  193. offset: const Offset(0, 1),
  194. )
  195. ],
  196. ),
  197. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
  198. child: Column(
  199. crossAxisAlignment: CrossAxisAlignment.start,
  200. children: [
  201. // ── 上行:方向 + 金额 ──────────────────────────────
  202. Row(
  203. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  204. children: [
  205. Text(
  206. direction,
  207. style: TextStyle(
  208. color: cs.onSurface,
  209. fontSize: 12,
  210. fontWeight: FontWeight.w600,
  211. ),
  212. ),
  213. Text(
  214. '-$amountStr $coinUnit',
  215. style: TextStyle(
  216. color: cs.onSurface,
  217. fontSize: 14,
  218. fontWeight: FontWeight.w700,
  219. ),
  220. ),
  221. ],
  222. ),
  223. const SizedBox(height: 6),
  224. // ── 下行:时间(左)+ 网络(居中)+ 状态(右)────────────
  225. Row(
  226. children: [
  227. Expanded(
  228. child: Text(
  229. record.createTime,
  230. style: TextStyle(
  231. color: cs.onSurface.withAlpha(100), fontSize: 10),
  232. ),
  233. ),
  234. if (network.isNotEmpty)
  235. Text(
  236. network,
  237. style: TextStyle(
  238. color: cs.onSurface.withAlpha(100), fontSize: 10),
  239. ),
  240. Expanded(
  241. child: Row(
  242. mainAxisAlignment: MainAxisAlignment.end,
  243. children: [
  244. Text(
  245. _localizedStatus(l10n),
  246. style: TextStyle(
  247. color: statusColor,
  248. fontSize: 10,
  249. fontWeight: FontWeight.w500,
  250. ),
  251. ),
  252. if (onCancel != null) ...[
  253. const SizedBox(width: 10),
  254. GestureDetector(
  255. onTap: onCancel,
  256. child: Text(
  257. AppLocalizations.of(context)!.cancel,
  258. style: TextStyle(
  259. color: AppColors.brand,
  260. fontSize: 10,
  261. fontWeight: FontWeight.w500,
  262. ),
  263. ),
  264. ),
  265. ],
  266. ],
  267. ),
  268. ),
  269. ],
  270. ),
  271. ],
  272. ),
  273. ),
  274. );
  275. }
  276. }