market_screen.dart 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
  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. mainAxisSize: MainAxisSize.min,
  269. children: [
  270. Text(label, style: TextStyle(color: color, fontSize: 12)),
  271. const SizedBox(width: 2),
  272. _SortIcon(active: active, asc: sortAsc, color: color),
  273. ],
  274. ),
  275. );
  276. }
  277. Widget trailingSortCol({
  278. required String label,
  279. required MarketSortField field,
  280. }) {
  281. final active = sortField == field;
  282. final color = active ? cs.onSurface : cs.onSurface.withAlpha(120);
  283. return GestureDetector(
  284. onTap: () => ref.read(marketProvider.notifier).toggleSpotSort(field),
  285. behavior: HitTestBehavior.opaque,
  286. child: FittedBox(
  287. fit: BoxFit.scaleDown,
  288. alignment: Alignment.center,
  289. child: Row(
  290. mainAxisAlignment: MainAxisAlignment.center,
  291. mainAxisSize: MainAxisSize.min,
  292. children: [
  293. Text(label, style: TextStyle(color: color, fontSize: 12)),
  294. const SizedBox(width: 2),
  295. _SortIcon(active: active, asc: sortAsc, color: color),
  296. ],
  297. ),
  298. ),
  299. );
  300. }
  301. return Padding(
  302. padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
  303. child: MarketListHeaderRow(
  304. leading: const SizedBox(width: 30),
  305. leadingGap: 8,
  306. name: sortCell(
  307. label: AppLocalizations.of(context)!.nameVolume,
  308. field: MarketSortField.volume,
  309. ),
  310. price: sortCell(
  311. label: AppLocalizations.of(context)!.latestPriceFull,
  312. field: MarketSortField.price,
  313. align: MainAxisAlignment.end,
  314. ),
  315. change: trailingSortCol(
  316. label: AppLocalizations.of(context)!.change24hFull,
  317. field: MarketSortField.change,
  318. ),
  319. ),
  320. );
  321. }
  322. }
  323. // ── 现货行情行 ────────────────────────────────────────────
  324. class _SpotTickerRow extends ConsumerWidget {
  325. const _SpotTickerRow({required this.symbol});
  326. final String symbol;
  327. static String _formatVolume(double v) {
  328. if (v >= 1e9) return '${(v / 1e9).toStringAsFixed(2)}B';
  329. if (v >= 1e6) return '${(v / 1e6).toStringAsFixed(2)}M';
  330. if (v >= 1e3) return '${(v / 1e3).toStringAsFixed(2)}K';
  331. return v.toStringAsFixed(2);
  332. }
  333. @override
  334. Widget build(BuildContext context, WidgetRef ref) {
  335. final cs = Theme.of(context).colorScheme;
  336. final ticker = ref.watch(spotTickerProvider(symbol));
  337. if (ticker == null) return const SizedBox.shrink();
  338. final volumeStr = _formatVolume(ticker.volume24h);
  339. final changeColor = AppColors.changeColor(ticker.change24h);
  340. final changeStr = formatChange(ticker.change24h);
  341. return InkWell(
  342. onTap: () => _pushMarketQuoteDetail(context, ref, ticker.symbol),
  343. child: Padding(
  344. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  345. child: MarketListDataRow(
  346. leading: CoinIcon(
  347. symbol: ticker.baseAsset,
  348. iconUrl: ticker.icon,
  349. size: 30,
  350. ),
  351. leadingGap: 8,
  352. name: Column(
  353. crossAxisAlignment: CrossAxisAlignment.start,
  354. children: [
  355. Text(
  356. formatUsdtPairDisplay(ticker.symbol),
  357. style: TextStyle(
  358. color: cs.onSurface,
  359. fontSize: 14,
  360. fontWeight: FontWeight.w600,
  361. ),
  362. maxLines: 1,
  363. overflow: TextOverflow.ellipsis,
  364. ),
  365. Text(
  366. '${AppLocalizations.of(context)!.spot} · $volumeStr',
  367. style: TextStyle(
  368. color: cs.onSurface.withAlpha(120),
  369. fontSize: 11,
  370. ),
  371. maxLines: 1,
  372. overflow: TextOverflow.ellipsis,
  373. ),
  374. ],
  375. ),
  376. price: Column(
  377. crossAxisAlignment: CrossAxisAlignment.end,
  378. mainAxisSize: MainAxisSize.min,
  379. children: [
  380. Text(
  381. ticker.lastPrice > 0
  382. ? (ticker.lastPriceStr != null
  383. ? formatRawPrice(ticker.lastPriceStr!)
  384. : formatPrice(ticker.lastPrice))
  385. : '--',
  386. style: TextStyle(
  387. color: cs.onSurface,
  388. fontSize: 13,
  389. fontWeight: FontWeight.w500,
  390. fontFeatures: const [FontFeature.tabularFigures()],
  391. ),
  392. maxLines: 1,
  393. overflow: TextOverflow.ellipsis,
  394. ),
  395. Text(
  396. ticker.lastPrice > 0
  397. ? formatFiatPrice(ticker.lastPrice,
  398. pricePrecision: ticker.pricePrecision)
  399. : '--',
  400. style: TextStyle(
  401. color: cs.onSurface.withAlpha(120),
  402. fontSize: 11,
  403. fontFeatures: const [FontFeature.tabularFigures()],
  404. ),
  405. maxLines: 1,
  406. overflow: TextOverflow.ellipsis,
  407. ),
  408. ],
  409. ),
  410. change: Container(
  411. height: 34,
  412. decoration: BoxDecoration(
  413. color: changeColor,
  414. borderRadius: BorderRadius.circular(6),
  415. ),
  416. alignment: Alignment.center,
  417. child: Text(
  418. ticker.lastPrice > 0 ? changeStr : '--',
  419. style: const TextStyle(
  420. color: Colors.white,
  421. fontSize: 13,
  422. fontWeight: FontWeight.w600,
  423. fontFeatures: [FontFeature.tabularFigures()],
  424. ),
  425. textAlign: TextAlign.center,
  426. ),
  427. ),
  428. ),
  429. ),
  430. );
  431. }
  432. }
  433. // ── 列表表头(合约) ──────────────────────────────────────────
  434. class _ListHeader extends ConsumerWidget {
  435. const _ListHeader();
  436. @override
  437. Widget build(BuildContext context, WidgetRef ref) {
  438. final cs = Theme.of(context).colorScheme;
  439. final sortField = ref.watch(marketProvider.select((s) => s.sortField));
  440. final sortAsc = ref.watch(marketProvider.select((s) => s.sortAsc));
  441. Widget buildSortCell({
  442. required String label,
  443. required MarketSortField field,
  444. MainAxisAlignment align = MainAxisAlignment.start,
  445. required String semanticsLabel,
  446. }) {
  447. final active = sortField == field;
  448. final color = active ? cs.onSurface : cs.onSurface.withAlpha(120);
  449. return Semantics(
  450. label: semanticsLabel,
  451. button: true,
  452. onTap: () => ref.read(marketProvider.notifier).toggleSort(field),
  453. child: GestureDetector(
  454. onTap: () => ref.read(marketProvider.notifier).toggleSort(field),
  455. behavior: HitTestBehavior.opaque,
  456. child: Row(
  457. mainAxisAlignment: align,
  458. mainAxisSize: MainAxisSize.min,
  459. children: [
  460. Text(label, style: TextStyle(color: color, fontSize: 12)),
  461. const SizedBox(width: 2),
  462. _SortIcon(active: active, asc: sortAsc, color: color),
  463. ],
  464. ),
  465. ),
  466. );
  467. }
  468. Widget buildTrailingSortCol({
  469. required String label,
  470. required MarketSortField field,
  471. required String semanticsLabel,
  472. }) {
  473. final active = sortField == field;
  474. final color = active ? cs.onSurface : cs.onSurface.withAlpha(120);
  475. return Semantics(
  476. label: semanticsLabel,
  477. button: true,
  478. onTap: () => ref.read(marketProvider.notifier).toggleSort(field),
  479. child: GestureDetector(
  480. onTap: () => ref.read(marketProvider.notifier).toggleSort(field),
  481. behavior: HitTestBehavior.opaque,
  482. child: FittedBox(
  483. fit: BoxFit.scaleDown,
  484. alignment: Alignment.center,
  485. child: Row(
  486. mainAxisAlignment: MainAxisAlignment.center,
  487. mainAxisSize: MainAxisSize.min,
  488. children: [
  489. Text(label, style: TextStyle(color: color, fontSize: 12)),
  490. const SizedBox(width: 2),
  491. _SortIcon(active: active, asc: sortAsc, color: color),
  492. ],
  493. ),
  494. ),
  495. ),
  496. );
  497. }
  498. return Padding(
  499. padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
  500. child: MarketListHeaderRow(
  501. leading: const SizedBox(width: 40),
  502. name: buildSortCell(
  503. label: AppLocalizations.of(context)!.nameVolume,
  504. field: MarketSortField.volume,
  505. semanticsLabel: 'market_sort_volume'),
  506. price: buildSortCell(
  507. label: AppLocalizations.of(context)!.latestPriceFull,
  508. field: MarketSortField.price,
  509. align: MainAxisAlignment.end,
  510. semanticsLabel: 'market_sort_price'),
  511. change: buildTrailingSortCol(
  512. label: AppLocalizations.of(context)!.change24hFull,
  513. field: MarketSortField.change,
  514. semanticsLabel: 'market_sort_change'),
  515. ),
  516. );
  517. }
  518. }
  519. /// 排序箭头图标:上下双三角,激活时高亮当前方向
  520. class _SortIcon extends StatelessWidget {
  521. const _SortIcon(
  522. {required this.active, required this.asc, required this.color});
  523. final bool active;
  524. final bool asc;
  525. final Color color;
  526. @override
  527. Widget build(BuildContext context) {
  528. final cs = Theme.of(context).colorScheme;
  529. final dim = cs.onSurface.withAlpha(50);
  530. return SizedBox(
  531. width: 12,
  532. height: 16,
  533. child: Stack(
  534. children: [
  535. Positioned(
  536. top: 0,
  537. left: 0,
  538. right: 0,
  539. child: Icon(Icons.arrow_drop_up,
  540. size: 14, color: active && asc ? color : dim),
  541. ),
  542. Positioned(
  543. bottom: 0,
  544. left: 0,
  545. right: 0,
  546. child: Icon(Icons.arrow_drop_down,
  547. size: 14, color: active && !asc ? color : dim),
  548. ),
  549. ],
  550. ),
  551. );
  552. }
  553. }
  554. // ── 行情行 ────────────────────────────────────────────────
  555. // 每行通过 tickerProvider(symbol) 独立订阅,
  556. // BTC 价格变化只重建 BTC 行,不影响其他行。
  557. class _TickerRow extends ConsumerWidget {
  558. const _TickerRow({required this.symbol});
  559. final String symbol;
  560. static String _formatVolume(double v) {
  561. if (v >= 1e9) return '${(v / 1e9).toStringAsFixed(2)}B';
  562. if (v >= 1e6) return '${(v / 1e6).toStringAsFixed(2)}M';
  563. if (v >= 1e3) return '${(v / 1e3).toStringAsFixed(2)}K';
  564. return v.toStringAsFixed(2);
  565. }
  566. @override
  567. Widget build(BuildContext context, WidgetRef ref) {
  568. final cs = Theme.of(context).colorScheme;
  569. final ticker = ref.watch(tickerProvider(symbol));
  570. if (ticker == null) return const SizedBox.shrink();
  571. final volumeStr = _formatVolume(ticker.volume24h);
  572. final changeColor = AppColors.changeColor(ticker.change24h);
  573. final changeStr = formatChange(ticker.change24h);
  574. return Semantics(
  575. label: 'market_item_${ticker.symbol}',
  576. button: true,
  577. onTap: () => _pushMarketQuoteDetail(context, ref, ticker.symbol),
  578. child: InkWell(
  579. onTap: () => _pushMarketQuoteDetail(context, ref, ticker.symbol),
  580. child: Padding(
  581. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
  582. child: MarketListDataRow(
  583. leading: CoinIcon(
  584. symbol: ticker.baseAsset,
  585. iconUrl: ticker.icon,
  586. size: 40,
  587. borderRadius: 12,
  588. ),
  589. name: Column(
  590. crossAxisAlignment: CrossAxisAlignment.start,
  591. children: [
  592. Text(
  593. formatUsdtPairDisplay(ticker.symbol),
  594. maxLines: 1,
  595. overflow: TextOverflow.ellipsis,
  596. style: TextStyle(
  597. color: cs.onSurface,
  598. fontSize: 13,
  599. fontWeight: FontWeight.w500,
  600. ),
  601. ),
  602. Text(
  603. '${AppLocalizations.of(context)!.perpetual} · $volumeStr',
  604. maxLines: 1,
  605. overflow: TextOverflow.ellipsis,
  606. style: TextStyle(
  607. color: cs.onSurface.withAlpha(153),
  608. fontSize: 12,
  609. ),
  610. ),
  611. ],
  612. ),
  613. price: Column(
  614. crossAxisAlignment: CrossAxisAlignment.end,
  615. mainAxisSize: MainAxisSize.min,
  616. children: [
  617. Text(
  618. ticker.lastPriceStr != null
  619. ? formatRawPrice(ticker.lastPriceStr!)
  620. : formatPrice(ticker.lastPrice),
  621. style: TextStyle(
  622. color: cs.onSurface,
  623. fontSize: 14,
  624. fontWeight: FontWeight.w500,
  625. fontFeatures: const [FontFeature.tabularFigures()],
  626. ),
  627. maxLines: 1,
  628. overflow: TextOverflow.ellipsis,
  629. ),
  630. Text(
  631. formatFiatPrice(ticker.lastPrice,
  632. pricePrecision: ticker.pricePrecision),
  633. style: TextStyle(
  634. color: cs.onSurface.withAlpha(153),
  635. fontSize: 11,
  636. fontFeatures: const [FontFeature.tabularFigures()],
  637. ),
  638. maxLines: 1,
  639. overflow: TextOverflow.ellipsis,
  640. ),
  641. ],
  642. ),
  643. change: Container(
  644. height: 34,
  645. decoration: BoxDecoration(
  646. color: changeColor,
  647. borderRadius: BorderRadius.circular(6),
  648. ),
  649. alignment: Alignment.center,
  650. child: Text(
  651. changeStr,
  652. style: const TextStyle(
  653. color: Colors.white,
  654. fontSize: 13,
  655. fontWeight: FontWeight.w600,
  656. fontFeatures: [FontFeature.tabularFigures()],
  657. ),
  658. textAlign: TextAlign.center,
  659. ),
  660. ),
  661. ),
  662. ),
  663. ),
  664. );
  665. }
  666. }
  667. // ── 行情骨架屏 ──────────────────────────────────────────────
  668. class _MarketShimmer extends StatelessWidget {
  669. const _MarketShimmer();
  670. @override
  671. Widget build(BuildContext context) {
  672. return AppShimmer(
  673. child: ListView.builder(
  674. physics: const NeverScrollableScrollPhysics(),
  675. itemCount: 10,
  676. itemBuilder: (_, __) => Padding(
  677. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  678. child: MarketListDataRow(
  679. leading: shimmerCircle(40),
  680. name: Column(
  681. crossAxisAlignment: CrossAxisAlignment.start,
  682. children: [
  683. shimmerBox(70, 14),
  684. const SizedBox(height: 6),
  685. shimmerBox(50, 11),
  686. ],
  687. ),
  688. price: Column(
  689. crossAxisAlignment: CrossAxisAlignment.end,
  690. children: [
  691. shimmerBox(80, 14),
  692. const SizedBox(height: 6),
  693. shimmerBox(50, 11),
  694. ],
  695. ),
  696. change: shimmerBox(kMarketListChangeBadgeWidth, 30, radius: 6),
  697. ),
  698. ),
  699. ),
  700. );
  701. }
  702. }