market_screen.dart 26 KB

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