market_screen.dart 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826
  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/constants/market_list_layout.dart';
  5. import '../../../core/l10n/app_localizations.dart';
  6. import '../../../core/theme/app_colors.dart';
  7. import '../../../core/utils/number_format.dart';
  8. import '../../../core/utils/symbol_display.dart';
  9. import '../../../providers/market_provider.dart';
  10. import '../../widgets/common/app_refresh_indicator.dart';
  11. import '../../widgets/common/app_shimmer.dart';
  12. import '../../widgets/common/coin_icon.dart';
  13. /// 行情页列表点击:按当前选中的「永续 / 现货」Tab 跳转对应 K 线详情。
  14. void _pushMarketQuoteDetail(
  15. BuildContext context,
  16. WidgetRef ref,
  17. String rawSymbol,
  18. ) {
  19. final mode = ref.read(marketProvider).mode;
  20. final sym = rawSymbol.replaceAll('/', '').replaceAll('-', '').toUpperCase();
  21. if (sym.isEmpty) return;
  22. final path =
  23. mode == MarketMode.futures ? '/market/futures/$sym' : '/market/spot/$sym';
  24. context.push(path);
  25. }
  26. class MarketScreen extends ConsumerWidget {
  27. const MarketScreen({super.key});
  28. @override
  29. Widget build(BuildContext context, WidgetRef ref) {
  30. final mode = ref.watch(marketProvider.select((s) => s.mode));
  31. final isLoading = ref.watch(marketProvider.select(
  32. (s) => mode == MarketMode.futures ? s.isLoading : s.spotLoading));
  33. return Scaffold(
  34. body: SafeArea(
  35. child: Column(
  36. children: [
  37. _SearchBar(
  38. onChanged: ref.read(marketProvider.notifier).setSearch,
  39. ),
  40. // ── 现货 / 合约 Tab 切换 ──────────────────────
  41. _MarketTabBar(
  42. mode: mode,
  43. onChanged: ref.read(marketProvider.notifier).setMode,
  44. ),
  45. Expanded(
  46. child: isLoading
  47. ? const _MarketShimmer()
  48. : mode == MarketMode.futures
  49. ? const _MarketList()
  50. : const _SpotMarketList(),
  51. ),
  52. ],
  53. ),
  54. ),
  55. );
  56. }
  57. }
  58. // ── 现货/合约 Tab 栏 ──────────────────────────────────────
  59. class _MarketTabBar extends StatelessWidget {
  60. const _MarketTabBar({required this.mode, required this.onChanged});
  61. final MarketMode mode;
  62. final ValueChanged<MarketMode> onChanged;
  63. @override
  64. Widget build(BuildContext context) {
  65. final cs = Theme.of(context).colorScheme;
  66. final l10n = AppLocalizations.of(context)!;
  67. Widget tab(String label, MarketMode value) {
  68. final active = mode == value;
  69. return Expanded(
  70. child: GestureDetector(
  71. onTap: () => onChanged(value),
  72. behavior: HitTestBehavior.opaque,
  73. child: Container(
  74. padding: const EdgeInsets.symmetric(vertical: 10),
  75. decoration: BoxDecoration(
  76. border: Border(
  77. bottom: BorderSide(
  78. color: active ? AppColors.brand : Colors.transparent,
  79. width: 2,
  80. ),
  81. ),
  82. ),
  83. alignment: Alignment.center,
  84. child: Text(
  85. label,
  86. style: TextStyle(
  87. color: active ? cs.onSurface : cs.onSurface.withAlpha(140),
  88. fontSize: 14,
  89. fontWeight: active ? FontWeight.w600 : FontWeight.w400,
  90. ),
  91. ),
  92. ),
  93. ),
  94. );
  95. }
  96. return Padding(
  97. padding: const EdgeInsets.symmetric(horizontal: 16),
  98. child: Row(
  99. children: [
  100. tab(l10n.perpetualFutures, MarketMode.futures),
  101. tab(l10n.spotTab, MarketMode.spot),
  102. ],
  103. ),
  104. );
  105. }
  106. }
  107. // ── 搜索框 ────────────────────────────────────────────────
  108. class _SearchBar extends StatelessWidget {
  109. const _SearchBar({required this.onChanged});
  110. final ValueChanged<String> onChanged;
  111. @override
  112. Widget build(BuildContext context) {
  113. final cs = Theme.of(context).colorScheme;
  114. return Padding(
  115. padding: const EdgeInsets.fromLTRB(16, 8, 16, 6),
  116. child: SizedBox(
  117. height: 38,
  118. child: Semantics(
  119. label: 'market_search_input',
  120. textField: true,
  121. child: TextField(
  122. onChanged: onChanged,
  123. style: TextStyle(color: cs.onSurface, fontSize: 13),
  124. textAlignVertical: TextAlignVertical.center,
  125. decoration: InputDecoration(
  126. hintText: AppLocalizations.of(context)!.searchMarket,
  127. hintStyle:
  128. TextStyle(color: cs.onSurface.withAlpha(100), fontSize: 13),
  129. prefixIcon: Icon(Icons.search,
  130. color: cs.onSurface.withAlpha(100), size: 18),
  131. prefixIconConstraints: const BoxConstraints(minWidth: 40),
  132. isDense: true,
  133. filled: true,
  134. fillColor: cs.surface,
  135. contentPadding:
  136. const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
  137. border: OutlineInputBorder(
  138. borderRadius: BorderRadius.circular(24),
  139. borderSide: BorderSide.none,
  140. ),
  141. enabledBorder: OutlineInputBorder(
  142. borderRadius: BorderRadius.circular(24),
  143. borderSide: BorderSide.none,
  144. ),
  145. focusedBorder: OutlineInputBorder(
  146. borderRadius: BorderRadius.circular(24),
  147. borderSide: BorderSide.none,
  148. ),
  149. ),
  150. ),
  151. ),
  152. ),
  153. );
  154. }
  155. }
  156. // ── 行情列表主体 ──────────────────────────────────────────
  157. // 只 select displaySymbols(symbol 列表),价格变化不触发列表重建。
  158. // 各行通过 tickerProvider(symbol) 独立订阅自己的 ticker 数据。
  159. class _MarketList extends ConsumerWidget {
  160. const _MarketList();
  161. @override
  162. Widget build(BuildContext context, WidgetRef ref) {
  163. final symbols = ref.watch(marketProvider.select((s) => s.displaySymbols));
  164. return Column(
  165. crossAxisAlignment: CrossAxisAlignment.start,
  166. children: [
  167. // "永续合约" 标题
  168. Padding(
  169. padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
  170. child: Text(
  171. AppLocalizations.of(context)!.perpetualFutures,
  172. style: TextStyle(
  173. fontSize: 16,
  174. fontWeight: FontWeight.w700,
  175. color: Theme.of(context).colorScheme.onSurface,
  176. ),
  177. ),
  178. ),
  179. // 列表表头
  180. const _ListHeader(),
  181. // 行情行列表
  182. Expanded(
  183. child: AppRefreshIndicator(
  184. onRefresh: () => ref.read(marketProvider.notifier).refresh(),
  185. child: ListView.builder(
  186. itemCount: symbols.length,
  187. itemBuilder: (context, index) {
  188. // 只传 symbol,不传 ticker 对象
  189. return _TickerRow(symbol: symbols[index]);
  190. },
  191. ),
  192. ),
  193. ),
  194. ],
  195. );
  196. }
  197. }
  198. // ── 现货行情列表 ──────────────────────────────────────────
  199. class _SpotMarketList extends ConsumerWidget {
  200. const _SpotMarketList();
  201. @override
  202. Widget build(BuildContext context, WidgetRef ref) {
  203. final symbols =
  204. ref.watch(marketProvider.select((s) => s.spotDisplaySymbols));
  205. return Column(
  206. crossAxisAlignment: CrossAxisAlignment.start,
  207. children: [
  208. Padding(
  209. padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
  210. child: Text(
  211. AppLocalizations.of(context)!.spotTab,
  212. style: TextStyle(
  213. fontSize: 16,
  214. fontWeight: FontWeight.w700,
  215. color: Theme.of(context).colorScheme.onSurface,
  216. ),
  217. ),
  218. ),
  219. const _SpotListHeader(),
  220. Expanded(
  221. child: AppRefreshIndicator(
  222. onRefresh: () => ref.read(marketProvider.notifier).refresh(),
  223. child: symbols.isEmpty
  224. ? Center(
  225. child: Text(
  226. AppLocalizations.of(context)!.noOrders,
  227. style: TextStyle(
  228. color: Theme.of(context)
  229. .colorScheme
  230. .onSurface
  231. .withAlpha(140),
  232. fontSize: 14,
  233. ),
  234. ),
  235. )
  236. : ListView.builder(
  237. itemCount: symbols.length,
  238. itemBuilder: (context, index) =>
  239. _SpotTickerRow(symbol: symbols[index]),
  240. ),
  241. ),
  242. ),
  243. ],
  244. );
  245. }
  246. }
  247. // ── 现货列表表头 ──────────────────────────────────────────
  248. class _SpotListHeader extends ConsumerWidget {
  249. const _SpotListHeader();
  250. @override
  251. Widget build(BuildContext context, WidgetRef ref) {
  252. final cs = Theme.of(context).colorScheme;
  253. final sortField = ref.watch(marketProvider.select((s) => s.spotSortField));
  254. final sortAsc = ref.watch(marketProvider.select((s) => s.spotSortAsc));
  255. Widget sortCell({
  256. required String label,
  257. required MarketSortField field,
  258. MainAxisAlignment align = MainAxisAlignment.start,
  259. }) {
  260. final active = sortField == field;
  261. final color = active ? cs.onSurface : cs.onSurface.withAlpha(120);
  262. return GestureDetector(
  263. onTap: () => ref.read(marketProvider.notifier).toggleSpotSort(field),
  264. behavior: HitTestBehavior.opaque,
  265. child: Row(
  266. mainAxisAlignment: align,
  267. mainAxisSize: MainAxisSize.min,
  268. children: [
  269. Text(label, style: TextStyle(color: color, fontSize: 12)),
  270. const SizedBox(width: 2),
  271. _SortIcon(active: active, asc: sortAsc, color: color),
  272. ],
  273. ),
  274. );
  275. }
  276. Widget trailingSortCol({
  277. required String label,
  278. required MarketSortField field,
  279. }) {
  280. final active = sortField == field;
  281. final color = active ? cs.onSurface : cs.onSurface.withAlpha(120);
  282. return SizedBox(
  283. width: kMarketListChangeBadgeWidth,
  284. child: GestureDetector(
  285. onTap: () => ref.read(marketProvider.notifier).toggleSpotSort(field),
  286. behavior: HitTestBehavior.opaque,
  287. child: FittedBox(
  288. fit: BoxFit.scaleDown,
  289. alignment: Alignment.centerRight,
  290. child: Row(
  291. mainAxisAlignment: MainAxisAlignment.end,
  292. mainAxisSize: MainAxisSize.min,
  293. children: [
  294. Text(label, style: TextStyle(color: color, fontSize: 12)),
  295. const SizedBox(width: 2),
  296. _SortIcon(active: active, asc: sortAsc, color: color),
  297. ],
  298. ),
  299. ),
  300. ),
  301. );
  302. }
  303. return Padding(
  304. padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
  305. child: Row(
  306. children: [
  307. Expanded(
  308. flex: kMarketListNameClusterFlex,
  309. child: sortCell(
  310. label: AppLocalizations.of(context)!.nameVolume,
  311. field: MarketSortField.volume,
  312. ),
  313. ),
  314. SizedBox(width: kMarketListNameToPriceGap),
  315. sortCell(
  316. label: AppLocalizations.of(context)!.latestPriceFull,
  317. field: MarketSortField.price,
  318. align: MainAxisAlignment.end,
  319. ),
  320. Spacer(flex: kMarketListPriceTailSpacerFlex),
  321. SizedBox(width: kMarketListPriceToBadgeGap),
  322. trailingSortCol(
  323. label: AppLocalizations.of(context)!.change24hFull,
  324. field: MarketSortField.change,
  325. ),
  326. ],
  327. ),
  328. );
  329. }
  330. }
  331. // ── 现货行情行 ────────────────────────────────────────────
  332. class _SpotTickerRow extends ConsumerWidget {
  333. const _SpotTickerRow({required this.symbol});
  334. final String symbol;
  335. static String _formatVolume(double v) {
  336. if (v >= 1e9) return '${(v / 1e9).toStringAsFixed(2)}B';
  337. if (v >= 1e6) return '${(v / 1e6).toStringAsFixed(2)}M';
  338. if (v >= 1e3) return '${(v / 1e3).toStringAsFixed(2)}K';
  339. return v.toStringAsFixed(2);
  340. }
  341. @override
  342. Widget build(BuildContext context, WidgetRef ref) {
  343. final cs = Theme.of(context).colorScheme;
  344. final ticker = ref.watch(spotTickerProvider(symbol));
  345. if (ticker == null) return const SizedBox.shrink();
  346. final volumeStr = _formatVolume(ticker.volume24h);
  347. final changeColor = AppColors.changeColor(ticker.change24h);
  348. final changeStr = formatChange(ticker.change24h);
  349. return InkWell(
  350. onTap: () => _pushMarketQuoteDetail(context, ref, ticker.symbol),
  351. child: Padding(
  352. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  353. child: Row(
  354. children: [
  355. Expanded(
  356. flex: kMarketListNameClusterFlex,
  357. child: Row(
  358. children: [
  359. CoinIcon(
  360. symbol: ticker.baseAsset, iconUrl: ticker.icon, size: 30),
  361. const SizedBox(width: 8),
  362. Expanded(
  363. child: Column(
  364. crossAxisAlignment: CrossAxisAlignment.start,
  365. mainAxisSize: MainAxisSize.min,
  366. children: [
  367. Text(
  368. formatUsdtPairDisplay(ticker.symbol),
  369. style: TextStyle(
  370. color: cs.onSurface,
  371. fontSize: 14,
  372. fontWeight: FontWeight.w600,
  373. ),
  374. maxLines: 1,
  375. overflow: TextOverflow.ellipsis,
  376. ),
  377. Text(
  378. '${AppLocalizations.of(context)!.spot} · $volumeStr',
  379. style: TextStyle(
  380. color: cs.onSurface.withAlpha(120),
  381. fontSize: 11,
  382. ),
  383. maxLines: 1,
  384. overflow: TextOverflow.ellipsis,
  385. ),
  386. ],
  387. ),
  388. ),
  389. ],
  390. ),
  391. ),
  392. SizedBox(width: kMarketListNameToPriceGap),
  393. Column(
  394. crossAxisAlignment: CrossAxisAlignment.end,
  395. mainAxisSize: MainAxisSize.min,
  396. children: [
  397. Text(
  398. ticker.lastPrice > 0
  399. ? (ticker.lastPriceStr != null
  400. ? formatRawPrice(ticker.lastPriceStr!)
  401. : formatPrice(ticker.lastPrice))
  402. : '--',
  403. style: TextStyle(
  404. color: cs.onSurface,
  405. fontSize: 13,
  406. fontWeight: FontWeight.w500,
  407. fontFeatures: const [FontFeature.tabularFigures()],
  408. ),
  409. textAlign: TextAlign.end,
  410. maxLines: 1,
  411. overflow: TextOverflow.ellipsis,
  412. ),
  413. Text(
  414. ticker.lastPrice > 0
  415. ? formatFiatPrice(ticker.lastPrice,
  416. pricePrecision: ticker.pricePrecision)
  417. : '--',
  418. style: TextStyle(
  419. color: cs.onSurface.withAlpha(120),
  420. fontSize: 11,
  421. fontFeatures: const [FontFeature.tabularFigures()],
  422. ),
  423. textAlign: TextAlign.end,
  424. maxLines: 1,
  425. overflow: TextOverflow.ellipsis,
  426. ),
  427. ],
  428. ),
  429. Spacer(flex: kMarketListPriceTailSpacerFlex),
  430. SizedBox(width: kMarketListPriceToBadgeGap),
  431. SizedBox(
  432. width: kMarketListChangeBadgeWidth,
  433. child: Container(
  434. height: 34,
  435. decoration: BoxDecoration(
  436. color: changeColor,
  437. borderRadius: BorderRadius.circular(6),
  438. ),
  439. alignment: Alignment.center,
  440. child: Text(
  441. ticker.lastPrice > 0 ? changeStr : '--',
  442. style: const TextStyle(
  443. color: Colors.white,
  444. fontSize: 13,
  445. fontWeight: FontWeight.w600,
  446. fontFeatures: [FontFeature.tabularFigures()],
  447. ),
  448. textAlign: TextAlign.center,
  449. ),
  450. ),
  451. ),
  452. ],
  453. ),
  454. ),
  455. );
  456. }
  457. }
  458. // ── 列表表头(合约) ──────────────────────────────────────────
  459. class _ListHeader extends ConsumerWidget {
  460. const _ListHeader();
  461. @override
  462. Widget build(BuildContext context, WidgetRef ref) {
  463. final cs = Theme.of(context).colorScheme;
  464. final sortField = ref.watch(marketProvider.select((s) => s.sortField));
  465. final sortAsc = ref.watch(marketProvider.select((s) => s.sortAsc));
  466. Widget buildSortCell({
  467. required String label,
  468. required MarketSortField field,
  469. MainAxisAlignment align = MainAxisAlignment.start,
  470. required String semanticsLabel,
  471. }) {
  472. final active = sortField == field;
  473. final color = active ? cs.onSurface : cs.onSurface.withAlpha(120);
  474. return Semantics(
  475. label: semanticsLabel,
  476. button: true,
  477. onTap: () => ref.read(marketProvider.notifier).toggleSort(field),
  478. child: GestureDetector(
  479. onTap: () => ref.read(marketProvider.notifier).toggleSort(field),
  480. behavior: HitTestBehavior.opaque,
  481. child: Row(
  482. mainAxisAlignment: align,
  483. mainAxisSize: MainAxisSize.min,
  484. children: [
  485. Text(label, style: TextStyle(color: color, fontSize: 12)),
  486. const SizedBox(width: 2),
  487. _SortIcon(active: active, asc: sortAsc, color: color),
  488. ],
  489. ),
  490. ),
  491. );
  492. }
  493. Widget buildTrailingSortCol({
  494. required String label,
  495. required MarketSortField field,
  496. required String semanticsLabel,
  497. }) {
  498. final active = sortField == field;
  499. final color = active ? cs.onSurface : cs.onSurface.withAlpha(120);
  500. return SizedBox(
  501. width: kMarketListChangeBadgeWidth,
  502. child: Semantics(
  503. label: semanticsLabel,
  504. button: true,
  505. onTap: () => ref.read(marketProvider.notifier).toggleSort(field),
  506. child: GestureDetector(
  507. onTap: () => ref.read(marketProvider.notifier).toggleSort(field),
  508. behavior: HitTestBehavior.opaque,
  509. child: FittedBox(
  510. fit: BoxFit.scaleDown,
  511. alignment: Alignment.centerRight,
  512. child: Row(
  513. mainAxisAlignment: MainAxisAlignment.end,
  514. mainAxisSize: MainAxisSize.min,
  515. children: [
  516. Text(label, style: TextStyle(color: color, fontSize: 12)),
  517. const SizedBox(width: 2),
  518. _SortIcon(active: active, asc: sortAsc, color: color),
  519. ],
  520. ),
  521. ),
  522. ),
  523. ),
  524. );
  525. }
  526. return Padding(
  527. padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
  528. child: Row(
  529. children: [
  530. Expanded(
  531. flex: kMarketListNameClusterFlex,
  532. child: buildSortCell(
  533. label: AppLocalizations.of(context)!.nameVolume,
  534. field: MarketSortField.volume,
  535. semanticsLabel: 'market_sort_volume'),
  536. ),
  537. SizedBox(width: kMarketListNameToPriceGap),
  538. buildSortCell(
  539. label: AppLocalizations.of(context)!.latestPriceFull,
  540. field: MarketSortField.price,
  541. align: MainAxisAlignment.end,
  542. semanticsLabel: 'market_sort_price'),
  543. Spacer(flex: kMarketListPriceTailSpacerFlex),
  544. SizedBox(width: kMarketListPriceToBadgeGap),
  545. buildTrailingSortCol(
  546. label: AppLocalizations.of(context)!.change24hFull,
  547. field: MarketSortField.change,
  548. semanticsLabel: 'market_sort_change'),
  549. ],
  550. ),
  551. );
  552. }
  553. }
  554. /// 排序箭头图标:上下双三角,激活时高亮当前方向
  555. class _SortIcon extends StatelessWidget {
  556. const _SortIcon(
  557. {required this.active, required this.asc, required this.color});
  558. final bool active;
  559. final bool asc;
  560. final Color color;
  561. @override
  562. Widget build(BuildContext context) {
  563. final cs = Theme.of(context).colorScheme;
  564. final dim = cs.onSurface.withAlpha(50);
  565. return SizedBox(
  566. width: 12,
  567. height: 16,
  568. child: Stack(
  569. children: [
  570. Positioned(
  571. top: 0,
  572. left: 0,
  573. right: 0,
  574. child: Icon(Icons.arrow_drop_up,
  575. size: 14, color: active && asc ? color : dim),
  576. ),
  577. Positioned(
  578. bottom: 0,
  579. left: 0,
  580. right: 0,
  581. child: Icon(Icons.arrow_drop_down,
  582. size: 14, color: active && !asc ? color : dim),
  583. ),
  584. ],
  585. ),
  586. );
  587. }
  588. }
  589. // ── 行情行 ────────────────────────────────────────────────
  590. // 每行通过 tickerProvider(symbol) 独立订阅,
  591. // BTC 价格变化只重建 BTC 行,不影响其他行。
  592. class _TickerRow extends ConsumerWidget {
  593. const _TickerRow({required this.symbol});
  594. final String symbol;
  595. static String _formatVolume(double v) {
  596. if (v >= 1e9) return '${(v / 1e9).toStringAsFixed(2)}B';
  597. if (v >= 1e6) return '${(v / 1e6).toStringAsFixed(2)}M';
  598. if (v >= 1e3) return '${(v / 1e3).toStringAsFixed(2)}K';
  599. return v.toStringAsFixed(2);
  600. }
  601. @override
  602. Widget build(BuildContext context, WidgetRef ref) {
  603. final cs = Theme.of(context).colorScheme;
  604. final ticker = ref.watch(tickerProvider(symbol));
  605. if (ticker == null) return const SizedBox.shrink();
  606. final volumeStr = _formatVolume(ticker.volume24h);
  607. final changeColor = AppColors.changeColor(ticker.change24h);
  608. final changeStr = formatChange(ticker.change24h);
  609. return Semantics(
  610. label: 'market_item_${ticker.symbol}',
  611. button: true,
  612. onTap: () => _pushMarketQuoteDetail(context, ref, ticker.symbol),
  613. child: InkWell(
  614. onTap: () => _pushMarketQuoteDetail(context, ref, ticker.symbol),
  615. child: Padding(
  616. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
  617. child: Row(
  618. children: [
  619. // 头像
  620. Expanded(
  621. flex: kMarketListNameClusterFlex,
  622. child: Row(
  623. children: [
  624. CoinIcon(
  625. symbol: ticker.baseAsset,
  626. iconUrl: ticker.icon,
  627. size: 40,
  628. borderRadius: 12,
  629. ),
  630. const SizedBox(width: 10),
  631. Expanded(
  632. child: Column(
  633. crossAxisAlignment: CrossAxisAlignment.start,
  634. children: [
  635. Text(
  636. formatUsdtPairDisplay(ticker.symbol),
  637. maxLines: 1,
  638. overflow: TextOverflow.ellipsis,
  639. style: TextStyle(
  640. color: cs.onSurface,
  641. fontSize: 13,
  642. fontWeight: FontWeight.w500,
  643. ),
  644. ),
  645. Text(
  646. '${AppLocalizations.of(context)!.perpetual} · $volumeStr',
  647. maxLines: 1,
  648. overflow: TextOverflow.ellipsis,
  649. style: TextStyle(
  650. color: cs.onSurface.withAlpha(153),
  651. fontSize: 12),
  652. ),
  653. ],
  654. ),
  655. ),
  656. ],
  657. ),
  658. ),
  659. SizedBox(width: kMarketListNameToPriceGap),
  660. Column(
  661. crossAxisAlignment: CrossAxisAlignment.end,
  662. mainAxisSize: MainAxisSize.min,
  663. children: [
  664. Text(
  665. ticker.lastPriceStr != null
  666. ? formatRawPrice(ticker.lastPriceStr!)
  667. : formatPrice(ticker.lastPrice),
  668. style: TextStyle(
  669. color: cs.onSurface,
  670. fontSize: 14,
  671. fontWeight: FontWeight.w500,
  672. fontFeatures: const [FontFeature.tabularFigures()],
  673. ),
  674. textAlign: TextAlign.end,
  675. maxLines: 1,
  676. overflow: TextOverflow.ellipsis,
  677. ),
  678. Text(
  679. formatFiatPrice(ticker.lastPrice,
  680. pricePrecision: ticker.pricePrecision),
  681. style: TextStyle(
  682. color: cs.onSurface.withAlpha(153),
  683. fontSize: 11,
  684. fontFeatures: const [FontFeature.tabularFigures()],
  685. ),
  686. textAlign: TextAlign.end,
  687. maxLines: 1,
  688. overflow: TextOverflow.ellipsis,
  689. ),
  690. ],
  691. ),
  692. Spacer(flex: kMarketListPriceTailSpacerFlex),
  693. const SizedBox(width: kMarketListPriceToBadgeGap),
  694. // 涨跌幅 Badge(固定宽度,避免内容宽度抖动)
  695. SizedBox(
  696. width: kMarketListChangeBadgeWidth,
  697. child: Container(
  698. height: 34,
  699. decoration: BoxDecoration(
  700. color: changeColor,
  701. borderRadius: BorderRadius.circular(6),
  702. ),
  703. alignment: Alignment.center,
  704. child: Text(
  705. changeStr,
  706. style: const TextStyle(
  707. color: Colors.white,
  708. fontSize: 13,
  709. fontWeight: FontWeight.w600,
  710. fontFeatures: [FontFeature.tabularFigures()],
  711. ),
  712. textAlign: TextAlign.center,
  713. ),
  714. ),
  715. ),
  716. ],
  717. ),
  718. ),
  719. ),
  720. );
  721. }
  722. }
  723. // ── 行情骨架屏 ──────────────────────────────────────────────
  724. class _MarketShimmer extends StatelessWidget {
  725. const _MarketShimmer();
  726. @override
  727. Widget build(BuildContext context) {
  728. return AppShimmer(
  729. child: ListView.builder(
  730. physics: const NeverScrollableScrollPhysics(),
  731. itemCount: 10,
  732. itemBuilder: (_, __) => Padding(
  733. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  734. child: Row(
  735. children: [
  736. Expanded(
  737. flex: kMarketListNameClusterFlex,
  738. child: Row(
  739. children: [
  740. shimmerCircle(36),
  741. const SizedBox(width: 10),
  742. Expanded(
  743. child: Column(
  744. crossAxisAlignment: CrossAxisAlignment.start,
  745. children: [
  746. shimmerBox(70, 14),
  747. const SizedBox(height: 6),
  748. shimmerBox(50, 11),
  749. ],
  750. ),
  751. ),
  752. ],
  753. ),
  754. ),
  755. SizedBox(width: kMarketListNameToPriceGap),
  756. Column(
  757. crossAxisAlignment: CrossAxisAlignment.end,
  758. children: [
  759. shimmerBox(80, 14),
  760. const SizedBox(height: 6),
  761. shimmerBox(50, 11),
  762. ],
  763. ),
  764. Spacer(flex: kMarketListPriceTailSpacerFlex),
  765. const SizedBox(width: kMarketListPriceToBadgeGap),
  766. shimmerBox(kMarketListChangeBadgeWidth, 30, radius: 6),
  767. ],
  768. ),
  769. ),
  770. ),
  771. );
  772. }
  773. }