spot_history_screen.dart 30 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import '../../../core/l10n/app_localizations.dart';
  4. import '../../../core/network/dio_client.dart';
  5. import '../../../core/theme/app_colors.dart';
  6. import '../../../core/utils/number_format.dart';
  7. import '../../../data/services/spot_service.dart';
  8. import '../../widgets/common/app_tab_bar.dart';
  9. import '../../../providers/spot_provider.dart';
  10. import '../../../providers/spot_symbol_cache_provider.dart';
  11. /// 现货历史记录:历史委托 / 历史成交
  12. class SpotHistoryScreen extends ConsumerStatefulWidget {
  13. const SpotHistoryScreen({super.key, required this.symbol});
  14. final String symbol;
  15. @override
  16. ConsumerState<SpotHistoryScreen> createState() => _SpotHistoryScreenState();
  17. }
  18. class _SpotHistoryScreenState extends ConsumerState<SpotHistoryScreen>
  19. with SingleTickerProviderStateMixin {
  20. late TabController _tabCtrl;
  21. late PageController _pageCtrl;
  22. static const int _pageSize = 20;
  23. static const int _maxSkipEmptyPages = 12;
  24. _KindFilter _kind = _KindFilter.all;
  25. _StatusFilter _status = _StatusFilter.all;
  26. _TimeFilter _time = _TimeFilter.all;
  27. /// true:仅当前路由交易对;false:全部交易对
  28. bool _useCurrentSymbolOnly = false;
  29. /// 是否已在交易对筛选里点选过;未点选时 pill 展示原型「交易对」
  30. bool _pairFilterTouched = false;
  31. final _ordersScroll = ScrollController();
  32. final List<SpotOrder> _orders = [];
  33. int _nextOrdersPage = 1;
  34. bool _ordersHasMore = true;
  35. bool _ordersLoading = false;
  36. int _ordersRawLoaded = 0;
  37. final _tradesScroll = ScrollController();
  38. final List<SpotOrder> _trades = [];
  39. int _nextTradesPage = 1;
  40. bool _tradesHasMore = true;
  41. bool _tradesLoading = false;
  42. int _tradesRawLoaded = 0;
  43. @override
  44. void initState() {
  45. super.initState();
  46. _tabCtrl = TabController(length: 2, vsync: this);
  47. _pageCtrl = PageController();
  48. // Tab 点击 → 驱动 PageView 动画(与合约历史页一致)
  49. _tabCtrl.addListener(() {
  50. if (!mounted) return;
  51. if (!_tabCtrl.indexIsChanging) return;
  52. _pageCtrl.animateToPage(
  53. _tabCtrl.index,
  54. duration: const Duration(milliseconds: 280),
  55. curve: Curves.easeOut,
  56. );
  57. });
  58. // 横向滑动 PageView → 指示器 offset 平滑插值
  59. _pageCtrl.addListener(() {
  60. if (!mounted) return;
  61. if (!_pageCtrl.hasClients) return;
  62. if (_tabCtrl.indexIsChanging) return;
  63. final page = _pageCtrl.page!;
  64. final offset = page - _tabCtrl.index;
  65. if (offset.abs() <= 1.0) {
  66. _tabCtrl.offset = offset.clamp(-1.0, 1.0);
  67. }
  68. });
  69. _ordersScroll.addListener(_onOrdersScroll);
  70. _tradesScroll.addListener(_onTradesScroll);
  71. _refreshOrders();
  72. }
  73. void _resetFiltersToDefault() {
  74. _kind = _KindFilter.all;
  75. _status = _StatusFilter.all;
  76. _time = _TimeFilter.all;
  77. _useCurrentSymbolOnly = false;
  78. _pairFilterTouched = false;
  79. }
  80. /// PageView 切换完成:重置筛选并刷新当前页数据
  81. void _onPageCommitted(int index) {
  82. if (!mounted) return;
  83. _resetFiltersToDefault();
  84. if (index == 0) {
  85. if (_ordersScroll.hasClients) {
  86. _ordersScroll.jumpTo(0);
  87. }
  88. _refreshOrders();
  89. } else {
  90. if (_tradesScroll.hasClients) {
  91. _tradesScroll.jumpTo(0);
  92. }
  93. _refreshTrades();
  94. }
  95. }
  96. void _onOrdersScroll() {
  97. if (_ordersScroll.position.pixels >=
  98. _ordersScroll.position.maxScrollExtent - 200) {
  99. _loadMoreOrders();
  100. }
  101. }
  102. void _onTradesScroll() {
  103. if (_tradesScroll.position.pixels >=
  104. _tradesScroll.position.maxScrollExtent - 200) {
  105. _loadMoreTrades();
  106. }
  107. }
  108. @override
  109. void dispose() {
  110. _tabCtrl.dispose();
  111. _pageCtrl.dispose();
  112. _ordersScroll.dispose();
  113. _tradesScroll.dispose();
  114. super.dispose();
  115. }
  116. String get _apiSymbol => widget.symbol
  117. .replaceAll('/', '')
  118. .replaceAll('-', '')
  119. .toUpperCase();
  120. /// 交易对 pill:未选过展示「交易对」;选过后展示「全部交易对」或具体币对
  121. String _pairChipDisplay(AppLocalizations l10n) {
  122. if (!_pairFilterTouched) return l10n.spotHistoryFilterSymbol;
  123. return _useCurrentSymbolOnly ? _apiSymbol : l10n.spotFilterSymbolAllPairs;
  124. }
  125. /// 委托类型:全部时展示「全部委托」,否则展示市价/限价(与原型一致)
  126. String _kindChipDisplay(AppLocalizations l10n) {
  127. switch (_kind) {
  128. case _KindFilter.all:
  129. return l10n.spotFilterEntrustAll;
  130. case _KindFilter.market:
  131. return l10n.spotFilterKindMarket;
  132. case _KindFilter.limit:
  133. return l10n.spotFilterKindLimit;
  134. }
  135. }
  136. String _statusChipDisplay(AppLocalizations l10n) {
  137. switch (_status) {
  138. case _StatusFilter.all:
  139. return l10n.spotHistoryFilterStatus;
  140. case _StatusFilter.completed:
  141. return l10n.spotOrderStatusCompleted;
  142. case _StatusFilter.partial:
  143. return l10n.spotOrderStatusPartialFilled;
  144. case _StatusFilter.cancelled:
  145. return l10n.spotOrderStatusCancelled;
  146. }
  147. }
  148. String _timeChipDisplay(AppLocalizations l10n) {
  149. switch (_time) {
  150. case _TimeFilter.all:
  151. return l10n.spotHistoryFilterTime;
  152. case _TimeFilter.d7:
  153. return l10n.spotFilterTime7d;
  154. case _TimeFilter.d30:
  155. return l10n.spotFilterTime30d;
  156. }
  157. }
  158. int? get _ctimeBeginMs {
  159. final now = DateTime.now();
  160. switch (_time) {
  161. case _TimeFilter.all:
  162. return null;
  163. case _TimeFilter.d7:
  164. return now.subtract(const Duration(days: 7)).millisecondsSinceEpoch;
  165. case _TimeFilter.d30:
  166. return now.subtract(const Duration(days: 30)).millisecondsSinceEpoch;
  167. }
  168. }
  169. /// 历史委托 Tab:传 type 给服务端(并做客户端二次过滤)
  170. int? _apiTypeOrders() {
  171. switch (_kind) {
  172. case _KindFilter.all:
  173. return null;
  174. case _KindFilter.limit:
  175. return 1;
  176. case _KindFilter.market:
  177. return 2;
  178. }
  179. }
  180. /// 历史成交 Tab:不按委托类型筛(与原型一致),仅客户端可按状态/时间筛
  181. int? _apiTypeTrades() => null;
  182. int? _apiStatus() {
  183. switch (_status) {
  184. case _StatusFilter.all:
  185. case _StatusFilter.partial:
  186. return null;
  187. case _StatusFilter.completed:
  188. return 2;
  189. case _StatusFilter.cancelled:
  190. return 4;
  191. }
  192. }
  193. bool _matchesFilters(SpotOrder o, {required bool tradesTab}) {
  194. if (_useCurrentSymbolOnly) {
  195. final os = o.symbol.replaceAll('/', '').toUpperCase();
  196. if (os != _apiSymbol) return false;
  197. }
  198. if (!tradesTab) {
  199. switch (_kind) {
  200. case _KindFilter.all:
  201. break;
  202. case _KindFilter.limit:
  203. if (o.typeCode != 1) return false;
  204. case _KindFilter.market:
  205. if (o.typeCode != 2) return false;
  206. }
  207. }
  208. switch (_status) {
  209. case _StatusFilter.all:
  210. break;
  211. case _StatusFilter.completed:
  212. if (o.statusCode != 2) return false;
  213. case _StatusFilter.partial:
  214. final isPartial = o.statusCode == 3 ||
  215. ((o.statusCode == 4 || o.statusCode == 5 || o.statusCode == 6) &&
  216. o.tradedAmount > 0 &&
  217. o.tradedAmount < o.amount);
  218. if (!isPartial) return false;
  219. case _StatusFilter.cancelled:
  220. final partial = o.tradedAmount > 0 && o.tradedAmount < o.amount;
  221. final isCancelState =
  222. o.statusCode == 4 || o.statusCode == 5 || o.statusCode == 6;
  223. if (!isCancelState || partial) return false;
  224. }
  225. final begin = _ctimeBeginMs;
  226. if (begin != null) {
  227. final t = o.createTime;
  228. if (t != null && t.millisecondsSinceEpoch < begin) return false;
  229. }
  230. return true;
  231. }
  232. Future<void> _refreshOrders() async {
  233. setState(() {
  234. _ordersLoading = true;
  235. _nextOrdersPage = 1;
  236. _ordersHasMore = true;
  237. _orders.clear();
  238. _ordersRawLoaded = 0;
  239. });
  240. await _fetchOrdersPage();
  241. }
  242. Future<void> _loadMoreOrders() async {
  243. if (_ordersLoading || !_ordersHasMore) return;
  244. setState(() => _ordersLoading = true);
  245. await _fetchOrdersPage();
  246. }
  247. Future<void> _fetchOrdersPage() async {
  248. try {
  249. final svc = SpotService(ref.read(dioClientProvider));
  250. int skipped = 0;
  251. while (skipped <= _maxSkipEmptyPages && _ordersHasMore) {
  252. final data = await svc.getHistoryOrders(
  253. page: _nextOrdersPage,
  254. size: _pageSize,
  255. symbol: _useCurrentSymbolOnly ? _apiSymbol : null,
  256. type: _apiTypeOrders(),
  257. status: _apiStatus(),
  258. ctimeBegin: _ctimeBeginMs,
  259. );
  260. final list = _extractRecordMaps(data);
  261. final total = _parsePageTotal(data);
  262. if (list.isEmpty) {
  263. _ordersHasMore = false;
  264. break;
  265. }
  266. _ordersRawLoaded += list.length;
  267. final filtered = list
  268. .map(SpotOrder.fromJson)
  269. .where((o) => _matchesFilters(o, tradesTab: false))
  270. .toList();
  271. _orders.addAll(filtered);
  272. _nextOrdersPage++;
  273. _ordersHasMore = total > _ordersRawLoaded ||
  274. (total == 0 && list.length >= _pageSize);
  275. if (filtered.isNotEmpty || !_ordersHasMore) break;
  276. skipped++;
  277. }
  278. } catch (_) {
  279. _ordersHasMore = false;
  280. } finally {
  281. if (mounted) setState(() => _ordersLoading = false);
  282. }
  283. }
  284. Future<void> _refreshTrades() async {
  285. setState(() {
  286. _tradesLoading = true;
  287. _nextTradesPage = 1;
  288. _tradesHasMore = true;
  289. _trades.clear();
  290. _tradesRawLoaded = 0;
  291. });
  292. await _fetchTradesPage();
  293. }
  294. Future<void> _loadMoreTrades() async {
  295. if (_tradesLoading || !_tradesHasMore) return;
  296. setState(() => _tradesLoading = true);
  297. await _fetchTradesPage();
  298. }
  299. Future<void> _fetchTradesPage() async {
  300. try {
  301. final svc = SpotService(ref.read(dioClientProvider));
  302. int skipped = 0;
  303. while (skipped <= _maxSkipEmptyPages && _tradesHasMore) {
  304. final data = await svc.getTrades(
  305. page: _nextTradesPage,
  306. size: _pageSize,
  307. symbol: _useCurrentSymbolOnly ? _apiSymbol : null,
  308. type: _apiTypeTrades(),
  309. status: _apiStatus(),
  310. ctimeBegin: _ctimeBeginMs,
  311. );
  312. final list = _extractRecordMaps(data);
  313. final total = _parsePageTotal(data);
  314. if (list.isEmpty) {
  315. _tradesHasMore = false;
  316. break;
  317. }
  318. _tradesRawLoaded += list.length;
  319. final filtered = list
  320. .map(SpotOrder.fromJson)
  321. .where((o) => _matchesFilters(o, tradesTab: true))
  322. .toList();
  323. _trades.addAll(filtered);
  324. _nextTradesPage++;
  325. _tradesHasMore = total > _tradesRawLoaded ||
  326. (total == 0 && list.length >= _pageSize);
  327. if (filtered.isNotEmpty || !_tradesHasMore) break;
  328. skipped++;
  329. }
  330. } catch (_) {
  331. _tradesHasMore = false;
  332. } finally {
  333. if (mounted) setState(() => _tradesLoading = false);
  334. }
  335. }
  336. Future<void> _applyFilters() async {
  337. await Future.wait([_refreshOrders(), _refreshTrades()]);
  338. }
  339. @override
  340. Widget build(BuildContext context) {
  341. final l10n = AppLocalizations.of(context)!;
  342. return Scaffold(
  343. appBar: AppBar(
  344. title: Text(l10n.spotHistoryRecordsTitle),
  345. bottom: AppTabBar(
  346. controller: _tabCtrl,
  347. tabs: [
  348. Tab(text: l10n.spotHistoryTitle),
  349. Tab(text: l10n.spotHistoryTradesTab),
  350. ],
  351. ),
  352. ),
  353. body: PageView(
  354. controller: _pageCtrl,
  355. physics: const BouncingScrollPhysics(
  356. parent: AlwaysScrollableScrollPhysics(),
  357. ),
  358. onPageChanged: (index) {
  359. if (!mounted) return;
  360. if (!_tabCtrl.indexIsChanging) {
  361. _tabCtrl.index = index;
  362. }
  363. _onPageCommitted(index);
  364. },
  365. children: [
  366. _OrdersTabBody(
  367. scrollController: _ordersScroll,
  368. orders: _orders,
  369. loading: _ordersLoading,
  370. hasMore: _ordersHasMore,
  371. onRefresh: _refreshOrders,
  372. tradesMode: false,
  373. pairText: _pairChipDisplay(l10n),
  374. kindText: _kindChipDisplay(l10n),
  375. statusText: _statusChipDisplay(l10n),
  376. timeText: _timeChipDisplay(l10n),
  377. onPickSymbol: () => _pickSymbol(l10n),
  378. onPickKind: () => _pickKind(l10n),
  379. onPickStatus: () => _pickStatus(l10n),
  380. onPickTime: () => _pickTime(l10n),
  381. ),
  382. _OrdersTabBody(
  383. scrollController: _tradesScroll,
  384. orders: _trades,
  385. loading: _tradesLoading,
  386. hasMore: _tradesHasMore,
  387. onRefresh: _refreshTrades,
  388. tradesMode: true,
  389. pairText: _pairChipDisplay(l10n),
  390. kindText: null,
  391. statusText: _statusChipDisplay(l10n),
  392. timeText: _timeChipDisplay(l10n),
  393. onPickSymbol: () => _pickSymbol(l10n),
  394. onPickKind: null,
  395. onPickStatus: () => _pickStatus(l10n),
  396. onPickTime: () => _pickTime(l10n),
  397. ),
  398. ],
  399. ),
  400. );
  401. }
  402. Future<void> _pickSymbol(AppLocalizations l10n) async {
  403. final cs = Theme.of(context).colorScheme;
  404. final v = await showModalBottomSheet<bool>(
  405. context: context,
  406. useRootNavigator: true,
  407. backgroundColor: cs.surface,
  408. shape: const RoundedRectangleBorder(
  409. borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
  410. ),
  411. builder: (ctx) => _SimpleSheet<bool>(
  412. selected: _useCurrentSymbolOnly,
  413. options: [
  414. (false, l10n.spotFilterSymbolAllPairs),
  415. (true, _apiSymbol),
  416. ],
  417. ),
  418. );
  419. if (v != null && mounted) {
  420. setState(() {
  421. _pairFilterTouched = true;
  422. _useCurrentSymbolOnly = v;
  423. });
  424. await _applyFilters();
  425. }
  426. }
  427. Future<void> _pickKind(AppLocalizations l10n) async {
  428. final cs = Theme.of(context).colorScheme;
  429. final v = await showModalBottomSheet<_KindFilter>(
  430. context: context,
  431. useRootNavigator: true,
  432. backgroundColor: cs.surface,
  433. shape: const RoundedRectangleBorder(
  434. borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
  435. ),
  436. builder: (ctx) => _SimpleSheet<_KindFilter>(
  437. selected: _kind,
  438. options: [
  439. (_KindFilter.all, l10n.spotFilterKindAll),
  440. (_KindFilter.market, l10n.spotFilterKindMarket),
  441. (_KindFilter.limit, l10n.spotFilterKindLimit),
  442. ],
  443. ),
  444. );
  445. if (v != null && mounted) {
  446. setState(() => _kind = v);
  447. await _applyFilters();
  448. }
  449. }
  450. Future<void> _pickStatus(AppLocalizations l10n) async {
  451. final cs = Theme.of(context).colorScheme;
  452. final v = await showModalBottomSheet<_StatusFilter>(
  453. context: context,
  454. useRootNavigator: true,
  455. backgroundColor: cs.surface,
  456. shape: const RoundedRectangleBorder(
  457. borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
  458. ),
  459. builder: (ctx) => _SimpleSheet<_StatusFilter>(
  460. selected: _status,
  461. options: [
  462. (_StatusFilter.all, l10n.spotFilterStatusAll),
  463. (_StatusFilter.completed, l10n.spotOrderStatusCompleted),
  464. (_StatusFilter.partial, l10n.spotOrderStatusPartialFilled),
  465. (_StatusFilter.cancelled, l10n.spotOrderStatusCancelled),
  466. ],
  467. ),
  468. );
  469. if (v != null && mounted) {
  470. setState(() => _status = v);
  471. await _applyFilters();
  472. }
  473. }
  474. Future<void> _pickTime(AppLocalizations l10n) async {
  475. final cs = Theme.of(context).colorScheme;
  476. final v = await showModalBottomSheet<_TimeFilter>(
  477. context: context,
  478. useRootNavigator: true,
  479. backgroundColor: cs.surface,
  480. shape: const RoundedRectangleBorder(
  481. borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
  482. ),
  483. builder: (ctx) => _SimpleSheet<_TimeFilter>(
  484. selected: _time,
  485. options: [
  486. (_TimeFilter.all, l10n.spotFilterTimeAll),
  487. (_TimeFilter.d7, l10n.spotFilterTime7d),
  488. (_TimeFilter.d30, l10n.spotFilterTime30d),
  489. ],
  490. ),
  491. );
  492. if (v != null && mounted) {
  493. setState(() => _time = v);
  494. await _applyFilters();
  495. }
  496. }
  497. }
  498. List<Map<String, dynamic>> _extractRecordMaps(Map<String, dynamic> data) {
  499. final raw = data['records'] ?? data['list'];
  500. if (raw is! List) return [];
  501. return raw.whereType<Map<String, dynamic>>().toList();
  502. }
  503. int _parsePageTotal(Map<String, dynamic> data) {
  504. final t = data['total'] ?? data['totalRow'] ?? data['totalCount'];
  505. if (t is int) return t;
  506. if (t is num) return t.toInt();
  507. return 0;
  508. }
  509. enum _KindFilter { all, market, limit }
  510. enum _StatusFilter { all, completed, partial, cancelled }
  511. enum _TimeFilter { all, d7, d30 }
  512. /// 选项样式与合约页「仓位模式」底部弹窗一致:圆角描边、选中加粗 + 对勾
  513. class _SimpleSheet<T> extends StatelessWidget {
  514. const _SimpleSheet({
  515. super.key,
  516. required this.selected,
  517. required this.options,
  518. });
  519. final T selected;
  520. final List<(T, String)> options;
  521. @override
  522. Widget build(BuildContext context) {
  523. final cs = Theme.of(context).colorScheme;
  524. return SafeArea(
  525. child: SingleChildScrollView(
  526. child: Padding(
  527. padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
  528. child: Column(
  529. mainAxisSize: MainAxisSize.min,
  530. crossAxisAlignment: CrossAxisAlignment.stretch,
  531. children: [
  532. for (var i = 0; i < options.length; i++) ...[
  533. if (i > 0) const SizedBox(height: 10),
  534. GestureDetector(
  535. onTap: () => Navigator.pop(context, options[i].$1),
  536. child: Container(
  537. width: double.infinity,
  538. padding:
  539. const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
  540. decoration: BoxDecoration(
  541. border: Border.all(
  542. color: options[i].$1 == selected
  543. ? cs.onSurface
  544. : cs.outline,
  545. width: options[i].$1 == selected ? 1.5 : 1,
  546. ),
  547. borderRadius: BorderRadius.circular(10),
  548. color: Colors.transparent,
  549. ),
  550. child: Row(
  551. children: [
  552. Expanded(
  553. child: Text(
  554. options[i].$2,
  555. style: TextStyle(
  556. color: cs.onSurface,
  557. fontWeight: FontWeight.w600,
  558. fontSize: 14,
  559. ),
  560. ),
  561. ),
  562. if (options[i].$1 == selected)
  563. Icon(Icons.check_circle,
  564. size: 16, color: cs.onSurface),
  565. ],
  566. ),
  567. ),
  568. ),
  569. ],
  570. ],
  571. ),
  572. ),
  573. ),
  574. );
  575. }
  576. }
  577. class _OrdersTabBody extends StatelessWidget {
  578. const _OrdersTabBody({
  579. required this.scrollController,
  580. required this.orders,
  581. required this.loading,
  582. required this.hasMore,
  583. required this.onRefresh,
  584. required this.tradesMode,
  585. required this.pairText,
  586. this.kindText,
  587. required this.statusText,
  588. required this.timeText,
  589. required this.onPickSymbol,
  590. this.onPickKind,
  591. required this.onPickStatus,
  592. required this.onPickTime,
  593. });
  594. final ScrollController scrollController;
  595. final List<SpotOrder> orders;
  596. final bool loading;
  597. final bool hasMore;
  598. final Future<void> Function() onRefresh;
  599. final bool tradesMode;
  600. final String pairText;
  601. final String? kindText;
  602. final String statusText;
  603. final String timeText;
  604. final VoidCallback onPickSymbol;
  605. final VoidCallback? onPickKind;
  606. final VoidCallback onPickStatus;
  607. final VoidCallback onPickTime;
  608. @override
  609. Widget build(BuildContext context) {
  610. final cs = Theme.of(context).colorScheme;
  611. final l10n = AppLocalizations.of(context)!;
  612. final showKind = !tradesMode && kindText != null && onPickKind != null;
  613. return Column(
  614. crossAxisAlignment: CrossAxisAlignment.stretch,
  615. children: [
  616. Padding(
  617. padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
  618. child: Row(
  619. children: [
  620. Expanded(
  621. child: _DropdownFilterPill(
  622. text: pairText,
  623. onTap: onPickSymbol,
  624. ),
  625. ),
  626. const SizedBox(width: 8),
  627. if (showKind) ...[
  628. Expanded(
  629. child: _DropdownFilterPill(
  630. text: kindText!,
  631. onTap: onPickKind!,
  632. ),
  633. ),
  634. const SizedBox(width: 8),
  635. ],
  636. Expanded(
  637. child: _DropdownFilterPill(
  638. text: statusText,
  639. onTap: onPickStatus,
  640. ),
  641. ),
  642. const SizedBox(width: 8),
  643. Expanded(
  644. child: _DropdownFilterPill(
  645. text: timeText,
  646. onTap: onPickTime,
  647. ),
  648. ),
  649. ],
  650. ),
  651. ),
  652. Expanded(
  653. child: RefreshIndicator(
  654. onRefresh: onRefresh,
  655. child: orders.isEmpty && !loading
  656. ? ListView(
  657. physics: const AlwaysScrollableScrollPhysics(),
  658. children: [
  659. const SizedBox(height: 120),
  660. Center(
  661. child: Text(
  662. tradesMode ? l10n.noTradeData : l10n.noHistoryOrders,
  663. style: TextStyle(
  664. color: cs.onSurface.withAlpha(140),
  665. fontSize: 14,
  666. ),
  667. ),
  668. ),
  669. ],
  670. )
  671. : ListView.builder(
  672. controller: scrollController,
  673. physics: const AlwaysScrollableScrollPhysics(),
  674. itemCount: orders.length + 1,
  675. itemBuilder: (_, i) {
  676. if (i == orders.length) {
  677. if (loading) {
  678. return const Padding(
  679. padding: EdgeInsets.all(16),
  680. child: Center(child: CircularProgressIndicator()),
  681. );
  682. }
  683. if (!hasMore) {
  684. return Padding(
  685. padding: const EdgeInsets.symmetric(vertical: 16),
  686. child: Center(
  687. child: Text(
  688. l10n.noMoreData,
  689. style: TextStyle(
  690. color: cs.onSurface.withAlpha(120),
  691. fontSize: 12,
  692. ),
  693. ),
  694. ),
  695. );
  696. }
  697. return const SizedBox.shrink();
  698. }
  699. return _HistoryCard(
  700. order: orders[i],
  701. tradesMode: tradesMode,
  702. );
  703. },
  704. ),
  705. ),
  706. ),
  707. ],
  708. );
  709. }
  710. }
  711. /// 原型:浅灰圆角条,单行文案 + 右侧下拉箭头;默认展示维度名,选中后展示选项文案
  712. class _DropdownFilterPill extends StatelessWidget {
  713. const _DropdownFilterPill({
  714. required this.text,
  715. required this.onTap,
  716. });
  717. final String text;
  718. final VoidCallback onTap;
  719. @override
  720. Widget build(BuildContext context) {
  721. final cs = Theme.of(context).colorScheme;
  722. final isDark = Theme.of(context).brightness == Brightness.dark;
  723. final bg = isDark
  724. ? cs.surfaceContainerHighest.withAlpha(120)
  725. : const Color(0xFFF2F2F4);
  726. return Material(
  727. color: bg,
  728. borderRadius: BorderRadius.circular(8),
  729. child: InkWell(
  730. onTap: onTap,
  731. borderRadius: BorderRadius.circular(8),
  732. child: Padding(
  733. padding: const EdgeInsets.fromLTRB(8, 10, 4, 10),
  734. child: Row(
  735. children: [
  736. Expanded(
  737. child: Text(
  738. text,
  739. maxLines: 1,
  740. overflow: TextOverflow.ellipsis,
  741. style: TextStyle(
  742. fontSize: 13,
  743. color: cs.onSurface,
  744. fontWeight: FontWeight.w500,
  745. ),
  746. ),
  747. ),
  748. Icon(
  749. Icons.keyboard_arrow_down,
  750. size: 20,
  751. color: cs.onSurface.withAlpha(130),
  752. ),
  753. ],
  754. ),
  755. ),
  756. ),
  757. );
  758. }
  759. }
  760. class _HistoryCard extends ConsumerWidget {
  761. const _HistoryCard({
  762. required this.order,
  763. required this.tradesMode,
  764. });
  765. final SpotOrder order;
  766. final bool tradesMode;
  767. @override
  768. Widget build(BuildContext context, WidgetRef ref) {
  769. final cs = Theme.of(context).colorScheme;
  770. final l10n = AppLocalizations.of(context)!;
  771. final base = _spotBaseAsset(order.symbol);
  772. final quote = 'USDT';
  773. final sym = order.symbol.replaceAll('/', '').toUpperCase();
  774. final precision = ref.watch(
  775. spotSymbolCacheProvider.select((map) => map[sym]));
  776. final pricePre = precision?.pricePre ?? 2;
  777. final volPre = precision?.volumePre ?? 4;
  778. final statusLabel = _historyStatusLabel(order, l10n);
  779. final statusColor = _historyStatusColor(cs, order);
  780. final sideColor =
  781. order.side == SpotSide.buy ? AppColors.rise : AppColors.fall;
  782. final typeSide = _orderTypeSideLine(order, l10n);
  783. return Container(
  784. padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
  785. decoration: BoxDecoration(
  786. border: Border(
  787. bottom: BorderSide(color: cs.outline.withAlpha(40)),
  788. ),
  789. ),
  790. child: Column(
  791. crossAxisAlignment: CrossAxisAlignment.start,
  792. children: [
  793. Row(
  794. children: [
  795. Expanded(
  796. child: Text(
  797. order.symbol.replaceAll('/', ''),
  798. style: TextStyle(
  799. color: cs.onSurface,
  800. fontSize: 15,
  801. fontWeight: FontWeight.w700,
  802. ),
  803. ),
  804. ),
  805. Text(
  806. statusLabel,
  807. style: TextStyle(
  808. color: statusColor,
  809. fontSize: 13,
  810. fontWeight: FontWeight.w600,
  811. ),
  812. ),
  813. ],
  814. ),
  815. const SizedBox(height: 6),
  816. Text(
  817. typeSide,
  818. style: TextStyle(
  819. color: sideColor,
  820. fontSize: 13,
  821. fontWeight: FontWeight.w600,
  822. ),
  823. ),
  824. const SizedBox(height: 10),
  825. if (tradesMode) ...[
  826. _kvRow(
  827. context,
  828. '${l10n.tradedDealAmount}($base)',
  829. '${formatAmount(order.tradedAmount, decimals: volPre)} / ${formatAmount(order.amount, decimals: volPre)}',
  830. ),
  831. const SizedBox(height: 6),
  832. _kvRow(
  833. context,
  834. '${l10n.spotAvgPrice}($quote)',
  835. order.avgPrice > 0
  836. ? formatAmount(order.avgPrice, decimals: pricePre)
  837. : '--',
  838. ),
  839. const SizedBox(height: 6),
  840. _kvRow(
  841. context,
  842. l10n.spotDealTime,
  843. order.createTime != null
  844. ? _fmtTime(order.createTime!)
  845. : '--',
  846. ),
  847. ] else ...[
  848. _kvRow(
  849. context,
  850. '${l10n.spotEntrustQuantity}($base)',
  851. formatAmount(order.amount, decimals: volPre),
  852. ),
  853. const SizedBox(height: 6),
  854. _kvRow(
  855. context,
  856. '${l10n.orderPriceLabel}($quote)',
  857. order.type == SpotOrderType.market && order.price <= 0
  858. ? l10n.marketPrice
  859. : formatAmount(order.price, decimals: pricePre),
  860. ),
  861. const SizedBox(height: 6),
  862. _kvRow(
  863. context,
  864. l10n.orderTime,
  865. order.createTime != null
  866. ? _fmtTime(order.createTime!)
  867. : '--',
  868. ),
  869. ],
  870. ],
  871. ),
  872. );
  873. }
  874. }
  875. Widget _kvRow(BuildContext context, String k, String v) {
  876. final cs = Theme.of(context).colorScheme;
  877. return Row(
  878. crossAxisAlignment: CrossAxisAlignment.start,
  879. children: [
  880. Expanded(
  881. flex: 12,
  882. child: Text(
  883. k,
  884. style: TextStyle(
  885. color: cs.onSurface.withAlpha(140),
  886. fontSize: 12,
  887. ),
  888. ),
  889. ),
  890. Expanded(
  891. flex: 13,
  892. child: Text(
  893. v,
  894. textAlign: TextAlign.right,
  895. style: TextStyle(
  896. color: cs.onSurface,
  897. fontSize: 13,
  898. fontWeight: FontWeight.w500,
  899. fontFeatures: const [FontFeature.tabularFigures()],
  900. ),
  901. ),
  902. ),
  903. ],
  904. );
  905. }
  906. String _fmtTime(DateTime t) {
  907. final x = t.toLocal();
  908. String two(int n) => n.toString().padLeft(2, '0');
  909. return '${x.year}-${two(x.month)}-${two(x.day)} ${two(x.hour)}:${two(x.minute)}:${two(x.second)}';
  910. }
  911. String _spotBaseAsset(String symbolUpper) {
  912. final s = symbolUpper.replaceAll('/', '').toUpperCase();
  913. const quotes = ['USDT', 'USDC', 'BUSD', 'TUSD'];
  914. for (final q in quotes) {
  915. if (s.endsWith(q) && s.length > q.length) {
  916. return s.substring(0, s.length - q.length);
  917. }
  918. }
  919. return s;
  920. }
  921. Color _historyStatusColor(ColorScheme cs, SpotOrder o) {
  922. if (o.statusCode == 2) return AppColors.rise;
  923. return cs.onSurface;
  924. }
  925. String _historyStatusLabel(SpotOrder o, AppLocalizations l10n) {
  926. if (o.statusCode == 2) return l10n.spotOrderStatusCompleted;
  927. if (o.statusCode == 3) return l10n.spotOrderStatusPartialFilled;
  928. if (o.statusCode == 4 || o.statusCode == 5 || o.statusCode == 6) {
  929. if (o.tradedAmount > 0 && o.tradedAmount < o.amount) {
  930. return l10n.spotOrderStatusPartialFilled;
  931. }
  932. return l10n.spotOrderStatusCancelled;
  933. }
  934. return o.status;
  935. }
  936. String _orderTypeSideLine(SpotOrder o, AppLocalizations l10n) {
  937. final type = switch (o.type) {
  938. SpotOrderType.market => l10n.marketOrder,
  939. SpotOrderType.conditionalMarket => l10n.spotOrderTypeMarketConditional,
  940. _ => l10n.limitOrder,
  941. };
  942. final side = o.side == SpotSide.buy ? l10n.buyAction : l10n.sellAction;
  943. return '$type / $side';
  944. }